feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/* ============================================================
|
||||
* test_travel_citytour_export.js —— 游山玩水 · 单文件 HTML 导出器单元测试
|
||||
*
|
||||
* 测什么:tc_export.js 的 build() 是纯字符串拼装(不碰 DOM),
|
||||
* 所以可以在 Node 里直接跑,把导出的 HTML 当成字符串做结构断言。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. report_spec 为空时必须抛错(否则又会出现「静默留白」)
|
||||
* 2. 12 个区块、hero、目录、卡片数、表格 pick_id、清单、地图 SVG、页脚 都要在
|
||||
* 3. 不得出现 undefined / [object Object] / <script(导出文档是静态的)
|
||||
* 4. 标签配平(section / table / ol 等)
|
||||
* 5. 有配图的样例(如东)不能崩,未内嵌时降级为占位块
|
||||
* 6. tc_export.css 必须覆盖导出文档用到的关键类名
|
||||
*
|
||||
* 运行: node dev_test_scripts/unit/test_travel_citytour_export.js
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..')
|
||||
// 技能全栈隔离:导出器与其样式、样例、照片、生成结果都在 skills/<skill_id>/ 内
|
||||
const SKILL_ID = 'sh_2d_car_hike'
|
||||
const SKILL_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
|
||||
const SKILL_SERVER = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
|
||||
|
||||
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
|
||||
const exporter = require(path.join(SKILL_PUBLIC, 'tc_export.js'))
|
||||
|
||||
let pass = 0
|
||||
const fails = []
|
||||
function ok(name, cond, extra) {
|
||||
if (cond) { pass++; console.log(' ✔ ' + name) }
|
||||
else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✘ ' + name + (extra ? ' → ' + extra : '')) }
|
||||
}
|
||||
function count(hay, needle) {
|
||||
let n = 0, i = 0
|
||||
while (true) {
|
||||
const j = hay.indexOf(needle, i)
|
||||
if (j < 0) break
|
||||
n++; i = j + needle.length
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const CSS = fs.readFileSync(path.join(SKILL_PUBLIC, 'tc_export.css'), 'utf-8')
|
||||
|
||||
/* ---------- 1. 空 spec 必须抛错 ---------- */
|
||||
console.log('\n[1] 空 report_spec')
|
||||
let threw = false
|
||||
try { exporter.build({ summary: { title: 'x' } }, {}, { css: CSS }) } catch (e) { threw = true }
|
||||
ok('sections 缺失时主动抛错(不静默留白)', threw)
|
||||
|
||||
/* ---------- 2. 真实结果:结构完整性 ---------- */
|
||||
console.log('\n[2] 真实生成结果(last_result.json)')
|
||||
const env = readJson(path.join(SKILL_SERVER, 'data', 'last_result.json'))
|
||||
const spec = readJson(path.join(SKILL_SERVER, 'report_spec.json'))
|
||||
const payload = env.payload
|
||||
|
||||
const html = exporter.build(payload, spec, {
|
||||
css: CSS,
|
||||
meta: env.meta,
|
||||
exportedAt: '2026-09-29 15:30:00'
|
||||
})
|
||||
|
||||
ok('产出完整 HTML 文档', html.startsWith('<!DOCTYPE html>') && html.trim().endsWith('</html>'))
|
||||
ok('lang=zh-CN', html.indexOf('<html lang="zh-CN">') > 0)
|
||||
ok('标题取自 payload.summary.title', html.indexOf('<title>' + payload.summary.title + ' · 行程报告</title>') > 0)
|
||||
ok('样式已内联(<style> 含 .hero)', html.indexOf('<style>') > 0 && html.indexOf('.hero{') > 0)
|
||||
|
||||
const specSections = spec.sections
|
||||
ok('区块数 = report_spec.sections 数(' + specSections.length + ')', count(html, '<section class="section"') === specSections.length - 1,
|
||||
'实际 ' + count(html, '<section class="section"'))
|
||||
let allIds = true
|
||||
const missIds = []
|
||||
specSections.forEach((s) => {
|
||||
const id = s.id
|
||||
if (html.indexOf('id="' + id + '"') < 0) { allIds = false; missIds.push(id) }
|
||||
})
|
||||
ok('每个区块 id 都落地', allIds, missIds.join(','))
|
||||
|
||||
ok('overview 渲染成 hero 头图', html.indexOf('<header class="hero" id="sec_overview">') > 0)
|
||||
ok('hero 里有标题/一句话/标签/统计', html.indexOf('<h1>' + payload.summary.title + '</h1>') > 0 &&
|
||||
html.indexOf('class="lede"') > 0 && html.indexOf('class="hero-tags"') > 0 && html.indexOf('class="hero-stats"') > 0)
|
||||
ok('hero meta 徽标(模型/耗时/校验)', html.indexOf('模型 ' + env.meta.model) > 0 && html.indexOf('耗时 ') > 0 &&
|
||||
html.indexOf('结构校验通过') > 0)
|
||||
|
||||
ok('目录锚点数 = 有标题的区块数', count(html, 'class="index"') === 1 &&
|
||||
count(html, '<div class="index"') === 0 && count(html, '<a href="#sec_') === specSections.filter((s) => s.title).length,
|
||||
'实际 ' + count(html, '<a href="#sec_') + ' / 期望 ' + specSections.filter((s) => s.title).length)
|
||||
|
||||
ok('卡片数 = payload.cards 数(' + payload.cards.length + ')', count(html, '<article class="card"') === payload.cards.length,
|
||||
'实际 ' + count(html, '<article class="card"'))
|
||||
|
||||
const pickIds = specSections.filter((s) => s.pick_id).map((s) => s.pick_id)
|
||||
const missTables = pickIds.filter((id) => html.indexOf('缺少表格:' + id) >= 0)
|
||||
ok('4 张按 pick_id 引用的表格全部命中', missTables.length === 0, missTables.join(','))
|
||||
|
||||
ok('待核实清单条数 = payload.checklist 数(' + payload.checklist.length + ')',
|
||||
count(html, '<li class="check-item">') === payload.checklist.length,
|
||||
'实际 ' + count(html, '<li class="check-item">'))
|
||||
|
||||
ok('地图内联 SVG(不依赖 Leaflet)', html.indexOf('<svg class="map-svg"') > 0 && html.indexOf('leaflet') < 0)
|
||||
ok('地图点位数 = payload.map.markers 数(' + payload.map.markers.length + ')',
|
||||
count(html, '<circle ') === payload.map.markers.length, '实际 ' + count(html, '<circle '))
|
||||
ok('地图线条数 > 0(路线已连线)', count(html, '<line ') > 0)
|
||||
ok('地图带图例与免责说明', html.indexOf('class="legend"') > 0 &&
|
||||
html.indexOf(payload.map.disclaimer || spec.map_route_disclaimer_default) > 0)
|
||||
ok('地图点位清单(.points)条数与点位数一致', count(html, '<div class="point">') === payload.map.markers.length)
|
||||
|
||||
ok('费用合计行渲染', html.indexOf('<tfoot>') > 0 && html.indexOf('合计') > 0)
|
||||
ok('页脚含导出时间与免责', html.indexOf('2026-09-29 15:30:00') > 0 && html.indexOf('出行前请自行核实') > 0)
|
||||
|
||||
/* ---------- 3. 静态 & 干净 ---------- */
|
||||
console.log('\n[3] 导出文档是静态的、无脏数据')
|
||||
ok('不含 <script>', html.indexOf('<script') < 0)
|
||||
ok('不含 <link rel="stylesheet">(样式必须内联)', html.indexOf('<link') < 0)
|
||||
ok('不含 undefined', html.indexOf('undefined') < 0)
|
||||
ok('不含 [object Object]', html.indexOf('[object Object]') < 0)
|
||||
ok('不含 null 字面量渲染', html.indexOf('>null<') < 0)
|
||||
|
||||
const pairs = [
|
||||
['<section class="section"', '</section>'],
|
||||
['<article class="card"', '</article>'],
|
||||
['<table class="tbl">', '</table>'],
|
||||
['<div class="map-wrap">', '</div>']
|
||||
]
|
||||
pairs.forEach(([open, close]) => {
|
||||
ok('标签配平:' + open.replace(/[<>=]/g, '').slice(0, 24), count(html, open) <= count(html, close))
|
||||
})
|
||||
|
||||
/* ---------- 4. 有配图的样例(如东):不能崩,且降级为占位块 ---------- */
|
||||
console.log('\n[4] 带配图的离线样例(如东)')
|
||||
const sample = readJson(path.join(SKILL_PUBLIC, 'sample', 'rudong_2026-09-19.json'))
|
||||
const sampleSpec = readJson(path.join(SKILL_PUBLIC, 'sample', 'report_spec.json'))
|
||||
const sampleHtml = exporter.build(sample, sampleSpec, { css: CSS, meta: sample.meta })
|
||||
|
||||
ok('样例可正常导出', sampleHtml.startsWith('<!DOCTYPE html>') && sampleHtml.length > 5000)
|
||||
const sampleImgs = exporter.collectImageUrls(sample)
|
||||
ok('识别出样例配图(' + sampleImgs.length + ' 张)', sampleImgs.length > 0, '实际 ' + sampleImgs.length)
|
||||
ok('未内嵌时降级为占位块(不出现断图 <img>)',
|
||||
sampleHtml.indexOf('配图未内嵌') > 0 && sampleHtml.indexOf('<img src="../assets') < 0)
|
||||
ok('图片署名/免责说明仍在', sampleHtml.indexOf('AI 示意图,非实景') > 0)
|
||||
|
||||
const withEmbedded = exporter.build(sample, sampleSpec, {
|
||||
css: CSS, images: { '../assets/photos/rudong_cover.jpg': 'data:image/jpeg;base64,AAAA' }
|
||||
})
|
||||
ok('内嵌成功后使用 data URI', withEmbedded.indexOf('src="data:image/jpeg;base64,AAAA"') > 0)
|
||||
|
||||
/* ---------- 5. 文件名 ---------- */
|
||||
console.log('\n[5] 导出文件名')
|
||||
ok('文件名 = 标题 + 行程报告.html', exporter.defaultFilename(payload) === payload.summary.title + '-行程报告.html',
|
||||
exporter.defaultFilename(payload))
|
||||
ok('非法字符被清掉', exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }) === 'a b c d-行程报告.html',
|
||||
exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }))
|
||||
ok('无标题时兜底', exporter.defaultFilename({}) === '行程报告-行程报告.html', exporter.defaultFilename({}))
|
||||
|
||||
/* ---------- 6. 样式覆盖 ---------- */
|
||||
console.log('\n[6] tc_export.css 覆盖导出用到的关键类名')
|
||||
const needClasses = [
|
||||
'.hero', '.kicker', '.hero-stats', '.index', '.section', '.sec-title', '.note-warn', '.note-info',
|
||||
'.note-danger', '.tablebox', 'table.tbl', '.row-hard', '.row-hl', '.col-time', '.col-kind', '.kind-act',
|
||||
'.kind-hard', '.badge-high', '.badge-medium', '.badge-low', '.badge-info', '.day', '.group', '.card-grid',
|
||||
'.card-title', '.card-sub', '.card-badges', '.field', '.field-label', '.field-value', '.verify', '.bullets',
|
||||
'.figcap', '.img-missing', '.map-wrap', '.map-svg', '.legend', '.swatch', '.map-note', '.points', '.point',
|
||||
'.checklist', '.check-item', '.doc-foot'
|
||||
]
|
||||
const missCss = needClasses.filter((c) => CSS.indexOf(c) < 0)
|
||||
ok('关键类名全部有样式', missCss.length === 0, missCss.join(','))
|
||||
|
||||
/* ---------- 汇总 ---------- */
|
||||
console.log('\n============================')
|
||||
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项')
|
||||
if (fails.length) {
|
||||
console.log('\n失败明细:')
|
||||
fails.forEach((f) => console.log(' - ' + f))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('导出的 HTML 体积约 ' + Math.round(html.length / 1024) + ' KB(含内联样式)')
|
||||
@@ -0,0 +1,426 @@
|
||||
/* ============================================================
|
||||
* test_travel_citytour_isolation.js —— 「游山玩水」skill 全栈隔离校验
|
||||
*
|
||||
* 为什么需要本脚本:
|
||||
* 本 lab 的架构约定是「skill 全栈隔离」——每个 skill 在 skills/<skill_id>/ 下自带一整套
|
||||
* 后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/
|
||||
* ⬆图/照片),彼此零耦合。这个约定光靠注释和口头说明必然会被后续开发侵蚀(有人图省事
|
||||
* 直接 require 兄弟 skill 或引用 lab 层业务文件),所以这里把它变成「失败即挡」的硬校验。
|
||||
*
|
||||
* 自动覆盖未来新增的 skill:
|
||||
* 本脚本不写死任何 skill_id,而是通过 lab 的 registry 枚举当前所有 skill,
|
||||
* 逐个断言。所以以后每加一个 skill,这套校验自动生效,无需改本文件。
|
||||
*
|
||||
* 校验项(对每个 skill):
|
||||
* A. 后端:skill 目录内所有 .js 不得 require 出本目录(禁止 '../')
|
||||
* B. 前端:skill 目录内所有 .html/.js/.css 的相对引用,解析后必须落在
|
||||
* 「本 skill 目录内」或「lab 级 assets/vendor/」;其余一律违规
|
||||
* (即:禁止引用兄弟 skill、禁止引用 lab 层业务文件/配置/照片)
|
||||
* C. 前端:不得通过绝对路径引用 lab 业务文件(只允许 /api/... 接口前缀)
|
||||
* D. 契约:config.json / index.js(导出 bindRoutes)/ index.html 三者齐全且可加载
|
||||
* E. 运行时:后端能 require、bindRoutes 能注册出预期路由、prompt 能组装、校验器不抛异常
|
||||
*
|
||||
* 运行:node dev_test_scripts/unit/test_travel_citytour_isolation.js
|
||||
* 退出码:0 = 全部通过;1 = 存在违规
|
||||
* ============================================================ */
|
||||
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 LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
|
||||
const BE_SKILLS = path.join(LAB_DIR, 'skills')
|
||||
const FE_SKILLS = path.join(LAB_PUBLIC, 'skills')
|
||||
|
||||
// 唯一允许 skill 向上引用的位置:lab 级第三方库(无业务语义)
|
||||
const ALLOWED_UP_REF_PREFIX = path.join(LAB_PUBLIC, 'assets', 'vendor')
|
||||
|
||||
const bad = []
|
||||
const info = []
|
||||
|
||||
const registry = require(path.join(LAB_DIR, 'index.js')).constructor ? require(path.join(LAB_DIR, 'registry.js')) : require(path.join(LAB_DIR, 'registry.js'))
|
||||
const { makeCtx } = require(path.join(LAB_DIR, 'index.js'))
|
||||
|
||||
const rel = (p) => path.relative(ROOT, p)
|
||||
|
||||
/* 递归收集目录下的文件(跳过 data / node_modules / .gitkeep) */
|
||||
const walkFiles = (dir, exts, out, skipDirs) => {
|
||||
out = out || []
|
||||
skipDirs = skipDirs || ['data', 'node_modules', '.git']
|
||||
if (!fs.existsSync(dir)) return out
|
||||
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
|
||||
if (e.isDirectory()) {
|
||||
if (skipDirs.indexOf(e.name) >= 0) return
|
||||
walkFiles(path.join(dir, e.name), exts, out, skipDirs)
|
||||
return
|
||||
}
|
||||
if (exts.indexOf(path.extname(e.name)) >= 0) out.push(path.join(dir, e.name))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- A. 后端:禁止 require 出本 skill 目录 ---------- */
|
||||
const checkBackendIsolation = (skillId) => {
|
||||
const dir = path.join(BE_SKILLS, skillId)
|
||||
walkFiles(dir, ['.js']).forEach((file) => {
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
// 去掉注释行,避免把「隔离说明」里提到的 ../ 误判成真实引用
|
||||
const code = txt.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
|
||||
const m = code.match(/require\(\s*['"]\.\.?\/(\.\.\/)*[^'"]+['"]\s*\)/g) || []
|
||||
m.forEach((hit) => {
|
||||
if (/require\(\s*['"]\.\//.test(hit)) return // './xxx' 同目录,允许
|
||||
bad.push(`[后端隔离] ${rel(file)} 出现向上越级 require:${hit}(skill 后端只能 require 本目录文件或 npm 包)`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- B/C. 前端:相对引用与绝对引用必须落在允许范围 ---------- */
|
||||
const REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']|fetch\(\s*['"]([^'"]+)['"]/g
|
||||
|
||||
// 两条【契约要求】的导航例外(不是业务耦合,必须放行):
|
||||
// 1. 站点首页 '/' —— 401 引导面板里的「重新从首页进入」按钮,指到工具箱根
|
||||
// 2. lab 聚合页 <lab>/index.html —— 每个 skill 页面都要求有「← 返回技能列表」
|
||||
const LAB_AGG_PAGE = path.join(LAB_PUBLIC, 'index.html')
|
||||
|
||||
const checkFrontendIsolation = (skillId) => {
|
||||
const skillRoot = path.join(FE_SKILLS, skillId)
|
||||
walkFiles(skillRoot, ['.html', '.js', '.css'], null, ['node_modules', '.git']).forEach((file) => {
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
REF_RE.lastIndex = 0
|
||||
let hit
|
||||
while ((hit = REF_RE.exec(txt)) !== null) {
|
||||
const ref = String(hit[1] || hit[2] || '').trim()
|
||||
if (!ref) continue
|
||||
if (/^(https?:)?\/\//.test(ref) || ref.indexOf('data:') === 0) continue // 外链/内联,放过
|
||||
if (ref === '/') continue // 例外 1:站点首页导航
|
||||
|
||||
if (ref.charAt(0) === '/') {
|
||||
// 绝对路径:只允许接口前缀,不允许指向 lab 业务文件
|
||||
if (ref.indexOf('/api/') !== 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 出现绝对路径引用:${ref}(只允许 /api/... 接口前缀)`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ref.charAt(0) === '#') continue
|
||||
|
||||
// 相对路径:解析后必须落在「本 skill 目录内」或「lab 级 assets/vendor/」
|
||||
const resolved = path.resolve(path.dirname(file), ref.split('?')[0].split('#')[0])
|
||||
if (resolved === LAB_AGG_PAGE) continue // 例外 2:返回聚合页
|
||||
const insideSkill = resolved === skillRoot || resolved.indexOf(skillRoot + path.sep) === 0
|
||||
const insideVendor = resolved === ALLOWED_UP_REF_PREFIX || resolved.indexOf(ALLOWED_UP_REF_PREFIX + path.sep) === 0
|
||||
if (!insideSkill && !insideVendor) {
|
||||
// 同 lab 但落在其他位置(兄弟 skill / lab 业务文件 / lab 配置)→ 违规
|
||||
if (resolved.indexOf(LAB_PUBLIC + path.sep) === 0 || resolved.indexOf(LAB_DIR + path.sep) === 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}`
|
||||
+ `(只允许本 skill 目录内,或 lab 级 ${rel(ALLOWED_UP_REF_PREFIX)}/)`)
|
||||
}
|
||||
// 完全跳出 lab 目录的(指向其他 tool / lab)也算违规
|
||||
else if (resolved.indexOf(path.join(ROOT, 'public', 'tools') + path.sep) === 0
|
||||
|| resolved.indexOf(path.join(ROOT, 'src') + path.sep) === 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}(禁止跨 tool / lab 引用)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 与兄弟 skill 的交叉引用(代码层面,注释不算) ---------- */
|
||||
const checkSiblingRefs = (skillId, allIds) => {
|
||||
const siblings = allIds.filter((x) => x !== skillId)
|
||||
if (!siblings.length) return
|
||||
const scan = (dir, exts) => walkFiles(dir, exts).forEach((file) => {
|
||||
const code = fs.readFileSync(file, 'utf-8')
|
||||
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*|<!--)/.test(l)).join('\n')
|
||||
siblings.forEach((sib) => {
|
||||
// 只查「像引用」的用法(路径/字符串字面量),不看注释
|
||||
const re = new RegExp('[\'"`/]' + sib + '[/\'"`]', 'g')
|
||||
if (re.test(code)) {
|
||||
bad.push(`[兄弟隔离] ${rel(file)} 代码中引用了兄弟 skill「${sib}」(skill 之间不得互相引用)`)
|
||||
}
|
||||
})
|
||||
})
|
||||
scan(path.join(BE_SKILLS, skillId), ['.js', '.json'])
|
||||
scan(path.join(FE_SKILLS, skillId), ['.js', '.html', '.json'])
|
||||
}
|
||||
|
||||
/* ---------- D/E. 契约与运行时 ---------- */
|
||||
const checkContractAndRuntime = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
const feDir = path.join(FE_SKILLS, skillId)
|
||||
const need = [
|
||||
[path.join(beDir, 'config.json'), '后端 config.json'],
|
||||
[path.join(beDir, 'index.js'), '后端 index.js'],
|
||||
[path.join(feDir, 'index.html'), '前端 index.html']
|
||||
]
|
||||
need.forEach(([p, label]) => {
|
||||
if (!fs.existsSync(p)) bad.push(`[契约] ${skillId} 缺 ${label}`)
|
||||
})
|
||||
if (bad.some((b) => b.indexOf('[契约] ' + skillId) === 0)) return
|
||||
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(beDir, 'config.json'), 'utf-8'))
|
||||
if (String(cfg.id || '') !== skillId) bad.push(`[契约] ${skillId} 的 config.json id 与目录名不一致:${cfg.id}`)
|
||||
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
if (typeof mod.bindRoutes !== 'function') {
|
||||
bad.push(`[契约] ${skillId} 的 index.js 未导出 bindRoutes`)
|
||||
return
|
||||
}
|
||||
// 后端不得自行实现鉴权(红线:鉴权必须由 lab 层通过 Router 注入)
|
||||
const code = fs.readFileSync(path.join(beDir, 'index.js'), 'utf-8')
|
||||
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
|
||||
;['nav_gate', 'parseCookie', 'verifyJwt', 'guidValue', 'DASHSCOPE_API_KEY'].forEach((k) => {
|
||||
if (code.indexOf(k) >= 0) {
|
||||
bad.push(`[红线] ${skillId}/index.js 出现「${k}」——鉴权/凭据必须由 lab 层 ctx 提供,skill 不得自行实现`)
|
||||
}
|
||||
})
|
||||
const routes = []
|
||||
const fakeRouter = { get: (p) => routes.push('GET ' + p), post: (p) => routes.push('POST ' + p), use: () => {} }
|
||||
mod.bindRoutes(fakeRouter, makeCtx(skillId))
|
||||
if (!routes.length) bad.push(`[契约] ${skillId} 的 bindRoutes 未注册任何路由`)
|
||||
info.push(`${skillId}:后端路由 ${routes.join(' / ')}`)
|
||||
} catch (e) {
|
||||
bad.push(`[契约] ${skillId} 后端加载失败:${String(e.message || e)}`)
|
||||
}
|
||||
|
||||
// 深度检查(可选):ai.js / validate.js 不是 lab 强制契约,只是两个已有 skill 的
|
||||
// 约定俗成的拆分方式。新 skill 完全可以不拆(把逻辑都写在 index.js 里)。
|
||||
// 所以这里只在「文件存在」时验证其可用性;不存在则跳过,避免把约定当强制而误报。
|
||||
const hasAi = fs.existsSync(path.join(beDir, 'ai.js'))
|
||||
const hasValidate = fs.existsSync(path.join(beDir, 'validate.js'))
|
||||
if (!hasAi && !hasValidate) {
|
||||
info.push(`${skillId}:未拆分 ai.js/validate.js(允许;lab 强制契约只有 config.json + index.js + index.html)`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
if (hasAi) {
|
||||
const aiMod = require(path.join(beDir, 'ai.js'))
|
||||
if (typeof mod.loadBundle === 'function' && typeof aiMod.buildMessages === 'function') {
|
||||
const b = mod.loadBundle()
|
||||
const msgs = aiMod.buildMessages(b, { destination: '测试地' }, null)
|
||||
if (!msgs || msgs.length !== 2) bad.push(`[运行时] ${skillId} buildMessages 未产出 system+user 两条消息`)
|
||||
info.push(`${skillId}:prompt ${msgs[0].content.length} 字符`)
|
||||
}
|
||||
}
|
||||
if (hasValidate) {
|
||||
const { validatePayload } = require(path.join(beDir, 'validate.js'))
|
||||
if (typeof mod.loadBundle === 'function' && typeof validatePayload === 'function') {
|
||||
const chk = validatePayload({}, mod.loadBundle())
|
||||
if (!chk.errors.length) bad.push(`[运行时] ${skillId} 校验器对空 payload 未报错(校验形同虚设)`)
|
||||
info.push(`${skillId}:空 payload 报 ${chk.errors.length} 个 error`)
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(path.join(beDir, 'repair.js'))) require(path.join(beDir, 'repair.js'))
|
||||
} catch (e) {
|
||||
bad.push(`[运行时] ${skillId} 引擎加载失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- F. 离线样例(若存在)必须通过本 skill 自己的校验器 ---------- */
|
||||
const checkSamples = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
const sampleDir = path.join(FE_SKILLS, skillId, 'sample')
|
||||
if (!fs.existsSync(sampleDir)) return
|
||||
const files = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
|
||||
if (!files.length) return
|
||||
// 样例校验依赖本 skill 的 validate.js(可选拆分);没拆就跳过,不视为错误
|
||||
if (!fs.existsSync(path.join(beDir, 'validate.js'))) {
|
||||
info.push(`${skillId}:有样例但未拆分 validate.js,跳过样例校验`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
const { validatePayload } = require(path.join(beDir, 'validate.js'))
|
||||
const b = mod.loadBundle()
|
||||
files.forEach((f) => {
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(sampleDir, f), 'utf-8'))
|
||||
const chk = validatePayload(payload, b)
|
||||
if (chk.errors.length) {
|
||||
bad.push(`[样例] ${skillId}/sample/${f} 未通过本 skill 校验器:${chk.errors.slice(0, 4).join(' / ')}`)
|
||||
} else {
|
||||
info.push(`${skillId}:样例 ${f} 校验通过(${chk.warnings.length} warnings,` +
|
||||
`卡片 ${chk.stats.cards} / 点位 ${chk.stats.markers}` +
|
||||
(chk.stats.addressCovered === undefined ? '' : ` / 地址覆盖 ${chk.stats.addressCovered}`) + ')')
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[样例] ${skillId} 样例校验失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- G. destinations.json 的 sample_ref 必须相对【本 skill 前端目录】可解析 ----------
|
||||
这是隔离后修正过的口径:以前 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。 */
|
||||
const checkSampleRefs = (skillId) => {
|
||||
const beDest = path.join(BE_SKILLS, skillId, 'destinations.json')
|
||||
if (!fs.existsSync(beDest)) return
|
||||
const feSkillDir = path.join(FE_SKILLS, skillId)
|
||||
try {
|
||||
const dest = JSON.parse(fs.readFileSync(beDest, 'utf-8'))
|
||||
;(dest.destinations || []).forEach((d) => {
|
||||
;((d && d.runs) || []).forEach((r) => {
|
||||
if (!r || !r.sample_ref) return
|
||||
const resolved = path.join(feSkillDir, r.sample_ref)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
bad.push(`[样例] ${skillId} destinations[${d.key}].runs.sample_ref 不存在(应相对本 skill 前端目录):${r.sample_ref}`)
|
||||
} else if (resolved.indexOf(feSkillDir + path.sep) !== 0) {
|
||||
bad.push(`[样例] ${skillId} destinations[${d.key}].sample_ref 越界到本 skill 目录之外:${r.sample_ref}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[样例] ${skillId} destinations.json 解析失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- H. 生成结果「有结构问题也必须落盘 + 200 回传」 ----------
|
||||
*
|
||||
* 背景(真实事故,本 skill 踩过):过去只要结构校验出现哪怕 1 个 error,服务端就整份丢弃、
|
||||
* 不落盘也不回传 payload,用户什么都拿不到。而那个 error 很可能只是校验器的误判 ——
|
||||
* 一次 4 分钟 + 上万 token 的生成就这样白烧了。
|
||||
*
|
||||
* 这条检查直接调用 skill 注册出来的 POST /generate(把 ctx.callLLM 换成桩,返回一份必然
|
||||
* 不合契约的空 payload),断言三件事:
|
||||
* 1) 结果仍被写入 skill 私有 data/last_result.json
|
||||
* 2) HTTPstatus 为 200(若为 422,前端会走失败分支,payload 反而渲染不出来)
|
||||
* 3) envelope.ok === false(如实告知有结构问题,不假装成功)
|
||||
*
|
||||
* 自动覆盖所有「拆了 ai.js」的 skill;未拆分的(如脚手架骨架)跳过,不算违规。
|
||||
* ---------- */
|
||||
const os = require('os')
|
||||
|
||||
const checkPersistOnError = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
if (!fs.existsSync(path.join(beDir, 'ai.js'))) {
|
||||
info.push(`[落盘] ${skillId}:未拆分 ai.js,跳过本项检查`)
|
||||
return null
|
||||
}
|
||||
|
||||
let mod = null
|
||||
let bundle = null
|
||||
let answers = {}
|
||||
try {
|
||||
mod = require(path.join(beDir, 'index.js'))
|
||||
bundle = mod.loadBundle()
|
||||
// 按本 skill 自己的 qa.json 组装必填答案(不写死任何字段名)
|
||||
const qa = bundle.qa || {}
|
||||
if ((qa.destination_input || {}).required !== false) answers.destination = '测试地'
|
||||
;(qa.questions || []).forEach((q) => {
|
||||
if (!q || q.required === false || !q.key) return
|
||||
answers[q.key] = (q.default !== undefined && q.default !== null && q.default !== '')
|
||||
? q.default
|
||||
: (q.type === 'number' ? 1 : '测试值')
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 准备失败:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
let tmpDir = ''
|
||||
try {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tclab-last-'))
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 无法创建临时目录:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const ctx = makeCtx(skillId)
|
||||
ctx.dataDir = tmpDir
|
||||
// 桩掉模型调用:返回空对象 → 必然触发一堆结构 error
|
||||
ctx.callLLM = async () => ({ content: JSON.stringify({}), finishReason: 'stop', usage: {} })
|
||||
|
||||
const handlers = {}
|
||||
const fakeRouter = {
|
||||
get: (p, ...mw) => { handlers['GET ' + p] = mw[mw.length - 1] },
|
||||
post: (p, ...mw) => { handlers['POST ' + p] = mw[mw.length - 1] },
|
||||
use: () => {}
|
||||
}
|
||||
|
||||
let gen = null
|
||||
try {
|
||||
mod.bindRoutes(fakeRouter, ctx)
|
||||
gen = handlers['POST /generate']
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 注册路由失败:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
if (typeof gen !== 'function') {
|
||||
bad.push(`[落盘] ${skillId} 未注册 POST /generate`)
|
||||
return null
|
||||
}
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
set() { return this },
|
||||
on() { return this },
|
||||
writableEnded: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => gen({ body: { answers }, headers: {} }, res))
|
||||
.then(() => {
|
||||
const saved = path.join(tmpDir, 'last_result.json')
|
||||
if (!fs.existsSync(saved)) {
|
||||
bad.push(`[落盘] ${skillId} 结果有结构问题时未落盘 —— 用户会失去整份生成结果(该行为必须避免)`)
|
||||
return
|
||||
}
|
||||
let env = null
|
||||
try {
|
||||
env = JSON.parse(fs.readFileSync(saved, 'utf-8'))
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 落盘文件不是合法 JSON`)
|
||||
return
|
||||
}
|
||||
const nErr = ((env.check || {}).errors || []).length
|
||||
if (nErr === 0) {
|
||||
bad.push(`[落盘] ${skillId} 用例失效:预期产生结构 error,实际为 0`)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
bad.push(`[落盘] ${skillId} 有 error 时返回 ${res.statusCode}(应为 200;422 会让前端渲染不出 payload)`)
|
||||
}
|
||||
if (!res.body || res.body.ok !== false) {
|
||||
bad.push(`[落盘] ${skillId} 有 error 时 envelope.ok 应为 false(如实告知结构问题)`)
|
||||
}
|
||||
info.push(`[落盘] ${skillId}:${nErr} 处结构问题时仍落盘 + 200 回传 + ok=false(用户可查看/导出)`)
|
||||
})
|
||||
.catch((e) => {
|
||||
bad.push(`[落盘] ${skillId} 「有 error 也落盘」测试异常:${String((e && e.message) || e)}`)
|
||||
})
|
||||
.then(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 主流程 ---------- */
|
||||
const health = registry.health()
|
||||
if (!health.ok) bad.push('[registry] 自检未通过:' + JSON.stringify(health.problems_detail))
|
||||
const ids = health.items.map((i) => i.id)
|
||||
if (!ids.length) bad.push('[registry] 未扫描到任何 skill')
|
||||
|
||||
ids.forEach((id) => {
|
||||
checkBackendIsolation(id)
|
||||
checkFrontendIsolation(id)
|
||||
checkContractAndRuntime(id)
|
||||
checkSamples(id)
|
||||
checkSampleRefs(id)
|
||||
})
|
||||
if (ids.length > 1) ids.forEach((id) => checkSiblingRefs(id, ids))
|
||||
|
||||
const report = () => {
|
||||
console.log('===== 游山玩水 · skill 全栈隔离校验 =====')
|
||||
info.push(`扫描到 ${ids.length} 个 skill:${ids.join(', ')}`)
|
||||
info.push('隔离口径:skill 内联引用只允许「本 skill 目录内」或「lab 级 assets/vendor/」;后端只允许 require 本目录或 npm 包')
|
||||
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✓ 隔离校验全部通过(' + ids.length + ' 个 skill)')
|
||||
}
|
||||
|
||||
Promise.all(ids.map((id) => checkPersistOnError(id)).filter(Boolean)).then(report)
|
||||
@@ -0,0 +1,313 @@
|
||||
/* ============================================================
|
||||
* 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')
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user