/* ============================================================ * 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// 私有 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// 前缀下 */ 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')