313 lines
19 KiB
JavaScript
313 lines
19 KiB
JavaScript
/* ============================================================
|
||
* 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') |