feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
/* ============================================================
|
||||
* test_travel_citytour_skill.js —— 游山玩水 lab · 技能包与样例一致性校验
|
||||
*
|
||||
* 用途:检查技能包(skill.json / output_schema.json / report_spec.json /
|
||||
* destinations.json)与离线样例 payload 是否自洽,防止 skill 升级后
|
||||
* 样例、契约、卡片槽位三者互相脱节。
|
||||
*
|
||||
* 运行:node dev_test_scripts/unit/test_travel_citytour_skill.js
|
||||
* 退出码:0 = 全部通过;1 = 存在不一致
|
||||
*
|
||||
* 说明:本脚本只读取文件,不修改任何数据;位置遵循项目规则
|
||||
* 「测试脚本统一放 dev_test_scripts/」。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = process.cwd()
|
||||
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
|
||||
// 技能全栈隔离:技能包、引擎三件套、样例、渲染器都归 skills/<skill_id>/ 私有
|
||||
const SKILL_ID = 'sh_2d_car_hike'
|
||||
const SKILL_DIR = path.join(LAB_DIR, 'skills', SKILL_ID)
|
||||
const LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
|
||||
const SKILL_PUBLIC = path.join(LAB_PUBLIC, 'skills', SKILL_ID)
|
||||
const SAMPLE_DIR = path.join(SKILL_PUBLIC, 'sample')
|
||||
|
||||
const bad = []
|
||||
const info = []
|
||||
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf-8'))
|
||||
|
||||
let skill, schema, spec, dest, payload, sampleSpec
|
||||
try {
|
||||
skill = readJson(path.join(SKILL_DIR, 'skill.json'))
|
||||
schema = readJson(path.join(SKILL_DIR, 'output_schema.json'))
|
||||
spec = readJson(path.join(SKILL_DIR, 'report_spec.json'))
|
||||
dest = readJson(path.join(SKILL_DIR, 'destinations.json'))
|
||||
payload = readJson(path.join(SAMPLE_DIR, 'rudong_2026-09-19.json'))
|
||||
sampleSpec = readJson(path.join(SAMPLE_DIR, 'report_spec.json'))
|
||||
} catch (e) {
|
||||
console.log('JSON 解析失败:' + e.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/* 1. 技能包引用文件齐全 */
|
||||
Object.keys(skill.refs || {}).forEach((k) => {
|
||||
const f = path.join(SKILL_DIR, skill.refs[k])
|
||||
if (!fs.existsSync(f)) bad.push('refs.' + k + ' 指向的文件不存在:' + skill.refs[k])
|
||||
})
|
||||
|
||||
/* 2. payload 根字段齐全 */
|
||||
;(schema.root.required || []).forEach((k) => {
|
||||
if (!(k in payload)) bad.push('payload 缺根字段:' + k)
|
||||
})
|
||||
|
||||
/* 2.1 版本一致性:样例记录的版本必须等于技能包当前版本(防止改了骨架忘了同步样例) */
|
||||
if (payload.meta.skill_version !== skill.version) {
|
||||
bad.push('样例 meta.skill_version(' + payload.meta.skill_version + ')≠ 技能包 version(' + skill.version + ')')
|
||||
}
|
||||
if (payload.meta.skill_id !== skill.id) {
|
||||
bad.push('样例 meta.skill_id(' + payload.meta.skill_id + ')≠ 技能包 id(' + skill.id + ')')
|
||||
}
|
||||
if (payload.meta.schema_version !== schema.version) {
|
||||
bad.push('样例 meta.schema_version(' + payload.meta.schema_version + ')≠ 契约 version(' + schema.version + ')')
|
||||
}
|
||||
dest.destinations.forEach((d) => {
|
||||
;(d.runs || []).forEach((r) => {
|
||||
if (r.skill_version && r.skill_version !== skill.version) {
|
||||
bad.push('destinations[' + d.key + '] runs.skill_version 与技能包 version 不一致')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* 2.2 skill 声明的默认出发点必须体现在样例问答里 */
|
||||
if (skill.base_origin && (!payload.meta.qa || !payload.meta.qa.departure_origin)) {
|
||||
bad.push('技能包声明了 base_origin,但样例 meta.qa 缺 departure_origin')
|
||||
}
|
||||
|
||||
/* 3. 卡片必须覆盖全部骨架槽位(一个不少、一个不多) */
|
||||
const anchorIds = skill.anchors.map((a) => a.id)
|
||||
const cardAnchorIds = payload.cards.map((c) => c.anchor_id)
|
||||
anchorIds.forEach((a) => {
|
||||
if (cardAnchorIds.indexOf(a) < 0) bad.push('卡片缺槽位:' + a)
|
||||
})
|
||||
cardAnchorIds.forEach((c) => {
|
||||
if (anchorIds.indexOf(c) < 0) bad.push('卡片引用了未定义的槽位:' + c)
|
||||
})
|
||||
|
||||
/* 4. 时序表只能引用合法槽位;硬时间点必须落到对应槽位 */
|
||||
payload.timeline.days.forEach((d) => {
|
||||
;(d.rows || []).forEach((r) => {
|
||||
if (anchorIds.indexOf(r.anchor_id) < 0) bad.push('时序引用未知槽位:' + r.anchor_id)
|
||||
})
|
||||
})
|
||||
const hardAnchors = skill.anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
|
||||
hardAnchors.forEach((a) => {
|
||||
const hit = payload.timeline.days.some((d) => (d.rows || []).some((r) => r.anchor_id === a && r.kind === 'hard'))
|
||||
if (!hit) bad.push('硬时间点槽位未在时序表中以 kind=hard 出现:' + a)
|
||||
})
|
||||
|
||||
/* 5. 契约要求的表格必须存在 */
|
||||
;(schema.required_table_ids || []).forEach((id) => {
|
||||
if (!payload.tables.some((t) => t.id === id)) bad.push('缺表格:' + id)
|
||||
})
|
||||
|
||||
/* 6. report_spec 的 pick_id 必须能在 payload.tables 里命中 */
|
||||
spec.sections.concat(sampleSpec.sections).forEach((s) => {
|
||||
if (!s.pick_id) return
|
||||
if (!payload.tables.some((t) => t.id === s.pick_id)) bad.push('report_spec pick_id 未命中:' + s.pick_id)
|
||||
})
|
||||
|
||||
/* 7. 地图:路线引用的点位必须存在,且每条路线至少 2 个点 */
|
||||
const markerIds = payload.map.markers.map((m) => m.id)
|
||||
payload.map.routes.forEach((r) => {
|
||||
;(r.marker_ids || []).forEach((id) => {
|
||||
if (markerIds.indexOf(id) < 0) bad.push('路线 ' + r.id + ' 引用未知点位:' + id)
|
||||
})
|
||||
if ((r.marker_ids || []).length < 2) bad.push('路线点位少于 2 个:' + r.id)
|
||||
if (!r.note) bad.push('路线缺示意图免责说明:' + r.id)
|
||||
})
|
||||
|
||||
/* 8. destinations 覆盖的槽位必须存在于骨架 */
|
||||
dest.destinations.forEach((d) => {
|
||||
Object.keys(d.anchors_override || {}).forEach((k) => {
|
||||
if (anchorIds.indexOf(k) < 0) bad.push('destinations[' + d.key + '] 覆盖了未知槽位:' + k)
|
||||
})
|
||||
;(d.runs || []).forEach((r) => {
|
||||
// sample_ref 相对【本 skill 自己的前端目录】解析(技能隔离后不再指向 lab 级共享目录)
|
||||
if (r.sample_ref && !fs.existsSync(path.join(SKILL_PUBLIC, r.sample_ref))) {
|
||||
bad.push('destinations[' + d.key + '] sample_ref 不存在:' + r.sample_ref)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* 9. 置信度:非 high 必须带 verify;主观项不得标 high */
|
||||
const unverified = skill.unverified_fields || []
|
||||
const subjectiveLabels = ['免费', '本地人', '卫生', '档次', '禁停', '过夜']
|
||||
let confTotal = 0
|
||||
let confNonHigh = 0
|
||||
;(function walk(node, trail) {
|
||||
if (Array.isArray(node)) return node.forEach((v, i) => walk(v, trail + '[' + i + ']'))
|
||||
if (node && typeof node === 'object') {
|
||||
if ('confidence' in node) {
|
||||
confTotal++
|
||||
const c = node.confidence
|
||||
if (['high', 'medium', 'low'].indexOf(c) < 0) bad.push('置信度取值非法:' + c + ' @ ' + trail)
|
||||
if (c !== 'high') {
|
||||
confNonHigh++
|
||||
if (!node.verify) bad.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24) + ' @ ' + trail)
|
||||
}
|
||||
return
|
||||
}
|
||||
Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k))
|
||||
}
|
||||
})(payload, '')
|
||||
|
||||
/* 10. 待核实清单必须覆盖每一个含非 high 字段的槽位(同槽位可归并成一条,但不许漏槽位) */
|
||||
const hasNonHighFact = (node) => {
|
||||
if (Array.isArray(node)) return node.some(hasNonHighFact)
|
||||
if (node && typeof node === 'object') {
|
||||
if ('confidence' in node) return node.confidence !== 'high'
|
||||
return Object.keys(node).some((k) => hasNonHighFact(node[k]))
|
||||
}
|
||||
return false
|
||||
}
|
||||
const checklistAnchors = payload.checklist.map((c) => c.anchor_id).filter(Boolean)
|
||||
payload.cards.forEach((c) => {
|
||||
if (hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) {
|
||||
bad.push('槽位含非 high 字段,但待核实清单未收录:' + c.anchor_id)
|
||||
}
|
||||
})
|
||||
payload.checklist.forEach((c) => {
|
||||
if (c.anchor_id && anchorIds.indexOf(c.anchor_id) < 0) bad.push('清单引用了未知槽位:' + c.anchor_id)
|
||||
})
|
||||
|
||||
/* 11. 图片字段若存在,必须带免责说明 */
|
||||
;(function walkImg(node, trail) {
|
||||
if (Array.isArray(node)) return node.forEach((v, i) => walkImg(v, trail + '[' + i + ']'))
|
||||
if (node && typeof node === 'object') {
|
||||
if (node.kind === 'ai_illustration' && !node.disclaimer) bad.push('AI 示意图缺免责说明 @ ' + trail)
|
||||
Object.keys(node).forEach((k) => walkImg(node[k], trail + '.' + k))
|
||||
}
|
||||
})(payload, '')
|
||||
|
||||
/* 12. 低耦合自检:技能包与页面不得引用其他 lab / tool 模块 */
|
||||
const OWN_DIRS = [
|
||||
path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour'),
|
||||
LAB_PUBLIC
|
||||
]
|
||||
const CROSS_REF_PATTERNS = [/require\s*\(\s*['"]\.\.\/\.\.\/\.\./, /from\s+['"]\.\.\/\.\.\/\.\./, /tools\/(?!thought_lab)[a-z_]+/]
|
||||
const scanDir = (dir) => {
|
||||
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
// vendor(第三方库)与 data(运行时产物)不参与跨模块引用扫描
|
||||
if (e.name === 'vendor' || e.name === 'data') return
|
||||
return scanDir(full)
|
||||
}
|
||||
if (!/\.(js|html|json)$/.test(e.name)) return
|
||||
const txt = fs.readFileSync(full, 'utf-8')
|
||||
CROSS_REF_PATTERNS.forEach((re) => {
|
||||
if (re.test(txt)) bad.push('疑似跨模块引用:' + path.relative(ROOT, full) + ' 命中 ' + re)
|
||||
})
|
||||
})
|
||||
}
|
||||
OWN_DIRS.forEach(scanDir)
|
||||
|
||||
/* 13. lab 后端路由注册自检(skill 全栈隔离后的新形态)
|
||||
* lab 只做:诊断(公开) + 聚合列表 + 自检 + 逐个 skill 挂载
|
||||
* skill 的 /meta /last /generate 由 lab 包好鉴权后挂到 skills/<id>/ 前缀下 */
|
||||
let makeCtx = null
|
||||
try {
|
||||
const labIndex = require(path.join(LAB_DIR, 'index.js'))
|
||||
if (typeof labIndex.bindPublicRoutes !== 'function') {
|
||||
bad.push('lab index.js 未导出 bindPublicRoutes(thought_lab 自动挂载会跳过本 lab)')
|
||||
} else if (typeof labIndex.bindRoutes === 'function') {
|
||||
bad.push('lab 同时导出了 bindRoutes,会与 bindPublicRoutes 重复注册同一批路径')
|
||||
} else {
|
||||
const routes = []
|
||||
const rec = (m) => (p) => routes.push([m, p])
|
||||
labIndex.bindPublicRoutes({ get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), use: rec('USE') })
|
||||
const expect = [
|
||||
['GET', '/api/thought_lab/travel_citytour/skills'],
|
||||
['GET', '/api/thought_lab/travel_citytour/skills/_health'],
|
||||
['GET', '/api/thought_lab/travel_citytour/auth/diag'],
|
||||
['USE', '/api/thought_lab/travel_citytour/skills/' + SKILL_ID]
|
||||
]
|
||||
expect.forEach((pair) => {
|
||||
if (!routes.some((r) => r[0] === pair[0] && r[1] === pair[1])) {
|
||||
bad.push('缺少路由:' + pair[0] + ' ' + pair[1])
|
||||
}
|
||||
})
|
||||
// lab 层不得再注册任何业务路由(业务已全部下沉到 skill)
|
||||
const biz = routes.filter((r) => /\/(meta|last|generate)$/.test(r[1]))
|
||||
if (biz.length) bad.push('lab 层仍在注册业务路由(应下沉到 skill):' + biz.map((r) => r[1]).join(' / '))
|
||||
info.push('lab 后端:bindPublicRoutes 无异常,注册 ' + routes.length + ' 条(含公开 auth/diag;业务路由 0 条)')
|
||||
}
|
||||
if (typeof labIndex.makeCtx !== 'function') bad.push('lab 未导出 makeCtx(离线链路测试无法复用红线实现)')
|
||||
else makeCtx = labIndex.makeCtx
|
||||
|
||||
// 鉴权口径必须是显式合法值(默认 nav_gate 为最安全口径)
|
||||
const labCfg = JSON.parse(fs.readFileSync(path.join(LAB_DIR, 'config.json'), 'utf-8'))
|
||||
const amode = String((labCfg.auth || {}).mode || '')
|
||||
if (['nav_gate', 'browser'].indexOf(amode) < 0) bad.push('config.json auth.mode 取值非法:' + amode)
|
||||
else info.push('lab 鉴权口径 auth.mode=' + amode + (amode === 'nav_gate' ? '(只认 nav_gate,不放宽)' : '(nav_gate 或同源 Referer)'))
|
||||
const rl = labCfg.rate_limit || {}
|
||||
if (!(Number(rl.generate_per_ip_hour) > 0)) bad.push('rate_limit.generate_per_ip_hour 未配置')
|
||||
else info.push('lab 限流:全局单飞=' + (rl.generate_single_flight !== false) + ' + 按 skill 单飞,单 IP 每小时 ' + rl.generate_per_ip_hour + ' 次生成,只读 ' + rl.read_per_ip_minute + ' 次/分钟')
|
||||
} catch (e) {
|
||||
bad.push('lab 后端加载失败:' + String(e.message || e))
|
||||
}
|
||||
|
||||
/* 13b. skill 侧自检:契约(config.json + index.js + index.html)与路由形状 */
|
||||
let skillIndex = null
|
||||
try {
|
||||
const registry = require(path.join(LAB_DIR, 'registry.js'))
|
||||
const h = registry.health()
|
||||
if (!h.ok) bad.push('registry 自检未通过:' + JSON.stringify(h.problems_detail))
|
||||
else info.push('registry:' + h.total + ' 个 skill 全部可挂载(config/后端入口/前端页面/图标齐全)')
|
||||
const cfg = registry.readSkillConfig(SKILL_ID)
|
||||
if (cfg.enabled === false) bad.push('skill config.json enabled=false,聚合页不会显示')
|
||||
if (String(cfg.id || '') !== SKILL_ID) bad.push('skill config.json 的 id 与目录名不一致:' + cfg.id)
|
||||
const need = ['name', 'desc']
|
||||
need.forEach((k) => { if (!String(cfg[k] || '').trim()) bad.push('skill config.json 缺展示字段:' + k) })
|
||||
|
||||
skillIndex = require(path.join(SKILL_DIR, 'index.js'))
|
||||
if (typeof skillIndex.bindRoutes !== 'function') bad.push('skill index.js 未导出 bindRoutes')
|
||||
else if (makeCtx) {
|
||||
const sroutes = []
|
||||
const fakeRouter = {
|
||||
get: (p) => sroutes.push('GET ' + p),
|
||||
post: (p) => sroutes.push('POST ' + p),
|
||||
use: () => {}
|
||||
}
|
||||
skillIndex.bindRoutes(fakeRouter, makeCtx(SKILL_ID))
|
||||
;[['GET', '/meta'], ['GET', '/last'], ['POST', '/generate']].forEach((pair) => {
|
||||
if (!sroutes.some((r) => r === pair[0] + ' ' + pair[1])) bad.push('skill 缺路由:' + pair[0] + ' ' + pair[1])
|
||||
})
|
||||
info.push('skill 后端:bindRoutes 注册 ' + sroutes.length + ' 条(' + sroutes.join(' / ') + ')')
|
||||
}
|
||||
} catch (e) {
|
||||
bad.push('skill 侧加载失败:' + String(e.message || e))
|
||||
}
|
||||
|
||||
/* 14. 生成链路关键口径自检(不联网):prompt 能组装、必填校验按 qa 定义生效 */
|
||||
try {
|
||||
const registry = require(path.join(LAB_DIR, 'registry.js'))
|
||||
const aiMod = require(path.join(SKILL_DIR, 'ai.js'))
|
||||
const { validatePayload } = require(path.join(SKILL_DIR, 'validate.js'))
|
||||
const b = skillIndex.loadBundle()
|
||||
const ctx = makeCtx(SKILL_ID)
|
||||
const msgs = aiMod.buildMessages(b, { destination: '测试地', departure_date: '2026-09-19' }, null)
|
||||
if (!msgs || msgs.length !== 2) bad.push('buildMessages 未产出 system+user 两条消息')
|
||||
else {
|
||||
if (String(msgs[0].content).indexOf('output_schema') < 0) bad.push('system prompt 未包含输出契约')
|
||||
if (String(msgs[0].content).indexOf(b.skill.anchors[0].id) < 0) bad.push('system prompt 未包含骨架槽位')
|
||||
info.push('生成链路:prompt 组装 OK(system ' + msgs[0].content.length + ' 字符 / user ' + msgs[1].content.length + ' 字符)')
|
||||
}
|
||||
|
||||
// 必填校验按 qa.json 定义生效(不写死字段名)
|
||||
const miss = skillIndex.validateAnswers(b, {})
|
||||
if (!miss.length) bad.push('validateAnswers 对空答案未报缺失')
|
||||
else info.push('问答必填校验:空答案下缺失 ' + miss.length + ' 项(' + miss.slice(0, 4).join('、') + '…)')
|
||||
|
||||
// 目的地在技能包内的覆盖能被命中
|
||||
const destName = (b.destinations && b.destinations.destinations && b.destinations.destinations[0] || {}).name
|
||||
if (destName && !skillIndex.findDestination(b, destName)) bad.push('findDestination 未能命中技能包内目的地:' + destName)
|
||||
|
||||
// ctx 红线能力必须齐全(skill 依赖它们,缺一个就会在运行时炸)
|
||||
;['callLLM', 'extractJson', 'nowBeijing', 'escapeHtml', 'dataDir', 'readConfig', 'genGuard', 'jsonParser', 'log', 'sanitizeObject'].forEach((k) => {
|
||||
if (ctx[k] === undefined) bad.push('ctx 缺红线能力:' + k)
|
||||
})
|
||||
if (ctx.dataDir.indexOf(path.join('skills', SKILL_ID, 'data')) < 0) bad.push('ctx.dataDir 未指向本 skill 私有 data/:' + ctx.dataDir)
|
||||
|
||||
// 用离线样例反推校验器:样例应当 0 errors(口径一致性)
|
||||
const sampleCheck = validatePayload(payload, b)
|
||||
if (sampleCheck.errors.length) bad.push('离线样例未通过服务端校验器:' + sampleCheck.errors.slice(0, 5).join(' / '))
|
||||
else info.push('校验口径一致:离线样例在服务端校验器下 0 errors(' + sampleCheck.warnings.length + ' warnings)')
|
||||
if (sampleCheck.warnings.length) bad.push('离线样例存在校验告警:' + sampleCheck.warnings.slice(0, 3).join(' / '))
|
||||
|
||||
// kind(时间点性质)与 activity(活动类型)必须严格分离
|
||||
const rowList = []
|
||||
;((payload.timeline && payload.timeline.days) || []).forEach((d) => ((d && d.rows) || []).forEach((r) => rowList.push(r)))
|
||||
const badKind = rowList.filter((r) => r && ['hard', 'soft'].indexOf(r.kind) < 0)
|
||||
const badAct = rowList.filter((r) => r && r.activity && ['move', 'eat', 'hike', 'shop', 'sleep', 'walk'].indexOf(r.activity) < 0)
|
||||
if (badKind.length) bad.push('样例时序行 kind 取值非法(只能 hard/soft):' + badKind.map((r) => r.kind).join(' / '))
|
||||
if (badAct.length) bad.push('样例时序行 activity 取值非法:' + badAct.map((r) => r.activity).join(' / '))
|
||||
const hardAnchors = (b.skill.anchors || []).filter((a) => a.time_anchor && a.time_anchor.type === 'hard')
|
||||
const hardMissing = hardAnchors.filter((a) => !rowList.some((r) => r.anchor_id === a.id && r.kind === 'hard'))
|
||||
if (hardMissing.length) bad.push('样例中硬时间点槽位未以 kind=hard 出现:' + hardMissing.map((a) => a.id).join(' / '))
|
||||
info.push('时序行 ' + rowList.length + ' 行:kind 全为 hard/soft,带 activity 的 ' + rowList.filter((r) => r && r.activity).length + ' 行;硬时间点槽位 ' + hardAnchors.length + ' 个全部命中')
|
||||
} catch (e) {
|
||||
bad.push('生成链路自检失败:' + String(e.message || e))
|
||||
}
|
||||
|
||||
info.push('卡片 ' + payload.cards.length + ' 张 / 骨架槽位 ' + anchorIds.length + ' 个')
|
||||
info.push('时序 ' + payload.timeline.days.length + ' 天 / 点位 ' + payload.map.markers.length + ' 个 / 路线 ' + payload.map.routes.length + ' 条 / 表格 ' + payload.tables.length + ' 张')
|
||||
info.push('置信度字段 ' + confTotal + ' 个,其中非 high ' + confNonHigh + ' 个;待核实清单 ' + payload.checklist.length + ' 条')
|
||||
info.push('骨架槽位覆盖:' + (anchorIds.length === cardAnchorIds.length ? '完整' : '不完整'))
|
||||
|
||||
console.log('===== 游山玩水 · 技能包自检 =====')
|
||||
info.forEach((l) => console.log('· ' + l))
|
||||
if (bad.length) {
|
||||
console.log('\n发现 ' + bad.length + ' 个问题:')
|
||||
bad.forEach((b) => console.log(' ✗ ' + b))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n✓ ALL CHECKS PASS')
|
||||
Reference in New Issue
Block a user