feat: 新增高铁游与车宿徒步游技能及相关基础设施

This commit is contained in:
yangxiangyuan
2026-09-30 13:47:51 +08:00
parent bbc7ca6523
commit ac475b6659
66 changed files with 14572 additions and 0 deletions
@@ -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')