/* ============================================================ * 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('') && html.trim().endsWith('')], ['无