Files
Toolbox/dev_test_scripts/unit/test_travel_citytour_rail_skill.js

313 lines
19 KiB
JavaScript
Raw Permalink 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_rail_skill.js —— 「高铁游」技能契约自检(本技能专属)
*
* 为什么单独一份:
* 本 lab 是「skill 全栈隔离」架构,每个 skill 有自己的骨架、词表、硬约束。
* 自驾 skill 的契约测试(test_travel_citytour_skill.js)查的是「停车方案、车内过夜」,
* 本技能查的是「不许有车次号、每处可导航、21:00 抵沪、共享单车/网约车」——
* 两者断言完全不同,所以各写一份。要新增 skill,请照本文件再写一份自己的契约测试。
*
* 校验项(本技能专属):
* 1. 技能包自洽:refs 指向的文件都存在;骨架槽位数与卡片数一致
* 2. 【核心】任何地方都不得出现高铁车次号(如 G1234)—— 模型无法查 12306,写出来必然是编的
* 3. 【核心】每个地图点位都有可导航 address;每张卡片建议带「导航」字段
* 4. 【核心】返程硬约束:报告里必须有「21:00 抵沪」的 warn/danger 提示;train_back 是 hard 时间点
* 5. 词表:activity 只能取本技能枚举(含 bike/taxi/metro/train/checkin/rest);
* marker.kind / route.kind 同理
* 6. 5 张必需表格齐全,且车次表里不得出现「车次号」列
* 7. 离线样例必须过本技能校验器,且 0 errors
* 8. 两条出发模式(周六出发 / 周五晚夜车)在 prompt 里的提示文案都要正确
*
* 运行:node dev_test_scripts/unit/test_travel_citytour_rail_skill.js
* ============================================================ */
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')
const SKILL_ID = 'sh_2d_rail_hike'
const BE = path.join(LAB_DIR, 'skills', SKILL_ID)
const FE = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf-8'))
const bad = []
const info = []
const skillIndex = require(path.join(BE, 'index.js'))
const ai = require(path.join(BE, 'ai.js'))
const { validatePayload, ACTIVITIES, MARKER_KINDS, ROUTE_KINDS } = require(path.join(BE, 'validate.js'))
const { repairPayload } = require(path.join(BE, 'repair.js'))
const bundle = skillIndex.loadBundle()
const skill = bundle.skill
const schema = bundle.schema
const spec = bundle.report_spec
/* ---------- 1. 技能包自洽 ---------- */
Object.keys(skill.refs || {}).forEach((k) => {
const f = path.join(BE, skill.refs[k])
if (!fs.existsSync(f)) bad.push('refs.' + k + ' 指向的文件不存在:' + skill.refs[k])
})
if (skill.id !== SKILL_ID) bad.push('skill.json id 与目录名不一致:' + skill.id)
if (skill.schema_version !== schema.version) bad.push('skill.schema_version 与 output_schema.version 不一致')
info.push(`技能包:${skill.id} v${skill.version}(契约 v${schema.version})· ${skill.anchors.length} 个骨架槽位`)
const anchorIds = skill.anchors.map((a) => a.id)
const hardIds = skill.anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
// 本技能专属:返程必须是硬时间点(不可漂移)
if (hardIds.indexOf('train_back') < 0) bad.push('骨架里 train_back 必须是 hard 时间点(返程死线不可漂移)')
if (hardIds.indexOf('lodging_night') < 0) bad.push('骨架里 lodging_night 必须是 hard 时间点(夜间入住不可漂移)')
info.push(`硬时间点槽位 ${hardIds.length} 个:${hardIds.join(', ')}`)
/* ---------- 2. 词表一致性(skill.json / output_schema / rules 要口径一致) ---------- */
const schemaActivities = schema.definitions.timeline.properties.days.items.properties.rows.items.properties.activity.enum
const missAct = schemaActivities.filter((a) => ACTIVITIES.indexOf(a) < 0)
if (missAct.length) bad.push('output_schema 的 activity 枚举与 validate.js 词表不一致:' + missAct.join(','))
const extraAct = ACTIVITIES.filter((a) => schemaActivities.indexOf(a) < 0)
if (extraAct.length) bad.push('validate.js 的 activity 词表未写进 output_schema:' + extraAct.join(','))
const newActs = ['stroll', 'spot', 'bike', 'taxi', 'metro', 'train', 'checkin', 'rest']
newActs.forEach((a) => {
if (ACTIVITIES.indexOf(a) < 0) bad.push('本技能必需的活动类型缺失:' + a)
if (String(bundle.rules || '').indexOf(a) < 0) bad.push('rules.md 未说明活动类型:' + a)
})
const schemaMarkerKinds = schema.definitions.marker.properties.kind.enum
const missMk = schemaMarkerKinds.filter((k) => MARKER_KINDS.indexOf(k) < 0)
if (missMk.length) bad.push('output_schema 的 marker.kind 与 validate.js 词表不一致:' + missMk.join(','))
const schemaRouteKinds = schema.definitions.route.properties.kind.enum
const missRk = schemaRouteKinds.filter((k) => ROUTE_KINDS.indexOf(k) < 0)
if (missRk.length) bad.push('output_schema 的 route.kind 与 validate.js 词表不一致:' + missRk.join(','))
// 自驾skill的 parking 不应出现在本技能(本技能无自驾)
if (schemaMarkerKinds.indexOf('parking') >= 0 || MARKER_KINDS.indexOf('parking') >= 0) {
bad.push('本技能不涉及自驾,不应出现 marker.kind=parking')
}
info.push(`词表:activity ${ACTIVITIES.length} 种(含本技能新增 ${newActs.join('/')})· marker.kind ${MARKER_KINDS.length} 种 · route.kind ${ROUTE_KINDS.join('/')}`)
/* ---------- 3. 渲染器/导出器的词表要与校验器一致(防漂移) ---------- */
const renderTxt = fs.readFileSync(path.join(FE, 'tc_render.js'), 'utf-8')
const exportTxt = fs.readFileSync(path.join(FE, 'tc_export.js'), 'utf-8')
newActs.forEach((a) => {
if (renderTxt.indexOf(a + ':') < 0) bad.push('tc_render.js 的 activity 中文名表缺:' + a)
if (exportTxt.indexOf(a + ':') < 0) bad.push('tc_export.js 的 activity 中文名表缺:' + a)
})
;['train_station', 'lodging', 'bike', 'taxi_point', 'night_market'].forEach((k) => {
if (renderTxt.indexOf(k + ':') < 0) bad.push('tc_render.js 的点位配色表缺:' + k)
if (exportTxt.indexOf(k + ':') < 0) bad.push('tc_export.js 的点位配色表缺:' + k)
})
// 车次表不得出现车次号列(本技能核心禁令)
if (String(bundle.rules || '').indexOf('禁止编造高铁车次号') < 0) bad.push('rules.md 缺少「禁止编造高铁车次号」硬规则')
// v1.1.0:两条新限制必须写进 rules.md
if (String(bundle.rules || '').indexOf('本地人爱去') < 0) bad.push('rules.md 缺少「餐馆双筛选:本地人爱去 + 评分好」规则')
if (String(bundle.rules || '').indexOf('19:00-21:00') < 0) bad.push('rules.md 缺少「周六晚 19:00-21:00 夜逛不得留白」规则')
info.push('渲染器/导出器词表与校验器一致;rules.md 已写明车次号禁令 + 餐馆双筛选 + 夜逛留白禁令')
/* ---------- 4. 必需表格 ---------- */
const need = ['train_out_options', 'train_back_options', 'city_transport', 'lodging_compare', 'food_summary']
need.forEach((id) => {
if ((schema.required_table_ids || []).indexOf(id) < 0) bad.push('output_schema.required_table_ids 缺:' + id)
const used = (spec.sections || []).some((s) => s.pick_id === id)
if (!used) bad.push('report_spec 未引用表格:' + id)
})
info.push(`必需表格 ${need.length} 张,report_spec 全部引用`)
/* ---------- 5. prompt 两条模式提示 ---------- */
const base = { destination: '苏州', departure_window: 'sat_morning' }
const msgSat = ai.buildMessages(bundle, base, null)
const sysSat = msgSat[0].content
if (sysSat.indexOf('模式 A') < 0) bad.push('周六出发时 prompt 未提示「模式 A」')
if (sysSat.indexOf('住宿 1 晚') < 0) bad.push('周六出发时 prompt 未说明「目的地住宿 1 晚」')
const sysFri = ai.buildMessages(bundle, { destination: '北京', departure_window: 'fri_night' }, null)[0].content
if (sysFri.indexOf('模式 B') < 0) bad.push('周五晚出发时 prompt 未提示「模式 B」')
if (sysFri.indexOf('车上过一夜') < 0) bad.push('周五晚出发时 prompt 未说明「车上过一夜」')
;['绝对禁止编造高铁车次号', '21:00', '每个地点都必须可导航', '禁停', '共享单车', '网约车',
'本地人爱去', '评分好', '夜逛', '19:00-21:00', '必须安排在 17:30-18:45'].forEach((k) => {
if (sysSat.indexOf(k) < 0) bad.push('system prompt 缺少关键约束:' + k)
})
// v1.1.0:骨架里必须有「周六下午白天景点」与「夜逛」两个槽位
;['spot_d1', 'night_stroll'].forEach((a) => {
if (anchorIds.indexOf(a) < 0) bad.push('骨架缺 v1.1.0 新增槽位:' + a)
})
// 白天景点必须是白天开放(不得出现「夜间开放」)
const spotD1 = skill.anchors.find((a) => a.id === 'spot_d1') || {}
if (JSON.stringify(spotD1).indexOf('白天开放') < 0) bad.push('spot_d1 未强调「白天开放」')
const strollAnchor = skill.anchors.find((a) => a.id === 'night_stroll') || {}
if (JSON.stringify(strollAnchor).indexOf('自由活动') < 0) bad.push('night_stroll 未强调「自由活动」性质')
if (JSON.stringify(skill.anchors).indexOf('19:00-21:00') < 0) bad.push('骨架未写明夜逛时段 19:00-21:00')
info.push(`prompt 组装:周六模式 ${sysSat.length} 字符 / 周五晚模式 ${sysFri.length} 字符,两条模式提示与 v1.1.0 新约束均正确`)
/* ---------- 6. 离线样例:必须过校验器且 0 errors ---------- */
const sampleDir = path.join(FE, 'sample')
const sampleFiles = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
if (!sampleFiles.length) bad.push('sample/ 下没有任何样例 payload')
sampleFiles.forEach((f) => {
const p = readJson(path.join(sampleDir, f))
const chk = validatePayload(p, bundle)
if (chk.errors.length) bad.push(`样例 ${f} 未通过校验器:${chk.errors.slice(0, 5).join(' / ')}`)
/* 6a.【核心】样例里不得出现车次号 */
const raw = JSON.stringify(p)
const hits = raw.match(/[GDCTZK]\d{3,4}/g)
if (hits) bad.push(`样例 ${f} 出现疑似编造的车次号:${Array.from(new Set(hits)).join(',')}`)
/* 6b.【核心】点位地址全覆盖 */
const markers = (p.map && p.map.markers) || []
const noAddr = markers.filter((m) => !m || !String(m.address || '').trim())
if (noAddr.length) bad.push(`样例 ${f} 有 ${noAddr.length} 个点位缺 address:${noAddr.map((m) => m && m.id).join(',')}`)
const shortAddr = markers.filter((m) => m && String(m.address || '').trim().length < 6)
if (shortAddr.length) bad.push(`样例 ${f} 有 ${shortAddr.length} 个点位地址过短(无法定位)`)
/* 6c. 卡片建议带「导航」字段 */
const cards = p.cards || []
const noNav = cards.filter((c) => !(c.fields || []).some((f) => String(f.label || '').indexOf('导航') >= 0))
if (noNav.length) bad.push(`样例 ${f} 有 ${noNav.length} 张卡片未给「导航」字段:${noNav.map((c) => c.anchor_id).join(',')}`)
/* 6d.【核心】返程死线提示必须存在 */
const notes = p.notes || []
const hasDeadline = notes.some((n) => n && (n.level === 'warn' || n.level === 'danger')
&& String(n.text || '').indexOf('21:00') >= 0)
if (!hasDeadline) bad.push(`样例 ${f} 缺少「21:00 抵沪」的 warn/danger 提示`)
/* 6e. 骨架槽位全覆盖 */
const cardAnchors = cards.map((c) => c.anchor_id)
const missing = anchorIds.filter((a) => cardAnchors.indexOf(a) < 0)
if (missing.length) bad.push(`样例 ${f} 卡片缺槽位:${missing.join(',')}`)
/* 6f. 车次表里不得出现车次号列 */
;(p.tables || []).forEach((t) => {
if (['train_out_options', 'train_back_options'].indexOf(t.id) < 0) return
;(t.columns || []).forEach((c) => {
const lab = String(c.label || c.key || '')
if (lab.indexOf('车次') >= 0 && lab.indexOf('时段') < 0) {
bad.push(`样例 ${f} 的车次表出现车次号列:${t.id}.${c.key}`)
}
})
})
/* 6g.【核心 v1.1.0】周六晚必须有夜逛,且落在 19:00 之后 */
const d1Rows = ((p.timeline && p.timeline.days) || [])
.filter((d) => d && String(d.label || '').indexOf('第一天') >= 0)
.reduce((acc, d) => acc.concat(d.rows || []), [])
const strollRows = d1Rows.filter((r) => r && r.anchor_id === 'night_stroll')
if (!strollRows.length) bad.push(`样例 ${f} 第一天缺夜逛行(night_stroll)`)
else {
const late = strollRows.some((r) => {
const m = String(r.time || '').match(/(\d{1,2}):(\d{2})/)
return m && (Number(m[1]) * 60 + Number(m[2])) >= 18 * 60 + 30
})
if (!late) bad.push(`样例 ${f} 夜逛时间早于 18:30:${strollRows.map((r) => r.time).join(' / ')}`)
if (!strollRows.some((r) => r.activity === 'stroll')) {
bad.push(`样例 ${f} 夜逛行的 activity 应为 stroll:${strollRows.map((r) => String(r.activity || '未填')).join(' / ')}`)
}
}
// 晚饭须在 19:00 前
const dinnerRows = d1Rows.filter((r) => r && r.anchor_id === 'dinner_d1')
if (dinnerRows.length && !dinnerRows.some((r) => {
const m = String(r.time || '').match(/(\d{1,2}):(\d{2})/)
return m && (Number(m[1]) * 60 + Number(m[2])) <= 19 * 60
})) {
bad.push(`样例 ${f} 晚饭时间晚于 19:00,会挤压夜逛:${dinnerRows.map((r) => r.time).join(' / ')}`)
}
/* 6h.【核心 v1.1.0】午饭与晚饭卡片必须给出「评分」与「本地」依据 */
;['lunch_d1', 'dinner_d1'].forEach((aid) => {
const card = cards.find((c) => c && c.anchor_id === aid)
if (!card) return
const txt = JSON.stringify(card)
if (txt.indexOf('评分') < 0) bad.push(`样例 ${f} 的 ${aid} 卡片未给出餐馆评分`)
if (txt.indexOf('本地') < 0) bad.push(`样例 ${f} 的 ${aid} 卡片未给出「本地人爱去」依据`)
})
info.push(`样例 ${f}:0 errors / ${chk.warnings.length} warnings,卡片 ${cards.length},` +
`点位 ${markers.length}(地址覆盖 ${chk.stats.addressCovered}),无车次号,含夜逛与餐馆评分`)
})
/* ---------- 6i.【回归】校验器不得依赖 day.label 的文案 ----------
背景(真实事故):v1.1.0 的校验器写成「找 label 含『第一天』的那一天」来判断第一天,
而 AI 把两天标成了「周六 / 周日」,find 返回 undefined,于是明明排了夜逛却被误判为
「缺夜逛」→ 整份 4 分钟生成被丢弃(payload_invalid)。这里把「改 label 不改变判定结果」
固化成回归断言,防止再犯。 */
try {
const base = JSON.parse(fs.readFileSync(path.join(sampleDir, sampleFiles[0]), 'utf-8'))
const variants = [
['周六/周日', ['周六', '周日']],
['第一天/第二天', ['第一天', '第二天']],
['Day1/Day2', ['Day1', 'Day2']],
['空 label', ['', '']]
]
variants.forEach(([name, labels]) => {
const p = JSON.parse(JSON.stringify(base))
;((p.timeline || {}).days || []).forEach((d, i) => { d.label = labels[i] !== undefined ? labels[i] : d.label })
const chk = validatePayload(p, bundle)
if (chk.errors.length) {
bad.push(`[回归] day.label 改成「${name}」后校验出现 error(说明校验器又依赖了 label 文案):${chk.errors.join(' / ')}`)
}
})
info.push('回归:day.label 换成 周六/周日、第一天/第二天、Day1/Day2、空值 四种写法,校验结果均一致(不依赖 label 文案)')
} catch (e) {
bad.push('[回归] day.label 无关性测试失败:' + String(e.message || e))
}
/* ---------- 6j.【回归】清单 confidence 越界(high)能被修复器降级 ---------- */
try {
const dirty = JSON.parse(JSON.stringify(readJson(path.join(sampleDir, sampleFiles[0]))))
dirty.checklist[0].confidence = 'high'
const before = validatePayload(dirty, bundle).warnings.filter((w) => w.indexOf('confidence 应为 medium/low') >= 0)
if (!before.length) bad.push('[回归] 清单项标 high 时校验器未报警')
const fixed = repairPayload(dirty, bundle)
if (fixed.payload.checklist[0].confidence !== 'medium') {
bad.push('[回归] 修复器未把清单项的 high 降级为 medium(实际:' + fixed.payload.checklist[0].confidence + ')')
} else {
const after = validatePayload(fixed.payload, bundle).warnings.filter((w) => w.indexOf('confidence 应为 medium/low') >= 0)
if (after.length) bad.push('[回归] 修复后清单仍报 confidence 越界')
info.push('回归:清单项 confidence=high 会被校验器报警、被修复器降级为 medium(' + fixed.fixes.length + ' 条修复)')
}
} catch (e) {
bad.push('[回归] 清单 confidence 修复测试失败:' + String(e.message || e))
}
/* ---------- 7. 修复器:本技能专属的「返程死线」补齐 ---------- */
try {
const bare = { notes: [] }
const r = repairPayload(bare, bundle)
const added = (r.payload.notes || []).some((n) => n && n.level === 'danger' && String(n.text).indexOf('21:00') >= 0)
if (!added) bad.push('repair.js 未能在缺失时补齐「21:00 抵沪」的 danger 提示')
else info.push('修复器:缺失时能自动补齐返程死线提示(' + r.fixes.length + ' 条修复)')
} catch (e) {
bad.push('修复器执行异常:' + String(e.message || e))
}
/* ---------- 8. 导出器:用样例跑一遍,产物必须干净且含导航信息 ---------- */
try {
const exporter = require(path.join(FE, 'tc_export.js'))
const sample = readJson(path.join(sampleDir, sampleFiles[0]))
const sampleSpec = readJson(path.join(sampleDir, 'report_spec.json'))
const css = fs.readFileSync(path.join(FE, 'tc_export.css'), 'utf-8')
const html = exporter.build(sample, sampleSpec, { css, meta: sample.meta, exportedAt: '2026-09-30 11:00:00' })
const cnt = (s, n) => s.split(n).length - 1
const checks = [
['产出完整文档', html.startsWith('<!DOCTYPE html>') && html.trim().endsWith('</html>')],
['无 <script>(导出件是静态的)', html.indexOf('<script') < 0],
['无 undefined / [object Object]', html.indexOf('undefined') < 0 && html.indexOf('[object Object]') < 0],
['无编造车次号', !/[GDCTZK]\d{3,4}/.test(html)],
['含高德搜索链接', html.indexOf('uri.amap.com') > 0],
['含百度搜索链接', html.indexOf('map.baidu.com') > 0],
['点位地址全部摊开', cnt(html, 'class="point-addr"') === sample.map.markers.length],
['返程死线提示在', html.indexOf('21:00 前必须抵达上海') > 0],
['样式已内联', html.indexOf('<style>') > 0 && html.indexOf('.hero{') > 0],
['区块数 = report_spec.sections 数 - 1', cnt(html, '<section class="section"') === sampleSpec.sections.length - 1]
]
const failed = checks.filter(([, ok]) => !ok).map(([n]) => n)
if (failed.length) bad.push('导出器验证未通过:' + failed.join(' / '))
else info.push(`导出器:产物 ${Math.round(html.length / 1024)} KB,${checks.length} 项断言全通过(含导航链接与无车次号)`)
} catch (e) {
bad.push('导出器验证异常:' + String(e.message || e))
}
/* ---------- 汇总 ---------- */
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')