feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/* ============================================================
|
||||
* debug_dashscope_image_models.js —— 探测百炼(DashScope)可用的文生图模型
|
||||
*
|
||||
* 目的:项目此前只用了 chat/completions 与 TTS,没有文生图调用。
|
||||
* 本脚本枚举候选模型与候选端点,找出「本账号实际能用」的组合,
|
||||
* 并把生成的图落盘到临时目录做 MD5 比对,确认不同 prompt 出的是不同的图。
|
||||
*
|
||||
* 运行:node dev_test_scripts/debug/debug_dashscope_image_models.js
|
||||
* 说明:只读探测,不写入仓库任何文件(产物落在系统临时目录)。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
|
||||
// 载入本机私有凭据(与项目其他模块同一份文件,不进仓库)
|
||||
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
|
||||
if (fs.existsSync(envPath)) {
|
||||
const text = fs.readFileSync(envPath, 'utf8')
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line || /^\s*#/.test(line)) continue
|
||||
const i = line.indexOf('=')
|
||||
if (i < 0) continue
|
||||
const k = line.slice(0, i).trim()
|
||||
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
|
||||
if (k && !(k in process.env)) process.env[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = process.env.DASHSCOPE_API_KEY || ''
|
||||
const MaaS = (process.env.DASHSCOPE_API_HOST_FOR_OPENAI || '').replace(/\/+$/, '')
|
||||
const PUBLIC_BASE = 'https://dashscope.aliyuncs.com'
|
||||
|
||||
const OUT = path.join(process.env.TEMP || 'C:\\Windows\\Temp', 'tc_img_probe')
|
||||
fs.mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const promptA = 'coastal embankment above tidal flat, wind turbines, autumn afternoon'
|
||||
const promptB = 'old Chinese town stone lane with grey brick houses and red lanterns'
|
||||
|
||||
console.log('[env] KEY 已加载:', !!KEY, KEY ? '(len=' + KEY.length + ')' : '')
|
||||
console.log('[env] MaaS host:', MaaS ? MaaS.replace(/\/compatible-mode.*$/, '/…') : '(空)')
|
||||
console.log('[out]', OUT)
|
||||
|
||||
const get = async (url, headers) => {
|
||||
const r = await fetch(url, { headers, signal: AbortSignal.timeout(25000) })
|
||||
const t = await r.text()
|
||||
return { status: r.status, body: t }
|
||||
}
|
||||
|
||||
/* ---------- 1. 先看这个 MaaS 端点声明了哪些模型 ---------- */
|
||||
async function listModels() {
|
||||
console.log('\n======== 1. 列出 MaaS 端点的模型 ========')
|
||||
if (!MaaS) { console.log('无 MaaS host,跳过'); return [] }
|
||||
try {
|
||||
const r = await get(MaaS + '/models', { Authorization: 'Bearer ' + KEY })
|
||||
console.log('status=', r.status)
|
||||
if (r.status !== 200) { console.log(r.body.slice(0, 400)); return [] }
|
||||
let j = null
|
||||
try { j = JSON.parse(r.body) } catch { console.log(r.body.slice(0, 400)); return [] }
|
||||
const ids = (j.data || []).map((m) => m.id)
|
||||
console.log('模型数 =', ids.length)
|
||||
const likely = ids.filter((id) => /image|t2i|wanx|wan2|flux|sd|diffus/i.test(id))
|
||||
console.log('疑似文生图相关:', likely.length ? likely.join(', ') : '(无)')
|
||||
console.log('全部模型(前 60):\n ' + ids.slice(0, 60).join('\n '))
|
||||
return ids
|
||||
} catch (e) { console.log('ERR', e.message) ; return [] }
|
||||
}
|
||||
|
||||
/* ---------- 2. 探 text2image/image-synthesis 端点 ---------- */
|
||||
const WANX_MODELS = [
|
||||
'wanx2.1-t2i-turbo',
|
||||
'wanx2.1-t2i-plus',
|
||||
'wanx2.0-t2i-turbo',
|
||||
'wanx-v1',
|
||||
'wan2.2-t2i-flash',
|
||||
'wan2.2-t2i-plus',
|
||||
'wan2.5-t2i-preview',
|
||||
'wan2.7-t2i-plus'
|
||||
]
|
||||
|
||||
async function tryImageSynthesis(model) {
|
||||
const url = PUBLIC_BASE + '/api/v1/services/aigc/text2image/image-synthesis'
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { prompt: promptA },
|
||||
parameters: { size: '1024*576', n: 1 }
|
||||
}),
|
||||
signal: AbortSignal.timeout(25000)
|
||||
})
|
||||
const t = await r.text()
|
||||
let j = null
|
||||
try { j = JSON.parse(t) } catch {}
|
||||
if (r.status === 200 && j && j.output && j.output.task_id) {
|
||||
console.log(' ✓ ' + model + ' task=' + j.output.task_id)
|
||||
return j.output.task_id
|
||||
}
|
||||
const msg = (j && (j.message || (j.output && j.output.task_status))) || t.slice(0, 160)
|
||||
console.log(' ✗ ' + model + ' HTTP ' + r.status + ' ' + String(msg).slice(0, 160))
|
||||
return null
|
||||
} catch (e) {
|
||||
console.log(' ✗ ' + model + ' ERR ' + e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(taskId, maxTry) {
|
||||
const url = PUBLIC_BASE + '/api/v1/tasks/' + taskId
|
||||
for (let i = 0; i < (maxTry || 20); i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
const r = await get(url, { Authorization: 'Bearer ' + KEY })
|
||||
let j = null
|
||||
try { j = JSON.parse(r.body) } catch {}
|
||||
const st = j && j.output && j.output.task_status
|
||||
if (st === 'SUCCEEDED') {
|
||||
const results = (j.output.results || []).map((x) => x.url).filter(Boolean)
|
||||
return { status: st, urls: results }
|
||||
}
|
||||
if (st === 'FAILED' || st === 'UNKNOWN') {
|
||||
return { status: st, msg: j.message || (j.output && j.output.message) }
|
||||
}
|
||||
}
|
||||
return { status: 'TIMEOUT' }
|
||||
}
|
||||
|
||||
/* ---------- 3. 探多模态生成端点(qwen-image 走 messages) ---------- */
|
||||
const QWEN_IMAGE_MODELS = ['qwen-image', 'qwen-image-plus', 'qwen-image-max']
|
||||
|
||||
async function tryMultimodal(model) {
|
||||
const url = PUBLIC_BASE + '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { messages: [{ role: 'user', content: [{ text: promptA }] }] },
|
||||
parameters: { size: '1024*576', n: 1 }
|
||||
}),
|
||||
signal: AbortSignal.timeout(40000)
|
||||
})
|
||||
const t = await r.text()
|
||||
let j = null
|
||||
try { j = JSON.parse(t) } catch {}
|
||||
const content = j && j.output && j.output.choices && j.output.choices[0] &&
|
||||
j.output.choices[0].message && j.output.choices[0].message.content
|
||||
const urls = Array.isArray(content) ? content.map((c) => c.image).filter(Boolean) : []
|
||||
if (r.status === 200 && urls.length) { console.log(' ✓ ' + model + ' 直接返回图片'); return { status: 'SUCCEEDED', urls } }
|
||||
if (r.status === 200 && j && j.output && j.output.task_id) { console.log(' ✓ ' + model + ' task=' + j.output.task_id); return { status: 'TASK', task: j.output.task_id } }
|
||||
const msg = (j && j.message) || t.slice(0, 160)
|
||||
console.log(' ✗ ' + model + ' HTTP ' + r.status + ' ' + String(msg).slice(0, 160))
|
||||
return null
|
||||
} catch (e) { console.log(' ✗ ' + model + ' ERR ' + e.message); return null }
|
||||
}
|
||||
|
||||
/* ---------- 4. 下载两张图,比对 MD5 ---------- */
|
||||
async function download(url, name) {
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(60000) })
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status)
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
const file = path.join(OUT, name)
|
||||
fs.writeFileSync(file, buf)
|
||||
return { file, size: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex') }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const available = await listModels()
|
||||
|
||||
console.log('\n======== 2. text2image/image-synthesis 候选模型 ========')
|
||||
const hit = []
|
||||
for (const m of WANX_MODELS) {
|
||||
const t = await tryImageSynthesis(m)
|
||||
if (t) hit.push({ model: m, task: t, kind: 'synthesis' })
|
||||
}
|
||||
|
||||
console.log('\n======== 3. multimodal-generation 候选模型 ========')
|
||||
for (const m of QWEN_IMAGE_MODELS) {
|
||||
if (available.length && available.indexOf(m) < 0) { console.log(' - ' + m + ' 不在 MaaS 模型列表,仍试一次'); }
|
||||
const r = await tryMultimodal(m)
|
||||
if (r && r.status === 'SUCCEEDED') hit.push({ model: m, urls: r.urls, kind: 'multimodal' })
|
||||
else if (r && r.status === 'TASK') hit.push({ model: m, task: r.task, kind: 'multimodal' })
|
||||
}
|
||||
|
||||
if (!hit.length) { console.log('\n结论:没有可用的文生图模型组合。'); return }
|
||||
|
||||
const first = hit[0]
|
||||
console.log('\n======== 4. 用可用模型 ' + first.model + ' 跑两张不同 prompt,比对 MD5 ========')
|
||||
let urls = first.urls
|
||||
if (!urls) {
|
||||
const p = await poll(first.task, 30)
|
||||
console.log('poll:', p.status, p.msg || '')
|
||||
urls = p.urls || []
|
||||
}
|
||||
if (!urls.length) { console.log('未拿到图片 URL,无法比对'); return }
|
||||
console.log('URL 数 =', urls.length, urls[0].slice(0, 100))
|
||||
|
||||
const a1 = await download(urls[0], 'probe_A.jpg')
|
||||
console.log('A:', a1.size, a1.md5)
|
||||
|
||||
// 换 prompt 再生成一张
|
||||
let taskB = null
|
||||
if (first.kind === 'synthesis') taskB = await tryImageSynthesisWith(first.model, promptB)
|
||||
if (taskB) {
|
||||
const p = await poll(taskB, 30)
|
||||
if (p.urls && p.urls.length) {
|
||||
const b1 = await download(p.urls[0], 'probe_B.jpg')
|
||||
console.log('B:', b1.size, b1.md5)
|
||||
console.log('\n两张图 MD5 是否相同:', a1.md5 === b1.md5 ? '❌ 相同(模型没区分 prompt)' : '✓ 不同(模型正常按 prompt 出图)')
|
||||
} else console.log('B 图未生成:', p.status)
|
||||
}
|
||||
}
|
||||
|
||||
async function tryImageSynthesisWith(model, prompt) {
|
||||
const url = PUBLIC_BASE + '/api/v1/services/aigc/text2image/image-synthesis'
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' },
|
||||
body: JSON.stringify({ model, input: { prompt }, parameters: { size: '1024*576', n: 1 } }),
|
||||
signal: AbortSignal.timeout(25000)
|
||||
})
|
||||
const j = JSON.parse(await r.text())
|
||||
return j && j.output && j.output.task_id ? j.output.task_id : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
main().catch((e) => console.error('FATAL', e && e.message))
|
||||
@@ -0,0 +1,88 @@
|
||||
/* ============================================================
|
||||
* debug_probe_max_tokens.js —— 探测 chat 模型可接受的 max_tokens 上限
|
||||
*
|
||||
* 背景:「游山玩水」整份行程 JSON 约 1.2-1.6 万 output token,
|
||||
* 用 qwen-plus-latest + max_tokens=8000 会截断(finish_reason=length)。
|
||||
* 本脚本用极短的 prompt 试不同 max_tokens,找出模型实际能接受的上限,
|
||||
* 顺便列出可用 chat 模型。
|
||||
*
|
||||
* 运行:node dev_test_scripts/debug/debug_probe_max_tokens.js
|
||||
* 说明:只读探测,用极短 prompt(输出 1-2 个 token),几乎不产生费用。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || /^\s*#/.test(line)) continue
|
||||
const i = line.indexOf('=')
|
||||
if (i < 0) continue
|
||||
const k = line.slice(0, i).trim()
|
||||
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
|
||||
if (k && !(k in process.env)) process.env[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const HOST = (process.env.DASHSCOPE_API_HOST_FOR_OPENAI || '').replace(/\/+$/, '')
|
||||
const KEY = process.env.DASHSCOPE_API_KEY || ''
|
||||
const URL = HOST ? HOST + '/chat/completions' : ''
|
||||
|
||||
const MODELS = [
|
||||
'qwen-plus-latest',
|
||||
'qwen-max-latest',
|
||||
'qwen3-max',
|
||||
'qwen3.5-plus',
|
||||
'qwen3.5-plus-latest',
|
||||
'qwen-turbo-latest'
|
||||
]
|
||||
const BUDGETS = [8192, 16384, 32000]
|
||||
|
||||
const tryOne = async (model, maxTokens) => {
|
||||
try {
|
||||
const r = await fetch(URL, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: '只回复两个字:好的' }], max_tokens: maxTokens }),
|
||||
signal: AbortSignal.timeout(60000)
|
||||
})
|
||||
const t = await r.text()
|
||||
let j = null
|
||||
try { j = JSON.parse(t) } catch {}
|
||||
if (r.status === 200 && j && j.choices) {
|
||||
const u = j.usage || {}
|
||||
return { ok: true, note: 'completion=' + (u.completion_tokens === undefined ? '?' : u.completion_tokens) }
|
||||
}
|
||||
return { ok: false, note: 'HTTP ' + r.status + ' ' + String((j && j.message) || t).slice(0, 130) }
|
||||
} catch (e) {
|
||||
return { ok: false, note: 'ERR ' + e.message }
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('host:', HOST ? '已配置' : '缺失', ' key:', KEY ? '已加载' : '缺失')
|
||||
|
||||
console.log('\n===== 可用 chat 模型(MaaS 声明) =====')
|
||||
try {
|
||||
const r = await fetch(HOST + '/models', { headers: { Authorization: 'Bearer ' + KEY } })
|
||||
const j = JSON.parse(await r.text())
|
||||
const ids = (j.data || []).map((m) => m.id)
|
||||
const chat = ids.filter((id) => /qwen|deepseek|glm|kimi|llama|gpt/i.test(id) && !/image|vl|audio|asr|tts|voice|video|t2i|t2v|embed/i.test(id))
|
||||
console.log('chat 类候选共 ' + chat.length + ' 个:\n ' + chat.slice(0, 50).join('\n '))
|
||||
} catch (e) {
|
||||
console.log('列模型失败:' + e.message)
|
||||
}
|
||||
|
||||
console.log('\n===== max_tokens 上限探测 =====')
|
||||
for (const m of MODELS) {
|
||||
const line = []
|
||||
for (const b of BUDGETS) {
|
||||
const r = await tryOne(m, b)
|
||||
line.push(b + ':' + (r.ok ? '✓' : '✗'))
|
||||
if (!r.ok) { line.push('(' + r.note.slice(0, 90) + ')'); break }
|
||||
}
|
||||
console.log(' ' + m.padEnd(20) + ' ' + line.join(' '))
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => console.error('FATAL', e && e.message))
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ============================================================
|
||||
* debug_rail_replay_wuyuan.js —— 用真实失败案例回放,验证修复有效
|
||||
*
|
||||
* 背景:2026-09-30 生成「婺源」时返回 payload_invalid,唯一 error 是
|
||||
* 「第一天缺「夜逛」环节(night_stroll)」——但 AI 其实排了 19:00 的夜逛。
|
||||
* 根因是校验器写死了 day.label 含「第一天」,而 AI 标的是「周六/周日」。
|
||||
*
|
||||
* 本脚本把那次失败响应的 payload 原样取出来,走一遍「修复 → 校验」,
|
||||
* 用来证明修复后不再误报(同时也顺带检查其余 warning 是否已消除)。
|
||||
*
|
||||
* 用法:
|
||||
* node dev_test_scripts/debug/debug_rail_replay_wuyuan.js 只回放校验,不写任何文件
|
||||
* node dev_test_scripts/debug/debug_rail_replay_wuyuan.js --save 额外把这份结果写回
|
||||
* 技能私有 data/last_result.json,
|
||||
* 这样用户可在页面上点「载入最近一次生成」
|
||||
* 直接看到并导出这份婺源行程(不必重烧 token)
|
||||
*
|
||||
* 为什么需要 --save:服务端只在「校验 0 errors」时才落盘,那次因误判被丢弃,
|
||||
* 用户 4 分钟的生成白费了。这个开关是给「已经拿到完整 payload、只想补落盘」的补救场景用的。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
|
||||
const SRC = path.join(
|
||||
os.homedir(), 'AppData', 'Roaming', 'Trae CN', 'User', 'workspaceStorage',
|
||||
'540d38674b58f7802a478abf6956abc7', 'long-text', '6ab1e0ff40ac82e5fecf1de9',
|
||||
'munlta58-u92r', '当前状态__失败__....txt'
|
||||
)
|
||||
|
||||
const SKILL = path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'travel_citytour', 'skills', 'sh_2d_rail_hike')
|
||||
const skillIndex = require(path.join(SKILL, 'index.js'))
|
||||
const { validatePayload } = require(path.join(SKILL, 'validate.js'))
|
||||
const { repairPayload } = require(path.join(SKILL, 'repair.js'))
|
||||
|
||||
if (!fs.existsSync(SRC)) {
|
||||
console.log('找不到失败响应文件(可能已被清理):' + SRC)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(SRC, 'utf-8')
|
||||
|
||||
// 响应体从「返回结果」后的第一个 '{' 开始,到「错误信息」之前结束
|
||||
const startMark = raw.indexOf('返回结果')
|
||||
const start = raw.indexOf('{', startMark)
|
||||
const endMark = raw.indexOf('错误信息', start)
|
||||
const text = raw.slice(start, endMark > 0 ? endMark : undefined).trim()
|
||||
|
||||
let envelope = null
|
||||
try {
|
||||
envelope = JSON.parse(text)
|
||||
} catch (e) {
|
||||
// 兜底:按大括号配平截取
|
||||
let depth = 0, endIdx = -1
|
||||
for (let i = start; i < raw.length; i++) {
|
||||
if (raw[i] === '{') depth++
|
||||
else if (raw[i] === '}') { depth--; if (depth === 0) { endIdx = i; break } }
|
||||
}
|
||||
envelope = JSON.parse(raw.slice(start, endIdx + 1))
|
||||
}
|
||||
|
||||
const payload = envelope.payload
|
||||
const bundle = skillIndex.loadBundle()
|
||||
|
||||
console.log('===== 婺源失败案例回放 =====')
|
||||
console.log('原文 error:' + JSON.stringify(envelope.check && envelope.check.errors))
|
||||
console.log('day.label 实际写法:' + ((payload.timeline && payload.timeline.days) || []).map((d) => JSON.stringify(d.label)).join(' / '))
|
||||
console.log('AI 是否排了夜逛行:' + ((payload.timeline && payload.timeline.days) || [])
|
||||
.reduce((a, d) => a.concat(d.rows || []), [])
|
||||
.some((r) => r.anchor_id === 'night_stroll'))
|
||||
console.log('')
|
||||
|
||||
const repaired = repairPayload(JSON.parse(JSON.stringify(payload)), bundle)
|
||||
const chk = validatePayload(repaired.payload, bundle)
|
||||
|
||||
console.log('— 修复说明 —')
|
||||
repaired.fixes.forEach((f) => console.log(' · ' + f))
|
||||
console.log('')
|
||||
console.log('— 修复后校验结果 —')
|
||||
console.log('errors : ' + chk.errors.length + (chk.errors.length ? '\n ✗ ' + chk.errors.join('\n ✗ ') : ''))
|
||||
console.log('warnings: ' + chk.warnings.length + (chk.warnings.length ? '\n ! ' + chk.warnings.join('\n ! ') : ''))
|
||||
console.log('')
|
||||
console.log(chk.errors.length === 0
|
||||
? '✓ 回放通过:这份真实失败的 payload 修复后 0 errors,不再被误判'
|
||||
: '✗ 仍有 error,需继续排查')
|
||||
|
||||
// --save:把修复后的结果补落盘,供页面「载入最近一次生成」使用
|
||||
if (process.argv.indexOf('--save') >= 0) {
|
||||
if (chk.errors.length) {
|
||||
console.log('\n⚠ 仍有 error,拒绝落盘(避免把不合格结果写进 last_result)')
|
||||
process.exit(1)
|
||||
}
|
||||
const outEnvelope = {
|
||||
ok: true,
|
||||
payload: repaired.payload,
|
||||
report_spec: bundle.report_spec || {},
|
||||
destination_override: null,
|
||||
check: chk,
|
||||
meta: Object.assign({}, envelope.meta, {
|
||||
repairs: repaired.fixes,
|
||||
recovered_from: 'payload_invalid 误判(校验器已修复)'
|
||||
})
|
||||
}
|
||||
const dataDir = path.join(SKILL, 'data')
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
const out = path.join(dataDir, 'last_result.json')
|
||||
fs.writeFileSync(out, JSON.stringify(outEnvelope), 'utf-8')
|
||||
console.log('\n✓ 已补落盘:' + out)
|
||||
console.log(' 下一步:打开本技能页面 → 点「载入最近一次生成」即可查看/导出这份婺源行程')
|
||||
}
|
||||
|
||||
process.exit(chk.errors.length === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,105 @@
|
||||
/* ============================================================
|
||||
* test_travel_citytour_generate_integration.js
|
||||
* 「游山玩水」生成链路集成测试:不依赖服务重启,直接调用 lab 内的 ai.js
|
||||
*
|
||||
* 验证目标:
|
||||
* 1. prompt 组装 + 百炼调用是否通
|
||||
* 2. qwen-plus-latest 能否产出符合 output_schema 的整份行程(会不会被截断)
|
||||
* 3. 结构校验(validate.js)出来多少 errors / warnings
|
||||
* 4. 端到端耗时(决定前端要不要长 loading 提示)
|
||||
*
|
||||
* 运行:node dev_test_scripts/integration/test_travel_citytour_generate_integration.js
|
||||
* 产物:payload 落在系统临时目录(不进仓库),仅用于人工查看
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 载入本机私有凭据(与服务端同一份文件)
|
||||
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || /^\s*#/.test(line)) continue
|
||||
const i = line.indexOf('=')
|
||||
if (i < 0) continue
|
||||
const k = line.slice(0, i).trim()
|
||||
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
|
||||
if (k && !(k in process.env)) process.env[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const LAB = path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
|
||||
|
||||
const SKILL_ID = process.argv[2] || 'sh_2d_car_hike'
|
||||
const DEST = process.argv[3] || '南通如东'
|
||||
const MODEL_OVERRIDE = process.argv[4] || ''
|
||||
const MAXTOK_OVERRIDE = Number(process.argv[5]) || 0
|
||||
|
||||
// 技能全栈自包含:引擎三件套与技能包都在 skills/<skill_id>/ 内
|
||||
const SKILL_DIR = path.join(LAB, 'skills', SKILL_ID)
|
||||
const skillIndex = require(path.join(SKILL_DIR, 'index.js'))
|
||||
const { validatePayload } = require(path.join(SKILL_DIR, 'validate.js'))
|
||||
const { repairPayload } = require(path.join(SKILL_DIR, 'repair.js'))
|
||||
const ai = require(path.join(SKILL_DIR, 'ai.js'))
|
||||
// 红线能力(callLLM / 北京时间 / JSON 提取)由 lab 层提供,测试直接复用同一实现,避免漂移
|
||||
const { makeCtx } = require(path.join(LAB, 'index.js'))
|
||||
const ctx = makeCtx(SKILL_ID)
|
||||
|
||||
const main = async () => {
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, 'config.json'), 'utf8'))
|
||||
const aiConfig = Object.assign({}, cfg.ai || {})
|
||||
if (MODEL_OVERRIDE) aiConfig.model = MODEL_OVERRIDE
|
||||
if (MAXTOK_OVERRIDE) aiConfig.max_tokens = MAXTOK_OVERRIDE
|
||||
const bundle = skillIndex.loadBundle()
|
||||
const dest = skillIndex.findDestination(bundle, DEST)
|
||||
const answers = {
|
||||
destination: DEST,
|
||||
departure_origin: bundle.skill.base_origin || '',
|
||||
departure_date: '2026-09-19',
|
||||
departure_time: '06:40',
|
||||
vehicle_type: 'fuel',
|
||||
headcount: 1
|
||||
}
|
||||
|
||||
console.log('===== 生成链路集成测试 =====')
|
||||
console.log('skill :', bundle.skill.id, 'v' + bundle.skill.version, '(' + (bundle.skill.anchors || []).length + ' 槽位)')
|
||||
console.log('skill_dir :', SKILL_DIR)
|
||||
console.log('destination:', DEST, dest ? '(技能包内有覆盖数据)' : '(首次生成,无覆盖数据)')
|
||||
console.log('model :', aiConfig.model, ' temp=', aiConfig.temperature, ' max_tokens=', aiConfig.max_tokens)
|
||||
console.log('ai_ready :', ctx.aiReady() ? '已配置(密钥由 lab 层持有)' : '缺失!')
|
||||
console.log('system 长度:', ai.buildMessages(bundle, answers, dest)[0].content.length, '字符')
|
||||
console.log('开始生成…\n')
|
||||
|
||||
const t0 = Date.now()
|
||||
let result = null
|
||||
try {
|
||||
result = await ai.generate({ bundle, answers, dest, aiConfig, ctx })
|
||||
} catch (e) {
|
||||
console.log('❌ 生成失败:' + String(e.message || e))
|
||||
if (e.raw) console.log('原始输出前 1200 字:\n' + String(e.raw).slice(0, 1200))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 与服务端一致:先无损修复,再校验
|
||||
const repaired = repairPayload(result.payload, bundle)
|
||||
const check = validatePayload(repaired.payload, bundle)
|
||||
const outDir = path.join(process.env.TEMP || 'C:\\Windows\\Temp', 'travel_citytour_out')
|
||||
fs.mkdirSync(outDir, { recursive: true })
|
||||
const outFile = path.join(outDir, 'payload_' + SKILL_ID + '.json')
|
||||
fs.writeFileSync(outFile, JSON.stringify(repaired.payload, null, 2), 'utf8')
|
||||
|
||||
console.log('✅ 生成成功')
|
||||
console.log('耗时 :', result.elapsed_ms + ' ms(含 ' + result.attempts + ' 次尝试)')
|
||||
console.log('总墙钟耗时 :', (Date.now() - t0) + ' ms')
|
||||
console.log('finish_reason:', result.finish_reason || '(空)')
|
||||
console.log('usage :', JSON.stringify(result.usage || {}))
|
||||
console.log('\n结构规模 :', JSON.stringify(check.stats))
|
||||
console.log('无损修复 :', repaired.fixes.length, repaired.fixes.length ? '\n - ' + repaired.fixes.slice(0, 12).join('\n - ') : '')
|
||||
console.log('errors :', check.errors.length, check.errors.length ? '\n - ' + check.errors.slice(0, 12).join('\n - ') : '')
|
||||
console.log('warnings :', check.warnings.length, check.warnings.length ? '\n - ' + check.warnings.slice(0, 12).join('\n - ') : '')
|
||||
console.log('\npayload 落盘:', outFile, '(' + (fs.statSync(outFile).size / 1024).toFixed(1) + ' KB)')
|
||||
console.log('summary 标题:', (repaired.payload.summary || {}).title || '(无)')
|
||||
console.log('\n' + (check.errors.length === 0 ? '✓ 结构校验通过,可直接渲染' : '✗ 结构校验未通过,需按上面 errors 调整规则或 prompt'))
|
||||
process.exit(check.errors.length === 0 ? 0 : 2)
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1) })
|
||||
@@ -0,0 +1,384 @@
|
||||
/* ============================================================
|
||||
* run_new_travel_skill.js —— 「游山玩水」新技能脚手架
|
||||
*
|
||||
* 用途:一条命令产出新 skill 的最小骨架(前后端各一套空壳),之后自己填。
|
||||
*
|
||||
* 用法:
|
||||
* node dev_test_scripts/runners/run_new_travel_skill.js <skill_id> "技能中文名"
|
||||
* 例:node dev_test_scripts/runners/run_new_travel_skill.js japan_7d "日本七天游"
|
||||
*
|
||||
* 产出(两棵树下各建同名目录):
|
||||
* src/server/thought_lab/labs/travel_citytour/skills/<id>/
|
||||
* ├── config.json 聚合页元信息 + 本技能 AI 参数(含注释)
|
||||
* ├── index.js 导出 bindRoutes(router, ctx) 的空壳(含注释与示例路由)
|
||||
* └── data/ 本技能私有数据目录(.gitignore 已排除,不进库)
|
||||
* public/tools/thought_lab/labs/travel_citytour/skills/<id>/
|
||||
* ├── index.html 空页面:含「返回列表」+ 鉴权失败引导面板(可复制片段)
|
||||
* ├── icon.svg 占位图标
|
||||
* └── assets/ 本技能私有静态资源目录
|
||||
*
|
||||
* 刻意不产出(按约定「从 0 开始」):
|
||||
* 渲染器 / 报告样式 / 导出器 / 问答表单 —— 这些由各 skill 自己写或自己复制改。
|
||||
*
|
||||
* 位置遵循项目规则:所有脚本统一放 dev_test_scripts/。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..')
|
||||
const LAB_ID = 'travel_citytour'
|
||||
const BE_SKILLS = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', LAB_ID, 'skills')
|
||||
const FE_SKILLS = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', LAB_ID, 'skills')
|
||||
|
||||
const die = (msg) => { console.error('✗ ' + msg); process.exit(1) }
|
||||
|
||||
const rawId = String(process.argv[2] || '').trim()
|
||||
const name = String(process.argv[3] || '').trim()
|
||||
|
||||
if (!rawId) die('用法:node dev_test_scripts/runners/run_new_travel_skill.js <skill_id> "技能中文名"')
|
||||
if (/[^A-Za-z0-9_]/.test(rawId)) die('skill_id 只允许字母 / 数字 / 下划线(会作为目录名与 API 路径段):' + rawId)
|
||||
if (!name) die('缺少技能中文名(聚合页卡片标题)')
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(rawId)) die('skill_id 建议小写下划线风格,例如 japan_7d')
|
||||
|
||||
const beDir = path.join(BE_SKILLS, rawId)
|
||||
const feDir = path.join(FE_SKILLS, rawId)
|
||||
|
||||
if (fs.existsSync(beDir) || fs.existsSync(feDir)) {
|
||||
die('同名 skill 已存在,拒绝覆盖:\n ' + beDir + '\n ' + feDir)
|
||||
}
|
||||
|
||||
/* ---------------- 后端 config.json ---------------- */
|
||||
|
||||
const beConfig = {
|
||||
_comment: '技能:' + name + ' —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容。',
|
||||
id_comment: '技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)',
|
||||
id: rawId,
|
||||
name_comment: '聚合页卡片标题',
|
||||
name: name,
|
||||
desc_comment: '聚合页卡片一句话简介(tooltip)',
|
||||
desc: '(待填)这套行程打法适用什么场景',
|
||||
icon_comment: '聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg',
|
||||
icon: '🧭',
|
||||
sort_order_comment: '聚合页排序,数值越小越靠前',
|
||||
sort_order: 50,
|
||||
enabled_comment: '是否启用(false 时 lab 扫描层直接跳过本 skill)',
|
||||
enabled: false,
|
||||
ui_mode_comment: '弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗',
|
||||
ui_mode: 'debug',
|
||||
ai_comment: '本技能生成链路的 AI 参数。密钥不在这里,由 lab 层 ctx.callLLM 从环境变量读取(红线:skill 不接触密钥)',
|
||||
ai: {
|
||||
model_comment: '纯文本生成默认 qwen-plus-latest(项目统一规则:自动跟随官方最新稳定快照)',
|
||||
model: 'qwen-plus-latest',
|
||||
temperature_comment: '采样温度(0-1)。结构化输出建议 0.2-0.4',
|
||||
temperature: 0.3,
|
||||
max_tokens_comment: '单次最大输出 token。按本技能的行程长度自行评估(天数越多、节点越多,需要越大)',
|
||||
max_tokens: 20000,
|
||||
timeout_ms_comment: '单次生成超时(毫秒)',
|
||||
timeout_ms: 600000
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 后端 index.js ---------------- */
|
||||
|
||||
const beIndex = `// ============================================================
|
||||
// skills/${rawId}/index.js —— 本技能的后端路由(技能私有、自包含)
|
||||
//
|
||||
// 契约(lab 扫描层唯一要求):
|
||||
// module.exports = { bindRoutes(router, ctx) }
|
||||
// - router:lab 层已经 .use(labAuth) 之后再交给我 → 本文件【不写任何鉴权代码】
|
||||
// - ctx :lab 层提供的红线能力(见文末清单),本文件只管业务
|
||||
//
|
||||
// 挂载点:/api/thought_lab/${LAB_ID}/skills/${rawId}/*
|
||||
// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。
|
||||
// ============================================================
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const SKILL_DIR = __dirname
|
||||
|
||||
// TODO: 本技能需要的技能包文件清单(读法自定,格式自定;不一定要沿用别家 skill)
|
||||
const SKILL_FILES = {
|
||||
// skill: 'skill.json',
|
||||
// rules: 'rules.md'
|
||||
}
|
||||
|
||||
const loadBundle = () => {
|
||||
const bundle = { id: path.basename(SKILL_DIR), dir: SKILL_DIR, rules: '' }
|
||||
Object.keys(SKILL_FILES).forEach((key) => {
|
||||
const file = path.join(SKILL_DIR, SKILL_FILES[key])
|
||||
if (!fs.existsSync(file)) { bundle[key] = key === 'rules' ? '' : {}; return }
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
bundle[key] = key === 'rules' ? txt : JSON.parse(txt)
|
||||
})
|
||||
return bundle
|
||||
}
|
||||
|
||||
const bindRoutes = (router, ctx) => {
|
||||
// 本技能元信息:页面双模式依据 + 本技能自己的配置
|
||||
router.get('/meta', (req, res) => {
|
||||
const cfg = ctx.readConfig()
|
||||
return res.json({
|
||||
ok: true,
|
||||
ui_mode: String(cfg.ui_mode || 'debug'),
|
||||
ai_model: String((cfg.ai && cfg.ai.model) || 'qwen-plus-latest'),
|
||||
ai_ready: ctx.aiReady(),
|
||||
skill: { id: '${rawId}', name: '${name}', version: '0.0.1' },
|
||||
// TODO: 按本技能需要补充 qa 定义 / 渲染描述 / 目的地清单等
|
||||
qa: {},
|
||||
report_spec: {},
|
||||
destinations: []
|
||||
})
|
||||
})
|
||||
|
||||
// 生成(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流)
|
||||
router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => {
|
||||
const body = ctx.sanitizeObject(req.body || {})
|
||||
const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {}
|
||||
try {
|
||||
ctx.log('generate start', { answers })
|
||||
const bundle = loadBundle()
|
||||
const cfg = ctx.readConfig()
|
||||
const aiConfig = cfg.ai || {}
|
||||
|
||||
// TODO: 这里写本技能的 prompt 与输出契约。
|
||||
// ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs })
|
||||
// → { content, finishReason, usage }
|
||||
// ctx.extractJson(content) → 解析出的对象(容忍代码块与前后废话)
|
||||
const messages = [
|
||||
{ role: 'system', content: 'TODO: 本技能的 system prompt' },
|
||||
{ role: 'user', content: JSON.stringify(answers) }
|
||||
]
|
||||
const r = await ctx.callLLM({
|
||||
messages,
|
||||
model: String(aiConfig.model || 'qwen-plus-latest'),
|
||||
temperature: Number(aiConfig.temperature ?? 0.3),
|
||||
maxTokens: Number(aiConfig.max_tokens || 8000),
|
||||
timeoutMs: Number(aiConfig.timeout_ms || 600000)
|
||||
})
|
||||
const payload = ctx.extractJson(r.content)
|
||||
payload.meta = payload.meta || {}
|
||||
payload.meta.skill_id = '${rawId}'
|
||||
payload.meta.generated_at = ctx.nowBeijing() // 红线:北京时间唯一实现
|
||||
ctx.log('generate done', { finish_reason: r.finishReason })
|
||||
return res.json({ ok: true, payload })
|
||||
} catch (e) {
|
||||
const msg = String((e && e.message) || e)
|
||||
ctx.log('generate failed', { error: msg })
|
||||
return res.status(msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502)
|
||||
.json({ ok: false, error: msg })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ctx 可用能力(红线统一实现,不要自己另写一套):
|
||||
// ctx.log(event, data) 日志(自动带 [travel_citytour/<skill_id>] 前缀)
|
||||
// ctx.nowBeijing() 北京时间字符串(唯一实现)
|
||||
// ctx.escapeHtml(s) HTML 转义(唯一实现)
|
||||
// ctx.callLLM(opts) 调模型(唯一持有密钥的地方)
|
||||
// ctx.extractJson(text) 稳健取 JSON
|
||||
// ctx.sanitizeObject(obj) 原型污染防护
|
||||
// ctx.jsonParser 请求体解析中间件
|
||||
// ctx.genGuard 生成并发/限流中间件
|
||||
// ctx.readLimit 只读限流中间件
|
||||
// ctx.readConfig() 读本技能自己的 config.json
|
||||
// ctx.dataDir 本技能私有 data/ 绝对路径
|
||||
// ctx.skillId / ctx.prefix skill_id 与 API 前缀
|
||||
module.exports = { bindRoutes, loadBundle }
|
||||
`
|
||||
|
||||
/* ---------------- 前端 index.html ---------------- */
|
||||
|
||||
const feHtml = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${name} · 游山玩水</title>
|
||||
<style>
|
||||
/* 本页样式自己维护,建议沿用 tc- 前缀,避免与其他 skill 的样式互相污染 */
|
||||
:root { --tc-bg:#f6f7f9; --tc-card:#fff; --tc-line:#e5e7eb; --tc-text:#1f2328; --tc-sub:#6b7280; --tc-accent:#2f6fed; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--tc-bg); color:var(--tc-text); font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; }
|
||||
.tc-page { max-width:1080px; margin:0 auto; padding:20px 16px 72px; }
|
||||
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
|
||||
.tc-top h1 { font-size:20px; margin:0; }
|
||||
.tc-sub { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-back { font-size:12px; color:var(--tc-accent); text-decoration:none; }
|
||||
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
|
||||
border-radius:8px; padding:8px 12px; margin:12px 0 16px; }
|
||||
.tc-block { background:var(--tc-card); border:1px solid var(--tc-line); border-radius:10px; padding:16px; margin-bottom:16px; }
|
||||
.tc-block-title { font-size:15px; margin:0 0 4px; }
|
||||
.tc-block-hint { font-size:12px; color:var(--tc-sub); }
|
||||
.tc-note { border-radius:8px; padding:10px 12px; font-size:13px; border:1px solid; margin-bottom:12px; }
|
||||
.tc-note-info { background:#f2f7ff; border-color:#cfe0ff; }
|
||||
.tc-note-danger { background:#fdece8; border-color:#f3c0b6; }
|
||||
.tc-note b { display:block; margin-bottom:2px; }
|
||||
.tc-note ol { margin:6px 0 0; padding-left:20px; }
|
||||
.tc-note ol li { margin-bottom:3px; }
|
||||
.tc-btn { display:inline-block; text-decoration:none; font-size:13px; border:none; cursor:pointer;
|
||||
background:var(--tc-accent); color:#fff; border-radius:8px; padding:9px 18px; font-family:inherit; }
|
||||
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
|
||||
.tc-pre { background:#f7f8fa; border:1px solid var(--tc-line); border-radius:8px; padding:10px;
|
||||
font:12px/1.5 Consolas,Monaco,monospace; white-space:pre-wrap; word-break:break-all;
|
||||
max-height:280px; overflow:auto; margin:0; }
|
||||
</style>
|
||||
<!-- 若需要报告渲染,请把渲染器与报告样式复制到本目录后在此引入(本 skill 私有,不引用别家 skill) -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="tc-page">
|
||||
<div class="tc-top">
|
||||
<h1>${name}</h1>
|
||||
<span class="tc-sub">游山玩水 · 单 skill 页</span>
|
||||
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
|
||||
</div>
|
||||
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
|
||||
|
||||
<!-- 鉴权失败引导:契约要求每个 skill 页面都要有,否则用户只会看到白屏 401。
|
||||
诊断接口归 lab 层所有(/auth/diag),本 skill 不重复实现。 -->
|
||||
<div class="tc-note tc-note-danger" id="tc_auth_note" style="display:none">
|
||||
<b>缺少导航通行凭证(nav_gate),接口被拒绝</b>
|
||||
本工具的<b>页面</b>允许同源导航进入,但<b>接口</b>只认 nav_gate cookie,所以会出现「页面能打开、接口全是 401」。按下面做一次即可恢复:
|
||||
<ol>
|
||||
<li>从<b>首页九宫格</b>点「游山玩水」卡片进来(必须经过首页,它才会种 cookie)</li>
|
||||
<li>全程用<b>同一个地址</b>:<code>localhost</code> 与 <code>127.0.0.1</code> 的 cookie 不通用</li>
|
||||
<li>nav_gate 是<b>会话级 cookie</b>,关闭浏览器后失效,需要重新从首页进一次</li>
|
||||
</ol>
|
||||
<div style="margin-top:10px">
|
||||
<a class="tc-btn" href="/">重新从首页进入</a>
|
||||
<button class="tc-btn tc-btn-ghost" id="tc_auth_retry">我已重新进入,重试</button>
|
||||
</div>
|
||||
<div id="tc_auth_diag"></div>
|
||||
</div>
|
||||
|
||||
<div class="tc-block">
|
||||
<h2 class="tc-block-title">1 · 填写行程参数</h2>
|
||||
<div class="tc-block-hint" id="tc_hint">TODO: 本技能的问答表单(按本技能的 qa 定义自己渲染)</div>
|
||||
<!-- TODO: 表单 -->
|
||||
</div>
|
||||
|
||||
<div id="tc_result"></div>
|
||||
|
||||
<div class="tc-note tc-note-info">
|
||||
<b>打不开 / 提示未授权?</b>
|
||||
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接粘贴链接会被拒绝(无同源来源)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict'
|
||||
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
|
||||
var API = '/api/thought_lab/${LAB_ID}/skills/${rawId}'
|
||||
var LAB_API = '/api/thought_lab/${LAB_ID}'
|
||||
var $ = function (id) { return document.getElementById(id) }
|
||||
|
||||
function esc(s) {
|
||||
return String(s === undefined || s === null ? '' : s)
|
||||
.replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] })
|
||||
}
|
||||
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
|
||||
|
||||
// 鉴权失败引导 + 拉取 lab 级公开诊断(这段是契约要求的必备片段,照抄即可)
|
||||
function showAuthNote() {
|
||||
$('tc_auth_note').style.display = ''
|
||||
fetch(LAB_API + '/auth/diag?t=' + Date.now(), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (d) {
|
||||
$('tc_auth_diag').innerHTML =
|
||||
'<div style="font-size:12px;font-weight:600;margin:12px 0 6px;color:#374151">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
|
||||
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
|
||||
})
|
||||
.catch(function () {})
|
||||
}
|
||||
|
||||
function requestJson(url, options) {
|
||||
return fetch(url + '?t=' + Date.now(), Object.assign({ cache: 'no-store' }, options || {}))
|
||||
.then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null
|
||||
try { j = JSON.parse(t) } catch (e) {}
|
||||
if (!r.ok) {
|
||||
var err = new Error((j && (j.error || j.hint)) || ('HTTP ' + r.status))
|
||||
err.detail = j || t.slice(0, 400)
|
||||
throw err
|
||||
}
|
||||
if (!j) throw new Error('返回不是合法 JSON')
|
||||
return j
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 读本技能元信息(ui_mode / 模型 / 本技能自己的 qa、report_spec…)
|
||||
requestJson(API + '/meta').then(function (r) {
|
||||
$('tc_meta').innerHTML =
|
||||
'ui_mode:<b>' + esc(r.ui_mode) + '</b>' +
|
||||
' · 技能:<b>' + esc(r.skill && r.skill.name) + '</b>' +
|
||||
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
|
||||
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
|
||||
$('tc_hint').textContent = '本技能骨架已就绪,接下来按本技能需要自己实现表单与渲染。'
|
||||
}).catch(function (e) {
|
||||
if (isUnauthorized(e)) showAuthNote()
|
||||
$('tc_meta').textContent = '技能元信息加载失败:' + (e && e.message || '')
|
||||
})
|
||||
|
||||
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
/* ---------------- 前端 icon.svg 占位 ---------------- */
|
||||
|
||||
const feIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="#2f6fed" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img"
|
||||
aria-label="${name}">
|
||||
<title>${name}</title>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7.5v9M7.5 12h9" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
/* ---------------- 落盘 ---------------- */
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(beDir, 'data'), { recursive: true })
|
||||
fs.mkdirSync(path.join(feDir, 'assets'), { recursive: true })
|
||||
fs.writeFileSync(path.join(beDir, 'config.json'), JSON.stringify(beConfig, null, 2) + '\n', 'utf-8')
|
||||
fs.writeFileSync(path.join(beDir, 'index.js'), beIndex, 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'index.html'), feHtml, 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'icon.svg'), feIcon, 'utf-8')
|
||||
// data/ 与 assets/ 是空目录:放一个说明文件,避免「空目录进不了 git」导致目录消失
|
||||
fs.writeFileSync(path.join(beDir, 'data', '.gitkeep'), '', 'utf-8')
|
||||
fs.writeFileSync(path.join(feDir, 'assets', '.gitkeep'), '', 'utf-8')
|
||||
} catch (e) {
|
||||
die('写入失败:' + String(e.message || e))
|
||||
}
|
||||
|
||||
console.log('✓ 新技能骨架已生成:' + rawId + '(' + name + ')')
|
||||
console.log('')
|
||||
console.log('后端:' + path.relative(ROOT, beDir))
|
||||
console.log(' config.json ← 聚合页元信息 + 本技能 AI 参数(enabled 默认 false)')
|
||||
console.log(' index.js ← 导出 bindRoutes(router, ctx),业务路由空壳')
|
||||
console.log(' data/ ← 本技能私有数据(.gitignore 已排除,不进库)')
|
||||
console.log('前端:' + path.relative(ROOT, feDir))
|
||||
console.log(' index.html ← 空页面(含返回列表 + 鉴权失败引导片段)')
|
||||
console.log(' icon.svg ← 占位图标')
|
||||
console.log(' assets/ ← 本技能私有静态资源')
|
||||
console.log('')
|
||||
console.log('接下来(按需自己写,脚手架刻意不产出):')
|
||||
console.log(' 1. 写本技能的技能包文件(格式自定),在 index.js 里读进来')
|
||||
console.log(' 2. 写本技能的 prompt 与输出契约(可参考兄弟 skill,但请【复制后再改】而不是引用它)')
|
||||
console.log(' 3. 写本技能的问答表单与结果渲染;要渲染器/导出器就从兄弟 skill 复制一份改')
|
||||
console.log(' 4. 自测:node dev_test_scripts/unit/test_travel_citytour_isolation.js')
|
||||
console.log(' ↑ 含隔离校验:会断言本 skill 没有跨目录引用(引用兄弟 skill 或 lab 业务文件都会失败)')
|
||||
console.log(' 5. 自写本技能的契约测试:照 test_travel_citytour_rail_skill.js 再写一份(每个 skill 一份)')
|
||||
console.log(' 6. 自检:GET /api/thought_lab/' + LAB_ID + '/skills/_health')
|
||||
console.log(' 7. 一切就绪后,把 config.json 的 enabled 改成 true(默认 false 是为了避免半成品进聚合页)')
|
||||
console.log('')
|
||||
console.log('★ 隔离规则(本 lab 的宪法,务必遵守):')
|
||||
console.log(' · 本 skill 只能引用自己目录内的文件;唯一允许向上引用的是 lab 级 assets/vendor/(第三方库)')
|
||||
console.log(' · 禁止引用兄弟 skill 的任何文件(哪怕只是一行代码);要复用就复制过来再改')
|
||||
console.log(' · 鉴权/凭据/北京时间/XSS 转义 不许自己实现,一律用 lab 层注入的 ctx.*')
|
||||
console.log(' · 数据落在本目录的 data/(已被 .gitignore 排除,不会进库)')
|
||||
console.log(' · 每个源文件头部请写清「为什么这个文件在 skill 目录而不是 lab 层」,方便后人理解')
|
||||
@@ -0,0 +1,202 @@
|
||||
/* ============================================================
|
||||
* tool_gen_travel_citytour_photos.js —— 用百炼生成「游山玩水」样例的 AI 示意图
|
||||
*
|
||||
* 背景:Trae 自带的 text_to_image 接口(coresg)对任意 prompt 只返回同一张固定图,
|
||||
* 无法逐节点出图;改用百炼(DashScope)文生图。
|
||||
* 实测可用两条通道:
|
||||
* A) multimodal-generation + qwen-image 系列 —— 直接返回图片 URL,无需轮询
|
||||
* B) text2image/image-synthesis + wanx/wan2.x —— 异步任务,需轮询
|
||||
* 脚本按优先级依次尝试,谁通用谁,避免单点故障。
|
||||
*
|
||||
* 产物:public/tools/thought_lab/labs/travel_citytour/assets/photos/*.jpg
|
||||
* 运行:node dev_test_scripts/tools/tool_gen_travel_citytour_photos.js
|
||||
* 说明:密钥来自本机私有凭据文件,不写入仓库;本脚本只读密钥、只写图片。
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || /^\s*#/.test(line)) continue
|
||||
const i = line.indexOf('=')
|
||||
if (i < 0) continue
|
||||
const k = line.slice(0, i).trim()
|
||||
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
|
||||
if (k && !(k in process.env)) process.env[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const KEY = process.env.DASHSCOPE_API_KEY || ''
|
||||
const BASE = 'https://dashscope.aliyuncs.com'
|
||||
const OUT_DIR = path.join(
|
||||
process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'assets', 'photos'
|
||||
)
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
// 优先级:先试直接返回的 qwen-image 系列,再退到异步的 wanx 系列
|
||||
const MODEL_CHAIN = [
|
||||
{ id: 'qwen-image-3.0-pro', kind: 'multimodal' },
|
||||
{ id: 'qwen-image-3.0', kind: 'multimodal' },
|
||||
{ id: 'qwen-image-max', kind: 'multimodal' },
|
||||
{ id: 'qwen-image-plus', kind: 'multimodal' },
|
||||
{ id: 'wan2.2-t2i-plus', kind: 'synthesis', size: '1280*720' },
|
||||
{ id: 'wanx2.1-t2i-plus', kind: 'synthesis', size: '1280*720' }
|
||||
]
|
||||
|
||||
// 尺寸候选:页面卡片里只显示 150px 高,用不到 1664×928,
|
||||
// 按「够用且省体积」的顺序试;qwen-image 对尺寸有白名单,故逐个降级尝试。
|
||||
const MULTIMODAL_SIZES = [
|
||||
process.env.TC_IMG_SIZE || '',
|
||||
'1024*576',
|
||||
'1280*720',
|
||||
'1328*1328'
|
||||
].filter(Boolean)
|
||||
|
||||
const STYLE = 'realistic travel photograph, natural daylight, no text, no watermark, no logo'
|
||||
|
||||
const JOBS = [
|
||||
{ file: 'rudong_cover.jpg', prompt: 'coastal highway along the Yellow Sea tidal flat in Jiangsu China, wind turbines in the distance, autumn afternoon, wide landscape' },
|
||||
{ file: 'rudong_hike1_embankment.jpg', prompt: 'long concrete sea embankment levee path above a mudflat, fishing nets and small boats below, overcast coastal sky, empty path' },
|
||||
{ file: 'rudong_bengcha.jpg', prompt: 'old Chinese town street with grey brick houses, stone slab lane, red lanterns, quiet morning light, low-rise traditional buildings' },
|
||||
{ file: 'rudong_lunch_local.jpg', prompt: 'small local seafood restaurant table in a Jiangsu coastal town, clam soup, steamed fish, plain ceramic bowls, simple interior' },
|
||||
{ file: 'rudong_bathhouse.jpg', prompt: 'outdoor hot spring pool in a resort garden at dusk, steam rising from the water, wooden deck, greenery around' },
|
||||
{ file: 'rudong_carsleep_park.jpg', prompt: 'quiet parking lot beside a city park in a small Chinese town at night, warm street lamps, one car parked alone, trees' },
|
||||
{ file: 'rudong_hike2_forest.jpg', prompt: 'coastal shelterbelt forest dirt trail, tall trees on both sides, dappled sunlight through leaves, green tunnel path' }
|
||||
]
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function genMultimodal(model, prompt, size) {
|
||||
const r = await fetch(BASE + '/api/v1/services/aigc/multimodal-generation/generation', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { messages: [{ role: 'user', content: [{ text: prompt + ', ' + STYLE }] }] },
|
||||
parameters: { size, n: 1, prompt_extend: true }
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000)
|
||||
})
|
||||
const t = await r.text()
|
||||
let j = null
|
||||
try { j = JSON.parse(t) } catch {}
|
||||
if (r.status !== 200) throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
|
||||
const content = j && j.output && j.output.choices && j.output.choices[0] &&
|
||||
j.output.choices[0].message && j.output.choices[0].message.content
|
||||
const url = Array.isArray(content) ? (content.find((c) => c.image) || {}).image : null
|
||||
if (!url) throw new Error('未返回图片:' + t.slice(0, 200))
|
||||
return url
|
||||
}
|
||||
|
||||
async function genSynthesis(model, prompt, size) {
|
||||
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: { prompt: prompt + ', ' + STYLE },
|
||||
parameters: { size, n: 1 }
|
||||
}),
|
||||
signal: AbortSignal.timeout(60000)
|
||||
})
|
||||
const t = await r.text()
|
||||
let j = null
|
||||
try { j = JSON.parse(t) } catch {}
|
||||
if (r.status !== 200 || !j || !j.output || !j.output.task_id) {
|
||||
throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
|
||||
}
|
||||
const taskId = j.output.task_id
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(3000)
|
||||
const pr = await fetch(BASE + '/api/v1/tasks/' + taskId, { headers: { Authorization: 'Bearer ' + KEY } })
|
||||
const pj = JSON.parse(await pr.text())
|
||||
const st = pj && pj.output && pj.output.task_status
|
||||
if (st === 'SUCCEEDED') {
|
||||
const url = ((pj.output.results || [])[0] || {}).url
|
||||
if (!url) throw new Error('任务成功但无图片 URL')
|
||||
return url
|
||||
}
|
||||
if (st === 'FAILED') throw new Error('任务失败:' + (pj.output.message || pj.message || ''))
|
||||
}
|
||||
throw new Error('任务超时')
|
||||
}
|
||||
|
||||
async function download(url, file) {
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(120000) })
|
||||
if (!r.ok) throw new Error('下载失败 HTTP ' + r.status)
|
||||
const buf = Buffer.from(await r.arrayBuffer())
|
||||
if (buf.length < 10000) throw new Error('文件过小,疑似异常:' + buf.length)
|
||||
const full = path.join(OUT_DIR, file)
|
||||
fs.writeFileSync(full, buf)
|
||||
return { size: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex') }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!KEY) { console.log('缺少 DASHSCOPE_API_KEY'); process.exit(1) }
|
||||
|
||||
// 先做一次连通性预检,确定这次用哪条通道、哪个尺寸
|
||||
let chosen = null
|
||||
for (const m of MODEL_CHAIN) {
|
||||
if (chosen) break
|
||||
if (m.kind === 'multimodal') {
|
||||
for (const size of MULTIMODAL_SIZES) {
|
||||
process.stdout.write('[preflight] ' + m.id + ' size=' + size + ' … ')
|
||||
try {
|
||||
await genMultimodal(m.id, 'a simple red apple on a wooden table', size)
|
||||
console.log('可用(直接返回)')
|
||||
chosen = { id: m.id, kind: 'multimodal', size }
|
||||
break
|
||||
} catch (e) {
|
||||
console.log('不可用:' + e.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.stdout.write('[preflight] ' + m.id + ' size=' + m.size + ' … ')
|
||||
try {
|
||||
// 异步模型预检只验证能否建任务,避免多花钱
|
||||
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' },
|
||||
body: JSON.stringify({ model: m.id, input: { prompt: 'test' }, parameters: { size: m.size, n: 1 } })
|
||||
})
|
||||
const j = JSON.parse(await r.text())
|
||||
if (!(j && j.output && j.output.task_id)) throw new Error(j && j.message ? j.message : 'HTTP ' + r.status)
|
||||
console.log('可用(异步任务)')
|
||||
chosen = m
|
||||
} catch (e) {
|
||||
console.log('不可用:' + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen) { console.log('没有可用模型/尺寸组合'); process.exit(1) }
|
||||
console.log('[chosen] ' + chosen.id + ' size=' + chosen.size + ' → 输出目录 ' + OUT_DIR)
|
||||
|
||||
const results = []
|
||||
for (const job of JOBS) {
|
||||
process.stdout.write('[gen] ' + job.file + ' … ')
|
||||
try {
|
||||
const url = chosen.kind === 'multimodal'
|
||||
? await genMultimodal(chosen.id, job.prompt, chosen.size)
|
||||
: await genSynthesis(chosen.id, job.prompt, chosen.size)
|
||||
const d = await download(url, job.file)
|
||||
results.push({ file: job.file, ...d })
|
||||
console.log('OK ' + d.size + ' bytes ' + d.md5.slice(0, 12))
|
||||
} catch (e) {
|
||||
console.log('FAIL ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n===== 汇总 =====')
|
||||
results.forEach((r) => console.log(r.file + ' ' + r.size + ' ' + r.md5))
|
||||
const uniq = new Set(results.map((r) => r.md5))
|
||||
console.log('生成 ' + results.length + '/' + JOBS.length + ' 张,去重后 ' + uniq.size + ' 张')
|
||||
console.log(uniq.size === results.length ? '✓ 每张都不同,可逐节点使用' : '❌ 存在重复图,不可用')
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1) })
|
||||
@@ -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