Files
Toolbox/dev_test_scripts/unit/test_travel_citytour_skill.js
T

347 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* 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')