feat: 新增高铁游与车宿徒步游技能及相关基础设施

This commit is contained in:
yangxiangyuan
2026-09-30 13:47:51 +08:00
parent bbc7ca6523
commit ac475b6659
66 changed files with 14572 additions and 0 deletions
@@ -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)