diff --git a/.gitignore b/.gitignore index 819cfee..50262f0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ umersoft.com_OV_SSL/ # ===== 调试/临时文件 ===== *.md +# 例外:游山玩水 lab 的技能包规则文件是业务资产,必须入库 +!**/thought_lab/labs/travel_citytour/skills/**/*.md *.txt cookies.txt _fix_*.js diff --git a/dev_test_scripts/debug/debug_dashscope_image_models.js b/dev_test_scripts/debug/debug_dashscope_image_models.js new file mode 100644 index 0000000..9cb2187 --- /dev/null +++ b/dev_test_scripts/debug/debug_dashscope_image_models.js @@ -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)) \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_probe_max_tokens.js b/dev_test_scripts/debug/debug_probe_max_tokens.js new file mode 100644 index 0000000..d9e8bc4 --- /dev/null +++ b/dev_test_scripts/debug/debug_probe_max_tokens.js @@ -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)) \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_rail_replay_wuyuan.js b/dev_test_scripts/debug/debug_rail_replay_wuyuan.js new file mode 100644 index 0000000..5f91fd0 --- /dev/null +++ b/dev_test_scripts/debug/debug_rail_replay_wuyuan.js @@ -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) \ No newline at end of file diff --git a/dev_test_scripts/integration/test_travel_citytour_generate_integration.js b/dev_test_scripts/integration/test_travel_citytour_generate_integration.js new file mode 100644 index 0000000..a26344e --- /dev/null +++ b/dev_test_scripts/integration/test_travel_citytour_generate_integration.js @@ -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// 内 +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) }) \ No newline at end of file diff --git a/dev_test_scripts/runners/run_new_travel_skill.js b/dev_test_scripts/runners/run_new_travel_skill.js new file mode 100644 index 0000000..3a64df4 --- /dev/null +++ b/dev_test_scripts/runners/run_new_travel_skill.js @@ -0,0 +1,384 @@ +/* ============================================================ + * run_new_travel_skill.js —— 「游山玩水」新技能脚手架 + * + * 用途:一条命令产出新 skill 的最小骨架(前后端各一套空壳),之后自己填。 + * + * 用法: + * node dev_test_scripts/runners/run_new_travel_skill.js "技能中文名" + * 例:node dev_test_scripts/runners/run_new_travel_skill.js japan_7d "日本七天游" + * + * 产出(两棵树下各建同名目录): + * src/server/thought_lab/labs/travel_citytour/skills// + * ├── config.json 聚合页元信息 + 本技能 AI 参数(含注释) + * ├── index.js 导出 bindRoutes(router, ctx) 的空壳(含注释与示例路由) + * └── data/ 本技能私有数据目录(.gitignore 已排除,不进库) + * public/tools/thought_lab/labs/travel_citytour/skills// + * ├── 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 "技能中文名"') +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/] 前缀) +// 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 = ` + + + + +${name} · 游山玩水 + + + + +
+
+

${name}

+ 游山玩水 · 单 skill 页 + ← 返回技能列表 +
+
正在读取技能状态…
+ + + + +
+

1 · 填写行程参数

+
TODO: 本技能的问答表单(按本技能的 qa 定义自己渲染)
+ +
+ +
+ +
+ 打不开 / 提示未授权? + 本技能受导航鉴权保护,必须从首页九宫格进入思想实验室、再点本卡片;直接粘贴链接会被拒绝(无同源来源)。 +
+
+ + + + +` + +/* ---------------- 前端 icon.svg 占位 ---------------- */ + +const feIcon = ` + ${name} + + + +` + +/* ---------------- 落盘 ---------------- */ + +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 层」,方便后人理解') \ No newline at end of file diff --git a/dev_test_scripts/tools/tool_gen_travel_citytour_photos.js b/dev_test_scripts/tools/tool_gen_travel_citytour_photos.js new file mode 100644 index 0000000..2a4b0e5 --- /dev/null +++ b/dev_test_scripts/tools/tool_gen_travel_citytour_photos.js @@ -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) }) \ No newline at end of file diff --git a/dev_test_scripts/unit/test_travel_citytour_export.js b/dev_test_scripts/unit/test_travel_citytour_export.js new file mode 100644 index 0000000..242c4d7 --- /dev/null +++ b/dev_test_scripts/unit/test_travel_citytour_export.js @@ -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] / / 内 +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('') && html.trim().endsWith('')) +ok('lang=zh-CN', html.indexOf('') > 0) +ok('标题取自 payload.summary.title', html.indexOf('' + payload.summary.title + ' · 行程报告') > 0) +ok('样式已内联( + + +
+
+

游山玩水

+ 思想实验室 · 选择一个技能进入(每个技能是完全独立的一套行程打法) +
+
正在读取技能列表…
+ + + +
加载中…
+ + +
+ + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bathhouse.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bathhouse.jpg new file mode 100644 index 0000000..3406986 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bathhouse.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bengcha.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bengcha.jpg new file mode 100644 index 0000000..5c77430 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_bengcha.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_carsleep_park.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_carsleep_park.jpg new file mode 100644 index 0000000..3b89062 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_carsleep_park.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_cover.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_cover.jpg new file mode 100644 index 0000000..0e8b37d Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_cover.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike1_embankment.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike1_embankment.jpg new file mode 100644 index 0000000..b0886c9 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike1_embankment.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike2_forest.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike2_forest.jpg new file mode 100644 index 0000000..9319b72 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_hike2_forest.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_lunch_local.jpg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_lunch_local.jpg new file mode 100644 index 0000000..d0ccbb1 Binary files /dev/null and b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/assets/photos/rudong_lunch_local.jpg differ diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/icon.svg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/icon.svg new file mode 100644 index 0000000..56f9a5a --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/icon.svg @@ -0,0 +1,11 @@ + + 上海周边2日车宿徒步游 + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.html b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.html new file mode 100644 index 0000000..25d6608 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.html @@ -0,0 +1,743 @@ + + + + + +上海周边2日车宿徒步游 · 游山玩水 + + + + + +
+
+

上海周边2日车宿徒步游

+ 游山玩水 · 单 skill 页 + ← 返回技能列表 +
+
正在读取技能状态…
+ + + + + +
+

1 · 填写行程参数

+
正在读取问答定义…
+
+
+ + + +
+
+ +
+ +
+ 打不开 / 提示未授权? + 本技能受导航鉴权保护,必须从首页九宫格进入思想实验室、再点本卡片;直接把链接粘贴到地址栏会被拒绝(无同源来源)。 +
查看离线样例:南通如东 +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/index.html b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/index.html new file mode 100644 index 0000000..34a085d --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/index.html @@ -0,0 +1,114 @@ + + + + + +南通如东 · 两日车宿徒步|游山玩水(离线样例) + + + + + +
+
+

南通如东 · 两日车宿徒步

+ 游山玩水 · 技能包「上海周边2日车宿徒步游」· 离线样例 +
+
正在载入样例数据…
+
+ 这是效果验证样例:行程由技能包规则生成,所有主观信息(免费停车、本地人常去、温泉档次与卫生)均标注置信度并列入「待核实清单」。 + 本样例不编造店名与价格,餐饮与超市只给业态与位置描述。出行前请自行复核关键信息。 +
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/report_spec.json b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/report_spec.json new file mode 100644 index 0000000..73872c2 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/report_spec.json @@ -0,0 +1,24 @@ +{ + "_comment": "【阶段一离线样例专用】本文件是技能包 report_spec.json 的样例副本,仅为了让 sample/index.html 能独立打开。阶段二由后端从 skills/sh_2d_car_hike/report_spec.json 读取并注入,届时本副本可直接删除。", + "version": "0.1.0", + "block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"], + "layout": "single_column", + "show_index": true, + "sections": [ + { "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" }, + { "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] }, + { "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" }, + { "id": "sec_cards", "type": "cards", "title": "节点详情", "data": "$.cards", "group_by": "day" }, + { "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" }, + { "id": "sec_drive_out", "type": "table", "title": "去程 · 双版本对比", "data": "$.tables", "pick_id": "drive_out_compare" }, + { "id": "sec_drive_back", "type": "table", "title": "返程 · 双版本对比", "data": "$.tables", "pick_id": "drive_back_compare" }, + { "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" }, + { "id": "sec_parking", "type": "table", "title": "各节点停车一览", "data": "$.tables", "pick_id": "parking_summary" }, + { "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" }, + { "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" }, + { "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" } + ], + "confidence_badge": { "high": "已核实", "medium": "待确认", "low": "需核实" }, + "image_disclaimer_default": "AI 示意图,非实景", + "map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹" +} \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/rudong_2026-09-19.json b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/rudong_2026-09-19.json new file mode 100644 index 0000000..a99a322 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/sample/rudong_2026-09-19.json @@ -0,0 +1,728 @@ +{ + "meta": { + "skill_id": "sh_2d_car_hike", + "skill_name": "上海周边2日车宿徒步游", + "skill_version": "0.3.0", + "schema_version": "0.2.0", + "destination": { + "key": "rudong", + "name": "南通如东", + "admin": "江苏省南通市如东县", + "center": [32.31, 121.19] + }, + "qa": { + "departure_origin": "上海宝山富锦路地铁站", + "departure_date": "2026-09-19", + "departure_time": "06:40", + "vehicle_type": "fuel", + "headcount": 1 + }, + "generated_at": "2026-09-17 21:30:00", + "generator": "manual_sample", + "disclaimer": "本行程按技能包规则生成,用于效果验证;所有主观信息均标注置信度,出行前请按『待核实清单』自行复核。" + }, + + "summary": { + "title": "南通如东 · 两日车宿徒步", + "one_liner": "沿海堤岸走两段平路徒步,落脚栟茶古镇逛三小时,傍晚泡温泉,夜里睡在县城公园,周日再走一段林带后返沪。", + "tags": ["2天1夜", "单人自驾", "车内过夜", "两次1.5h徒步", "历史街区", "温泉"], + "stats": [ + { "label": "全程车程", "value": "约 340 km", "hint": "往返里程,高速版口径;起点为宝山富锦路地铁站" }, + { "label": "徒步合计", "value": "3.0 小时", "hint": "两段各 1.5h,平路无爬升" }, + { "label": "预估花费", "value": "≈ 900 元", "hint": "含过路费/油费/两餐/温泉/补给" }, + { "label": "硬时间点", "value": "17:00 / ≤22:00 / 05:00", "hint": "浴场 / 车宿(不晚于)/ 晨起" }, + { "label": "地形提醒", "value": "平原·无山", "hint": "已触发地形降级,两段徒步爬升均约 0" } + ] + }, + + "timeline": { + "days": [ + { + "label": "第一天 · 周六", + "date": "2026-09-19", + "rows": [ + { "time": "06:40", "title": "宝山富锦路地铁站出发(可 06:30 提前)", "anchor_id": "drive_out", "kind": "soft", "activity": "move", "note": "高速版 2.6h / 非高速版 3.5h" }, + { "time": "09:15", "title": "抵达沿海堤岸徒步起点", "anchor_id": "hike_1", "kind": "soft", "activity": "move", "note": "06:40 出发 + 约 2.6h 车程;到达即走" }, + { "time": "09:20-10:50", "title": "第一段徒步 1.5h", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路,约 6.5km,爬升约 0m" }, + { "time": "11:05", "title": "抵达茶古镇外围,先解决停车", "anchor_id": "district_parking", "kind": "soft", "activity": "move", "note": "免费与否需现场确认" }, + { "time": "11:20-12:25", "title": "午饭(本地小馆)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "三选一,人均约 100" }, + { "time": "12:30-15:30", "title": "古镇打卡动线约 3 小时", "anchor_id": "district_walk", "kind": "soft", "activity": "walk", "note": "5 个点位,步行约 3.2km" }, + { "time": "16:15", "title": "离开古镇前往温泉", "anchor_id": "bathhouse", "kind": "soft", "activity": "move", "note": "车程约 35 分钟,含找车位" }, + { "time": "17:00", "title": "抵达温泉 / 洗浴中心", "anchor_id": "bathhouse", "kind": "hard", "activity": "move", "note": "硬时间点:骨架声明的到达时刻(16:45-17:15)" }, + { "time": "17:05-19:40", "title": "泡汤、淋浴、休息", "anchor_id": "bathhouse", "kind": "soft", "note": "建议不超过 2.5 小时" }, + { "time": "19:40-20:10", "title": "浴场旁超市补给(与浴场一起完成)", "anchor_id": "grocery", "kind": "soft", "activity": "shop", "note": "同商圈,与浴场共用停车点,不挪车" }, + { "time": "20:20", "title": "前往车宿点", "anchor_id": "car_sleep", "kind": "soft", "activity": "move", "note": "往县城方向,车程约 45 分钟" }, + { "time": "21:10", "title": "抵达车宿停车点", "anchor_id": "car_sleep", "kind": "hard", "activity": "sleep", "note": "硬点要求不晚于 22:00,本次提前到位" }, + { "time": "21:10-22:00", "title": "车内整理、吃夜宵、准备就寝", "anchor_id": "car_sleep", "kind": "soft", "activity": "sleep", "note": "提前到就提前休息,不必等到 22:00" } + ] + }, + { + "label": "第二天 · 周日", + "date": "2026-09-20", + "rows": [ + { "time": "05:00", "title": "起床散步", "anchor_id": "morning_walk", "kind": "hard", "activity": "walk", "note": "硬时间点;日出约 05:35,此时为黎明微光" }, + { "time": "05:05-06:10", "title": "公园环线晨走", "anchor_id": "morning_walk", "kind": "soft", "activity": "walk", "note": "约 3km 环线" }, + { "time": "06:15-07:10", "title": "早饭 + 洗漱", "anchor_id": "breakfast_wash", "kind": "soft", "activity": "eat", "note": "早点铺 + 公园公厕/加油站卫生间" }, + { "time": "07:20", "title": "驱车前往第二徒步点", "anchor_id": "hike_2", "kind": "soft", "activity": "move", "note": "沿海林带方向" }, + { "time": "08:15-09:45", "title": "第二段徒步 1.5h", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "林道 + 水岸,约 6.5km,爬升约 0m" }, + { "time": "10:00-11:20", "title": "打卡点 1-2 个", "anchor_id": "photo_spots", "kind": "soft", "activity": "walk", "note": "人文点位为主,与昨日古镇点位去重" }, + { "time": "11:30-12:40", "title": "午饭(县城本地家常菜)", "anchor_id": "lunch_d2", "kind": "soft", "activity": "eat", "note": "优先能停车" }, + { "time": "13:00", "title": "返程上海", "anchor_id": "drive_back", "kind": "soft", "activity": "move", "note": "注意周日下午进城方向高峰" } + ] + } + ] + }, + + "cards": [ + { + "id": "c_drive_out", + "anchor_id": "drive_out", + "day": 1, + "title": "去程 · 双版本自驾", + "subtitle": "宝山富锦路地铁站 → 如东(沿海徒步点)", + "image": { + "url": "../assets/photos/rudong_cover.jpg", + "kind": "ai_illustration", + "caption": "沿海公路与堤岸意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "双版本", "tone": "info" }, + { "text": "里程为估算", "tone": "warn" } + ], + "fields": [ + { "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时里程与路况" } }, + { "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国道走向与限速以导航为准,国省道易遇施工与拥堵" } }, + { "label": "加油点", "value": "高速版服务区 + 沿途连锁加油站充足;出高速后县城加油站密集" }, + { "label": "拥堵风险", "value": { "text": "周六上午出城方向 07:00-09:00 相对顺畅,过江通道节假日上午易排队", "confidence": "medium", "verify": "出行当天看实时路况" } }, + { "label": "取舍建议", "value": "赶时间走高速;想省钱且不赶时间走非高速,但非高速要多留 1 小时余量" } + ], + "bullets": [ + "起点:上海宝山富锦路地铁站(示例坐标仅用于地图示意)", + "中途拐点:过江后经南通方向转如东", + "终点:沿海堤岸徒步起点,路边可临停卸装备", + "出发前把两套路线都存进导航,现场按实时路况二选一" + ], + "map_marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"] + }, + { + "id": "c_hike_1", + "anchor_id": "hike_1", + "day": 1, + "title": "第一段徒步 · 沿海堤岸", + "subtitle": "1.5 小时 · 到达即走", + "image": { + "url": "../assets/photos/rudong_hike1_embankment.jpg", + "kind": "ai_illustration", + "caption": "海堤与滩涂意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "已触发地形降级", "tone": "warn" }, + { "text": "平路无爬升", "tone": "warn" } + ], + "fields": [ + { "label": "起点 / 终点", "value": { "text": "沿海堤岸起点 → 沿堤折返或走至下一闸口", "confidence": "low", "verify": "堤岸是否允许步行、是否有施工封闭,需现场确认" } }, + { "label": "折算里程", "value": "按 4.3 km/h 常规步速,1.5 小时约 6.5 km" }, + { "label": "累计爬升", "value": "约 0 m(平路徒步,运动量按时长与步数计)" }, + { "label": "路面类型", "value": "堤顶水泥路 / 土路混合,风力较大" }, + { "label": "难度", "value": "低(无技术路段),但日晒与海风体感明显" }, + { "label": "停车", "value": { "text": "堤岸起点附近路侧空地可临停", "confidence": "low", "verify": "是否允许停车、是否属禁停区需现场看标识" } }, + { "label": "补水 / 卫生间", "value": { "text": "堤岸沿线基本无补给,需自带水;卫生间需回镇上解决", "confidence": "medium", "verify": "出发前确认最近公厕位置" } } + ], + "bullets": [ + "降级说明:如东为平原沿海地区,无山可爬,本段改走堤岸与滩涂边,累计爬升约 0,不得按山地徒步的运动量期待", + "想出汗的做法:把配速提到 5.5-6 km/h 快走,或做 4 组 200 米折返快走", + "海边风力大,注意防晒与帽子固定;鞋建议防滑、不穿白鞋(滩涂泥)" + ], + "map_marker_ids": ["m_hike1_start", "m_hike1_end"] + }, + { + "id": "c_district_arrive", + "anchor_id": "district_arrive", + "day": 1, + "title": "街区 · 茶古镇一带", + "subtitle": "历史街区", + "badges": [ + { "text": "历史街区", "tone": "good" }, + { "text": "待确认", "tone": "warn" } + ], + "fields": [ + { "label": "街区定性", "value": "历史街区(古镇型),非纯商业步行街" }, + { "label": "范围", "value": { "text": "古镇老街一带,可逛核心范围不大,约 2-3 条主街", "confidence": "medium", "verify": "实际开放范围与是否局部封闭,出发前查当地公告" } }, + { "label": "可逛时长", "value": "约 3 小时(含吃饭与休憩),节奏偏慢" }, + { "label": "选择理由", "value": "如东县城以现代街区为主,历史街区以古镇型最符合本条要求" }, + { "label": "备选", "value": "如东县城掘港镇老街一带(更偏闹市街区,若古镇封闭可切换)" } + ], + "bullets": [ + "古镇型街区的价值在街巷肌理与老建筑,不追求商铺数量", + "若遇整修或大面积围挡,直接切到备选的县城闹市街区,动线按同规则重排" + ], + "map_marker_ids": ["m_bengcha_gate", "m_bengcha_center"] + }, + { + "id": "c_district_parking", + "anchor_id": "district_parking", + "day": 1, + "title": "街区停车(免费优先)", + "subtitle": "先停车,再吃饭", + "badges": [ + { "text": "需核实", "tone": "danger" }, + { "text": "不得默认免费", "tone": "warn" } + ], + "fields": [ + { "label": "首选停车点", "value": { "text": "古镇入口外围路侧泊位 / 镇区公共停车场", "confidence": "low", "verify": "出发前打电话给古镇管理方或看地图实景确认是否免费" } }, + { "label": "免费判定依据", "value": { "text": "暂无可靠依据,需现场看标识或电话确认", "confidence": "low", "verify": "以现场收费公示牌为准" } }, + { "label": "预估费用", "value": "若收费,按小客车 5-10 元/次估(占位口径)" }, + { "label": "步行距离", "value": "预计 5 分钟内进主街;实际以现场为准" }, + { "label": "风险提示", "value": "古镇周末可能有临时交通管制,停车点会临时变化" } + ], + "bullets": [ + "规则要求:本节点不允许把收费点写成免费点;如无免费方案,必须给收费方案与费用", + "若愿意多走 5-10 分钟,可把车停在镇区外围再步行进古镇核心" + ], + "map_marker_ids": ["m_bengcha_park"] + }, + { + "id": "c_lunch_d1", + "anchor_id": "lunch_d1", + "day": 1, + "title": "第一天午饭 · 本地小馆", + "subtitle": "人均约 100 · 单人份", + "image": { + "url": "../assets/photos/rudong_lunch_local.jpg", + "kind": "ai_illustration", + "caption": "沿海小镇家常菜意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "3 家候选", "tone": "info" }, + { "text": "店名待核实", "tone": "danger" } + ], + "fields": [ + { "label": "候选 1(走路可达)", "value": { "text": "古镇外围沿街海鲜小馆 —— 以文蛤、小海鲜、时令河鲜为招牌的本地小馆", "confidence": "low", "verify": "店名与实际人均需现场确认;本样例不编造店名" } }, + { "label": "候选 2(走路可达)", "value": { "text": "镇区非游客主街的家常菜馆 —— 做本地家常菜,中午有本地人吃饭", "confidence": "low", "verify": "同上" } }, + { "label": "候选 3(需挪车)", "value": { "text": "镇区外围临街面馆 / 小炒 —— 停车方便,适合赶时间", "confidence": "low", "verify": "同上" } }, + { "label": "判断依据", "value": "加分项:不在游客主街、有本地招牌菜、评价中本地标签占比高、开店年限长;减分项:门口拉客、中英双语菜单、团餐大巴停靠" }, + { "label": "单人怎么点", "value": "一荤一素 + 一份主食,或点一碗海鲜面/文蛤面;人均 100 通常吃得很饱" }, + { "label": "停车", "value": "候选 1、2 用街区停车点;候选 3 门口可停" } + ], + "bullets": [ + "本地口味偏清淡鲜甜,偏咸辣可要求少盐", + "海鲜按时令,若遇禁渔期价格会明显上行,点单前先问价", + "不确定店名时合规做法:只给业态 + 位置描述,不给编造名字" + ], + "map_marker_ids": ["m_bengcha_center", "m_lunch_d1_alt"] + }, + { + "id": "c_district_walk", + "anchor_id": "district_walk", + "day": 1, + "title": "街区打卡动线 · 约 3 小时", + "subtitle": "5 个点位 · 步行约 3.2 km", + "image": { + "url": "../assets/photos/rudong_bengcha.jpg", + "kind": "ai_illustration", + "caption": "古镇街巷意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "含点线顺序", "tone": "info" }, + { "text": "逗留节奏偏慢", "tone": "plain" } + ], + "fields": [ + { "label": "总时长", "value": "约 3 小时(12:40-15:40),含 1 次坐下休憩" }, + { "label": "步行距离", "value": "约 3.2 km,平路,随时可缩短" }, + { "label": "休憩点", "value": { "text": "主街上的茶馆 / 咖啡位(店名待核实)", "confidence": "low", "verify": "现场找有座位的店即可,不必指定" } } + ], + "bullets": [ + "① 古镇入口牌坊 12:40-12:55 —— 拍门头与街口全景,建立方位感", + "② 主街老建筑段 12:55-13:40 —— 看沿街立面与老字号门面,慢走细看", + "③ 巷弄纵深段 13:40-14:25 —— 拐进横向支巷,人少、更出片", + "④ 水岸 / 桥头 14:25-15:05 —— 坐 20 分钟休憩,看水面与老码头方向", + "⑤ 出口前土产段 15:05-15:40 —— 顺路买点海产干货或糕点带走", + "地图连线顺序即为 ①→⑤,用于生成点线路线图", + "如某个点位封闭或施工,直接跳过并把时间匀给 ③④" + ], + "map_marker_ids": ["m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"] + }, + { + "id": "c_bathhouse", + "anchor_id": "bathhouse", + "day": 1, + "title": "下午浴场 · 温泉替代", + "subtitle": "17:00 抵达(硬时间点)", + "image": { + "url": "../assets/photos/rudong_bathhouse.jpg", + "kind": "ai_illustration", + "caption": "温泉汤池意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "已触发降级", "tone": "warn" }, + { "text": "需核实", "tone": "danger" } + ], + "fields": [ + { "label": "降级说明", "value": "如东本地无传统大型浴场,本条按规则降级为温泉 / 大型洗浴中心(营业面积大、本地评价量级高)" }, + { "label": "候选", "value": { "text": "沿海温泉 / 度假区方向的大型汤池(候选)", "confidence": "low", "verify": "营业状态、是否需预约、当日是否开放,出发前电话确认" } }, + { "label": "免费停车", "value": { "text": "度假区类通常有免费停车,但需确认", "confidence": "low", "verify": "电话确认是否免费、是否对非住客开放" } }, + { "label": "人均", "value": { "text": "约 150-200 元 / 次(温泉类普遍高于市区洗浴)", "confidence": "low", "verify": "以当日价目为准" } }, + { "label": "卫生依据", "value": { "text": "看本地评价中卫生关键词、开业年限与翻新时间;不空口说‘干净’", "confidence": "low", "verify": "出发前看最新评价" } }, + { "label": "建议时长", "value": "2-2.5 小时(17:10-19:30),长时间泡汤易累,之后还要开车" }, + { "label": "自带物品", "value": "毛巾、拖鞋、洗护、换洗衣物、密封袋装湿衣、少量现金" } + ], + "bullets": [ + "硬时间点 17:00 是为了给 19:45 的补给和 22:00 的车宿留余量", + "泡完不要立刻长时间开车,建议先吃点东西再走", + "若当日温泉闭馆,备选:市区规模较大的洗浴中心,或直接跳过本条改早到车宿点" + ], + "map_marker_ids": ["m_bathhouse"] + }, + { + "id": "c_grocery", + "anchor_id": "grocery", + "day": 1, + "title": "大卖场补给", + "subtitle": "19:45-20:25 · 买次日早餐", + "badges": [ + { "text": "店名待核实", "tone": "danger" } + ], + "fields": [ + { "label": "首选", "value": { "text": "县城掘港镇大型超市 / 卖场(连锁品牌优先)", "confidence": "low", "verify": "店名与营业时间用地图核实;本样例不编造店名" } }, + { "label": "营业时间", "value": { "text": "大型卖场通常到 21:30-22:00,需确认", "confidence": "low", "verify": "出发前查当日营业时间" } }, + { "label": "停车", "value": { "text": "大卖场自带停车场,通常免费 1-2 小时", "confidence": "medium", "verify": "以现场公示为准" } }, + { "label": "购物清单", "value": "常温牛奶(小包装)、面包 / 包子、鸡蛋或卤蛋、水果、瓶装水 2L、湿巾、垃圾袋" }, + { "label": "牛奶提示", "value": "车内过夜无冷藏,优先常温奶或小包装,避免大瓶冷藏奶;看清保质期与是否需要冷藏" } + ], + "bullets": [ + "顺手补:纸巾、驱蚊液、车载垃圾袋、备用水", + "不要买需要冷藏的熟食,夜间车内温度不可控" + ], + "map_marker_ids": ["m_grocery"] + }, + { + "id": "c_car_sleep", + "anchor_id": "car_sleep", + "day": 1, + "title": "车宿过夜停车点", + "subtitle": "不晚于 22:00 抵达(硬时间点)", + "image": { + "url": "../assets/photos/rudong_carsleep_park.jpg", + "kind": "ai_illustration", + "caption": "夜间城市公园停车场意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "禁停风险 中", "tone": "warn" }, + { "text": "需核实", "tone": "danger" } + ], + "fields": [ + { "label": "首选", "value": { "text": "县城公园类市政停车场(市中心、空气好、有公厕)", "confidence": "low", "verify": "过夜是否被劝离,以现场标识与当地执法口径为准" } }, + { "label": "卫生间", "value": { "text": "公园公厕白天开放;夜间是否开放需现场确认", "confidence": "low", "verify": "到场后先确认最近公厕位置与是否上锁" } }, + { "label": "照明", "value": { "text": "市政公园停车场通常有路灯,但部分区域后半夜会关灯", "confidence": "medium", "verify": "到场后选有常亮路灯的位置停" } }, + { "label": "早锻炼人群", "value": { "text": "5-6 点公园通常已有晨练人群,安全性反而更好", "confidence": "medium", "verify": "现场观察" } }, + { "label": "备选点", "value": "① 高速服务区(最稳,但离市区远)② 24 小时加油站(次稳)③ 收费营地 / 房车营地(花钱买安心)" }, + { "label": "风险等级", "value": { "text": "中 —— 市政公园对过夜停车普遍不明确表态,存在夜间劝离可能", "confidence": "low", "verify": "到场看标识;若被劝离直接切服务区备选" } }, + { "label": "本次到达", "value": "21:10 抵达,早于硬点(不晚于 22:00),属提前到位,不算漂移" } + ], + "bullets": [ + "车宿安全:靠路灯停、避开主通道与出入口、留 2-3cm 通风缝、不长时间怠速开空调", + "贵重物品不外露,前排不要放包;垃圾自己带走,不留痕迹", + "停车位置避开低洼与树下(秋季落叶堵塞排水、潮气重)" + ], + "map_marker_ids": ["m_car_sleep"] + }, + { + "id": "c_morning_walk", + "anchor_id": "morning_walk", + "day": 2, + "title": "晨间散步 · 05:00", + "subtitle": "公园环线约 3 km", + "badges": [ + { "text": "硬时间点", "tone": "info" }, + { "text": "天未全亮", "tone": "warn" } + ], + "fields": [ + { "label": "日出时间", "value": { "text": "2026-09-19/20 如东日出约 05:35,05:00 起床时天为黎明微光", "confidence": "medium", "verify": "出行前用天气 App 核对当日日出时间" } }, + { "label": "路线", "value": "从车宿点出发绕公园主环线一圈(不走机动车道,不远离停车位)" }, + { "label": "距离 / 用时", "value": "约 3 km / 60-70 分钟(含拉伸)" }, + { "label": "照明", "value": { "text": "公园主路有路灯,支路可能关灯", "confidence": "medium", "verify": "到场确认;建议带头灯或手机照明" } }, + { "label": "公厕", "value": { "text": "公园公厕位置到场确认,作为洗漱点备选", "confidence": "low", "verify": "现场确认" } } + ], + "bullets": [ + "05:00 天没亮,穿反光条或亮色外套更安全", + "晨走就是当天的‘第一次热身’,为 08:15 的第二段徒步做准备" + ], + "map_marker_ids": ["m_car_sleep", "m_morning_park"] + }, + { + "id": "c_breakfast_wash", + "anchor_id": "breakfast_wash", + "day": 2, + "title": "早饭 + 洗漱", + "subtitle": "06:15-07:10", + "badges": [ + { "text": "开门时间待核实", "tone": "warn" } + ], + "fields": [ + { "label": "方案 A · 店内吃", "value": { "text": "县城本地早点铺(面条 / 汤包 / 烧饼类)", "confidence": "low", "verify": "5-6 点是否开门需现场或电话确认;很多面馆 06:30 后才开" } }, + { "label": "方案 B · 车内吃", "value": "昨晚大卖场买的常温奶 + 面包 + 卤蛋,最稳妥,不依赖店铺开门" }, + { "label": "洗漱点·首选", "value": { "text": "公园公厕(免费,需确认是否上锁)", "confidence": "low", "verify": "现场确认" } }, + { "label": "洗漱点·备选 1", "value": { "text": "24 小时加油站卫生间(多数可借用)", "confidence": "medium", "verify": "看店内标识" } }, + { "label": "洗漱点·备选 2", "value": { "text": "连锁健身房单次体验卡(花钱但热水稳定)", "confidence": "low", "verify": "当地是否有门店需核实" } } + ], + "bullets": [ + "建议顺序:先晨走 → 洗漱 → 早饭,避免吃完再运动", + "自备:漱口水、湿巾、免洗洗手液、便携毛巾(公厕不一定有热水)" + ], + "map_marker_ids": ["m_breakfast"] + }, + { + "id": "c_hike_2", + "anchor_id": "hike_2", + "day": 2, + "title": "第二段徒步 · 沿海林带", + "subtitle": "1.5 小时 · 与昨日不重复", + "image": { + "url": "../assets/photos/rudong_hike2_forest.jpg", + "kind": "ai_illustration", + "caption": "沿海林带林道意象", + "disclaimer": "AI 示意图,非实景" + }, + "badges": [ + { "text": "已触发地形降级", "tone": "warn" }, + { "text": "免费停车待核实", "tone": "danger" } + ], + "fields": [ + { "label": "起点 / 终点", "value": { "text": "沿海林带 / 河道绿廊入口 → 纵深林道折返", "confidence": "low", "verify": "林带开放情况与出入口位置需核实" } }, + { "label": "折算里程", "value": "约 6.5 km(林道平路,1.5 小时口径)" }, + { "label": "累计爬升", "value": "约 0 m(平路 + 林道)" }, + { "label": "路面类型", "value": "林地土路 / 碎石路,树荫好、比昨天晒得少" }, + { "label": "停车", "value": { "text": "林带入口路边空地(尽量免费)", "confidence": "low", "verify": "是否允许停车需现场确认" } }, + { "label": "与第一段的区别", "value": "第一段是开阔海堤(风大日晒),第二段是林荫林道(凉爽安静),景观与体感差异明显" } + ], + "bullets": [ + "降级说明同昨日:如东无山,‘山水优先’退化为‘水 + 林’,本段爬升约 0", + "林地秋季蚊虫仍多,出发前喷驱蚊液、穿长裤", + "林道岔路多,建议开轨迹记录,避免绕远" + ], + "map_marker_ids": ["m_hike2_start", "m_hike2_end"] + }, + { + "id": "c_photo_spots", + "anchor_id": "photo_spots", + "day": 2, + "title": "打卡点 · 1-2 个", + "subtitle": "10:00-11:20 · 人文优先", + "badges": [ + { "text": "点位待核实", "tone": "danger" } + ], + "fields": [ + { "label": "候选 1(人文)", "value": { "text": "海堤 / 范公堤类人文点 —— 沿海堤防历史线索,看的是‘人与海的关系’", "confidence": "low", "verify": "具体点位、是否有标识与开放说明需核实" } }, + { "label": "候选 2(人文/街区)", "value": { "text": "古镇内人文点位(故居 / 纪念馆类)—— 如开放则优先,未开放则跳过", "confidence": "low", "verify": "开放时间与门票需核实" } }, + { "label": "开放时间 / 门票", "value": { "text": "均为待核实项", "confidence": "low", "verify": "出发前查当地文旅公告" } }, + { "label": "停车", "value": "沿用林带入口或就近路侧泊位" }, + { "label": "去重要求", "value": "已排除昨天古镇动线里的 5 个点位,避免重复" } + ], + "bullets": [ + "本条不追求名气,追求‘有典故、看得到东西’", + "若两个候选都不可达,宁可把时间留给午饭,不要硬凑景点" + ], + "map_marker_ids": ["m_spot1"] + }, + { + "id": "c_lunch_d2", + "anchor_id": "lunch_d2", + "day": 2, + "title": "第二天午饭 · 县城家常菜", + "subtitle": "11:30-12:40 · 优先能停车", + "badges": [ + { "text": "店名待核实", "tone": "danger" }, + { "text": "与昨天不同店型", "tone": "info" } + ], + "fields": [ + { "label": "候选 1", "value": { "text": "县城本地家常菜老店 —— 做本地菜、中午本地人多、门口可停车", "confidence": "low", "verify": "店名与实际停车条件需现场核实" } }, + { "label": "候选 2", "value": { "text": "县城面馆 / 小炒(备选)—— 出餐快、便于下午长途驾驶", "confidence": "low", "verify": "同上" } }, + { "label": "判断依据", "value": "非游客区、本地客占比高、开店年限长、菜品以本地食材为主" }, + { "label": "停车", "value": { "text": "优先选门口能停的;不能停则用就近社会停车场", "confidence": "medium", "verify": "现场确认" } }, + { "label": "单人怎么点", "value": "一菜一汤 + 米饭;若下午要开 3 小时车,避免过饱与饮酒" } + ], + "bullets": [ + "与昨天的区别:昨天是古镇海鲜小馆,今天是县城家常菜,避免两天吃同一类型", + "出发前先把返程导航设好,吃完直接走" + ], + "map_marker_ids": ["m_lunch2"] + }, + { + "id": "c_drive_back", + "anchor_id": "drive_back", + "day": 2, + "title": "返程 · 双版本自驾", + "subtitle": "如东 → 上海宝山富锦路地铁站", + "badges": [ + { "text": "双版本", "tone": "info" }, + { "text": "下午高峰", "tone": "warn" } + ], + "fields": [ + { "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时路况" } }, + { "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国省道限速与施工以导航为准" } }, + { "label": "加油点", "value": "县城与高速服务区均可加油;建议午饭后就近加满" }, + { "label": "高峰提示", "value": { "text": "周日下午 15:00-20:00 为进城方向高峰,过江通道易缓行", "confidence": "medium", "verify": "出发前看实时路况" } }, + { "label": "建议", "value": "13:00 出发走高速最稳;若想省钱走非高速,务必 12:30 前出发" } + ], + "bullets": [ + "返程前检查:胎压、玻璃水、油量/电量、车内垃圾清空", + "疲劳管理:每 1.5-2 小时进一次服务区,下车走 5 分钟" + ], + "map_marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"] + } + ], + + "map": { + "center": [31.95, 121.15], + "zoom": 9, + "disclaimer": "点位顺序示意,非真实道路/轨迹;坐标多为概略值", + "markers": [ + { "id": "m_sh_start", "name": "上海宝山富锦路地铁站(出发)", "kind": "start", "lat": 31.376, "lng": 121.414, "coord_precision": "approx", "day": 1, "seq": 1 }, + { "id": "m_way_sutong", "name": "过江通道方向", "kind": "waypoint", "lat": 31.72, "lng": 120.95, "coord_precision": "approx", "day": 1, "seq": 2 }, + { "id": "m_way_nantong", "name": "南通方向拐点", "kind": "waypoint", "lat": 31.98, "lng": 120.89, "coord_precision": "approx", "day": 1, "seq": 3 }, + { "id": "m_hike1_start", "name": "第一段徒步起点(堤岸)", "kind": "hike", "lat": 32.50, "lng": 121.10, "coord_precision": "approx", "day": 1, "seq": 4 }, + { "id": "m_hike1_end", "name": "第一段徒步终点(闸口)", "kind": "hike", "lat": 32.47, "lng": 121.06, "coord_precision": "approx", "day": 1, "seq": 5 }, + { "id": "m_bengcha_park", "name": "古镇停车点", "kind": "parking", "lat": 32.556, "lng": 120.946, "coord_precision": "approx", "day": 1, "seq": 6, "note": "免费与否待核实" }, + { "id": "m_bengcha_gate", "name": "古镇入口牌坊", "kind": "district", "lat": 32.554, "lng": 120.949, "coord_precision": "approx", "day": 1, "seq": 7 }, + { "id": "m_bengcha_center", "name": "古镇主街", "kind": "district", "lat": 32.552, "lng": 120.952, "coord_precision": "approx", "day": 1, "seq": 8 }, + { "id": "m_bengcha_alley", "name": "巷弄纵深", "kind": "district", "lat": 32.550, "lng": 120.955, "coord_precision": "approx", "day": 1, "seq": 9 }, + { "id": "m_bengcha_water", "name": "水岸 / 桥头", "kind": "district", "lat": 32.548, "lng": 120.958, "coord_precision": "approx", "day": 1, "seq": 10 }, + { "id": "m_bengcha_shop", "name": "出口土产段", "kind": "district", "lat": 32.546, "lng": 120.960, "coord_precision": "approx", "day": 1, "seq": 11 }, + { "id": "m_lunch_d1_alt", "name": "午饭候选 3(需挪车)", "kind": "food", "lat": 32.558, "lng": 120.943, "coord_precision": "approx", "day": 1, "seq": 12 }, + { "id": "m_bathhouse", "name": "温泉 / 洗浴中心(候选)", "kind": "bathhouse", "lat": 32.53, "lng": 121.03, "coord_precision": "approx", "day": 1, "seq": 13 }, + { "id": "m_grocery", "name": "浴场旁超市(同商圈)", "kind": "shop", "lat": 32.534, "lng": 121.038, "coord_precision": "approx", "day": 1, "seq": 14, "note": "与浴场共用停车点" }, + { "id": "m_car_sleep", "name": "车宿停车点(候选)", "kind": "sleep", "lat": 32.312, "lng": 121.196, "coord_precision": "approx", "day": 1, "seq": 15 }, + { "id": "m_morning_park", "name": "公园晨走环线", "kind": "walk", "lat": 32.314, "lng": 121.199, "coord_precision": "approx", "day": 2, "seq": 16 }, + { "id": "m_breakfast", "name": "早点铺(候选)", "kind": "breakfast", "lat": 32.309, "lng": 121.193, "coord_precision": "approx", "day": 2, "seq": 17 }, + { "id": "m_hike2_start", "name": "第二段徒步起点(林带)", "kind": "hike", "lat": 32.45, "lng": 121.25, "coord_precision": "approx", "day": 2, "seq": 18 }, + { "id": "m_hike2_end", "name": "第二段徒步纵深处", "kind": "hike", "lat": 32.43, "lng": 121.30, "coord_precision": "approx", "day": 2, "seq": 19 }, + { "id": "m_spot1", "name": "人文打卡点(候选)", "kind": "spot", "lat": 32.47, "lng": 121.15, "coord_precision": "approx", "day": 2, "seq": 20 }, + { "id": "m_lunch2", "name": "第二天午饭(候选)", "kind": "food", "lat": 32.30, "lng": 121.18, "coord_precision": "approx", "day": 2, "seq": 21 }, + { "id": "m_sh_end", "name": "返回上海", "kind": "end", "lat": 31.23, "lng": 121.47, "coord_precision": "approx", "day": 2, "seq": 22 } + ], + "routes": [ + { + "id": "r_drive_out", + "name": "去程(示意)", + "kind": "drive", + "color": "#2f6fed", + "dashed": false, + "marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"], + "note": "点位顺序示意,非真实道路" + }, + { + "id": "r_hike_1", + "name": "第一段徒步(示意)", + "kind": "hike", + "color": "#2e9e5b", + "dashed": true, + "marker_ids": ["m_hike1_start", "m_hike1_end"], + "note": "点位顺序示意,非真实轨迹" + }, + { + "id": "r_district_walk", + "name": "古镇动线 ①→⑤", + "kind": "stroll", + "color": "#c2701a", + "dashed": false, + "marker_ids": ["m_bengcha_park", "m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"], + "note": "按打卡点顺序连线,示意" + }, + { + "id": "r_morning_walk", + "name": "晨走环线(示意)", + "kind": "stroll", + "color": "#8a5cf6", + "dashed": true, + "marker_ids": ["m_car_sleep", "m_morning_park", "m_breakfast"], + "note": "公园环线示意,非真实步道" + }, + { + "id": "r_hike_2", + "name": "第二段徒步(示意)", + "kind": "hike", + "color": "#1f8a70", + "dashed": true, + "marker_ids": ["m_hike2_start", "m_spot1", "m_hike2_end"], + "note": "点位顺序示意,非真实轨迹" + }, + { + "id": "r_drive_back", + "name": "返程(示意)", + "kind": "drive", + "color": "#d14343", + "dashed": false, + "marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"], + "note": "点位顺序示意,非真实道路" + } + ] + }, + + "tables": [ + { + "id": "drive_out_compare", + "title": "去程 · 高速版 vs 非高速版", + "columns": [ + { "key": "version", "label": "版本" }, + { "key": "distance", "label": "里程", "align": "right" }, + { "key": "hours", "label": "预计时长", "align": "right" }, + { "key": "toll", "label": "过路费", "align": "right" }, + { "key": "supply", "label": "加油 / 补能" }, + { "key": "risk", "label": "风险提示" }, + { "key": "fit", "label": "适合" } + ], + "rows": [ + { "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "节假日上午过江通道可能排队", "fit": "赶时间 / 首选" }, + { "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "国省道施工、限速变化、穿镇拥堵", "fit": "省钱 / 不赶时间" } + ], + "note": "起点:上海宝山富锦路地铁站。里程与时长均为估算示意,未接实时路况;换出发点需重算。", + "highlight_row_keys": ["hw"] + }, + { + "id": "drive_back_compare", + "title": "返程 · 高速版 vs 非高速版", + "columns": [ + { "key": "version", "label": "版本" }, + { "key": "distance", "label": "里程", "align": "right" }, + { "key": "hours", "label": "预计时长", "align": "right" }, + { "key": "toll", "label": "过路费", "align": "right" }, + { "key": "supply", "label": "加油 / 补能" }, + { "key": "risk", "label": "风险提示" }, + { "key": "fit", "label": "适合" } + ], + "rows": [ + { "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "周日下午 15:00-20:00 进城方向高峰", "fit": "13:00 出发首选" }, + { "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "穿城镇路段多,夜间视距差", "fit": "12:30 前出发" } + ], + "note": "终点:上海宝山富锦路地铁站。估算示意,未接实时路况。", + "highlight_row_keys": ["hw"] + }, + { + "id": "food_summary", + "title": "三餐与补给一览", + "columns": [ + { "key": "meal", "label": "餐次" }, + { "key": "time", "label": "时间" }, + { "key": "candidates", "label": "候选" }, + { "key": "area", "label": "位置" }, + { "key": "per", "label": "人均", "align": "right" }, + { "key": "parking", "label": "停车" }, + { "key": "conf", "label": "置信度" } + ], + "rows": [ + { "_key": "d1", "meal": "D1 午饭", "time": "11:30-12:35", "candidates": "3 家(海鲜小馆 / 家常菜 / 面馆)", "area": "栟茶古镇一带", "per": "约 100 元", "parking": "用街区停车点", "conf": "需核实" }, + { "_key": "d2", "meal": "D2 午饭", "time": "11:30-12:40", "candidates": "2 家(家常菜老店 / 面馆)", "area": "如东县城", "per": "约 90 元", "parking": "优先门口可停", "conf": "需核实" }, + { "_key": "bf", "meal": "D2 早饭", "time": "06:15-07:10", "candidates": "本地早点铺 / 车内补给", "area": "车宿点附近", "per": "约 15 元", "parking": "路边临停", "conf": "需核实" }, + { "_key": "sp", "meal": "夜间补给", "time": "19:40-20:10", "candidates": "浴场旁超市(与浴场一起完成)", "area": "小洋口 / 浴场商圈", "per": "约 60 元", "parking": "与浴场共用", "conf": "需核实" } + ], + "note": "本样例不编造店名,只给业态 + 位置描述,店名与人均需现场核实。" + }, + { + "id": "parking_summary", + "title": "各节点停车一览", + "columns": [ + { "key": "node", "label": "节点" }, + { "key": "park", "label": "停车点" }, + { "key": "free", "label": "是否免费" }, + { "key": "fee", "label": "预估费用", "align": "right" }, + { "key": "walk", "label": "步行距离" }, + { "key": "risk", "label": "风险" }, + { "key": "conf", "label": "置信度" } + ], + "rows": [ + { "_key": "hike1", "node": "第一段徒步", "park": "堤岸起点路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" }, + { "_key": "d1", "node": "古镇街区", "park": "古镇外围泊位 / 镇区公共停车场", "free": "待核实(不得默认免费)", "fee": "0-10 元/次", "walk": "≤5 min", "risk": "周末或临时管制", "conf": "需核实" }, + { "_key": "bath", "node": "温泉 / 洗浴", "park": "度假区停车场", "free": "待核实", "fee": "0-10 元", "walk": "≤3 min", "risk": "非住客是否可停", "conf": "需核实" }, + { "_key": "shop", "node": "浴场旁超市", "park": "与浴场共用停车点", "free": "随浴场", "fee": "0 元", "walk": "≤5 min", "risk": "低", "conf": "需核实" }, + { "_key": "sleep", "node": "车宿点", "park": "公园类市政停车场", "free": "待核实", "fee": "0 元", "walk": "0 min", "risk": "夜间可能被劝离", "conf": "需核实" }, + { "_key": "hike2", "node": "第二段徒步", "park": "林带入口路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" } + ], + "note": "免费与否一律不得默认写成免费,需现场看收费公示牌。" + } + ], + + "cost": { + "currency": "CNY", + "items": [ + { "label": "过路费(往返高速版)", "amount": 170, "unit": "元", "note": "走非高速版则为 0" }, + { "label": "油费(往返约 340 km)", "amount": 205, "unit": "元", "note": "按 8L/100km、7.5 元/L 估" }, + { "label": "D1 午饭", "amount": 100, "unit": "元", "note": "单人" }, + { "label": "D2 午饭", "amount": 90, "unit": "元", "note": "单人" }, + { "label": "温泉 / 洗浴", "amount": 180, "unit": "元", "note": "温泉类估值偏高" }, + { "label": "超市补给(浴场旁)", "amount": 60, "unit": "元", "note": "含次日早餐" }, + { "label": "D2 早饭", "amount": 15, "unit": "元", "note": "若车内吃可省" }, + { "label": "停车费预埋", "amount": 30, "unit": "元", "note": "若全部免费可省" }, + { "label": "杂项(水、零食、湿巾)", "amount": 50, "unit": "元", "note": "浮动项" } + ], + "total": 900, + "note": "全部为估算口径,供预算参考;走非高速版可省约 170 元过路费但多约 1 小时。起点为宝山富锦路地铁站,换出发点需重算。" + }, + + "checklist": [ + { "anchor_id": "district_parking", "field": "free_parking", "text": "古镇停车是否免费、周末是否交通管制", "confidence": "low", "verify": "出发前致电古镇管理方或看地图实景" }, + { "anchor_id": "bathhouse", "field": "bathhouse_grade", "text": "温泉 / 洗浴中心当日是否营业、是否需预约、是否对非住客开放", "confidence": "low", "verify": "出发前电话确认" }, + { "anchor_id": "bathhouse", "field": "hygiene", "text": "温泉卫生情况与规模", "confidence": "low", "verify": "看最新本地评价中的卫生关键词" }, + { "anchor_id": "district_arrive", "field": "open_status", "text": "古镇是否局部施工或围挡、夜间照明情况", "confidence": "medium", "verify": "查当地文旅公告" }, + { "anchor_id": "district_walk", "field": "rest_point", "text": "动线中段可坐下的茶馆 / 咖啡位是否营业、是否有空位", "confidence": "low", "verify": "现场找有座位的店即可,不必指定具体店" }, + { "anchor_id": "car_sleep", "field": "car_sleep_legality", "text": "公园停车场是否允许过夜停车", "confidence": "low", "verify": "以现场标识与当地执法口径为准;被劝离直接切服务区备选" }, + { "anchor_id": "morning_walk", "field": "sunrise_and_facility", "text": "当日日出时间、公园路灯后半夜是否关灯、公厕夜间是否上锁", "confidence": "low", "verify": "出行前查天气 App 的日出时间;到场确认照明与公厕" }, + { "anchor_id": "hike_1", "field": "access", "text": "海堤 / 滩涂段是否允许步行、是否有施工封闭", "confidence": "low", "verify": "现场确认" }, + { "anchor_id": "hike_2", "field": "access", "text": "林带是否开放、出入口位置", "confidence": "low", "verify": "现场确认" }, + { "anchor_id": "grocery", "field": "store_info", "text": "浴场旁超市的位置、约 20 点是否还在营业、能否与浴场共用停车点", "confidence": "low", "verify": "地图或电话核实;同商圈若无超市,按规则就近找并标注" }, + { "anchor_id": "breakfast_wash", "field": "opening", "text": "早点铺 5-6 点是否开门;公园公厕夜间是否上锁", "confidence": "low", "verify": "现场或电话确认" }, + { "anchor_id": "photo_spots", "field": "open_status", "text": "打卡点开放时间与门票", "confidence": "low", "verify": "查当地文旅公告" }, + { "anchor_id": "lunch_d1", "field": "locals_favorite", "text": "午饭 3 家候选的店名、人均与真实口碑", "confidence": "low", "verify": "到店前看最新本地评价" }, + { "anchor_id": "lunch_d2", "field": "locals_favorite", "text": "第二天午饭店名与停车条件", "confidence": "low", "verify": "现场或电话确认" }, + { "anchor_id": "drive_out", "field": "route_data", "text": "去程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" }, + { "anchor_id": "drive_back", "field": "route_data", "text": "返程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" } + ], + + "notes": [ + { + "level": "danger", + "title": "车宿合规风险:中", + "text": "如东县城公园类停车场能否过夜停车并无明确说法,存在夜间被劝离的可能。若被劝离,请直接切换到备选点:高速服务区(最稳)或 24 小时加油站。" + }, + { + "level": "warn", + "title": "两段徒步均为平路,爬升约 0", + "text": "如东是平原沿海地区,无山可爬,已触发行程规则中的地形降级。1.5 小时的口径按步速折算里程执行,运动量靠时长与配速来,不靠爬升。若你需要‘爬坡出汗’的强度,这个目的地不合适,建议换丘陵/山地目的地。" + }, + { + "level": "warn", + "title": "06:40 出发是底线", + "text": "若 06:40 之后出发,17:00 抵达温泉与 22:00 抵达车宿点都将被压缩。车程偏长时请提前到 06:00-06:30 出发。" + }, + { + "level": "warn", + "title": "牛奶与熟食不要冷藏型", + "text": "车内过夜没有冷藏条件,牛奶优先常温奶或小包装,不要买需要冷藏的熟食与鲜奶大瓶装。" + }, + { + "level": "info", + "title": "地图与路线为示意图", + "text": "所有点位顺序连线仅用于理解动线关系,不是真实道路或徒步轨迹;坐标多为概略值。" + }, + { + "level": "info", + "title": "本样例不编造店名与价格", + "text": "为如实反映信息可信度,餐饮、超市、温泉等未给出编造的店名,只给业态与位置描述,并全部列入『待核实清单』。" + }, + { + "level": "info", + "title": "出发点可改,换了就要重算", + "text": "本行程以「上海宝山富锦路地铁站」为起点。换出发点后,去返程的里程、时长、过路费与油费都需重算,不要沿用本页数字。" + }, + { + "level": "info", + "title": "补给已并入浴场,不再单独跑大卖场", + "text": "按你的口径,本 Skill 取消了「找大卖场」这条行程:买牛奶与次日早餐改在浴场同商圈的超市顺手完成,与浴场共用停车点、不挪车。" + } + ] +} \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_export.css b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_export.css new file mode 100644 index 0000000..3e286c1 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_export.css @@ -0,0 +1,212 @@ +/* ============================================================ + * tc_export.css —— 游山玩水 · 导出文档样式(只服务 tc_export.js) + * + * 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。 + * tc_export.js 导出时会把本文件全文读出来,内联进 \n' + html += '\n\n' + html += '
\n' + if (heroHtml) html += heroHtml + '\n' + if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n' + if (bodyParts.length) html += bodyParts.join('\n') + '\n' + html += footHtml(ctx) + '\n' + html += '
\n\n\n' + return html + } + + /* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */ + + function collectImageUrls(payload) { + var seen = {} + var out = [] + ;(function walk(node, depth) { + if (depth > 12 || node === null || typeof node !== 'object') return + if (isArr(node)) { + node.forEach(function (x) { walk(x, depth + 1) }) + return + } + if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) { + if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) } + } + Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) }) + })(payload, 0) + return out + } + + function toDataUrl(url, baseUrl) { + return fetch(absUrl(url, baseUrl)) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status) + return r.blob() + }) + .then(function (blob) { + if (blob.size > MAX_IMAGE_BYTES) { + throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过') + } + return new Promise(function (resolve, reject) { + var fr = new FileReader() + fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) } + fr.onerror = function () { reject(new Error('读取图片失败')) } + fr.readAsDataURL(blob) + }) + }) + } + + function embedImages(payload) { + var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : '' + var urls = collectImageUrls(payload) + var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 } + var warnings = [] + var map = {} + if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') { + return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }) + } + return Promise.all(urls.map(function (u) { + return toDataUrl(u, baseUrl).then(function (d) { + map[u] = d.dataUri + stats.embedded++ + stats.bytes += d.bytes + }, function (err) { + stats.failed++ + warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u) + }) + })).then(function () { + return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings } + }) + } + + /* ---------------- 样式读取 / 文件落盘 ---------------- */ + + var cssCache = null + + function loadCss(cssUrl) { + if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)')) + if (cssCache) return Promise.resolve(cssCache) + return fetch(cssUrl, { cache: 'force-cache' }) + .then(function (r) { + if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')') + return r.text() + }) + .then(function (txt) { + if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl) + cssCache = txt + return cssCache + }) + } + + function saveBlob(html, filename) { + var blob = new Blob([html], { type: 'text/html;charset=utf-8' }) + var url = URL.createObjectURL(blob) + var a = document.createElement('a') + a.href = url + a.download = filename + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000) + } + + function defaultFilename(payload) { + var s = isPlainObject(payload && payload.summary) ? payload.summary : {} + var name = String(s.title || '行程报告') + .replace(/[\\/:*?"<>|\r\n\t]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!name) name = '行程报告' + if (name.length > 60) name = name.slice(0, 60) + return name + '-行程报告.html' + } + + /* ---------------- 对外入口 ---------------- */ + + /** + * 导出并触发下载(浏览器) + * @returns {Promise<{ok, filename, bytes, images, warnings}>} + */ + function download(payload, spec, opts) { + opts = opts || {} + var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css') + return Promise.all([loadCss(cssUrl), embedImages(payload)]) + .then(function (res) { + var html = build(payload, spec, { + css: res[0], + images: res[1].map, + baseUrl: res[1].baseUrl, + meta: opts.meta, + title: opts.title, + kicker: opts.kicker, + chip: opts.chip, + exportedAt: opts.exportedAt || nowBeijing() + }) + var filename = opts.filename || defaultFilename(payload) + saveBlob(html, filename) + var warnings = res[1].warnings.slice() + return { + ok: true, + filename: filename, + bytes: utf8Length(html), + images: res[1].stats, + sections: (isArr(spec && spec.sections) ? spec.sections.length : 0), + warnings: warnings + } + }) + } + + var api = { + build: build, + download: download, + defaultFilename: defaultFilename, + collectImageUrls: collectImageUrls + } + + if (typeof window !== 'undefined') window.TCExport = api + if (typeof module !== 'undefined' && module.exports) module.exports = api +})() \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_render.js b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_render.js new file mode 100644 index 0000000..af06c8a --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_render.js @@ -0,0 +1,652 @@ +/* ============================================================ + * tc_render.js —— 游山玩水 · 通用汇报渲染器(阶段一离线版) + * + * 设计原则(插件式 / 低耦合): + * 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。 + * 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段 + * (不知道什么叫"浴场""徒步""停车"),换 skill 只换 report_spec,本文件一行不改。 + * 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染, + * 保证 Leaflet 加载失败时页面照样能看点线关系。 + * 4. 删除本文件不影响任何其他 lab / tool。 + * + * 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测) + * ============================================================ */ +(function (global) { + 'use strict' + + /* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */ + const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const MAP_TILE_ATTR = '© OpenStreetMap contributors' + const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹' + + const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' } + const FALLBACK_IMAGE_TEXT = '配图未生成' + + const mapRegistry = [] + + /* ---------------- 基础工具 ---------------- */ + + function h(tag, attrs, children) { + const el = document.createElement(tag) + if (attrs) { + Object.keys(attrs).forEach(function (k) { + const v = attrs[k] + if (v === null || v === undefined || v === false) return + if (k === 'class') el.className = v + else if (k === 'text') el.textContent = v + else if (k === 'html') el.innerHTML = v + else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v) + else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v) + else el.setAttribute(k, v === true ? '' : String(v)) + }) + } + appendChildren(el, children) + return el + } + + function appendChildren(el, children) { + if (children === null || children === undefined) return + if (Array.isArray(children)) { + children.forEach(function (c) { appendChildren(el, c) }) + return + } + if (children instanceof Node) { el.appendChild(children); return } + el.appendChild(document.createTextNode(String(children))) + } + + function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) } + + function resolvePath(root, path) { + if (!path) return root + const p = String(path).replace(/^\$\.?/, '') + if (!p) return root + return p.split('.').reduce(function (acc, key) { + if (acc === null || acc === undefined) return undefined + return acc[key] + }, root) + } + + function pickById(arr, id) { + if (!Array.isArray(arr)) return null + for (let i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id === id) return arr[i] + } + return null + } + + function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) } + + function badgeText(spec, level) { + const map = (spec && spec.confidence_badge) || DEFAULT_BADGE + return map[level] || DEFAULT_BADGE[level] || level + } + + /* ---------------- 置信度字段渲染(通用) ---------------- */ + + // fact 可以是纯字符串,也可以是 { text, confidence, verify } + function renderFact(value, spec) { + if (isPlainObject(value)) { + const box = h('span', { class: 'tcr-fact' }) + box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) })) + if (value.confidence) { + box.appendChild(h('span', { + class: 'tcr-badge tcr-badge-' + value.confidence, + text: badgeText(spec, value.confidence), + title: value.verify ? ('核实方式:' + value.verify) : '' + })) + } + if (value.verify) { + box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify })) + } + return box + } + return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) }) + } + + /* ---------------- 各 block 渲染 ---------------- */ + + function renderOverview(data, spec) { + if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' }) + const box = h('div', { class: 'tcr-overview' }) + if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title })) + if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner })) + if (Array.isArray(data.tags) && data.tags.length) { + const tagRow = h('div', { class: 'tcr-tags' }) + data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) }) + box.appendChild(tagRow) + } + if (Array.isArray(data.stats) && data.stats.length) { + const grid = h('div', { class: 'tcr-stats' }) + data.stats.forEach(function (s) { + const cell = h('div', { class: 'tcr-stat' }) + cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' })) + cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) })) + if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint })) + grid.appendChild(cell) + }) + box.appendChild(grid) + } + return box + } + + function renderTimeline(data, spec) { + if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' }) + const box = h('div', { class: 'tcr-timeline' }) + data.days.forEach(function (day) { + const dayBox = h('div', { class: 'tcr-day' }) + const head = h('div', { class: 'tcr-day-head' }) + head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' })) + if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date })) + dayBox.appendChild(head) + + const table = h('table', { class: 'tcr-table tcr-timeline-table' }) + const thead = h('thead', null, h('tr', null, [ + h('th', { class: 'tcr-col-time', text: '时间' }), + h('th', { text: '安排' }), + h('th', { class: 'tcr-col-kind', text: '类型' }) + ])) + table.appendChild(thead) + const tbody = h('tbody') + const rows = Array.isArray(day.rows) ? day.rows : [] + rows.forEach(function (r) { + const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' }) + tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' })) + const td = h('td') + td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' })) + if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note })) + tr.appendChild(td) + const kindCell = h('td', { class: 'tcr-col-kind' }) + kindCell.appendChild(h('span', { + class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'), + text: kindLabel(r.kind) + })) + if (r.activity) { + kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) })) + } + tr.appendChild(kindCell) + tbody.appendChild(tr) + }) + table.appendChild(tbody) + dayBox.appendChild(table) + box.appendChild(dayBox) + }) + return box + } + + function kindLabel(kind) { + const map = { hard: '硬时间点', soft: '常规' } + return map[kind] || (kind || '') + } + + // 活动类型与 kind(时间点性质)是两个正交字段,分开显示 + function activityLabel(activity) { + const map = { + move: '移动', eat: '吃饭', hike: '徒步', + shop: '采买', sleep: '过夜', walk: '步行' + } + return map[activity] || (activity || '') + } + + function renderCards(data, spec, section) { + const list = Array.isArray(data) ? data : [] + if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' }) + const box = h('div', { class: 'tcr-cards' }) + + const grouped = section && section.group_by === 'day' + if (!grouped) { + list.forEach(function (c) { box.appendChild(renderCard(c, spec)) }) + return box + } + const days = [] + list.forEach(function (c) { + const d = c.day === undefined ? 0 : c.day + if (days.indexOf(d) < 0) days.push(d) + }) + days.sort(function (a, b) { return a - b }) + days.forEach(function (d) { + const group = h('div', { class: 'tcr-card-group' }) + group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') })) + const grid = h('div', { class: 'tcr-card-grid' }) + list.forEach(function (c) { + const cd = c.day === undefined ? 0 : c.day + if (cd === d) grid.appendChild(renderCard(c, spec)) + }) + group.appendChild(grid) + box.appendChild(group) + }) + return box + } + + function renderCard(card, spec) { + const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') }) + const head = h('div', { class: 'tcr-card-head' }) + const titles = h('div', { class: 'tcr-card-titles' }) + titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' })) + if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle })) + head.appendChild(titles) + if (Array.isArray(card.badges) && card.badges.length) { + const bs = h('div', { class: 'tcr-badges' }) + card.badges.forEach(function (b) { + bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' })) + }) + head.appendChild(bs) + } + el.appendChild(head) + + if (card.image && card.image.url) { + const fig = h('figure', { class: 'tcr-figure' }) + const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' }) + img.addEventListener('error', function () { + clear(fig) + fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' })) + fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' })) + }) + fig.appendChild(img) + const cap = h('figcaption', { class: 'tcr-figcap' }) + if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' })) + cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' })) + fig.appendChild(cap) + el.appendChild(fig) + } + + if (Array.isArray(card.fields) && card.fields.length) { + const dl = h('div', { class: 'tcr-fields' }) + card.fields.forEach(function (f) { + const row = h('div', { class: 'tcr-field' }) + row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' })) + const val = h('div', { class: 'tcr-field-value' }) + val.appendChild(renderFact(f.value, spec)) + if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint })) + row.appendChild(val) + dl.appendChild(row) + }) + el.appendChild(dl) + } + + if (Array.isArray(card.bullets) && card.bullets.length) { + const ul = h('ul', { class: 'tcr-bullets' }) + card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) }) + el.appendChild(ul) + } + return el + } + + function renderTable(data, spec, section) { + let table = null + if (Array.isArray(data)) { + table = pickById(data, section.pick_id) + if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id }) + if (!table) table = data[0] + } else { + table = data + } + if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' }) + + const box = h('div', { class: 'tcr-tablebox' }) + if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title })) + + const columns = Array.isArray(table.columns) ? table.columns : [] + const rows = Array.isArray(table.rows) ? table.rows : [] + const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : [] + + const el = h('table', { class: 'tcr-table' }) + const tr = h('tr') + columns.forEach(function (c) { + tr.appendChild(h('th', { + class: c.align ? ('tcr-al-' + c.align) : '', + style: c.width ? { width: c.width } : null, + text: c.label || c.key + })) + }) + el.appendChild(h('thead', null, tr)) + + const tbody = h('tbody') + rows.forEach(function (r) { + const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' }) + columns.forEach(function (c) { + const v = r[c.key] + const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' }) + if (v === null || v === undefined) td.textContent = '—' + else td.textContent = String(v) + row.appendChild(td) + }) + tbody.appendChild(row) + }) + el.appendChild(tbody) + box.appendChild(h('div', { class: 'tcr-table-scroll' }, el)) + if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note })) + return box + } + + function renderCost(data, spec) { + if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' }) + const box = h('div', { class: 'tcr-cost' }) + const items = Array.isArray(data.items) ? data.items : [] + const el = h('table', { class: 'tcr-table' }) + el.appendChild(h('thead', null, h('tr', null, [ + h('th', { text: '项目' }), + h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }), + h('th', { text: '口径' }) + ]))) + const tbody = h('tbody') + items.forEach(function (it) { + const row = h('tr') + row.appendChild(h('td', { text: it.label || '' })) + row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) })) + row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' })) + tbody.appendChild(row) + }) + el.appendChild(tbody) + if (data.total !== undefined) { + const tf = h('tfoot') + const tr = h('tr', { class: 'tcr-row-hl' }) + tr.appendChild(h('td', { text: '合计' })) + tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) })) + tr.appendChild(h('td', { text: '' })) + tf.appendChild(tr) + el.appendChild(tf) + } + box.appendChild(h('div', { class: 'tcr-table-scroll' }, el)) + if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note })) + return box + } + + function renderChecklist(data, spec) { + const list = Array.isArray(data) ? data : [] + if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' }) + const ol = h('ol', { class: 'tcr-checklist' }) + list.forEach(function (c) { + const li = h('li', { class: 'tcr-check-item' }) + li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' })) + if (c.confidence) { + li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) })) + } + if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify })) + ol.appendChild(li) + }) + return ol + } + + function renderNotes(data, spec, section) { + let list = Array.isArray(data) ? data : [] + if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) { + const allow = section.filter_levels + list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 }) + } + if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' }) + const box = h('div', { class: 'tcr-notes' }) + list.forEach(function (n) { + const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') }) + el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' })) + if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text })) + box.appendChild(el) + }) + return box + } + + /* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */ + + function renderMap(data, spec) { + if (!isPlainObject(data) || !Array.isArray(data.markers)) { + return h('div', { class: 'tcr-empty', text: '无地图数据' }) + } + const box = h('div', { class: 'tcr-mapbox' }) + const holder = h('div', { class: 'tcr-map-holder' }) + box.appendChild(holder) + const legend = renderMapLegend(data) + if (legend) box.appendChild(legend) + const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER + box.appendChild(h('div', { class: 'tcr-map-note', text: dis })) + mapRegistry.push({ el: holder, data: data }) + return box + } + + function renderMapLegend(data) { + const routes = Array.isArray(data.routes) ? data.routes : [] + if (!routes.length) return null + const box = h('div', { class: 'tcr-map-legend' }) + routes.forEach(function (r) { + const item = h('span', { class: 'tcr-legend-item' }) + const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') }) + if (r.color) swatch.style.background = r.color + if (r.dashed) { + swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)' + } + item.appendChild(swatch) + item.appendChild(h('span', { text: r.name || r.id })) + box.appendChild(item) + }) + return box + } + + function initMaps() { + mapRegistry.forEach(function (reg) { + try { + if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data) + else drawSvgFallback(reg.el, reg.data) + } catch (e) { + try { drawSvgFallback(reg.el, reg.data) } catch (e2) { + clear(reg.el) + reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' })) + } + } + }) + mapRegistry.length = 0 + } + + function drawLeaflet(el, data) { + const L = global.L + clear(el) + const map = L.map(el, { zoomControl: true, attributionControl: true }) + L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map) + + const markers = data.markers || [] + const byId = {} + const latlngs = [] + markers.forEach(function (m) { + if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return + byId[m.id] = m + latlngs.push([m.lat, m.lng]) + const icon = L.divIcon({ + className: 'tcr-pin-wrap', + html: '' + (m.seq === undefined ? '' : m.seq) + '', + iconSize: [22, 22], + iconAnchor: [11, 11] + }) + const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map) + marker.bindPopup('' + escapeHtml(m.name || '') + '' + + (m.note ? '
' + escapeHtml(m.note) : '') + + (m.coord_precision === 'approx' ? '
坐标为概略值' : '')) + }) + + ;(data.routes || []).forEach(function (r) { + const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean) + .map(function (m) { return [m.lat, m.lng] }) + if (pts.length < 2) return + const line = L.polyline(pts, { + color: r.color || '#2f6fed', + weight: r.kind === 'drive' ? 3 : 4, + dashArray: r.dashed ? '6 6' : null, + opacity: 0.9 + }).addTo(map) + if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true }) + else line.bindTooltip(r.name || '', { sticky: true }) + }) + + if (latlngs.length) { + try { + map.fitBounds(L.latLngBounds(latlngs).pad(0.15)) + } catch (e) { + map.setView(data.center || latlngs[0], data.zoom || 9) + } + } else { + map.setView(data.center || [31.95, 121.15], data.zoom || 9) + } + setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60) + } + + function drawSvgFallback(el, data) { + clear(el) + const markers = (data.markers || []).filter(function (m) { + return typeof m.lat === 'number' && typeof m.lng === 'number' + }) + const W = 860, H = 420, PAD = 34 + const svgNS = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(svgNS, 'svg') + svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H) + svg.setAttribute('class', 'tcr-svg-map') + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' })) + + if (!markers.length) { + el.appendChild(svg) + el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' })) + return + } + + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity + markers.forEach(function (m) { + minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat) + minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng) + }) + const spanLat = Math.max(maxLat - minLat, 0.01) + const spanLng = Math.max(maxLng - minLng, 0.01) + function px(m) { + const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2) + const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2) + return { x: x, y: y } + } + + const byId = {} + markers.forEach(function (m) { byId[m.id] = m }) + + ;(data.routes || []).forEach(function (r) { + const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean) + if (pts.length < 2) return + const d = pts.map(function (m, i) { + const p = px(m) + return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1) + }).join(' ') + svg.appendChild(mkSvg('path', { + d: d, + fill: 'none', + stroke: r.color || '#2f6fed', + 'stroke-width': r.kind === 'drive' ? 2.5 : 3.2, + 'stroke-dasharray': r.dashed ? '6 6' : 'none', + 'stroke-linejoin': 'round', + class: 'tcr-svg-line' + })) + }) + + markers.forEach(function (m) { + const p = px(m) + svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 })) + const t = mkSvg('text', { + x: p.x, y: p.y + 3.4, 'text-anchor': 'middle', + class: 'tcr-svg-seq', fill: '#fff' + }) + t.textContent = m.seq === undefined ? '' : String(m.seq) + svg.appendChild(t) + const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' }) + label.textContent = m.name || '' + svg.appendChild(label) + }) + + el.appendChild(svg) + el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' })) + } + + function mkSvg(tag, attrs) { + const el = document.createElementNS('http://www.w3.org/2000/svg', tag) + Object.keys(attrs || {}).forEach(function (k) { + if (attrs[k] === null || attrs[k] === undefined) return + el.setAttribute(k, String(attrs[k])) + }) + return el + } + + function pinColor(kind) { + const map = { + start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', parking: '#c2701a', + hike: '#2e9e5b', district: '#b8860b', food: '#e0673a', bathhouse: '#1f8a70', + shop: '#5a67d8', sleep: '#2d3748', walk: '#8a5cf6', breakfast: '#c98a00', spot: '#0ea5e9' + } + return map[kind] || '#4b5563' + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + }) + } + + /* ---------------- 顶层渲染 ---------------- */ + + const BLOCKS = { + overview: renderOverview, + timeline: renderTimeline, + cards: renderCards, + map: renderMap, + table: renderTable, + cost: renderCost, + checklist: renderChecklist, + notes: renderNotes + } + + function renderSection(section, payload, spec) { + const data = section.data ? resolvePath(payload, section.data) : payload + const fn = BLOCKS[section.type] + const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) }) + if (section.title) { + const head = h('div', { class: 'tcr-section-head' }) + head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title })) + head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' })) + wrap.appendChild(head) + } + if (typeof fn !== 'function') { + wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type })) + return wrap + } + wrap.appendChild(fn(data, spec, section)) + return wrap + } + + function renderIndex(spec) { + const sections = (spec.sections || []).filter(function (s) { return s.title }) + if (!sections.length) return null + const nav = h('nav', { class: 'tcr-index' }) + sections.forEach(function (s) { + nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title })) + }) + return nav + } + + function render(payload, spec, mount) { + if (!mount) throw new Error('tc_render: 缺少挂载节点') + spec = spec || {} + const sections = Array.isArray(spec.sections) ? spec.sections : [] + // 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。 + // 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。 + if (!sections.length) { + throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空') + } + clear(mount) + if (spec.show_index) { + const idx = renderIndex(spec) + if (idx) mount.appendChild(idx) + } + sections.forEach(function (section) { + mount.appendChild(renderSection(section, payload || {}, spec)) + }) + // 地图必须等节点入 DOM 后再初始化 + if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps) + else setTimeout(initMaps, 0) + } + + global.TCRender = { + render: render, + resolvePath: resolvePath, + pickById: pickById, + MAP_TILE_URL: MAP_TILE_URL + } +})(window) \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_report.css b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_report.css new file mode 100644 index 0000000..d5ec111 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/tc_report.css @@ -0,0 +1,193 @@ +/* ============================================================ + * tc_report.css —— 游山玩水 · 报告样式(只服务 tc_render.js) + * + * 为什么单独一份: + * tc_render.js 是「通用汇报渲染器」,它生成的节点类名统一用 tcr- 前缀。 + * 页面上还有表单(.tc-field / .tc-badge / .tc-note 等页面级类名), + * 两套类名如果共用 tc- 前缀就会互相污染(例如 .tc-field 表单要 flex 纵向、 + * 报告卡片里要 grid 两列),所以报告一律走 tcr- 独立命名空间。 + * + * 使用方式:任何页面只要引用本文件 + tc_render.js,就能渲染任意 skill 的报告。 + * 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件) + * 删除本文件只会让报告掉样式,不会影响本 lab 之外的任何 tool / lab。 + * ============================================================ */ + +:root { + --tcr-bg: #f6f7f9; + --tcr-card: #ffffff; + --tcr-line: #e5e7eb; + --tcr-text: #1f2328; + --tcr-sub: #6b7280; + --tcr-accent: #2f6fed; +} + +/* ---------------- 区块骨架 ---------------- */ + +.tcr-index { + display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; + background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px; +} +.tcr-index-item { + font-size: 12px; color: var(--tcr-accent); text-decoration: none; + padding: 3px 8px; border-radius: 6px; background: #f0f4ff; +} +.tcr-index-item:hover { background: #e2eaff; } + +.tcr-section { + background: var(--tcr-card); border: 1px solid var(--tcr-line); + border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px; +} +.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } +.tcr-section-title { font-size: 16px; margin: 0; } +.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; } +.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; } + +/* ---------------- 概览 ---------------- */ + +.tcr-ov-title { margin: 0 0 6px; font-size: 18px; } +.tcr-ov-line { margin: 0 0 10px; color: #374151; } +.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; } +.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; } +.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; } +.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); } +.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; } +.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; } + +/* ---------------- 表格 ---------------- */ + +.tcr-table-scroll { overflow-x: auto; } +table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; } +table.tcr-table th, table.tcr-table td { + border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top; +} +table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; } +.tcr-al-right { text-align: right !important; } +.tcr-al-center { text-align: center !important; } +.tcr-row-hl td { background: #eef4ff; } +.tcr-row-hard td { background: #fff7ed; } +.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; } +.tcr-col-kind { width: 138px; } +.tcr-row-title { font-weight: 500; } +.tcr-row-note { font-size: 12px; color: var(--tcr-sub); } +.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; } +.tcr-kind { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: #eef2f7; color: #4b5563; white-space: nowrap; +} +.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; } +.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; } + +.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; } +.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; } + +/* ---------------- 时序分组 ---------------- */ + +.tcr-day { margin-bottom: 16px; } +.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; } +.tcr-day-label { font-weight: 600; } +.tcr-day-date { font-size: 12px; color: var(--tcr-sub); } + +/* ---------------- 卡片 ---------------- */ + +.tcr-card-group { margin-bottom: 14px; } +.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; } +.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } +.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; } +.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } +.tcr-card-title { font-size: 15px; margin: 0; } +.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); } +.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; } +.tcr-badge { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default; +} +.tcr-badge-high { background: #e8f6ee; color: #1a7a45; } +.tcr-badge-medium { background: #fff5e0; color: #8a6100; } +.tcr-badge-low { background: #fdece8; color: #b42318; } +.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; } +.tcr-tone-good { background: #e8f6ee; color: #1a7a45; } +.tcr-tone-warn { background: #fff5e0; color: #8a6100; } +.tcr-tone-danger { background: #fdece8; color: #b42318; } +.tcr-tone-plain { background: #eef2f7; color: #4b5563; } + +/* ---------------- 配图 ---------------- */ + +.tcr-figure { margin: 10px 0 4px; } +.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; } +.tcr-img-missing { + height: 90px; display: flex; align-items: center; justify-content: center; + background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px; + color: var(--tcr-sub); font-size: 12px; +} +.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; } +.tcr-figcap-note { color: #b42318; } + +/* ---------------- 卡片字段 / 事实 ---------------- */ + +.tcr-fields { margin-top: 10px; } +.tcr-field { + display: grid; grid-template-columns: 96px 1fr; gap: 6px; + padding: 5px 0; border-top: 1px dashed #eef0f3; +} +.tcr-field:first-child { border-top: none; } +.tcr-field-label { color: var(--tcr-sub); font-size: 12px; } +.tcr-field-value { font-size: 13px; } +.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); } +.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; } +.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; } +.tcr-bullets li { margin-bottom: 3px; } + +/* ---------------- 地图 ---------------- */ + +.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); } +.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; } +.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; } +.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; } +.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; } +.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; } +.tcr-svg-map { width: 100%; height: auto; display: block; } +.tcr-svg-bg { fill: #f2f5f8; } +.tcr-svg-seq { font-size: 10px; font-weight: 700; } +.tcr-svg-label { font-size: 11px; fill: #374151; } +.tcr-pin-wrap { background: transparent; border: none; } +.tcr-pin { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px; + font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3); + background: #4b5563; +} +.tcr-pin-start { background: #2f6fed; } +.tcr-pin-end { background: #d14343; } +.tcr-pin-hike { background: #2e9e5b; } +.tcr-pin-district { background: #b8860b; } +.tcr-pin-food { background: #e0673a; } +.tcr-pin-bathhouse { background: #1f8a70; } +.tcr-pin-shop { background: #5a67d8; } +.tcr-pin-sleep { background: #2d3748; } +.tcr-pin-walk { background: #8a5cf6; } +.tcr-pin-breakfast { background: #c98a00; } +.tcr-pin-parking { background: #c2701a; } +.tcr-pin-spot { background: #0ea5e9; } +.tcr-pin-waypoint { background: #8a8f98; } + +/* ---------------- 提示 ---------------- */ + +.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; } +.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; } +.tcr-note-text { font-size: 13px; color: #374151; } +.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; } +.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; } +.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; } + +/* ---------------- 待核实清单 ---------------- */ + +.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; } +.tcr-check-item { margin-bottom: 7px; } +.tcr-check-text { margin-right: 6px; } + +@media (max-width: 640px) { + .tcr-card-grid { grid-template-columns: 1fr; } + .tcr-field { grid-template-columns: 82px 1fr; } + .tcr-map-holder { height: 320px; } +} \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/assets/.gitkeep b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/icon.svg b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/icon.svg new file mode 100644 index 0000000..fd5e26b --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/icon.svg @@ -0,0 +1,11 @@ + + 上海周边高铁游周末两天一晚 + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.html b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.html new file mode 100644 index 0000000..ccac9cb --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.html @@ -0,0 +1,768 @@ + + + + + + +上海周边高铁游周末两天一晚 · 游山玩水 + + + + + +
+
+

上海周边高铁游周末两天一晚

+ 游山玩水 · 单 skill 页 + ← 返回技能列表 +
+
正在读取技能状态…
+ + + + + +
+ 两条本技能的固定规则,先说在前面 + ① 不给具体车次号:AI 无法查 12306,写出来的车次号一定是编的,所以报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」,实际车次请以 12306 为准。
+ ② 返程死线 21:00:周日必须下午出发,最晚 21:00 抵达上海(周一要上班,晚了没地铁)。若班次偏晚,报告会提示你改早班次。 +
+ +
+

1 · 填写行程参数

+
正在读取问答定义…
+
+
+ + + +
+
+ +
+ +
+ 打不开 / 提示未授权? + 本技能受导航鉴权保护,必须从首页九宫格进入思想实验室、再点本卡片;直接把链接粘贴到地址栏会被拒绝(无同源来源)。 +
查看离线样例:苏州(不烧 token,直接看报告长什么样) +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/index.html b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/index.html new file mode 100644 index 0000000..0809dda --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/index.html @@ -0,0 +1,148 @@ + + + + + + +苏州古城水岸 · 两日高铁轻旅行|游山玩水(离线样例) + + + + + +
+
+

苏州古城水岸 · 两日高铁轻旅行

+ 游山玩水 · 技能「上海周边高铁游周末两天一晚」· 离线样例 + ← 返回本技能 +
+
正在载入样例数据…
+
+ 这是效果验证样例:由技能包规则手工编写,用来验证渲染器/导出器能正确处理本技能的新词表 + (共享单车 / 网约车 / 车站 / 住宿)与「每处可导航地址」。其中的高铁时段、票价、店铺、酒店、 + 共享单车禁停区均属示意,真实行程以 AI 生成 + 12306 核实为准。 +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/report_spec.json b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/report_spec.json new file mode 100644 index 0000000..c0a1610 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/report_spec.json @@ -0,0 +1,52 @@ +{ + "_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端渲染逻辑不用改结构。", + "_isolation_comment": "【隔离用意】本文件是【本 skill 私有的渲染描述】。本 skill 复用了与 sh_2d_car_hike 相同的 8 种 block 类型(overview/timeline/cards/map/table/cost/checklist/notes),这是刻意为之 —— block 类型是『展示形态』的抽象,够通用;而『车次表』『市内交通表』这类业务差异,靠 table 里的列定义与 pick_id 表达即可,不必新增 block 类型。所以本 skill 的渲染器只需改自己的字典(activity 中文名、marker 颜色),不必改渲染结构。这是隔离后最省力的定制方式。若将来某个 skill 真需要全新展示形态(例如航班时刻轴),那时再为它单独加 block,不影响本文件。", + "version": "1.1.1", + + "block_types_comment": "通用渲染器支持的 8 种 block;本 skill 未新增 block 类型", + "block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"], + + "layout_comment": "页面骨架:单列纵向", + "layout": "single_column", + "show_index_comment": "是否在页首显示区块锚点导航", + "show_index": true, + + "sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项", + "sections": [ + { "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" }, + + { "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] }, + + { "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" }, + + { "id": "sec_cards", "type": "cards", "title": "节点详情(含导航地址)", "data": "$.cards", "group_by": "day" }, + + { "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" }, + + { "id": "sec_train_out", "type": "table", "title": "去程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_out_options" }, + + { "id": "sec_train_back", "type": "table", "title": "返程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_back_options" }, + + { "id": "sec_city_transport", "type": "table", "title": "市内交通方案 · 共享单车 / 网约车", "data": "$.tables", "pick_id": "city_transport" }, + + { "id": "sec_lodging", "type": "table", "title": "住宿候选对比", "data": "$.tables", "pick_id": "lodging_compare" }, + + { "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" }, + + { "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" }, + + { "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" }, + + { "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" } + ], + + "confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)", + "confidence_badge": { + "high": "已核实", + "medium": "待确认", + "low": "需核实" + }, + "image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景", + "image_disclaimer_default": "AI 示意图,非实景", + "map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准" +} \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/suzhou_2026-10-17.json b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/suzhou_2026-10-17.json new file mode 100644 index 0000000..74af9c0 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/sample/suzhou_2026-10-17.json @@ -0,0 +1,486 @@ +{ + "_comment": "【离线样例 · 手工编写,仅用于渲染验证与产物预览】本文件是 sh_2d_rail_hike 的一份完整 payload 示例,用来:(1) 验证渲染器/导出器能正确处理本 skill 的新词表(共享单车/网约车/车站/住宿)与 address 字段;(2) 让用户在不想烧 token 时也能先看报告长什么样。注意:本样例中的高铁时段、票价、店铺、酒店、共享单车禁停区等均属【示意】,不代表真实情况,真实行程以 AI 生成 + 12306 核实为准。", + "meta": { + "skill_id": "sh_2d_rail_hike", + "skill_name": "上海周边高铁游周末两天一晚", + "skill_version": "1.1.1", + "schema_version": "1.1.1", + "destination": { + "key": "suzhou", + "name": "苏州", + "admin": "江苏省苏州市", + "center": [31.299, 120.585] + }, + "qa": { + "destination": "苏州", + "departure_station": "上海虹桥站", + "departure_window": "sat_morning", + "departure_date": "2026-10-17", + "lodging_mode": "bathhouse", + "budget_level": "comfort", + "headcount": 1, + "luggage": "backpack", + "hike_intensity": "medium" + }, + "generated_at": "2026-09-30 10:20:00", + "generator": "manual_sample", + "disclaimer": "本样例内容含标注为「待确认 / 需核实」的信息,出行前请按『待核实清单』自行复核。" + }, + + "summary": { + "title": "苏州古城水岸 · 两日高铁轻旅行", + "one_liner": "虹桥半小时直达:环古城河步道走一段,下午赶在闭园前看拙政园或苏博,晚饭选本地人爱去的馆子,饭后把 19:00-21:00 交给平江路夜景与观前街夜市;泡个本地大浴场留宿一夜,隔天金鸡湖环湖收尾再返沪。", + "tags": ["高铁半小时", "古城水岸", "白天景点", "夜逛闹市", "本地人饭馆", "洗浴留宿"], + "stats": [ + { "label": "去程车程", "value": "约 25 分钟", "hint": "上海虹桥 → 苏州站" }, + { "label": "徒步合计", "value": "约 3 小时", "hint": "两段各约 1.5 小时,均为平路" }, + { "label": "周六夜夜逛", "value": "19:00-21:00", "hint": "平江路夜景 + 观前街夜市,自由活动" }, + { "label": "返程死线", "value": "21:00 前抵沪", "hint": "周日下午出发,为地铁末班留余量" } + ] + }, + + "timeline": { + "days": [ + { + "label": "第一天", + "date": "2026-10-17(周六)", + "rows": [ + { "time": "07:40", "title": "抵达上海虹桥站,安检进站", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "大站建议提前 40 分钟到,含安检与检票" }, + { "time": "08:20", "title": "乘高铁赴苏州站(约 25 分钟)", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "建议时段 08:00-09:30;实际车次以 12306 为准" }, + { "time": "09:00", "title": "苏州站出站,扫码取共享单车", "anchor_id": "city_mobility", "kind": "soft", "activity": "bike", "note": "站前广场有指定单车停放区;带拉杆箱请改打车" }, + { "time": "09:20", "title": "骑行至酒店寄存行李", "anchor_id": "station_to_lodging", "kind": "soft", "activity": "bike", "note": "约 3km,骑行 15 分钟;14:00 后方可入住,先寄存" }, + { "time": "12:00", "title": "平江路北段午饭(本地人爱去 + 评分 4.4)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "临河本地苏帮菜小馆,人均约 90 元;非游客主街、本地评价占比高" }, + { "time": "14:00", "title": "环古城河步道徒步(相门—平门段)", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路水岸,累计爬升约 0" }, + { "time": "15:40", "title": "拙政园 / 苏州博物馆(白天开放,务必赶在最后入场前)", "anchor_id": "spot_d1", "kind": "soft", "activity": "walk", "note": "拙政园约 16:30 停止入场、苏博约 16:00 停止入馆;徒步结束后直接前往" }, + { "time": "17:30", "title": "观前街晚饭(本地人爱去 + 评分 4.3,19:00 前吃完)", "anchor_id": "dinner_d1", "kind": "soft", "activity": "eat", "note": "巷内本地家常菜馆,与午饭菜系区分;吃完步行 10 分钟即可开始夜逛" }, + { "time": "19:00", "title": "夜逛:平江路夜景 + 观前街夜市(自由活动)", "anchor_id": "night_stroll", "kind": "soft", "activity": "stroll", "note": "可选去处 ≥3 个,不强制顺序;地铁末班约 22:30,返回可打车" }, + { "time": "21:20", "title": "返回住处 · 入住本地大型浴场(留宿)", "anchor_id": "lodging_night", "kind": "hard", "activity": "checkin", "note": "不晚于 22:30 抵达;需确认是否可过夜" } + ] + }, + { + "label": "第二天", + "date": "2026-10-18(周日)", + "rows": [ + { "time": "07:20", "title": "住处附近本地早点", "anchor_id": "breakfast_d2", "kind": "soft", "activity": "eat", "note": "面馆 6:30 开门" }, + { "time": "08:40", "title": "打车至金鸡湖,环湖步道徒步", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "带装备建议打车,约 8km、20 分钟" }, + { "time": "10:40", "title": "虎丘(与昨天景点去重,白天开放)", "anchor_id": "spot_d2", "kind": "soft", "activity": "walk", "note": "距苏州站约 6km,返程前需留足车程" }, + { "time": "13:20", "title": "取行李,打车赴苏州站(返程起点)", "anchor_id": "train_back", "kind": "soft", "activity": "taxi", "note": "返程前一站;留出 40 分钟安检检票缓冲" }, + { "time": "14:30", "title": "乘高铁返沪(约 25 分钟)", "anchor_id": "train_back", "kind": "hard", "activity": "train", "note": "周日必须下午出发,最晚 21:00 抵沪;实际车次以 12306 为准" }, + { "time": "15:10", "title": "抵达上海虹桥站,地铁返家", "anchor_id": "train_back", "kind": "soft", "activity": "metro", "note": "返程终点;虹桥 2/10/17 号线,末班约 22:30 后,距 21:00 死线尚早" } + ] + } + ] + }, + + "cards": [ + { + "id": "card_train_out", "anchor_id": "train_out", "day": 1, + "title": "去程高铁 · 时段与车站", + "subtitle": "虹桥 → 苏州站,班次极密", + "badges": [{ "text": "建议时段 08:00-09:30", "tone": "info" }], + "fields": [ + { "label": "出发站", "value": "上海虹桥站" }, + { "label": "到达站", "value": "苏州站" }, + { "label": "建议时段", "value": { "text": "08:00-09:30(班次密集,高峰约 10 分钟一班)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } }, + { "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } }, + { "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } }, + { "label": "进站缓冲", "value": "虹桥为大站,建议提前 40 分钟到站" }, + { "label": "导航", "value": "上海市闵行区申贵路1500号 上海虹桥站" } + ], + "bullets": [ + "本技能不给具体车次号(模型无法查 12306,写出来必然是编的)", + "苏州站出站后即为地铁 2/4 号线,站前广场设共享单车停放区" + ] + }, + { + "id": "card_city_mobility", "anchor_id": "city_mobility", "day": 1, + "title": "市内交通方案 · 共享单车 + 网约车", + "subtitle": "苏州老城禁停区较多,先看清再骑", + "badges": [{ "text": "预估市内交通约 ¥45", "tone": "info" }], + "fields": [ + { "label": "策略", "value": "3km 内优先共享单车;超过 5km 或带行李时打车;老城核心区一律步行" }, + { "label": "共享单车", "value": { "text": "美团 / 哈啰 / 青桔均可,扫码开锁;需确认当地在运营区", "confidence": "medium", "verify": "打开对应 App 查看是否显示可租车辆" } }, + { "label": "禁停区", "value": { "text": "平江路、观前街、拙政园周边大片区域禁行禁停,须停到外围指定停车点", "confidence": "low", "verify": "以 App 内显示的 P 点为准,出发前查看" } }, + { "label": "网约车上车点", "value": { "text": "苏州站北广场西侧临时停靠点;平江路口禁停,建议在干将东路侧上车", "confidence": "low", "verify": "叫车后以司机约定的上车点为准" } }, + { "label": "预估费用", "value": "共享单车按 1.5 元/15 分钟计;网约车起步约 ¥10,全程约 ¥45" }, + { "label": "行李约束", "value": "本次为轻装背包,可骑车;若改带拉杆箱则全程改打车并先寄存" }, + { "label": "导航", "value": "苏州市姑苏区干将东路(平江路口西侧临时停靠点,网约车常用上车点)" } + ] + }, + { + "id": "card_station_to_lodging", "anchor_id": "station_to_lodging", "day": 1, + "title": "出站接驳 + 行李寄存", + "subtitle": "先寄存,再开逛", + "fields": [ + { "label": "接驳方式", "value": "苏州站 → 酒店约 3km,共享单车 15 分钟 / 网约车 10 分钟" }, + { "label": "顺序", "value": "多数酒店 14:00 后才可入住,早到必须先免费寄存行李再出行" }, + { "label": "导航", "value": { "text": "苏州市姑苏区临顿路(观前街南段)酒店集群", "confidence": "medium", "verify": "具体酒店以实际预订为准" } } + ] + }, + { + "id": "card_lunch_d1", "anchor_id": "lunch_d1", "day": 1, + "title": "第一天午饭 · 平江路北段", + "subtitle": "本地人爱去 + 评分好,人均约 ¥90", + "fields": [ + { "label": "推荐业态", "value": { "text": "平江路北段临河的本地苏帮菜小馆(苏式汤面、松鼠鳜鱼小份、时令河鲜)", "confidence": "low", "verify": "不编造店名;到店前用高德/大众点评搜索『平江路 苏帮菜』按评分挑选" } }, + { "label": "为什么是本地人爱去", "value": { "text": "位于平江路北段非游客主街、以本地招牌菜(苏式汤面)为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比与复购提及" } }, + { "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量数千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准(评分会随时间变化)" } }, + { "label": "人均", "value": "约 ¥90(单人点面 + 一菜即可)" }, + { "label": "到达方式", "value": "从寄存点步行约 12 分钟;沿途为步行街区,不可骑入" }, + { "label": "导航", "value": "苏州市姑苏区平江路历史街区北段(白塔东路入口)" } + ] + }, + { + "id": "card_hike_1", "anchor_id": "hike_1", "day": 1, + "title": "第一段徒步 · 环古城河步道(相门—平门段)", + "subtitle": "平路水岸,约 1.5 小时", + "badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }], + "fields": [ + { "label": "起点", "value": "相门城墙遗址(苏州市姑苏区干将东路相门桥)" }, + { "label": "终点", "value": "平门桥(苏州市姑苏区人民路北端)" }, + { "label": "方式", "value": "单程走,不走回头路;全程沿河,约 4.5km" }, + { "label": "耗时", "value": "约 1.5 小时(按 3km/h 慢走折算,含驻足看景)" }, + { "label": "路面", "value": "塑胶步道 + 石板路,平坦好走,适合运动鞋" }, + { "label": "补水点", "value": "沿途有便利店与驿站,可买水" }, + { "label": "到达起点", "value": "从午饭点步行约 15 分钟;若骑车,须停在相门桥南侧指定停车点" }, + { "label": "导航", "value": "江苏省苏州市姑苏区干将东路 相门桥(相门城墙遗址)" } + ], + "bullets": [ + "已触发地形降级 terrain_flat:苏州无山,本段为平路徒步,累计爬升约 0,运动量按时长与步数计", + "想出汗可提高配速或走两个来回" + ] + }, + { + "id": "card_spot_d1", "anchor_id": "spot_d1", "day": 1, + "title": "周六下午 · 拙政园 / 苏州博物馆(白天开放)", + "subtitle": "走完步道直接去,务必赶在最后入场前", + "badges": [{ "text": "白天开放 · 需赶在停止入场前", "tone": "warn" }], + "fields": [ + { "label": "看点", "value": "拙政园为中国四大名园之一,明代造园代表;苏州博物馆由贝聿铭设计,建筑本身就是看点" }, + { "label": "开放时间", "value": { "text": "拙政园旺季约 7:30-17:30;苏博约 9:00-17:00(周一闭馆)", "confidence": "low", "verify": "以官方公众号公布时间为准" } }, + { "label": "最后入场时间", "value": { "text": "拙政园约 16:30 停止入场;苏博约 16:00 停止入馆(闭馆前 30-60 分钟停止入场,这比闭馆时间更关键)", "confidence": "low", "verify": "官方公众号或购票页公示「停止入场时间」" } }, + { "label": "时间校验", "value": "徒步 15:30 结束 → 打车/步行约 10 分钟到东北街 → 约 15:40 抵达,仍有约 50 分钟入场余量" }, + { "label": "门票", "value": { "text": "拙政园旺季约 ¥70-90;苏博免费", "confidence": "low", "verify": "以官方渠道为准" } }, + { "label": "预约", "value": { "text": "苏州博物馆需提前在官方渠道预约,旺季名额紧张", "confidence": "low", "verify": "出发前 3-7 天在官方公众号预约" } }, + { "label": "与次日去重", "value": "第二天 spot_d2 改用虎丘或盘门景区,不与此处重复" }, + { "label": "导航", "value": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆" } + ], + "bullets": ["本槽位核心是「白天开放」:若当天来不及,必须改选更近的景点或缩短徒步,绝不允许安排到门口已停止入场"] + }, + { + "id": "card_dinner_d1", "anchor_id": "dinner_d1", "day": 1, + "title": "第一天晚饭 · 观前街(本地人爱去 + 评分好)", + "subtitle": "与午饭换菜系,19:00 前吃完,直接接夜逛", + "fields": [ + { "label": "推荐业态", "value": { "text": "观前街巷内本地家常菜馆(与午饭的苏帮菜区分,可选苏式面/馄饨/砂锅)", "confidence": "low", "verify": "不编造店名;按大众点评本地评价占比挑选" } }, + { "label": "为什么是本地人爱去", "value": { "text": "位于观前街巷内而非主街、以本地家常菜为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比" } }, + { "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准" } }, + { "label": "营业时间", "value": { "text": "多数至 21:00-22:00", "confidence": "medium", "verify": "到店前电话或 App 确认打烊时间" } }, + { "label": "时间约束", "value": "必须 19:00 前吃完,把 19:00-21:00 留给夜逛;不得选上菜慢或长队排队的店" }, + { "label": "到夜逛起点", "value": "出门即观前街夜市,步行 0 分钟;平江路夜景步行约 10 分钟" }, + { "label": "人均", "value": "约 ¥80" }, + { "label": "导航", "value": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)" } + ] + }, + { + "id": "card_night_stroll", "anchor_id": "night_stroll", "day": 1, + "title": "周六夜 · 夜逛闹市(自由活动,19:00-21:00)", + "subtitle": "平江路夜景 + 观前街夜市,不强制顺序,自己挑", + "badges": [{ "text": "自由活动 · 约 2 小时", "tone": "info" }], + "fields": [ + { "label": "为什么有这一段", "value": "用户反馈过:原来 18:00 吃完晚饭就直接回住处,19:00-21:00 空着太浪费。所以晚饭提前,这两小时用来夜逛" }, + { "label": "可逛范围", "value": "闹市步行街 + 历史街区夜景(平江路夜里挂灯,观前街为夜间商圈)" }, + { "label": "可选去处 1", "value": "平江路夜间段(临河灯笼夜景,安静适合拍照)— 江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)" }, + { "label": "可选去处 2", "value": "观前街夜间商圈(玄妙观前广场,热闹、小吃多)— 江苏省苏州市姑苏区观前街 玄妙观" }, + { "label": "可选去处 3", "value": "山塘街夜景(七里山塘,需打车约 10 分钟)— 江苏省苏州市姑苏区山塘街(渡僧桥入口)" }, + { "label": "夜间是否开放", "value": { "text": "平江路与观前街为开放街区,夜间常开;沿街店铺多数营业至 21:00-22:00", "confidence": "low", "verify": "夜市与灯光开放时间会随季节调整,出发前确认" } }, + { "label": "夜间交通", "value": { "text": "苏州地铁 2/4 号线末班约 22:30 前后;该时段网约车可正常叫到;共享单车夜间可用但老城禁停区仍生效", "confidence": "medium", "verify": "地铁末班时刻以官方 App 为准" } }, + { "label": "返回住处", "value": "夜逛区域距住处约 1km,步行 12 分钟;打车约 8 分钟 / ¥12" }, + { "label": "安全提示", "value": "夜间人流较多,注意随身财物;沿河路段注意照明较暗处" }, + { "label": "导航", "value": "夜逛主区域:江苏省苏州市姑苏区平江路历史街区(南入口:干将东路);另两处见上方可选去处" } + ], + "bullets": [ + "自由活动性质:以上 3 处任选,不要求走完;累了随时可回住处", + "本段结束时间控制在 21:00 左右,回住处后仍有充裕时间洗漱休息" + ] + }, + { + "id": "card_lodging_night", "anchor_id": "lodging_night", "day": 1, + "title": "住宿 · 本地大型浴场(留宿)", + "subtitle": "已按你的选择:高级洗浴带留宿", + "badges": [{ "text": "硬时间点:不晚于 22:30 抵达", "tone": "warn" }], + "fields": [ + { "label": "形态", "value": "高级洗浴(带留宿)——按 lodging_mode=bathhouse 执行" }, + { "label": "规模依据", "value": { "text": "营业面积通常在数千平方米量级,本地评价量级高(本地人复购为主)", "confidence": "low", "verify": "出发前电话确认营业面积、是否可过夜、是否需预约" } }, + { "label": "能否过夜", "value": { "text": "部分浴场仅营业到凌晨,不可留宿;必须电话确认 24 小时营业或有安静休息区", "confidence": "low", "verify": "致电前台确认『能否过夜、是否有独立休息区』" } }, + { "label": "男女分区", "value": { "text": "通常分男女浴区,休息大厅为公共区域", "confidence": "medium", "verify": "到店确认" } }, + { "label": "人均", "value": "约 ¥120-180(含过夜)" }, + { "label": "次日寄存", "value": "次日离店后一般可免费寄存行李,便于继续游玩;需前台确认" }, + { "label": "导航", "value": { "text": "苏州市姑苏区观前街周边大型洗浴中心(以实际选定门店为准)", "confidence": "low", "verify": "选店后请以门店地址导航" } } + ], + "bullets": ["已排除社区小浴室:本条要求规模较大、档次中上"] + }, + { + "id": "card_breakfast_d2", "anchor_id": "breakfast_d2", "day": 2, + "title": "第二天早饭 · 本地面馆", + "subtitle": "7 点前已营业", + "fields": [ + { "label": "方案", "value": "店内吃:本地苏式面馆(枫镇大肉面/焖肉面时令)" }, + { "label": "开门时间", "value": { "text": "多数面馆 6:00-6:30 开门", "confidence": "medium", "verify": "出发前一晚确认次日开门时间" } }, + { "label": "距离", "value": "从住处步行约 8 分钟" }, + { "label": "导航", "value": "苏州市姑苏区观前街周边(临顿路沿线本地面馆)" } + ] + }, + { + "id": "card_hike_2", "anchor_id": "hike_2", "day": 2, + "title": "第二段徒步 · 金鸡湖环湖步道", + "subtitle": "与昨天的古城水岸形成对比:现代湖景", + "badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }], + "fields": [ + { "label": "起点", "value": "金鸡湖西岸 · 东方之门前广场(苏州市工业园区星港街)" }, + { "label": "终点", "value": "湖东 · 摩天轮乐园段,或原路折返" }, + { "label": "方式", "value": "环湖步道,可走一段折返;全程约 5km" }, + { "label": "耗时", "value": "约 1.5 小时" }, + { "label": "路面", "value": "木栈道 + 铺装步道,平坦" }, + { "label": "到达起点", "value": "从住处打车约 20 分钟 / ¥25(约 8km,带装备建议打车)" }, + { "label": "导航", "value": "江苏省苏州市工业园区星港街 东方之门(金鸡湖西岸)" } + ], + "bullets": ["已触发地形降级 terrain_flat:本段同属平路,运动量按时长与步数计"] + }, + { + "id": "card_spot_d2", "anchor_id": "spot_d2", "day": 2, + "title": "返程前穿插 · 虎丘(与昨天景点去重)", + "subtitle": "白天开放,与苏州站顺路", + "fields": [ + { "label": "看点", "value": "虎丘有「吴中第一名胜」之称,云岩寺塔(虎丘塔)为斜塔代表,历史可上溯春秋" }, + { "label": "去重说明", "value": "第一天的 spot_d1 已用拙政园/苏州博物馆,本处改用虎丘,两处不重复" }, + { "label": "开放时间", "value": { "text": "约 7:30-17:30(旺季),闭园前 30-60 分钟停止入场", "confidence": "low", "verify": "以官方公众号公布时间为准" } }, + { "label": "门票", "value": { "text": "旺季约 ¥70-80", "confidence": "low", "verify": "以官方渠道为准" } }, + { "label": "预约", "value": { "text": "通常无需预约,但旺季建议线上购票", "confidence": "low", "verify": "官方公众号购票页确认" } }, + { "label": "衔接返程", "value": "虎丘距苏州站约 6km,打车约 20 分钟;返程前务必留足车程 + 40 分钟安检缓冲" }, + { "label": "导航", "value": "江苏省苏州市姑苏区虎丘山门内8号 虎丘" } + ] + }, + { + "id": "card_train_back", "anchor_id": "train_back", "day": 2, + "title": "返程高铁 · 周日 14:30 发车,21:00 前抵沪", + "subtitle": "死线约束:周日必须下午返", + "badges": [{ "text": "21:00 前必须抵沪", "tone": "danger" }], + "fields": [ + { "label": "出发站", "value": "苏州站" }, + { "label": "到达站", "value": "上海虹桥站" }, + { "label": "建议时段", "value": { "text": "14:00-17:00 发车(越早越稳)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } }, + { "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } }, + { "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } }, + { "label": "到站缓冲", "value": "从最后一个景点到苏州站约 15 分钟,另留 40 分钟安检检票" }, + { "label": "抵沪后回家", "value": "虹桥站地铁 2/10/17 号线,末班约 22:30 后;本次 15:10 抵沪,时间充裕" }, + { "label": "导航", "value": "江苏省苏州市姑苏区苏站路27号 苏州站" } + ], + "bullets": [ + "周日下午为返程高峰,票源紧张且车站人多,建议提前购票", + "本技能不给具体车次号;请以 12306 为准并核对发到时刻" + ] + } + ], + + "map": { + "center": [31.299, 120.585], + "zoom": 12, + "markers": [ + { "id": "m_hongqiao", "name": "上海虹桥站", "kind": "start", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 1, "seq": 1, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "去程出发站,提前 40 分钟到" }, + { "id": "m_suzhou_station", "name": "苏州站", "kind": "train_station", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 1, "seq": 2, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "去程到达站 / 返程出发站" }, + { "id": "m_bike_pickup", "name": "共享单车停放区(站前广场)", "kind": "bike", "lat": 31.3245, "lng": 120.607, "coord_precision": "approx", "day": 1, "seq": 3, "address": "江苏省苏州市姑苏区苏站路 苏州站北广场共享单车停放区", "note": "扫码取车;以 App 显示的可租车辆为准" }, + { "id": "m_hotel", "name": "住宿(观前街/临顿路一带)", "kind": "lodging", "lat": 31.3096, "lng": 120.6226, "coord_precision": "approx", "day": 1, "seq": 4, "address": "江苏省苏州市姑苏区临顿路(观前街南段)", "note": "先寄存行李再出行" }, + { "id": "m_lunch", "name": "午饭 · 平江路北段", "kind": "food", "lat": 31.3152, "lng": 120.6289, "coord_precision": "approx", "day": 1, "seq": 5, "address": "江苏省苏州市姑苏区平江路历史街区北段(白塔东路入口)", "note": "步行街区,不可骑入;本地人爱去 + 评分 4.4" }, + { "id": "m_hike1_start", "name": "相门城墙遗址(徒步起点)", "kind": "hike", "lat": 31.3167, "lng": 120.6385, "coord_precision": "approx", "day": 1, "seq": 6, "address": "江苏省苏州市姑苏区干将东路 相门桥", "note": "单车须停南侧指定停车点" }, + { "id": "m_hike1_end", "name": "平门桥(徒步终点)", "kind": "hike", "lat": 31.3238, "lng": 120.6148, "coord_precision": "approx", "day": 1, "seq": 7, "address": "江苏省苏州市姑苏区人民路北端 平门桥", "note": "单程 4.5km,平路" }, + { "id": "m_spot_d1", "name": "拙政园 / 苏州博物馆(白天开放)", "kind": "spot", "lat": 31.3242, "lng": 120.6294, "coord_precision": "approx", "day": 1, "seq": 8, "address": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆", "note": "务必赶在停止入场前(拙政园约 16:30、苏博约 16:00);苏博需预约" }, + { "id": "m_dinner", "name": "晚饭 · 观前街巷内", "kind": "food", "lat": 31.3109, "lng": 120.6231, "coord_precision": "approx", "day": 1, "seq": 9, "address": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)", "note": "本地人爱去 + 评分 4.3;须 19:00 前吃完" }, + { "id": "m_pingjiang", "name": "平江路夜景区(夜逛主区域)", "kind": "night_market", "lat": 31.3162, "lng": 120.6275, "coord_precision": "approx", "day": 1, "seq": 10, "address": "江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)", "note": "夜逛自由活动;沿河灯光夜景;禁停共享单车" }, + { "id": "m_guangqian", "name": "观前街夜市 · 玄妙观", "kind": "district", "lat": 31.3105, "lng": 120.6248, "coord_precision": "approx", "day": 1, "seq": 11, "address": "江苏省苏州市姑苏区观前街 玄妙观", "note": "夜逛可选去处 2;夜间商圈,小吃多" }, + { "id": "m_bathhouse", "name": "住宿 · 大型浴场(留宿)", "kind": "bathhouse", "lat": 31.3082, "lng": 120.6301, "coord_precision": "approx", "day": 1, "seq": 12, "address": "江苏省苏州市姑苏区观前街周边大型洗浴中心", "note": "需确认能否过夜;夜逛结束后返回" }, + { "id": "m_breakfast", "name": "早饭 · 临顿路面馆", "kind": "food", "lat": 31.3088, "lng": 120.6262, "coord_precision": "approx", "day": 2, "seq": 13, "address": "江苏省苏州市姑苏区临顿路沿线本地面馆", "note": "6:30 前已开门" }, + { "id": "m_taxi_call", "name": "网约车上车点(干将东路侧)", "kind": "taxi_point", "lat": 31.3121, "lng": 120.6282, "coord_precision": "approx", "day": 2, "seq": 14, "address": "江苏省苏州市姑苏区干将东路(平江路口西侧临时停靠点)", "note": "平江路口禁停,在此上车" }, + { "id": "m_jinji_lake", "name": "金鸡湖西岸 · 东方之门(徒步起点)", "kind": "hike", "lat": 31.3175, "lng": 120.6742, "coord_precision": "approx", "day": 2, "seq": 15, "address": "江苏省苏州市工业园区星港街 东方之门", "note": "湖景步道,平坦" }, + { "id": "m_motianlun", "name": "金鸡湖东岸 · 摩天轮乐园段", "kind": "hike", "lat": 31.3155, "lng": 120.7219, "coord_precision": "approx", "day": 2, "seq": 16, "address": "江苏省苏州市工业园区华池街 金鸡湖摩天轮乐园", "note": "徒步终点,可折返" }, + { "id": "m_huqiu", "name": "虎丘(或用盘门景区替代)", "kind": "spot", "lat": 31.3376, "lng": 120.5756, "coord_precision": "approx", "day": 2, "seq": 17, "address": "江苏省苏州市姑苏区虎丘山门内8号 虎丘", "note": "与第一天 spot_d1(拙政园/苏博)去重;距苏州站约 6km" }, + { "id": "m_back_station", "name": "苏州站(返程)", "kind": "end", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 2, "seq": 18, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "留 40 分钟缓冲" }, + { "id": "m_hongqiao_back", "name": "上海虹桥站(抵沪)", "kind": "end", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 2, "seq": 19, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "地铁 2/10/17 号线返家" } + ], + "routes": [ + { "id": "r_train_out", "name": "去程高铁", "kind": "train", "marker_ids": ["m_hongqiao", "m_suzhou_station"], "color": "#1d4ed8", "dashed": true, "note": "点位顺序示意,非真实铁路线;实际车次以 12306 为准" }, + { "id": "r_bike_to_hotel", "name": "共享单车接驳", "kind": "bike", "marker_ids": ["m_suzhou_station", "m_bike_pickup", "m_hotel"], "color": "#0ea5e9", "dashed": false, "note": "点位顺序示意,非真实骑行路径" }, + { "id": "r_hike_1", "name": "环古城河步道", "kind": "hike", "marker_ids": ["m_hike1_start", "m_hike1_end"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" }, + { "id": "r_to_spot_d1", "name": "徒步终点 → 白天景点", "kind": "stroll", "marker_ids": ["m_hike1_end", "m_spot_d1"], "color": "#059669", "dashed": false, "note": "点位顺序示意,非真实道路;步行约 10 分钟,须赶在停止入场前" }, + { "id": "r_night_stroll", "name": "夜逛动线(自由活动)", "kind": "stroll", "marker_ids": ["m_dinner", "m_guangqian", "m_pingjiang"], "color": "#7c2d92", "dashed": false, "note": "点位顺序示意,非真实道路;夜逛自由活动,不强制顺序,可随时返回住处" }, + { "id": "r_taxi_jinji", "name": "网约车至金鸡湖", "kind": "taxi", "marker_ids": ["m_taxi_call", "m_jinji_lake"], "color": "#f59e0b", "dashed": true, "note": "点位顺序示意,非真实行驶路径" }, + { "id": "r_hike_2", "name": "金鸡湖环湖步道", "kind": "hike", "marker_ids": ["m_jinji_lake", "m_motianlun"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" }, + { "id": "r_train_back", "name": "返程高铁", "kind": "train", "marker_ids": ["m_back_station", "m_hongqiao_back"], "color": "#d14343", "dashed": true, "note": "点位顺序示意,非真实铁路线;最晚 21:00 抵沪" } + ] + }, + + "tables": [ + { + "id": "train_out_options", + "title": "去程高铁 · 时段与车站", + "columns": [ + { "key": "window", "label": "建议时段" }, + { "key": "from_station", "label": "出发站" }, + { "key": "to_station", "label": "到达站" }, + { "key": "duration", "label": "历时", "align": "right" }, + { "key": "fare", "label": "二等座参考票价", "align": "right" }, + { "key": "note", "label": "备注" } + ], + "rows": [ + { "_key": "out_early", "window": "07:30-08:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "最早到,第一天时间最充裕(推荐)" }, + { "_key": "out_mid", "window": "08:30-09:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;到站后仍可从容吃午饭" } + ], + "note": "本表为时段建议,非具体车次。本技能不给车次号(模型无法查询 12306,写出的车次号必然是编造的);车次、时刻与票价请以 12306 实时查询为准。" + }, + { + "id": "train_back_options", + "title": "返程高铁 · 时段与车站(死线 21:00 抵沪)", + "columns": [ + { "key": "window", "label": "建议时段" }, + { "key": "from_station", "label": "出发站" }, + { "key": "to_station", "label": "到达站" }, + { "key": "duration", "label": "历时", "align": "right" }, + { "key": "fare", "label": "二等座参考票价", "align": "right" }, + { "key": "note", "label": "备注" } + ], + "rows": [ + { "_key": "back_mid", "window": "14:00-15:30", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;抵沪后地铁余量充足(推荐)" }, + { "_key": "back_late", "window": "16:00-17:00", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "仍可满足 21:00 前抵沪,但余量变小;周日下午票源紧张" } + ], + "highlight_row_keys": ["back_mid"], + "note": "本表为时段建议,非具体车次。周日必须下午出发,最晚 21:00 抵达上海(周一上班,晚了没地铁)。周日下午返程高峰,建议提前购票。" + }, + { + "id": "city_transport", + "title": "市内交通方案 · 共享单车 / 网约车", + "columns": [ + { "key": "leg", "label": "路段" }, + { "key": "mode", "label": "方式" }, + { "key": "distance", "label": "距离/时长", "align": "right" }, + { "key": "cost", "label": "预估费用", "align": "right" }, + { "key": "tip", "label": "提示" } + ], + "rows": [ + { "_key": "t1", "leg": "苏州站 → 酒店", "mode": "共享单车", "distance": "约 3km / 15 分钟", "cost": "¥1.5-3", "tip": "站前广场有指定停放区;带拉杆箱改打车" }, + { "_key": "t2", "leg": "酒店 → 平江路", "mode": "步行", "distance": "约 1km / 12 分钟", "cost": "¥0", "tip": "步行街禁停单车,直接走过去" }, + { "_key": "t3", "leg": "平江路 → 相门(徒步起点)", "mode": "步行", "distance": "约 1.2km / 15 分钟", "cost": "¥0", "tip": "若骑车须停相门桥南侧指定点" }, + { "_key": "t4", "leg": "观前街 → 浴场", "mode": "步行", "distance": "约 800m / 10 分钟", "cost": "¥0", "tip": "夜间城区路况好,步行即可" }, + { "_key": "t5", "leg": "住处 → 金鸡湖", "mode": "网约车", "distance": "约 8km / 20 分钟", "cost": "约 ¥25", "tip": "超过 5km 建议打车;上车点用干将东路侧" }, + { "_key": "t6", "leg": "金鸡湖 → 拙政园/苏博", "mode": "网约车", "distance": "约 9km / 22 分钟", "cost": "约 ¥28", "tip": "跨区,打车最稳" }, + { "_key": "t7", "leg": "拙政园 → 苏州站(返程)", "mode": "网约车", "distance": "约 4km / 15 分钟", "cost": "约 ¥15", "tip": "留 40 分钟安检检票缓冲,务必准时" } + ], + "note": "禁停区范围与网约车上车点属易变信息,出行前请以共享单车 App 内显示的 P 点、以及叫车后司机约定的上车点为准。共享单车按 1.5 元/15 分钟估算。" + }, + { + "id": "lodging_compare", + "title": "住宿候选对比", + "columns": [ + { "key": "name", "label": "候选" }, + { "key": "type", "label": "类型" }, + { "key": "price", "label": "价格区间", "align": "right" }, + { "key": "overnight", "label": "能否过夜" }, + { "key": "note", "label": "备注" } + ], + "rows": [ + { "_key": "l1", "name": "观前街周边大型洗浴中心", "type": "洗浴留宿(已选)", "price": "¥120-180", "overnight": "需确认", "note": "须电话确认 24 小时营业或有安静休息区;含过夜" }, + { "_key": "l2", "name": "临顿路连锁酒店", "type": "酒店(备选)", "price": "¥380-520", "overnight": "可", "note": "14:00 入住 / 12:00 退房,多数含早,可免费寄存" }, + { "_key": "l3", "name": "平江路民宿", "type": "民宿(备选)", "price": "¥450-700", "overnight": "可", "note": "位置好但多为老宅改造,隔音一般;需确认能否寄存" } + ], + "highlight_row_keys": ["l1"], + "note": "已按你的选择优先『高级洗浴留宿』;档次与能否过夜属主观/易变信息,务必出发前电话核实。" + }, + { + "id": "food_summary", + "title": "三餐候选一览", + "columns": [ + { "key": "meal", "label": "餐次" }, + { "key": "area", "label": "位置" }, + { "key": "type", "label": "业态" }, + { "key": "per_capita", "label": "人均", "align": "right" }, + { "key": "access", "label": "到达方式" } + ], + "rows": [ + { "_key": "f1", "meal": "D1 午餐", "area": "平江路北段", "type": "苏帮菜小馆(临河)", "per_capita": "¥90", "access": "步行 12 分钟" }, + { "_key": "f2", "meal": "D1 晚餐", "area": "观前街", "type": "本地家常菜", "per_capita": "¥80", "access": "步行 15 分钟" }, + { "_key": "f3", "meal": "D2 早餐", "area": "临顿路", "type": "苏式面馆", "per_capita": "¥25", "access": "步行 8 分钟" } + ], + "note": "按单人出行设计;不编造店名,到店前用高德/大众点评按当前位置挑选本地评价占比高的店。" + } + ], + + "cost": { + "currency": "CNY", + "items": [ + { "label": "往返高铁票(二等座,按区间中值 ×2)", "amount": 90, "note": "¥45×2,实际以 12306 为准" }, + { "label": "住宿(洗浴留宿,含过夜)", "amount": 150, "unit": "晚", "note": "单人间基准,档次以门店为准" }, + { "label": "市内交通 · 共享单车", "amount": 5, "note": "按 1.5 元/15 分钟估" }, + { "label": "市内交通 · 网约车", "amount": 70, "note": "金鸡湖往返 + 返程赴站" }, + { "label": "餐饮(2 正餐 + 1 早餐)", "amount": 195, "note": "按人均估算" }, + { "label": "门票(拙政园,如选苏博则免)", "amount": 80, "note": "以官方票价为准" } + ], + "total": 590, + "note": "高铁票价按区间中值估算,实际以 12306 为准;住宿与门票以实际预订为准。" + }, + + "checklist": [ + { "anchor_id": "train_out", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "出发前在 12306 核对去程车次、发车时刻与到达时刻(本报告不给车次号)", "verify": "12306 App 或官网查询 上海虹桥 → 苏州站" }, + { "anchor_id": "train_back", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "核对返程车次,确认最晚 21:00 前能抵达上海虹桥(返程死线)", "verify": "12306 App 查询 苏州站 → 上海虹桥,优先选 17:00 前发车" }, + { "anchor_id": "train_out", "field": "train_fare", "kind": "optional", "confidence": "low", "text": "确认二等座实际票价(报告仅给 ¥40-50 区间)", "verify": "12306 下单页显示价格" }, + { "anchor_id": "lodging_night", "field": "bathhouse_overnight_ok", "kind": "must", "confidence": "low", "text": "电话确认所选浴场是否真能过夜(部分仅营业到凌晨)", "verify": "致电前台:是否 24 小时营业、有无安静休息区、是否需预约" }, + { "anchor_id": "station_to_lodging", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认所选住宿能否提前免费寄存行李(多数 14:00 后才可入住)", "verify": "预订时备注早到;或致电前台确认行李寄存政策" }, + { "anchor_id": "lodging_night", "field": "bathhouse_grade", "kind": "optional", "confidence": "low", "text": "确认浴场规模与卫生(营业面积、开业年限、本地评价量级)", "verify": "大众点评查看门店信息与近期评价" }, + { "anchor_id": "city_mobility", "field": "bike_no_park_zone", "kind": "must", "confidence": "low", "text": "确认平江路/观前街周边的共享单车禁停范围与合法还车点", "verify": "打开美团/哈啰 App 查看 P 点分布" }, + { "anchor_id": "city_mobility", "field": "taxi_pickup_point", "kind": "optional", "confidence": "low", "text": "确认平江路口等禁停路段的网约车实际可上车位置", "verify": "叫车后与司机确认上车点" }, + { "anchor_id": "spot_d1", "field": "opening_hours", "kind": "must", "confidence": "low", "text": "确认拙政园/苏博当日的开放时间与「最后入场时间」(拙政园约 16:30、苏博约 16:00 停止入场,比闭馆时间更关键)", "verify": "官方公众号或购票页公示的停止入场时间" }, + { "anchor_id": "spot_d1", "field": "reservation_required", "kind": "must", "confidence": "low", "text": "苏州博物馆需提前预约(旺季名额紧张);若选拙政园需确认门票", "verify": "苏州博物馆官方公众号预约" }, + { "anchor_id": "spot_d2", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认虎丘当日开放时间与停止入场时间(返程前需留足车程)", "verify": "官方公众号或官网公布时间" }, + { "anchor_id": "lunch_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对午饭餐馆的评分与评价数(「本地人爱去 + 评分好」两条都要成立)", "verify": "大众点评/高德按「平江路 苏帮菜」筛选,看评分与本地评价占比" }, + { "anchor_id": "dinner_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对晚饭餐馆的评分、评价数与打烊时间(须 19:00 前能吃完)", "verify": "大众点评/高德筛选,并致电确认营业时段" }, + { "anchor_id": "night_stroll", "field": "night_market_open", "kind": "must", "confidence": "low", "text": "确认平江路/观前街夜间开放情况与沿街店铺打烊时间(会随季节调整)", "verify": "出发前查当地文旅公众号或致电街区管理方" }, + { "anchor_id": "night_stroll", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认苏州地铁 2/4 号线末班时刻(夜逛结束返程用)", "verify": "苏州地铁官方 App 或站内公示" }, + { "anchor_id": "breakfast_d2", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认面馆次日开门时间(7 点前是否已营业)", "verify": "前一晚现场确认或致电" } + ], + + "notes": [ + { + "level": "danger", + "title": "返程死线:周日 21:00 前必须抵达上海", + "text": "周日务必下午出发返程,最晚 21:00 抵达上海,为地铁末班留出余量(周一要上班)。本次建议 14:00-16:00 发车;若所选班次偏晚请改早班次。抵沪后如接近末班时间,请直接打车回家。" + }, + { + "level": "warn", + "title": "本技能不给高铁车次号", + "text": "AI 无法查询 12306,写出的车次号必然是编造的,会误导你赶错车。因此报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」。出发前请在 12306 核对车次与发到时刻。" + }, + { + "level": "warn", + "title": "降级说明:地形平坦处理", + "text": "苏州为平原水乡、无山。两段徒步均按 terrain_flat 规则执行:环古城河步道与金鸡湖环湖步道均为平路水岸,累计爬升约 0。已明确标注「平路徒步」,未冒充山地徒步效果。想出汗可提高配速或折返走两趟。" + }, + { + "level": "warn", + "title": "共享单车禁停区提示", + "text": "平江路、观前街、拙政园一带为共享单车禁行/禁停重点区域。请在干将东路、白塔东路等外围指定停车点还车后再步行进入,否则可能产生调度费。" + }, + { + "level": "info", + "title": "周六夜怎么安排:19:00-21:00 是自由活动", + "text": "晚饭安排在 17:30 左右,吃完正好接上夜逛。平江路夜间段(临河灯笼夜景,安静适合拍照)与观前街夜市(热闹、小吃多)相距约 1.5km,可任选或都逛;想走远一点可打车去七里山塘看夜景。不强制顺序,累了随时回住处。逛街时请留意沿街店铺打烊时间(多数 21:00-22:00)。" + }, + { + "level": "info", + "title": "两顿饭都按「本地人爱去 + 评分好」筛选", + "text": "午饭(平江路北段苏帮菜)与晚饭(观前街本地家常菜)都要求同时满足两条:本地人爱去(非游客主街、本地招牌菜、本地评价占比高)与评分好(大众点评约 4.3-4.5)。报告未指定具体店名,避免编造;到店前请用大众点评/高德按当前位置筛选,评分与打烊时间以实时显示为准。" + }, + { + "level": "info", + "title": "季节提示", + "text": "2026 年 10 月中旬苏州气温约 15-23℃,秋高气爽,适合步行。平江路早晚温差较大,建议带一件薄外套。" + } + ] +} \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_export.css b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_export.css new file mode 100644 index 0000000..400c6f2 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_export.css @@ -0,0 +1,220 @@ +/* ============================================================ + * sh_2d_rail_hike/tc_export.css —— 高铁游 · 导出文档样式(只服务本 skill 的 tc_export.js) + * + * 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。 + * tc_export.js 导出时会把本文件全文读出来,内联进 \n' + html += '\n\n' + html += '
\n' + if (heroHtml) html += heroHtml + '\n' + if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n' + if (bodyParts.length) html += bodyParts.join('\n') + '\n' + html += footHtml(ctx) + '\n' + html += '
\n\n\n' + return html + } + + /* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */ + + function collectImageUrls(payload) { + var seen = {} + var out = [] + ;(function walk(node, depth) { + if (depth > 12 || node === null || typeof node !== 'object') return + if (isArr(node)) { + node.forEach(function (x) { walk(x, depth + 1) }) + return + } + if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) { + if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) } + } + Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) }) + })(payload, 0) + return out + } + + function toDataUrl(url, baseUrl) { + return fetch(absUrl(url, baseUrl)) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status) + return r.blob() + }) + .then(function (blob) { + if (blob.size > MAX_IMAGE_BYTES) { + throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过') + } + return new Promise(function (resolve, reject) { + var fr = new FileReader() + fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) } + fr.onerror = function () { reject(new Error('读取图片失败')) } + fr.readAsDataURL(blob) + }) + }) + } + + function embedImages(payload) { + var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : '' + var urls = collectImageUrls(payload) + var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 } + var warnings = [] + var map = {} + if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') { + return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }) + } + return Promise.all(urls.map(function (u) { + return toDataUrl(u, baseUrl).then(function (d) { + map[u] = d.dataUri + stats.embedded++ + stats.bytes += d.bytes + }, function (err) { + stats.failed++ + warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u) + }) + })).then(function () { + return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings } + }) + } + + /* ---------------- 样式读取 / 文件落盘 ---------------- */ + + var cssCache = null + + function loadCss(cssUrl) { + if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)')) + if (cssCache) return Promise.resolve(cssCache) + return fetch(cssUrl, { cache: 'force-cache' }) + .then(function (r) { + if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')') + return r.text() + }) + .then(function (txt) { + if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl) + cssCache = txt + return cssCache + }) + } + + function saveBlob(html, filename) { + var blob = new Blob([html], { type: 'text/html;charset=utf-8' }) + var url = URL.createObjectURL(blob) + var a = document.createElement('a') + a.href = url + a.download = filename + a.rel = 'noopener' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000) + } + + function defaultFilename(payload) { + var s = isPlainObject(payload && payload.summary) ? payload.summary : {} + var name = String(s.title || '行程报告') + .replace(/[\\/:*?"<>|\r\n\t]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!name) name = '行程报告' + if (name.length > 60) name = name.slice(0, 60) + return name + '-行程报告.html' + } + + /* ---------------- 对外入口 ---------------- */ + + /** + * 导出并触发下载(浏览器) + * @returns {Promise<{ok, filename, bytes, images, warnings}>} + */ + function download(payload, spec, opts) { + opts = opts || {} + var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css') + return Promise.all([loadCss(cssUrl), embedImages(payload)]) + .then(function (res) { + var html = build(payload, spec, { + css: res[0], + images: res[1].map, + baseUrl: res[1].baseUrl, + meta: opts.meta, + title: opts.title, + kicker: opts.kicker, + chip: opts.chip, + exportedAt: opts.exportedAt || nowBeijing() + }) + var filename = opts.filename || defaultFilename(payload) + saveBlob(html, filename) + var warnings = res[1].warnings.slice() + return { + ok: true, + filename: filename, + bytes: utf8Length(html), + images: res[1].stats, + sections: (isArr(spec && spec.sections) ? spec.sections.length : 0), + warnings: warnings + } + }) + } + + var api = { + build: build, + download: download, + defaultFilename: defaultFilename, + collectImageUrls: collectImageUrls + } + + if (typeof window !== 'undefined') window.TCExport = api + if (typeof module !== 'undefined' && module.exports) module.exports = api +})() \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_render.js b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_render.js new file mode 100644 index 0000000..3743d7c --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_render.js @@ -0,0 +1,701 @@ +/* ============================================================ + * sh_2d_rail_hike/tc_render.js —— 高铁游 · 报告渲染器(技能私有) + * + * 设计原则(插件式 / 低耦合): + * 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。 + * 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段。 + * 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染。 + * + * 【隔离用意 · 请后续开发者先读这段】 + * 本文件从 sh_2d_car_hike/tc_render.js 复制而来(用户已确认「复制现有再改」方案), + * 复制后它只服务【本 skill】。相对原版刻意的三处改动,全部由本 skill 的业务特点决定: + * (1) ACTIVITY_LABEL 扩充:新增 共享单车/网约车/地铁/高铁/入住寄存/休息 + * (2) PIN_COLORS 扩充:新增 train_station/lodging/bike/taxi_point 四种点位配色 + * (3) 地图弹窗新增「导航地址 + 一键打开高德/百度」——本 skill 用户的核心诉求: + * 每到一处都有具体地点,方便直接导航 + * 为什么不去改自驾 skill 那份来共用?因为一旦共用,本 skill 想加导航链接就得动到 + * 自驾 skill 的渲染逻辑,风险互相传导。这就是本 lab「skill 全栈隔离」的取舍: + * 宁可各存一份,也不要跨 skill 的共用渲染器。要新增 skill,请复制本文件后再改。 + * + * 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测) + * ============================================================ */ +(function (global) { + 'use strict' + + /* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */ + const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const MAP_TILE_ATTR = '© OpenStreetMap contributors' + const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹' + + const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' } + const FALLBACK_IMAGE_TEXT = '配图未生成' + + const mapRegistry = [] + + /* ---------------- 基础工具 ---------------- */ + + function h(tag, attrs, children) { + const el = document.createElement(tag) + if (attrs) { + Object.keys(attrs).forEach(function (k) { + const v = attrs[k] + if (v === null || v === undefined || v === false) return + if (k === 'class') el.className = v + else if (k === 'text') el.textContent = v + else if (k === 'html') el.innerHTML = v + else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v) + else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v) + else el.setAttribute(k, v === true ? '' : String(v)) + }) + } + appendChildren(el, children) + return el + } + + function appendChildren(el, children) { + if (children === null || children === undefined) return + if (Array.isArray(children)) { + children.forEach(function (c) { appendChildren(el, c) }) + return + } + if (children instanceof Node) { el.appendChild(children); return } + el.appendChild(document.createTextNode(String(children))) + } + + function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) } + + function resolvePath(root, path) { + if (!path) return root + const p = String(path).replace(/^\$\.?/, '') + if (!p) return root + return p.split('.').reduce(function (acc, key) { + if (acc === null || acc === undefined) return undefined + return acc[key] + }, root) + } + + function pickById(arr, id) { + if (!Array.isArray(arr)) return null + for (let i = 0; i < arr.length; i++) { + if (arr[i] && arr[i].id === id) return arr[i] + } + return null + } + + function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) } + + function badgeText(spec, level) { + const map = (spec && spec.confidence_badge) || DEFAULT_BADGE + return map[level] || DEFAULT_BADGE[level] || level + } + + /* ---------------- 置信度字段渲染(通用) ---------------- */ + + // fact 可以是纯字符串,也可以是 { text, confidence, verify } + function renderFact(value, spec) { + if (isPlainObject(value)) { + const box = h('span', { class: 'tcr-fact' }) + box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) })) + if (value.confidence) { + box.appendChild(h('span', { + class: 'tcr-badge tcr-badge-' + value.confidence, + text: badgeText(spec, value.confidence), + title: value.verify ? ('核实方式:' + value.verify) : '' + })) + } + if (value.verify) { + box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify })) + } + return box + } + return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) }) + } + + /* ---------------- 各 block 渲染 ---------------- */ + + function renderOverview(data, spec) { + if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' }) + const box = h('div', { class: 'tcr-overview' }) + if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title })) + if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner })) + if (Array.isArray(data.tags) && data.tags.length) { + const tagRow = h('div', { class: 'tcr-tags' }) + data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) }) + box.appendChild(tagRow) + } + if (Array.isArray(data.stats) && data.stats.length) { + const grid = h('div', { class: 'tcr-stats' }) + data.stats.forEach(function (s) { + const cell = h('div', { class: 'tcr-stat' }) + cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' })) + cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) })) + if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint })) + grid.appendChild(cell) + }) + box.appendChild(grid) + } + return box + } + + function renderTimeline(data, spec) { + if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' }) + const box = h('div', { class: 'tcr-timeline' }) + data.days.forEach(function (day) { + const dayBox = h('div', { class: 'tcr-day' }) + const head = h('div', { class: 'tcr-day-head' }) + head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' })) + if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date })) + dayBox.appendChild(head) + + const table = h('table', { class: 'tcr-table tcr-timeline-table' }) + const thead = h('thead', null, h('tr', null, [ + h('th', { class: 'tcr-col-time', text: '时间' }), + h('th', { text: '安排' }), + h('th', { class: 'tcr-col-kind', text: '类型' }) + ])) + table.appendChild(thead) + const tbody = h('tbody') + const rows = Array.isArray(day.rows) ? day.rows : [] + rows.forEach(function (r) { + const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' }) + tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' })) + const td = h('td') + td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' })) + if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note })) + tr.appendChild(td) + const kindCell = h('td', { class: 'tcr-col-kind' }) + kindCell.appendChild(h('span', { + class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'), + text: kindLabel(r.kind) + })) + if (r.activity) { + kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) })) + } + tr.appendChild(kindCell) + tbody.appendChild(tr) + }) + table.appendChild(tbody) + dayBox.appendChild(table) + box.appendChild(dayBox) + }) + return box + } + + function kindLabel(kind) { + const map = { hard: '硬时间点', soft: '常规' } + return map[kind] || (kind || '') + } + + // 活动类型与 kind(时间点性质)是两个正交字段,分开显示 + // 【本技能私有词表】比自驾 skill 多:stroll 逛街/夜逛 / spot 游览景点 / bike 共享单车 / + // taxi 网约车 / metro 地铁 / train 高铁 / checkin 入住寄存 / rest 休息。改这里只影响本 skill。 + function activityLabel(activity) { + const map = { + walk: '步行', stroll: '逛街夜游', spot: '游览景点', bike: '共享单车', taxi: '网约车', + metro: '地铁', train: '高铁', eat: '吃饭', hike: '徒步', shop: '采买', + checkin: '入住寄存', rest: '休息' + } + return map[activity] || (activity || '') + } + + function renderCards(data, spec, section) { + const list = Array.isArray(data) ? data : [] + if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' }) + const box = h('div', { class: 'tcr-cards' }) + + const grouped = section && section.group_by === 'day' + if (!grouped) { + list.forEach(function (c) { box.appendChild(renderCard(c, spec)) }) + return box + } + const days = [] + list.forEach(function (c) { + const d = c.day === undefined ? 0 : c.day + if (days.indexOf(d) < 0) days.push(d) + }) + days.sort(function (a, b) { return a - b }) + days.forEach(function (d) { + const group = h('div', { class: 'tcr-card-group' }) + group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') })) + const grid = h('div', { class: 'tcr-card-grid' }) + list.forEach(function (c) { + const cd = c.day === undefined ? 0 : c.day + if (cd === d) grid.appendChild(renderCard(c, spec)) + }) + group.appendChild(grid) + box.appendChild(group) + }) + return box + } + + function renderCard(card, spec) { + const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') }) + const head = h('div', { class: 'tcr-card-head' }) + const titles = h('div', { class: 'tcr-card-titles' }) + titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' })) + if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle })) + head.appendChild(titles) + if (Array.isArray(card.badges) && card.badges.length) { + const bs = h('div', { class: 'tcr-badges' }) + card.badges.forEach(function (b) { + bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' })) + }) + head.appendChild(bs) + } + el.appendChild(head) + + if (card.image && card.image.url) { + const fig = h('figure', { class: 'tcr-figure' }) + const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' }) + img.addEventListener('error', function () { + clear(fig) + fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' })) + fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' })) + }) + fig.appendChild(img) + const cap = h('figcaption', { class: 'tcr-figcap' }) + if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' })) + cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' })) + fig.appendChild(cap) + el.appendChild(fig) + } + + if (Array.isArray(card.fields) && card.fields.length) { + const dl = h('div', { class: 'tcr-fields' }) + card.fields.forEach(function (f) { + const row = h('div', { class: 'tcr-field' }) + row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' })) + const val = h('div', { class: 'tcr-field-value' }) + val.appendChild(renderFact(f.value, spec)) + if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint })) + row.appendChild(val) + dl.appendChild(row) + }) + el.appendChild(dl) + } + + if (Array.isArray(card.bullets) && card.bullets.length) { + const ul = h('ul', { class: 'tcr-bullets' }) + card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) }) + el.appendChild(ul) + } + return el + } + + function renderTable(data, spec, section) { + let table = null + if (Array.isArray(data)) { + table = pickById(data, section.pick_id) + if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id }) + if (!table) table = data[0] + } else { + table = data + } + if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' }) + + const box = h('div', { class: 'tcr-tablebox' }) + if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title })) + + const columns = Array.isArray(table.columns) ? table.columns : [] + const rows = Array.isArray(table.rows) ? table.rows : [] + const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : [] + + const el = h('table', { class: 'tcr-table' }) + const tr = h('tr') + columns.forEach(function (c) { + tr.appendChild(h('th', { + class: c.align ? ('tcr-al-' + c.align) : '', + style: c.width ? { width: c.width } : null, + text: c.label || c.key + })) + }) + el.appendChild(h('thead', null, tr)) + + const tbody = h('tbody') + rows.forEach(function (r) { + const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' }) + columns.forEach(function (c) { + const v = r[c.key] + const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' }) + if (v === null || v === undefined) td.textContent = '—' + else td.textContent = String(v) + row.appendChild(td) + }) + tbody.appendChild(row) + }) + el.appendChild(tbody) + box.appendChild(h('div', { class: 'tcr-table-scroll' }, el)) + if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note })) + return box + } + + function renderCost(data, spec) { + if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' }) + const box = h('div', { class: 'tcr-cost' }) + const items = Array.isArray(data.items) ? data.items : [] + const el = h('table', { class: 'tcr-table' }) + el.appendChild(h('thead', null, h('tr', null, [ + h('th', { text: '项目' }), + h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }), + h('th', { text: '口径' }) + ]))) + const tbody = h('tbody') + items.forEach(function (it) { + const row = h('tr') + row.appendChild(h('td', { text: it.label || '' })) + row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) })) + row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' })) + tbody.appendChild(row) + }) + el.appendChild(tbody) + if (data.total !== undefined) { + const tf = h('tfoot') + const tr = h('tr', { class: 'tcr-row-hl' }) + tr.appendChild(h('td', { text: '合计' })) + tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) })) + tr.appendChild(h('td', { text: '' })) + tf.appendChild(tr) + el.appendChild(tf) + } + box.appendChild(h('div', { class: 'tcr-table-scroll' }, el)) + if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note })) + return box + } + + function renderChecklist(data, spec) { + const list = Array.isArray(data) ? data : [] + if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' }) + const ol = h('ol', { class: 'tcr-checklist' }) + list.forEach(function (c) { + const li = h('li', { class: 'tcr-check-item' }) + li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' })) + if (c.confidence) { + li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) })) + } + if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify })) + ol.appendChild(li) + }) + return ol + } + + function renderNotes(data, spec, section) { + let list = Array.isArray(data) ? data : [] + if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) { + const allow = section.filter_levels + list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 }) + } + if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' }) + const box = h('div', { class: 'tcr-notes' }) + list.forEach(function (n) { + const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') }) + el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' })) + if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text })) + box.appendChild(el) + }) + return box + } + + /* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */ + + function renderMap(data, spec) { + if (!isPlainObject(data) || !Array.isArray(data.markers)) { + return h('div', { class: 'tcr-empty', text: '无地图数据' }) + } + const box = h('div', { class: 'tcr-mapbox' }) + const holder = h('div', { class: 'tcr-map-holder' }) + box.appendChild(holder) + const legend = renderMapLegend(data) + if (legend) box.appendChild(legend) + const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER + box.appendChild(h('div', { class: 'tcr-map-note', text: dis })) + mapRegistry.push({ el: holder, data: data }) + return box + } + + function renderMapLegend(data) { + const routes = Array.isArray(data.routes) ? data.routes : [] + if (!routes.length) return null + const box = h('div', { class: 'tcr-map-legend' }) + routes.forEach(function (r) { + const item = h('span', { class: 'tcr-legend-item' }) + const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') }) + if (r.color) swatch.style.background = r.color + if (r.dashed) { + swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)' + } + item.appendChild(swatch) + item.appendChild(h('span', { text: r.name || r.id })) + box.appendChild(item) + }) + return box + } + + function initMaps() { + mapRegistry.forEach(function (reg) { + try { + if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data) + else drawSvgFallback(reg.el, reg.data) + } catch (e) { + try { drawSvgFallback(reg.el, reg.data) } catch (e2) { + clear(reg.el) + reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' })) + } + } + }) + mapRegistry.length = 0 + } + + /* 【本技能专属】单个点位的弹窗内容:名称 + 备注 + 导航地址 + 一键打开高德/百度。 + 用户诉求是「每到一处都有具体地点,方便我导航」,所以地址必须显眼、可复制、可点开。 + + 坐标与地址的关系(重要):AI 给出的经纬度一律按「概略值」处理(契约要求 coord_precision=approx), + 真实导航请以 address 为准。所以这里把 address 放在主位,坐标仅作兜底的跳转参数。 + 高德 uri API 能接受「坐标或地址关键字」,这里优先用地址关键字,避免坐标偏移把用户带错。 */ +function markerPopupHtml(m) { + const name = m.name || '' + const addr = String(m.address || '').trim() + const parts = ['' + escapeHtml(name) + ''] + if (addr) parts.push('
' + escapeHtml(addr) + '') + if (m.note) parts.push('
' + escapeHtml(m.note) + '') + if (m.coord_precision === 'approx') parts.push('
坐标为概略值,导航请用上面的地址') + + const links = [] + const q = encodeURIComponent(addr || name) + if (q) { + // 高德:关键字搜索(最稳,不受坐标系偏移影响) + links.push('高德搜') + // 百度:同样按关键字搜索 + links.push('百度搜') + } + if (typeof m.lat === 'number' && typeof m.lng === 'number') { + // 坐标直达(作为参考;因 AI 坐标是概略值,可能落在附近而非精确点上) + links.push('坐标定位') + } + if (links.length) parts.push('
' + links.join(' · ') + '') + return parts.join('') +} + +function drawLeaflet(el, data) { + const L = global.L + clear(el) + const map = L.map(el, { zoomControl: true, attributionControl: true }) + L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map) + + const markers = data.markers || [] + const byId = {} + const latlngs = [] + markers.forEach(function (m) { + if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return + byId[m.id] = m + latlngs.push([m.lat, m.lng]) + const icon = L.divIcon({ + className: 'tcr-pin-wrap', + html: '' + (m.seq === undefined ? '' : m.seq) + '', + iconSize: [22, 22], + iconAnchor: [11, 11] + }) + const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map) + marker.bindPopup(markerPopupHtml(m)) + }) + + ;(data.routes || []).forEach(function (r) { + const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean) + .map(function (m) { return [m.lat, m.lng] }) + if (pts.length < 2) return + const line = L.polyline(pts, { + color: r.color || '#2f6fed', + weight: r.kind === 'drive' ? 3 : 4, + dashArray: r.dashed ? '6 6' : null, + opacity: 0.9 + }).addTo(map) + if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true }) + else line.bindTooltip(r.name || '', { sticky: true }) + }) + + if (latlngs.length) { + try { + map.fitBounds(L.latLngBounds(latlngs).pad(0.15)) + } catch (e) { + map.setView(data.center || latlngs[0], data.zoom || 9) + } + } else { + map.setView(data.center || [31.95, 121.15], data.zoom || 9) + } + setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60) + } + + function drawSvgFallback(el, data) { + clear(el) + const markers = (data.markers || []).filter(function (m) { + return typeof m.lat === 'number' && typeof m.lng === 'number' + }) + const W = 860, H = 420, PAD = 34 + const svgNS = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(svgNS, 'svg') + svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H) + svg.setAttribute('class', 'tcr-svg-map') + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') + svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' })) + + if (!markers.length) { + el.appendChild(svg) + el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' })) + return + } + + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity + markers.forEach(function (m) { + minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat) + minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng) + }) + const spanLat = Math.max(maxLat - minLat, 0.01) + const spanLng = Math.max(maxLng - minLng, 0.01) + function px(m) { + const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2) + const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2) + return { x: x, y: y } + } + + const byId = {} + markers.forEach(function (m) { byId[m.id] = m }) + + ;(data.routes || []).forEach(function (r) { + const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean) + if (pts.length < 2) return + const d = pts.map(function (m, i) { + const p = px(m) + return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1) + }).join(' ') + svg.appendChild(mkSvg('path', { + d: d, + fill: 'none', + stroke: r.color || '#2f6fed', + 'stroke-width': r.kind === 'drive' ? 2.5 : 3.2, + 'stroke-dasharray': r.dashed ? '6 6' : 'none', + 'stroke-linejoin': 'round', + class: 'tcr-svg-line' + })) + }) + + markers.forEach(function (m) { + const p = px(m) + svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 })) + const t = mkSvg('text', { + x: p.x, y: p.y + 3.4, 'text-anchor': 'middle', + class: 'tcr-svg-seq', fill: '#fff' + }) + t.textContent = m.seq === undefined ? '' : String(m.seq) + svg.appendChild(t) + const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' }) + label.textContent = m.name || '' + // 【本技能专属】SVG 降级模式下也用 挂上导航地址,鼠标悬停即可看到 + const tip = mkSvg('title', {}) + tip.textContent = (m.name || '') + (m.address ? '|' + m.address : '') + label.appendChild(tip) + svg.appendChild(label) + }) + + el.appendChild(svg) + el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' })) + } + + function mkSvg(tag, attrs) { + const el = document.createElementNS('http://www.w3.org/2000/svg', tag) + Object.keys(attrs || {}).forEach(function (k) { + if (attrs[k] === null || attrs[k] === undefined) return + el.setAttribute(k, String(attrs[k])) + }) + return el + } + + // 【本技能私有词表】点位配色。比自驾 skill 去掉 parking(无自驾), + // 新增 train_station 车站 / lodging 住宿 / bike 单车点 / taxi_point 网约车上车点 / + // night_market 夜市夜逛区(v1.1.0,用于周六夜夜逛) + function pinColor(kind) { + const map = { + start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', + train_station: '#1d4ed8', lodging: '#7c3aed', bathhouse: '#1f8a70', + district: '#b8860b', food: '#e0673a', hike: '#2e9e5b', + bike: '#0ea5e9', taxi_point: '#f59e0b', night_market: '#7c2d92', + spot: '#059669', shop: '#5a67d8', walk: '#8a5cf6' + } + return map[kind] || '#4b5563' + } + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + }) + } + + /* ---------------- 顶层渲染 ---------------- */ + + const BLOCKS = { + overview: renderOverview, + timeline: renderTimeline, + cards: renderCards, + map: renderMap, + table: renderTable, + cost: renderCost, + checklist: renderChecklist, + notes: renderNotes + } + + function renderSection(section, payload, spec) { + const data = section.data ? resolvePath(payload, section.data) : payload + const fn = BLOCKS[section.type] + const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) }) + if (section.title) { + const head = h('div', { class: 'tcr-section-head' }) + head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title })) + head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' })) + wrap.appendChild(head) + } + if (typeof fn !== 'function') { + wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type })) + return wrap + } + wrap.appendChild(fn(data, spec, section)) + return wrap + } + + function renderIndex(spec) { + const sections = (spec.sections || []).filter(function (s) { return s.title }) + if (!sections.length) return null + const nav = h('nav', { class: 'tcr-index' }) + sections.forEach(function (s) { + nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title })) + }) + return nav + } + + function render(payload, spec, mount) { + if (!mount) throw new Error('tc_render: 缺少挂载节点') + spec = spec || {} + const sections = Array.isArray(spec.sections) ? spec.sections : [] + // 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。 + // 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。 + if (!sections.length) { + throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空') + } + clear(mount) + if (spec.show_index) { + const idx = renderIndex(spec) + if (idx) mount.appendChild(idx) + } + sections.forEach(function (section) { + mount.appendChild(renderSection(section, payload || {}, spec)) + }) + // 地图必须等节点入 DOM 后再初始化 + if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps) + else setTimeout(initMaps, 0) + } + + global.TCRender = { + render: render, + resolvePath: resolvePath, + pickById: pickById, + MAP_TILE_URL: MAP_TILE_URL + } +})(window) \ No newline at end of file diff --git a/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_report.css b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_report.css new file mode 100644 index 0000000..36d7234 --- /dev/null +++ b/public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/tc_report.css @@ -0,0 +1,210 @@ +/* ============================================================ + * sh_2d_rail_hike/tc_report.css —— 高铁游 · 报告样式(只服务本 skill 的 tc_render.js) + * + * 【隔离用意 · 请后续开发者先读这段】 + * 本文件从 sh_2d_car_hike/tc_report.css 复制而来(用户已确认「复制现有再改」)。 + * 复制后它只服务【本 skill】的报告。相对原版刻意的改动: + * (1) 新增本技能的点位配色类(.tcr-pin-train_station / -lodging / -bike / -taxi_point) + * (2) 新增地图弹窗里的导航样式(.tcr-pop-addr / .tcr-pop-nav …) + * 为什么不与自驾 skill 共用一份 CSS?因为本 skill 想改配色或加导航样式时, + * 共用就意味着动到别人的文件、可能弄坏别人的报告。宁可有这份重复,也不要跨 skill 耦合。 + * 要新增 skill,请复制本文件后再改。 + * + * 使用方式:本 skill 的页面引用本文件 + 同目录 tc_render.js,即可渲染本 skill 的报告。 + * 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件) + * ============================================================ */ + +:root { + --tcr-bg: #f6f7f9; + --tcr-card: #ffffff; + --tcr-line: #e5e7eb; + --tcr-text: #1f2328; + --tcr-sub: #6b7280; + --tcr-accent: #2f6fed; +} + +/* ---------------- 区块骨架 ---------------- */ + +.tcr-index { + display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; + background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px; +} +.tcr-index-item { + font-size: 12px; color: var(--tcr-accent); text-decoration: none; + padding: 3px 8px; border-radius: 6px; background: #f0f4ff; +} +.tcr-index-item:hover { background: #e2eaff; } + +.tcr-section { + background: var(--tcr-card); border: 1px solid var(--tcr-line); + border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px; +} +.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; } +.tcr-section-title { font-size: 16px; margin: 0; } +.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; } +.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; } + +/* ---------------- 概览 ---------------- */ + +.tcr-ov-title { margin: 0 0 6px; font-size: 18px; } +.tcr-ov-line { margin: 0 0 10px; color: #374151; } +.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; } +.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; } +.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; } +.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); } +.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; } +.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; } + +/* ---------------- 表格 ---------------- */ + +.tcr-table-scroll { overflow-x: auto; } +table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; } +table.tcr-table th, table.tcr-table td { + border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top; +} +table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; } +.tcr-al-right { text-align: right !important; } +.tcr-al-center { text-align: center !important; } +.tcr-row-hl td { background: #eef4ff; } +.tcr-row-hard td { background: #fff7ed; } +.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; } +.tcr-col-kind { width: 138px; } +.tcr-row-title { font-weight: 500; } +.tcr-row-note { font-size: 12px; color: var(--tcr-sub); } +.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; } +.tcr-kind { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: #eef2f7; color: #4b5563; white-space: nowrap; +} +.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; } +.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; } + +.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; } +.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; } + +/* ---------------- 时序分组 ---------------- */ + +.tcr-day { margin-bottom: 16px; } +.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; } +.tcr-day-label { font-weight: 600; } +.tcr-day-date { font-size: 12px; color: var(--tcr-sub); } + +/* ---------------- 卡片 ---------------- */ + +.tcr-card-group { margin-bottom: 14px; } +.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; } +.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } +.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; } +.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } +.tcr-card-title { font-size: 15px; margin: 0; } +.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); } +.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; } +.tcr-badge { + font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default; +} +.tcr-badge-high { background: #e8f6ee; color: #1a7a45; } +.tcr-badge-medium { background: #fff5e0; color: #8a6100; } +.tcr-badge-low { background: #fdece8; color: #b42318; } +.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; } +.tcr-tone-good { background: #e8f6ee; color: #1a7a45; } +.tcr-tone-warn { background: #fff5e0; color: #8a6100; } +.tcr-tone-danger { background: #fdece8; color: #b42318; } +.tcr-tone-plain { background: #eef2f7; color: #4b5563; } + +/* ---------------- 配图 ---------------- */ + +.tcr-figure { margin: 10px 0 4px; } +.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; } +.tcr-img-missing { + height: 90px; display: flex; align-items: center; justify-content: center; + background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px; + color: var(--tcr-sub); font-size: 12px; +} +.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; } +.tcr-figcap-note { color: #b42318; } + +/* ---------------- 卡片字段 / 事实 ---------------- */ + +.tcr-fields { margin-top: 10px; } +.tcr-field { + display: grid; grid-template-columns: 96px 1fr; gap: 6px; + padding: 5px 0; border-top: 1px dashed #eef0f3; +} +.tcr-field:first-child { border-top: none; } +.tcr-field-label { color: var(--tcr-sub); font-size: 12px; } +.tcr-field-value { font-size: 13px; } +.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); } +.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; } +.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; } +.tcr-bullets li { margin-bottom: 3px; } + +/* ---------------- 地图 ---------------- */ + +.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); } +.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; } +.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; } +.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; } +.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; } +.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; } +.tcr-svg-map { width: 100%; height: auto; display: block; } +.tcr-svg-bg { fill: #f2f5f8; } +.tcr-svg-seq { font-size: 10px; font-weight: 700; } +.tcr-svg-label { font-size: 11px; fill: #374151; } +.tcr-pin-wrap { background: transparent; border: none; } +.tcr-pin { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px; + font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3); + background: #4b5563; +} +.tcr-pin-start { background: #2f6fed; } +.tcr-pin-end { background: #d14343; } +.tcr-pin-hike { background: #2e9e5b; } +.tcr-pin-district { background: #b8860b; } +.tcr-pin-food { background: #e0673a; } +.tcr-pin-bathhouse { background: #1f8a70; } +.tcr-pin-shop { background: #5a67d8; } +.tcr-pin-sleep { background: #2d3748; } +.tcr-pin-walk { background: #8a5cf6; } +.tcr-pin-breakfast { background: #c98a00; } +.tcr-pin-parking { background: #c2701a; } +.tcr-pin-spot { background: #0ea5e9; } +.tcr-pin-waypoint { background: #8a8f98; } +/* 【本技能新增配色】车站 / 住宿 / 共享单车站点 / 网约车上车点 / 夜市夜逛区 */ +.tcr-pin-train_station { background: #1d4ed8; } +.tcr-pin-lodging { background: #7c3aed; } +.tcr-pin-bike { background: #0ea5e9; } +.tcr-pin-taxi_point { background: #f59e0b; } +.tcr-pin-night_market { background: #7c2d92; } + +/* ---------------- 地图弹窗里的导航信息(本技能专属) ---------------- */ + +.tcr-pop-addr { color: #1f2328; font-size: 12px; } +.tcr-pop-note { color: #6b7280; font-size: 12px; } +.tcr-pop-dim { color: #8a6100; font-size: 11px; } +.tcr-pop-navs { display: inline-block; margin-top: 4px; font-size: 12px; } +.tcr-pop-nav { color: #2f6fed; text-decoration: none; } +.tcr-pop-nav:hover { text-decoration: underline; } + +/* ---------------- 提示 ---------------- */ + +.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; } +.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; } +.tcr-note-text { font-size: 13px; color: #374151; } +.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; } +.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; } +.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; } + +/* ---------------- 待核实清单 ---------------- */ + +.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; } +.tcr-check-item { margin-bottom: 7px; } +.tcr-check-text { margin-right: 6px; } + +@media (max-width: 640px) { + .tcr-card-grid { grid-template-columns: 1fr; } + .tcr-field { grid-template-columns: 82px 1fr; } + .tcr-map-holder { height: 320px; } +} \ No newline at end of file diff --git a/src/server/index.js b/src/server/index.js index 85c174f..9c3edb2 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -111,8 +111,25 @@ const allowedOrigins = [ 'http://127.0.0.1:8081' ] const isAllowedCorsOrigin = (origin) => !origin || allowedOrigins.includes(origin) + +// 同源请求不属于 CORS 管辖范围:浏览器对非 GET/HEAD 的同源请求同样会带 Origin 头, +// 但既不做预检、也不按 CORS 响应头过滤,因此必须直接放行。 +// 否则 http://localhost:8976 / http://127.0.0.1:8976 / 局域网 IP 等「自己人」会被误伤成 403, +// 表现为「页面能打开、GET 正常,所有 POST 全挂」。 +const isSameOriginRequest = (req, origin) => { + if (!origin) return false + try { + const host = String(req.headers.host || '').trim() + if (!host) return false + return new URL(origin).host === host + } catch (e) { + return false + } +} + app.use((req, res, next) => { const origin = String(req.headers.origin || '').trim() + if (isSameOriginRequest(req, origin)) return next() const isAllowed = isAllowedCorsOrigin(origin) if (origin && isAllowed) { diff --git a/src/server/thought_lab/labs/travel_citytour/config.json b/src/server/thought_lab/labs/travel_citytour/config.json new file mode 100644 index 0000000..c6d43e5 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/config.json @@ -0,0 +1,32 @@ +{ + "_comment": "思想实验室 · 游山玩水(travel_citytour):多 skill 行程生成的聚合 lab。每个 skill 是 skills/<skill_id>/ 下一个完全独立的全栈单元(后端 index.js + config.json,前端自己的页面/渲染/样式/导出/照片),彼此零耦合;本文件只保留【lab 级策略】(导航卡片信息 + 鉴权口径 + 限流),skill 的业务参数在各 skill 自己的 config.json 里。", + + "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致", + "id": "travel_citytour", + "name_comment": "首页九宫格卡片显示名称", + "name": "游山玩水", + "icon_comment": "首页卡片图标(emoji)", + "icon": "🚗", + "desc_comment": "一句话简介,作为卡片 tooltip 展示", + "desc": "多 skill × 多目的地 · 每个 skill 全栈独立(行程生成与定稿快照)", + "sort_order_comment": "卡片排序,数值越小越靠前(现网已用到 97,本 lab 固定排最后,避免插队影响其他 lab 视觉顺序)", + "sort_order": 100, + "enabled_comment": "是否启用(false 时 /api/thought_lab/labs 不返回该 lab)", + "enabled": true, + + "auth_comment": "本 lab 接口的鉴权口径(红线,lab 级统一实现,skill 不可绕过、不可自写)。背景:lab 页面允许同源 Referer 进入,而全局接口守卫只认 nav_gate cookie,两者不一致会导致『页面能开、接口全 401』;同类 lab(bot_chess)的做法是不走全局守卫、自己鉴权", + "auth": { + "mode_comment": "nav_gate=只认 nav_gate cookie(与全局守卫等效,不放宽任何东西);browser=nav_gate 或同源 Referer(照 bot_chess 写法,拿不到 nav_gate 的环境也能用)", + "mode": "browser" + }, + + "rate_limit_comment": "调用限制(红线,lab 级统一实现)。本 lab 的生成会真实消耗 AI 额度,公网环境下必须防刷", + "rate_limit": { + "generate_single_flight_comment": "全局是否只允许一个生成任务(建议 true:一次要 3-4 分钟,并发会白烧额度)。在此之上还有一层『按 skill 单飞』,即 A 技能生成不影响 B 技能", + "generate_single_flight": true, + "generate_per_ip_hour_comment": "单个 IP 每小时、每个 skill 最多生成次数", + "generate_per_ip_hour": 3, + "read_per_ip_minute_comment": "只读接口(meta / last / skills)单 IP 每分钟上限", + "read_per_ip_minute": 120 + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/index.js b/src/server/thought_lab/labs/travel_citytour/index.js new file mode 100644 index 0000000..d150e94 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/index.js @@ -0,0 +1,350 @@ +// ============================================================ +// travel_citytour/index.js —— 「游山玩水」lab 主入口(聚合层 + 红线层) +// +// 架构(skill 全栈隔离): +// - 每个 skill 是 skills/<skill_id>/ 下一个完全独立的全栈单元 +// (后端 index.js + config.json,前端 index.html + 自己的渲染/样式/导出/照片) +// - 本文件只做四件事:扫描、鉴权、挂载、提供红线能力 +// - 本文件【不读任何业务文件、不认任何业务字段、不 require skill 的 prompt/校验/渲染】 +// +// 为什么鉴权/限流/密钥/时间不放进 skill(红线): +// 下沉到 N 个 skill 就必然有人漏做 → 等于开后门。所以本层把鉴权包进 Router +// 再交给 skill,skill 拿不到绕过鉴权的机会;密钥同理,只经 ctx.callLLM 出口。 +// +// 挂载方式:由 thought_lab/index.js 自动扫描 labs/<id>/index.js 并绑定 +// —— 只导出 bindPublicRoutes(在导航 API 守卫之前注册,自行鉴权)。 +// +// 说明:AI 生成是「一次请求一次响应」,不是常驻后台任务,因此留在 8976; +// 将来若要批量预生成 / 定时重算,应迁到 8977 自动化实例。 +// ============================================================ +const fs = require('fs') +const path = require('path') +const express = require('express') +const axios = require('axios') +const registry = require('./registry') + +const B = '/api/thought_lab/travel_citytour' +const CONFIG_PATH = path.join(__dirname, 'config.json') +const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json') +const BODY_LIMIT = '1mb' +const LOG_TAG = '[travel_citytour]' + +const readConfig = () => { + try { + if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) + } catch {} + return {} +} + +/* ============================================================ + * 红线一:鉴权(统一实现,skill 不可绕过) + * ============================================================ */ +const parseCookie = (raw) => { + const out = {} + try { + String(raw || '').split(';').forEach((item) => { + const idx = item.indexOf('=') + if (idx < 1) return + out[item.slice(0, idx).trim()] = item.slice(idx + 1).trim() + }) + } catch {} + return out +} +const isNavAuthEnabled = () => { + try { + const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8')) + return ((f && f.navAuth) || {}).enable_auth !== false + } catch { return true } +} +const hasNavGate = (req) => parseCookie(req.headers.cookie || '').nav_gate === '1' +const sameOrigin = (req) => { + try { + const referer = String(req.headers.referer || '') + const host = String(req.headers.host || '') + return !!referer && !!host && new URL(referer).host === host + } catch { return false } +} +const authMode = () => String((readConfig().auth || {}).mode || 'nav_gate') + +// 本 lab 支持两种口径,由 config.json 的 auth.mode 决定: +// nav_gate:只认 nav_gate cookie(与全局守卫等效,不放宽任何东西) +// browser :nav_gate 或同源 Referer(照 bot_chess,拿不到 nav_gate 的环境也能用) +const labAuth = (req, res, next) => { + if (!isNavAuthEnabled()) return next() + if (hasNavGate(req)) return next() + if (authMode() === 'browser' && sameOrigin(req)) return next() + return res.status(401).json({ + ok: false, + error: 'unauthorized', + hint: authMode() === 'browser' + ? '既没有 nav_gate,也不是同源来源;请从工具箱内导航进入' + : '本 lab 当前口径为 nav_gate(只认同源导航凭证)。若该环境拿不到 nav_gate,可在本 lab 的 config.json 把 auth.mode 改为 browser' + }) +} + +/* ============================================================ + * 红线二:限流与生成并发(统一实现) + * + * readLimit :只读接口按 IP 每分钟计数(挂在每个 skill Router 上) + * genGuard :按 skill 单飞(A 生成不阻塞 B)+ 全局只允许一个生成(防并发烧额度) + * + 按 IP 按小时计数 + * ============================================================ */ +const RL = { hits: {} } +const GEN = { global: false, bySkill: {} } + +const clientIp = (req) => { + const xf = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() + return xf || String((req.socket && req.socket.remoteAddress) || '') +} +const recentHits = (key, windowMs) => { + const now = Date.now() + const arr = (RL.hits[key] || []).filter((t) => now - t < windowMs) + RL.hits[key] = arr + return arr +} + +const readLimit = (req, res, next) => { + const max = Number((readConfig().rate_limit || {}).read_per_ip_minute || 120) + const arr = recentHits('r:' + clientIp(req), 60 * 1000) + if (arr.length >= max) return res.status(429).json({ ok: false, error: 'rate_limited', hint: '只读接口调用过于频繁' }) + arr.push(Date.now()) + next() +} + +const makeGenGuard = (skillId) => (req, res, next) => { + const rl = readConfig().rate_limit || {} + if (rl.generate_single_flight !== false && GEN.global) { + return res.status(429).json({ ok: false, error: 'busy', hint: '已有一次生成正在进行,请等它结束(一次约 3-4 分钟)' }) + } + if (GEN.bySkill[skillId]) { + return res.status(429).json({ ok: false, error: 'busy', hint: '本技能已有一次生成正在进行,请等它结束' }) + } + const maxHour = Number(rl.generate_per_ip_hour || 3) + const arr = recentHits('g:' + skillId + ':' + clientIp(req), 60 * 60 * 1000) + if (arr.length >= maxHour) { + return res.status(429).json({ ok: false, error: 'rate_limited', hint: `单个 IP 每小时最多生成 ${maxHour} 次(可在本 lab 的 config.json 调整)` }) + } + arr.push(Date.now()) + GEN.global = true + GEN.bySkill[skillId] = true + let released = false + const release = () => { + if (released) return + released = true + GEN.global = false + delete GEN.bySkill[skillId] + } + res.on('finish', release) + res.on('close', release) + next() +} + +/* ============================================================ + * 红线三:凭据(skill 永不接触密钥) + * 红线四:北京时间(唯一实现) + * 红线五:HTML 转义(唯一实现) + * 其余:日志前缀、数据目录、配置读取 + * ============================================================ */ +const CHAT_URL = process.env.DASHSCOPE_API_HOST_FOR_OPENAI + ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` + : '' + +const BJ_FMT = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit' +}) +const nowBeijing = () => { + const parts = BJ_FMT.formatToParts(new Date()) + const g = (t) => (parts.find((p) => p.type === t) || {}).value || '' + return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}` +} + +const escapeHtml = (s) => String(s === undefined || s === null ? '' : s) + .replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) + +// 从模型输出里稳健地取出 JSON(容忍代码块、前后废话) +const extractJson = (text) => { + let t = String(text || '').trim() + const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fence && fence[1]) t = fence[1].trim() + const start = t.indexOf('{') + const end = t.lastIndexOf('}') + if (start < 0 || end <= start) throw new Error('no_json_found') + return JSON.parse(t.slice(start, end + 1)) +} + +// 唯一持有 DASHSCOPE_API_KEY 的地方(红线:skill 不接触密钥) +const callLLM = async ({ messages, model, temperature, maxTokens, timeoutMs }) => { + if (!CHAT_URL) throw new Error('dashscope_host_missing') + const apiKey = process.env.DASHSCOPE_API_KEY || '' + if (!apiKey) throw new Error('dashscope_key_missing') + const resp = await axios.post(CHAT_URL, { + model, + messages, + temperature, + max_tokens: maxTokens + }, { + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + timeout: timeoutMs, + maxContentLength: 64 * 1024 * 1024 + }) + const choice = resp.data && resp.data.choices && resp.data.choices[0] + if (!choice || !choice.message) throw new Error('llm_empty_response') + return { + content: choice.message.content || '', + finishReason: choice.finish_reason || '', + usage: resp.data.usage || null + } +} + +// 原型污染防护(项目统一风格) +const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__']) +const sanitizeObject = (obj, depth = 0) => { + if (depth > 10) return {} + if (obj === null || typeof obj !== 'object') return obj + if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, depth + 1)) + const clean = {} + for (const [key, val] of Object.entries(obj)) { + if (PROTO_KEYS.has(key)) continue + clean[key] = sanitizeObject(val, depth + 1) + } + return clean +} + +const setNoCache = (res) => { + try { + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') + res.set('Pragma', 'no-cache') + res.set('Expires', '0') + } catch {} +} + +// 交给 skill 的能力包:红线能力 + 本 skill 专属路径/配置 +const makeCtx = (skillId) => ({ + skillId, + prefix: B + '/skills/' + skillId, + dataDir: path.join(registry.SKILLS_DIR, skillId, 'data'), + readConfig: () => registry.readSkillConfig(skillId), + aiReady: () => !!(CHAT_URL && process.env.DASHSCOPE_API_KEY), + log: (event, extra) => { + try { + console.log(`${LOG_TAG}/${skillId} ${event}` + (extra === undefined ? '' : ' ' + JSON.stringify(extra))) + } catch {} + }, + nowBeijing, + escapeHtml, + extractJson, + callLLM, + sanitizeObject, + jsonParser: express.json({ limit: BODY_LIMIT }), + readLimit, + genGuard: makeGenGuard(skillId) +}) + +/* ============================================================ + * 公开诊断路由(由 thought_lab 注册,位于导航 API 守卫之前) + * + * 存在意义:本 lab 的接口默认受 nav_gate 保护,而静态页允许同源 Referer 进入, + * 两者口径不一致时会表现为「页面能打开、接口全 401」,很容易被误判成代码 bug。 + * 这里只回传【调用方自己请求里已有的信息】用于定位,不回传任何 cookie 值。 + * ============================================================ */ +const bindDiagRoute = (app) => { + app.get(B + '/auth/diag', (req, res) => { + try { + const raw = String(req.headers.cookie || '') + const names = raw.split(';').map((s) => String(s).split('=')[0].trim()).filter(Boolean) + const navEnabled = isNavAuthEnabled() + const hasGate = hasNavGate(req) + return res.json({ + ok: true, + nav_auth_enabled: navEnabled, + has_nav_gate_cookie: hasGate, + cookie_names: names, + host: String(req.headers.host || ''), + referer: String(req.headers.referer || ''), + origin: String(req.headers.origin || ''), + auth_mode: authMode(), + server_time_beijing: nowBeijing(), + verdict: !navEnabled + ? '导航鉴权已关闭,接口不应返回 401' + : (hasGate + ? '本次请求携带了 nav_gate,接口不应 401;若仍 401 请把这份诊断发我' + : (authMode() === 'browser' && sameOrigin(req) + ? '本次请求无 nav_gate,但属同源来源,按 browser 口径应放行' + : '本次请求未携带 nav_gate cookie —— 页面能开是因为同源 Referer 放行,而接口守卫只认 cookie,所以必然 401')), + fix_hint: '用带 token 的入口重新进一次(首页或工具入口),让服务端种下 nav_gate;并确认 DevTools 里该 cookie 的 Path 是 /' + }) + } catch (e) { + return res.status(500).json({ ok: false, error: 'diag_failed' }) + } + }) +} + +/* ============================================================ + * 对外路由:本 lab 全部接口自行鉴权(不走全局 nav 守卫) + * 注册顺序:诊断(公开) → 聚合列表 → 逐个 skill 挂载 + * ============================================================ */ +const bindPublicRoutes = (app) => { + bindDiagRoute(app) + + // 聚合页用的技能列表(只含展示字段,不含业务内容) + app.get(B + '/skills', labAuth, readLimit, (req, res) => { + setNoCache(res) + try { + return res.json({ ok: true, skills: registry.listSkills() }) + } catch (e) { + return res.status(500).json({ ok: false, error: 'internal_error' }) + } + }) + + // 技能自检:一次列出全部异常(加完 skill 后不必逐个点开试) + app.get(B + '/skills/_health', labAuth, readLimit, (req, res) => { + setNoCache(res) + try { + return res.json(registry.health()) + } catch (e) { + return res.status(500).json({ ok: false, error: 'internal_error' }) + } + }) + + // 逐个 skill 挂载:lab 层先包好鉴权与限流,再把 Router 交给 skill + let mounted = 0 + registry.listSkillIds().forEach((id) => { + const info = registry.inspectSkill(id) + if (info.config.enabled === false) return + if (!info.mountable) { + console.error(`${LOG_TAG} 跳过 skill ${id}:${info.broken}`) + return + } + const entry = path.join(registry.SKILLS_DIR, id, 'index.js') + let mod = null + try { + mod = require(entry) + } catch (e) { + console.error(`${LOG_TAG} 加载 skill 失败 ${id}:${String((e && e.message) || e)}`) + return + } + if (!mod || typeof mod.bindRoutes !== 'function') { + console.error(`${LOG_TAG} 跳过 skill ${id}:index.js 未导出 bindRoutes`) + return + } + const router = express.Router() + router.use(labAuth) // 红线:统一鉴权(skill 拿不到绕过机会) + router.use(readLimit) // 红线:统一只读限流 + try { + mod.bindRoutes(router, makeCtx(id)) + } catch (e) { + console.error(`${LOG_TAG} 注册 skill 路由失败 ${id}:${String((e && e.message) || e)}`) + return + } + app.use(B + '/skills/' + id, router) + mounted++ + }) + console.log(`${LOG_TAG} 已挂载 ${mounted}/${registry.listSkillIds().length} 个 skill(前缀 ${B}/skills/<skill_id>/)`) +} + +// 本 lab 全部接口统一注册在 bindPublicRoutes 内并自行鉴权(不走全局 nav 守卫) +// makeCtx 一并导出:无副作用的 ctx 工厂,供 dev_test_scripts 的离线链路测试复用, +// 避免测试脚本自己再抄一份 callLLM/extractJson(那会与红线实现漂移)。 +module.exports = { bindPublicRoutes, makeCtx } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/registry.js b/src/server/thought_lab/labs/travel_citytour/registry.js new file mode 100644 index 0000000..cc4106d --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/registry.js @@ -0,0 +1,139 @@ +// ============================================================ +// travel_citytour/registry.js —— 技能注册表(lab 扫描层) +// +// 职责(只有三件事,绝不做业务): +// 1. 扫 skills/ 下的目录,得到 skill_id 列表 +// 2. 读每个 skill 的 config.json(聚合页唯一需要的文件) +// 3. 前后端交叉校验,输出健康状态(broken) +// +// 明确的「不做」: +// - 不读 qa.json / report_spec.json / rules.md 等任何业务文件 +// - 不 require 任何 skill 的 index.js(挂载由 lab index.js 负责) +// - 不认任何业务字段名 +// +// 契约(与 market_skills / data_gateway 的既有约定一致): +// 后端 skills/<id>/config.json + skills/<id>/index.js +// 前端 skills/<id>/index.html +// 三者缺一 → broken,聚合页置灰不可点 +// ============================================================ +const fs = require('fs') +const path = require('path') + +const SKILLS_DIR = path.join(__dirname, 'skills') +const PUBLIC_SKILLS_DIR = path.join(process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills') + +// 只允许字母数字下划线,杜绝 ../ 之类的路径穿越 +const normalizeSkillId = (id) => String(id || '').replace(/[^A-Za-z0-9_]/g, '') + +const listSkillIds = () => { + try { + if (!fs.existsSync(SKILLS_DIR)) return [] + return fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((n) => normalizeSkillId(n) === n) + } catch { + return [] + } +} + +const readSkillConfig = (id) => { + try { + const p = path.join(SKILLS_DIR, id, 'config.json') + if (!fs.existsSync(p)) return {} + const cfg = JSON.parse(fs.readFileSync(p, 'utf-8')) + return (cfg && typeof cfg === 'object') ? cfg : {} + } catch { + return {} + } +} + +const exists = (p) => { try { return fs.existsSync(p) } catch { return false } } + +// 单个 skill 的「可挂载性」与「可点性」判定 +const inspectSkill = (id) => { + const dir = path.join(SKILLS_DIR, id) + const entry = path.join(dir, 'index.js') + const cfgFile = path.join(dir, 'config.json') + const pageFile = path.join(PUBLIC_SKILLS_DIR, id, 'index.html') + const iconFile = path.join(PUBLIC_SKILLS_DIR, id, 'icon.svg') + + const hasConfig = exists(cfgFile) + const cfg = readSkillConfig(id) + const hasEntry = exists(entry) + const hasPage = exists(pageFile) + + let broken = '' + if (!hasConfig) broken = '缺少 config.json' + else if (!hasEntry) broken = '缺少后端 index.js' + else if (!hasPage) broken = '缺少前端页面 index.html' + else if (cfg.id && String(cfg.id) !== id) broken = 'config.json 的 id 与目录名不一致' + + return { + id, + config: cfg, + hasConfig, + hasEntry, + hasPage, + hasIcon: exists(iconFile), + mountable: hasConfig && hasEntry && !broken, + broken + } +} + +// 供聚合页使用的列表(只暴露展示必需字段,不含任何业务内容) +const listSkills = () => { + const out = [] + listSkillIds().forEach((id) => { + const s = inspectSkill(id) + const cfg = s.config + if (cfg.enabled === false) return + out.push({ + id, + name: String(cfg.name || (cfg.skill && cfg.skill.name) || id), + desc: String(cfg.desc || ''), + icon: String(cfg.icon || '🧭'), + icon_url: s.hasIcon ? ('./skills/' + id + '/icon.svg') : '', + sort_order: Number(cfg.sort_order || 0), + page: './skills/' + id + '/index.html', + broken: s.broken + }) + }) + out.sort((a, b) => { + if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order + return String(a.id).localeCompare(String(b.id)) + }) + return out +} + +// 自检:一次列出全部异常,方便加完 skill 后不用逐个点开试 +const health = () => { + const items = [] + listSkillIds().forEach((id) => { + const s = inspectSkill(id) + const cfg = s.config + items.push({ + id, + enabled: cfg.enabled !== false, + mountable: s.mountable, + has_config: s.hasConfig, + has_backend_index: s.hasEntry, + has_frontend_page: s.hasPage, + has_icon: s.hasIcon, + broken: s.broken + }) + }) + const problems = items.filter((i) => i.broken) + return { ok: problems.length === 0, total: items.length, problems: problems.length, items, problems_detail: problems } +} + +module.exports = { + SKILLS_DIR, + PUBLIC_SKILLS_DIR, + normalizeSkillId, + listSkillIds, + readSkillConfig, + inspectSkill, + listSkills, + health +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/ai.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/ai.js new file mode 100644 index 0000000..1d71ef6 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/ai.js @@ -0,0 +1,147 @@ +// ============================================================ +// sh_2d_car_hike/ai.js —— 本技能的 AI 生成链路(技能私有) +// +// 职责:把「本技能包 + 问答答案 + 目的地覆盖」组装成一次成卷的 prompt, +// 调 lab 层 ctx.callLLM 拿回结构化 JSON,做健壮解析,失败重试一次。 +// +// 边界(红线): +// - 不接触密钥、不发 HTTP、不管超时:全部由 lab 层 ctx.callLLM 负责。 +// - prompt 内容 100% 归本技能所有 —— 里面出现「浴场 / 车宿 / 两天」是本技能的业务, +// 不构成对别的 skill 的耦合(这正是 skill 目录隔离的意义)。 +// ============================================================ + +// 只把「会进 prompt」的骨架字段投出去,避免把一堆 _comment 也塞进模型 +const slimSkill = (skill) => ({ + id: skill.id, + name: skill.name, + version: skill.version, + days: skill.days, + nights: skill.nights, + base_city: skill.base_city, + base_origin: skill.base_origin, + travel_mode: skill.travel_mode, + region: skill.region, + anchors: skill.anchors, + degradation_rules: skill.degradation_rules, + confidence_levels: skill.confidence_levels, + unverified_fields: skill.unverified_fields, + output_discipline: skill.output_discipline +}) + +const buildMessages = (bundle, answers, dest) => { + const system = [ + '你是一个严格按「技能包」执行行程生成的引擎。下面给你一个技能包(骨架、规则、输出契约)与本次问答答案,请产出唯一一份行程 JSON。', + '', + '【硬性要求】', + '1. 只输出 JSON 本身,不要任何解释、前后缀、Markdown 代码块标记。', + '2. 结构必须严格符合下方「输出契约」,根字段一个都不能少。', + '3. skill.json 的 anchors 是骨架:每一张 card 必须对应一个 anchor_id,一个都不能少、不能多。', + '4. 契约里的 required_table_ids 是必须存在的表格 id。', + '5. 主观项(免费停车、本地人常去、浴场档次与卫生、车宿合法性)不允许标 high,且必须带 verify 说明核实方式。', + '6. 不确定就降置信度并写进 checklist,绝对不许编造店名与价格。', + '7. 触发降级规则时必须显式写出降级说明,不许静默降低要求。', + '8. timeline 行的 kind 只表示「时间点性质」,只能取 "hard" 或 "soft":凡骨架里 time_anchor.type 为 "hard" 的槽位,其对应时间行必须写 kind="hard";其余行写 "soft"。', + '8b. timeline 行的 activity 表示「活动类型」,可选,只能取 move / eat / hike / shop / sleep / walk 之一。kind 与 activity 是正交的两个字段:「21:10 抵达车宿点」应写 kind="hard" 且 activity="sleep",绝不能因为它是过夜就把 kind 写成 "sleep"。', + '9. checklist:必须覆盖每一个在卡片里出现过非 high 字段的 anchor_id,一个槽位至少一条,不许整槽位漏收。', + '10. map.markers 要覆盖当天的所有落脚点(徒步起终点、停车点、街区打卡点、吃饭点、浴场、超市、车宿点、打卡点),点位过少地图会显得很空。', + '11. map.routes[].marker_ids 里的每个 id 必须能在 map.markers 里找到,禁止引用未定义的点位 id。', + '12. card.day 只能填 1 或 2;第一天所有节点填 1,第二天填 2。', + '', + '===== 技能包:规则 rules.md =====', + String(bundle.rules || ''), + '', + '===== 技能包:骨架与降级规则 skill.json =====', + JSON.stringify(slimSkill(bundle.skill), null, 2), + '', + '===== 技能包:输出契约 output_schema.json =====', + JSON.stringify(bundle.schema || {}, null, 2) + ].join('\n') + + const user = [ + '===== 本次输入 =====', + JSON.stringify(answers || {}, null, 2), + '', + dest + ? '===== 该目的地在技能包里的已知覆盖(请以此为准,不要另造数据) =====\n' + JSON.stringify(dest, null, 2) + : '===== 该目的地是首次生成,技能包里没有覆盖数据,请按骨架与规则自行规划,并把不确定项如实标注置信度 =====', + '', + '请直接输出这份行程的 JSON。' + ].join('\n') + + return [ + { role: 'system', content: system }, + { role: 'user', content: user } + ] +} + +// 服务端权威字段:身份、版本、时间戳一律由服务端写死,避免模型编造 +const finalizeMeta = (payload, bundle, answers, model, ctx) => { + const p = payload || {} + p.meta = p.meta || {} + p.meta.skill_id = bundle.skill.id + p.meta.skill_name = bundle.skill.name + p.meta.skill_version = bundle.skill.version + p.meta.schema_version = (bundle.schema && bundle.schema.version) || '' + p.meta.qa = answers || {} + p.meta.generated_at = ctx.nowBeijing() + p.meta.generator = 'ai:' + model + if (!p.meta.destination || typeof p.meta.destination !== 'object') p.meta.destination = {} + if (!p.meta.destination.name) p.meta.destination.name = String((answers && answers.destination) || '') + if (!p.meta.destination.key) { + const d = String(p.meta.destination.name || '').trim() + p.meta.destination.key = d || 'unknown' + } + if (!p.meta.disclaimer) { + p.meta.disclaimer = '本行程由 AI 按技能包规则生成;所有主观信息均已标注置信度,出行前请按『待核实清单』自行复核。' + } + return p +} + +// 生成一次行程;解析失败自动重试一次(带上纠正提示) +const generate = async ({ bundle, answers, dest, aiConfig, ctx }) => { + const model = String(aiConfig.model || 'qwen-plus-latest') + const temperature = Number.isFinite(Number(aiConfig.temperature)) ? Number(aiConfig.temperature) : 0.3 + const maxTokens = Number.isFinite(Number(aiConfig.max_tokens)) ? Number(aiConfig.max_tokens) : 8000 + const timeoutMs = Number.isFinite(Number(aiConfig.timeout_ms)) ? Number(aiConfig.timeout_ms) : 240000 + + const base = buildMessages(bundle, answers, dest) + const started = Date.now() + let attempt = 0 + let lastErr = null + let raw = '' + + while (attempt < 2) { + attempt++ + const messages = attempt === 1 + ? base + : base.concat([ + { role: 'assistant', content: raw.slice(0, 4000) }, + { role: 'user', content: '上面的输出不是合法 JSON(或已被截断)。请只输出完整合法的 JSON 对象本身,不要任何解释、不要代码块标记。' } + ]) + const r = await ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs }) + raw = r.content + try { + const payload = ctx.extractJson(raw) + return { + payload: finalizeMeta(payload, bundle, answers, model, ctx), + model, + elapsed_ms: Date.now() - started, + attempts: attempt, + usage: r.usage, + finish_reason: r.finishReason, + raw + } + } catch (e) { + lastErr = e + if (r.finishReason === 'length') { + lastErr = new Error('output_truncated') + break + } + } + } + const err = new Error(lastErr ? String(lastErr.message || lastErr) : 'generate_failed') + err.raw = raw + throw err +} + +module.exports = { generate, buildMessages, finalizeMeta } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/config.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/config.json new file mode 100644 index 0000000..f62fed4 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/config.json @@ -0,0 +1,36 @@ +{ + "_comment": "技能:上海周边2日车宿徒步游 —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容;业务参数(ui_mode / ai)只有本 skill 自己的后端会读。", + + "id_comment": "技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)", + "id": "sh_2d_car_hike", + + "name_comment": "聚合页卡片标题", + "name": "上海周边2日车宿徒步游", + + "desc_comment": "聚合页卡片一句话简介(tooltip)", + "desc": "自驾 + 车内过夜 + 两段徒步 + 街区/浴场/补给;上海周边 300km 内目的地", + + "icon_comment": "聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg", + "icon": "🚗", + + "sort_order_comment": "聚合页排序,数值越小越靠前", + "sort_order": 10, + + "enabled_comment": "是否启用(false 时 lab 扫描层直接跳过本 skill,聚合页不显示)", + "enabled": true, + + "ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗。本项归 skill 私有,各 skill 可不同", + "ui_mode": "debug", + + "ai_comment": "本 skill 的生成链路 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。本 skill 为 2 天行程,实测整份 JSON 需 1.2-1.6 万 token,这里留 20000", + "max_tokens": 20000, + "timeout_ms_comment": "单次生成超时(毫秒)。实测约 33 token/s,1.6 万 token 需 6-8 分钟,故放到 10 分钟", + "timeout_ms": 600000 + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/destinations.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/destinations.json new file mode 100644 index 0000000..3bf9389 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/destinations.json @@ -0,0 +1,115 @@ +{ + "_comment": "技能包:目的地清单与变量覆盖。骨架(skill.json 的 anchors)永远不变,本文件只覆盖‘换个目的地要换哪些值’。一个 skill 可挂多个目的地;同一目的地也可被其他 skill 挂载,互不干扰。", + "version": "0.1.0", + "sample_dir_note": "sample_ref 相对【本 skill 自己的前端目录】解析(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/),离线样例放在该目录下的 sample/ 里", + + "destinations": [ + { + "key": "rudong", + "name": "南通如东", + "admin": "江苏省南通市如东县", + "center": [32.31, 121.19], + "center_coord_precision": "approx", + "center_note": "县中心为掘港镇,坐标仅用于地图初始视野,非精确点位", + + "terrain": "平原 · 沿海,无山", + "degradations_applied": ["terrain_flat", "no_large_bathhouse"], + + "from_base_comment": "默认出发点=上海宝山富锦路地铁站 → 如东的里程/时长/过路费,均为估算示意,未接实时路况;换出发点必须重算这一组数字,不得沿用旧值", + "from_base": { + "origin": "上海宝山富锦路地铁站", + "highway": { "distance_km": 170, "hours": 2.6, "toll_cny": 85 }, + "non_highway": { "distance_km": 162, "hours": 3.5, "toll_cny": 0 }, + "basis": "估算示意" + }, + + "anchors_override_comment": "每个槽位在如东的落点候选。凡 confidence != high 的,生成时必须原样带进报告的‘待核实清单’", + "anchors_override": { + "district_arrive": { + "pick": "栟茶古镇一带(历史街区)", + "candidates": ["茶古镇老街一带", "如东县城掘港镇老街一带(闹市街区备选)"], + "confidence": "medium", + "verify": "古镇开放范围、夜间照明与停车安排,出发前查当地公告或致电古镇管理方" + }, + "district_parking": { + "pick": "古镇外围绕行找路侧泊位或镇区公共停车", + "free_parking_expected": "unknown", + "confidence": "low", + "verify": "是否有免费停车场需现场确认,不可默认写成免费" + }, + "lunch_d1": { + "pick": ["古镇周边本地小馆(海鲜/文蛤/河鲜类)", "镇区非游客主街的家常菜馆"], + "confidence": "low", + "verify": "店名与人均需现场或本地口碑核实;不确定时只给业态+位置描述,不得编造店名" + }, + "hike_1": { + "pick": "沿海堤岸 / 湿地段徒步", + "terrain_note": "水线徒步,累计爬升约 0", + "confidence": "low", + "verify": "堤岸是否允许步行、是否有施工封闭,需现场确认" + }, + "bathhouse": { + "pick": "沿海温泉 / 大型洗浴中心(降级替代)", + "degraded": true, + "confidence": "low", + "verify": "营业状态、是否需预约、免费停车是否落实,出发前电话确认" + }, + "grocery": { + "pick": "浴场同一商圈的超市(不跑大卖场)", + "confidence": "low", + "verify": "超市是否还在营业(泡完汤约 20 点)、能否与浴场共用停车点,需现场核实" + }, + "car_sleep": { + "pick": "县城公园类市政停车场(候选)", + "risk_level": "medium", + "confidence": "low", + "verify": "过夜停车是否被劝离,以现场标识与当地执法口径为准" + }, + "morning_walk": { + "pick": "车宿点所在公园内环线", + "confidence": "medium", + "verify": "夜间路灯与公厕开放情况需现场确认" + }, + "breakfast_wash": { + "pick": "县城本地早点铺 + 公园公厕/加油站卫生间洗漱", + "confidence": "low", + "verify": "早点铺开门时间(5-6 点是否营业)需现场确认" + }, + "hike_2": { + "pick": "沿海林带 / 河道绿廊段徒步", + "terrain_note": "林道 + 水岸,累计爬升约 0", + "confidence": "low", + "verify": "林带开放情况与出入口位置需核实" + }, + "photo_spots": { + "pick": ["海堤 / 范公堤类人文点(候选)", "古镇内人文点位(候选)"], + "confidence": "low", + "verify": "具体点位、开放时间与门票需核实" + }, + "lunch_d2": { + "pick": ["县城本地家常菜老店(可停车优先)"], + "confidence": "low", + "verify": "店名与停车条件需现场核实" + } + }, + + "notes": [ + "如东为平原沿海地区,已触发 terrain_flat:两段徒步均为平路,累计爬升约 0,报告必须显式写明‘平路徒步’,不得冒充山地徒步效果", + "本地无传统大型浴场,已触发 no_large_bathhouse:降级为温泉 / 大型洗浴中心并显式标注", + "默认出发点为上海宝山富锦路地铁站;换出发点后 from_base 的里程/时长/过路费必须重算", + "补给按用户口径改为‘浴场旁超市与浴场一起完成’,不再单独安排大卖场行程", + "沿海地区风力较大、夏秋季注意蚊虫与防晒;海鲜类餐食注意时令" + ], + + "runs": [ + { + "date": "2026-09-19", + "status": "draft", + "generator": "manual_sample", + "skill_version": "0.3.0", + "sample_ref": "sample/rudong_2026-09-19.json" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.js new file mode 100644 index 0000000..32c3631 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/index.js @@ -0,0 +1,231 @@ +// ============================================================ +// sh_2d_car_hike/index.js —— 本技能的后端路由(技能私有、自包含) +// +// 契约(lab 扫描层唯一要求): +// module.exports = { bindRoutes(router, ctx) } +// - router:lab 层已经 .use(labAuth) + .use(readLimit) 之后再交给我, +// 所以本文件【不写任何鉴权代码】,也拿不到绕过鉴权的机会。 +// - ctx :lab 层提供的红线能力(密钥/时间/转义/日志/数据目录…)。 +// +// 挂载点:/api/thought_lab/travel_citytour/skills/sh_2d_car_hike/* +// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。 +// ============================================================ +const fs = require('fs') +const path = require('path') +const ai = require('./ai') +const { validatePayload } = require('./validate') +const { repairPayload } = require('./repair') + +const SKILL_DIR = __dirname + +// 本技能的技能包文件清单。缺哪个都不算错(除 skill.json 外),只是能力变少。 +const SKILL_FILES = { + skill: 'skill.json', + qa: 'qa.json', + schema: 'output_schema.json', + report_spec: 'report_spec.json', + destinations: 'destinations.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') + if (key === 'rules') { bundle.rules = txt; return } + try { + bundle[key] = JSON.parse(txt) + } catch (e) { + throw new Error('skill_file_broken:' + SKILL_FILES[key]) + } + }) + if (!bundle.skill || !bundle.skill.id) throw new Error('skill_meta_missing') + bundle.id = bundle.skill.id + return bundle +} + +// 目的地覆盖:用户输入的目的地若在本技能已跑过,取出它的覆盖值 +const findDestination = (bundle, destName) => { + const list = (bundle.destinations && bundle.destinations.destinations) || [] + const key = String(destName || '').trim() + if (!key) return null + return list.find((d) => d.name === key || d.key === key || (Array.isArray(d.aliases) && d.aliases.indexOf(key) >= 0)) || null +} + +// 按本技能 qa.json 的定义做通用必填校验(不写死任何字段名) +const validateAnswers = (bundle, answers) => { + const missing = [] + const qa = bundle.qa || {} + const destInput = qa.destination_input || {} + if (destInput.required !== false && !String(answers.destination || '').trim()) { + missing.push(destInput.label || 'destination') + } + ;(Array.isArray(qa.questions) ? qa.questions : []).forEach((q) => { + if (!q || q.required === false) return + const v = answers[q.key] + if (v === undefined || v === null || String(v).trim() === '') missing.push(q.label || q.key) + }) + return missing +} + +// ============================================================ +// 最近一次生成结果(本技能私有 data/) +// +// 存在意义:一次生成约 4 分钟,而长请求经 8975 反代存在「服务端已返回、客户端没收到」的情况。 +// 有了这份落盘结果,刷新页面后点「载入最近一次生成」即可看结果,不必重新烧 4 分钟。 +// 各技能各存一份,互不覆盖(这是目录隔离顺带修掉的老毛病)。 +// ============================================================ +const LAST_FILE = 'last_result.json' +const LAST_KEEP_MS = 7 * 24 * 3600 * 1000 + +const writeLastResult = (ctx, envelope) => { + try { + fs.mkdirSync(ctx.dataDir, { recursive: true }) + fs.writeFileSync(path.join(ctx.dataDir, LAST_FILE), JSON.stringify(envelope), 'utf-8') + } catch (e) { + ctx.log('last_result 写入失败', { error: String((e && e.message) || e) }) + } +} + +const readLastResult = (ctx) => { + try { + const file = path.join(ctx.dataDir, LAST_FILE) + if (!fs.existsSync(file)) return null + if (Date.now() - fs.statSync(file).mtimeMs > LAST_KEEP_MS) return null + return JSON.parse(fs.readFileSync(file, 'utf-8')) + } catch (e) { + return null + } +} + +// ============================================================ +// 路由:全部为相对路径,挂载前缀由 lab 层决定 +// ============================================================ +const bindRoutes = (router, ctx) => { + // 本技能元信息:页面双模式依据 + 渲染描述 + 已跑目的地 + 问答定义 + router.get('/meta', (req, res) => { + try { + const cfg = ctx.readConfig() + const bundle = loadBundle() + 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: bundle.id, + skill: { + id: bundle.skill.id, + name: bundle.skill.name, + version: bundle.skill.version, + days_label: bundle.skill.days ? `${bundle.skill.days} 天 ${bundle.skill.nights || bundle.skill.days - 1} 夜` : '', + base_origin: bundle.skill.base_origin || '', + anchors_count: Array.isArray(bundle.skill.anchors) ? bundle.skill.anchors.length : 0, + anchors: bundle.skill.anchors || [] + }, + qa: bundle.qa || {}, + report_spec: bundle.report_spec || {}, + destinations: (bundle.destinations && bundle.destinations.destinations) || [] + }) + } catch (e) { + const msg = String((e && e.message) || e) + return res.status(msg.indexOf('skill_file_broken') === 0 ? 500 : 500).json({ ok: false, error: msg }) + } + }) + + // 最近一次生成结果 + router.get('/last', (req, res) => { + const env = readLastResult(ctx) + if (!env) { + return res.status(404).json({ ok: false, error: 'no_last_result', hint: '还没有生成过,或结果已超过 7 天保留期' }) + } + return res.json(env) + }) + + // 生成行程(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流) + router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => { + const body = ctx.sanitizeObject(req.body || {}) + let bundle = null + try { + bundle = loadBundle() + } catch (e) { + return res.status(500).json({ ok: false, error: String((e && e.message) || e) }) + } + + const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {} + const missing = validateAnswers(bundle, answers) + if (missing.length) return res.status(400).json({ ok: false, error: 'missing_answers', missing }) + + const cfg = ctx.readConfig() + const aiConfig = cfg.ai || {} + const dest = findDestination(bundle, answers.destination) + + // 生成本身约 4 分钟,经 IIS 反代(8975)时可能被中途掐断。 + // 这行日志用于区分「服务端已返回」与「客户端真的收到」——两者现象一样,但根因完全不同。 + res.on('close', () => { + ctx.log('generate resp closed', { delivered: !!res.writableEnded, dest: answers.destination }) + }) + + try { + ctx.log('generate start', { dest: answers.destination, model: aiConfig.model || 'qwen-plus-latest' }) + const result = await ai.generate({ bundle, answers, dest, aiConfig, ctx }) + // AI 一定会变形:先做无损修复,再校验(修复说明一并在响应里回传,前端交互弹窗可见) + const repaired = repairPayload(result.payload, bundle) + const check = validatePayload(repaired.payload, bundle) + const elapsed = result.elapsed_ms + + ctx.log('generate done', { + dest: answers.destination, ms: elapsed, attempts: result.attempts, + repairs: repaired.fixes.length, errors: check.errors.length, warnings: check.warnings.length + }) + + const envelope = { + ok: check.errors.length === 0, + payload: repaired.payload, + report_spec: bundle.report_spec || {}, + destination_override: dest ? { key: dest.key, name: dest.name, degradations_applied: dest.degradations_applied || [] } : null, + check, + meta: { + model: result.model, + elapsed_ms: elapsed, + attempts: result.attempts, + finish_reason: result.finish_reason, + usage: result.usage, + repairs: repaired.fixes, + is_new_destination: !dest + } + } + if (check.errors.length) { + envelope.error = 'payload_invalid' + envelope.raw_excerpt = String(result.raw || '').slice(0, 6000) + } + // 【为什么有 error 也照样落盘 + 照样用 200 回传】 + // 一次生成要烧 3-4 分钟与上万 token。过去只要结构校验出现哪怕 1 个 error 就整份丢弃、 + // 既不落盘也不回传 payload,用户什么都拿不到 —— 而那个 error 很可能只是校验器的误判 + // (真实事故:校验器写死 day.label 含「第一天」,AI 标成「周六」→ 明明排了夜逛却报「缺夜逛」, + // 4 分钟的生成被白扔)。 + // 现在改为:结果一律先落盘再回传;有结构问题时用 ok:false + error 字段如实告知, + // 前端会显示醒目的「N 处结构问题」标记,用户仍可查看/导出并自行判断是否可用。 + // 用 200 而不是 422:422 会让前端走「失败」分支,payload 反而渲染不出来。(本 skill 私有决策) + writeLastResult(ctx, envelope) + return res.json(envelope) + } catch (e) { + const msg = String((e && e.message) || e) + ctx.log('generate failed', { dest: answers.destination, error: msg }) + const status = msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502 + const hint = msg === 'output_truncated' + ? '模型输出被截断(max_tokens 不足),可在本技能的 config.json 调大 ai.max_tokens 后重试' + : (msg === 'no_json_found' ? '模型没有按要求输出 JSON,可降低 ai.temperature 或重试' : '') + return res.status(status).json({ + ok: false, + error: msg, + hint, + raw_excerpt: String((e && e.raw) || '').slice(0, 6000) + }) + } + }) +} + +module.exports = { bindRoutes, loadBundle, findDestination, validateAnswers } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/output_schema.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/output_schema.json new file mode 100644 index 0000000..09af2da --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/output_schema.json @@ -0,0 +1,287 @@ +{ + "_comment": "技能包:输出契约。生成结果(payload)必须严格符合本结构;后端只按本结构做通用校验与透传,不认识任何具体业务字段。新增 skill 自带一份自己的 output_schema.json,互不干扰。", + "version": "0.2.0", + "dialect": "json-schema-lite", + + "root_comment": "根对象字段固定;业务内容全部装在 cards / tables / timeline 里,因此换 skill 不需要改库、不需要改后端", + "root": { + "type": "object", + "required": ["meta", "summary", "timeline", "cards", "map", "tables", "cost", "checklist", "notes"], + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "summary": { "$ref": "#/definitions/summary" }, + "timeline": { "$ref": "#/definitions/timeline" }, + "cards": { "type": "array", "items": { "$ref": "#/definitions/card" }, "minItems": 1 }, + "map": { "$ref": "#/definitions/map" }, + "tables": { "type": "array", "items": { "$ref": "#/definitions/table" } }, + "cost": { "$ref": "#/definitions/cost" }, + "checklist": { "type": "array", "items": { "$ref": "#/definitions/checklist_item" } }, + "notes": { "type": "array", "items": { "$ref": "#/definitions/note" } } + } + }, + + "required_table_ids_comment": "这些 table 由本 skill 的 report_spec 按 pick_id 引用,必须存在,否则该区块渲染为空", + "required_table_ids": ["drive_out_compare", "drive_back_compare", "food_summary", "parking_summary"], + + "required_card_anchor_ids_comment": "card.anchor_id 必须覆盖 skill.json 的全部 anchors,一个都不能少(槽位齐全即代表 15 条要求全落地)", + "required_card_anchor_ids_source": "skill.json#/anchors", + + "definitions": { + "fact_comment": "可置信度化字段:纯字符串(视为 high,仅限客观信息)或对象(主观项必须用对象并带 verify)", + "fact": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["text", "confidence"], + "properties": { + "text": { "type": "string" }, + "confidence": { "enum": ["high", "medium", "low"] }, + "verify": { "type": "string", "description": "核实方式,confidence != high 时必填" } + } + } + ] + }, + + "meta": { + "type": "object", + "required": ["skill_id", "skill_name", "skill_version", "schema_version", "destination", "qa", "generated_at", "generator", "disclaimer"], + "properties": { + "skill_id": { "type": "string" }, + "skill_name": { "type": "string" }, + "skill_version": { "type": "string" }, + "schema_version": { "type": "string" }, + "destination": { + "type": "object", + "required": ["key", "name"], + "properties": { + "key": { "type": "string" }, + "name": { "type": "string" }, + "admin": { "type": "string" }, + "center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } + } + }, + "qa": { "type": "object", "description": "问答答案原样回填,字段名由 qa.json 决定" }, + "generated_at": { "type": "string", "description": "北京时间 YYYY-MM-DD HH:mm:ss" }, + "generator": { "type": "string", "description": "manual_sample / ai:<model>" }, + "disclaimer": { "type": "string" } + } + }, + + "summary": { + "type": "object", + "required": ["title", "one_liner", "stats"], + "properties": { + "title": { "type": "string" }, + "one_liner": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "stats": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" }, + "hint": { "type": "string" } + } + } + } + } + }, + + "timeline": { + "type": "object", + "required": ["days"], + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "date", "rows"], + "properties": { + "label": { "type": "string" }, + "date": { "type": "string" }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["time", "title", "anchor_id", "kind"], + "properties": { + "time": { "type": "string" }, + "title": { "type": "string" }, + "anchor_id": { "type": "string" }, + "kind": { "enum": ["hard", "soft"], "description": "时间点性质,只表性质、不表活动:hard=骨架声明为硬时间点(不可漂移);soft=常规。禁止把活动类型写进本字段。" }, + "activity": { "enum": ["move", "eat", "hike", "shop", "sleep", "walk"], "description": "活动类型,可选。与 kind 正交——同一行既要能标 hard,也要能标 sleep。" }, + "note": { "type": "string" } + } + } + } + } + } + } + } + }, + + "image": { + "type": "object", + "required": ["url", "kind", "caption", "disclaimer"], + "properties": { + "url": { "type": "string", "description": "优先本地相对路径(已落盘);远程 URL 视为未落盘" }, + "kind": { "enum": ["ai_illustration", "map_capture", "icon"] }, + "caption": { "type": "string" }, + "disclaimer": { "type": "string" } + } + }, + + "card": { + "type": "object", + "required": ["id", "anchor_id", "day", "title", "fields"], + "properties": { + "id": { "type": "string" }, + "anchor_id": { "type": "string", "description": "必须对应 skill.json 的一个 anchor.id" }, + "day": { "type": "integer", "enum": [1, 2] }, + "title": { "type": "string" }, + "subtitle": { "type": "string" }, + "image": { "$ref": "#/definitions/image" }, + "badges": { + "type": "array", + "items": { + "type": "object", + "required": ["text"], + "properties": { + "text": { "type": "string" }, + "tone": { "enum": ["info", "good", "warn", "danger", "plain"] } + } + } + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "$ref": "#/definitions/fact" }, + "hint": { "type": "string" } + } + } + }, + "bullets": { "type": "array", "items": { "type": "string" } }, + "map_marker_ids": { "type": "array", "items": { "type": "string" } } + } + }, + + "marker": { + "type": "object", + "required": ["id", "name", "kind", "lat", "lng", "day", "seq"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "kind": { "enum": ["start", "end", "waypoint", "parking", "hike", "district", "food", "bathhouse", "shop", "sleep", "walk", "breakfast", "spot"] }, + "lat": { "type": "number" }, + "lng": { "type": "number" }, + "coord_precision": { "enum": ["exact", "approx"], "description": "approx 表示仅用于示意图定位" }, + "day": { "type": "integer", "enum": [1, 2] }, + "seq": { "type": "integer" }, + "note": { "type": "string" } + } + }, + + "route": { + "type": "object", + "required": ["id", "name", "kind", "marker_ids"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "kind": { "enum": ["drive", "hike", "stroll"] }, + "marker_ids": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, + "color": { "type": "string" }, + "dashed": { "type": "boolean" }, + "note": { "type": "string", "description": "必须说明是点位顺序示意,非真实道路/轨迹" } + } + }, + + "map": { + "type": "object", + "required": ["center", "zoom", "markers", "routes"], + "properties": { + "center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + "zoom": { "type": "integer" }, + "markers": { "type": "array", "items": { "$ref": "#/definitions/marker" } }, + "routes": { "type": "array", "items": { "$ref": "#/definitions/route" } } + } + }, + + "table": { + "type": "object", + "required": ["id", "title", "columns", "rows"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "label"], + "properties": { + "key": { "type": "string" }, + "label": { "type": "string" }, + "align": { "enum": ["left", "center", "right"] }, + "width": { "type": "string" } + } + } + }, + "rows": { "type": "array", "items": { "type": "object", "description": "键为 columns[].key" } }, + "note": { "type": "string" }, + "highlight_row_keys_comment": "需要强调的行,值为 rows 里的 row._key", + "highlight_row_keys": { "type": "array", "items": { "type": "string" } } + } + }, + + "cost": { + "type": "object", + "required": ["currency", "items", "total"], + "properties": { + "currency": { "enum": ["CNY"] }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "amount"], + "properties": { + "label": { "type": "string" }, + "amount": { "type": "number" }, + "unit": { "type": "string" }, + "note": { "type": "string" } + } + } + }, + "total": { "type": "number" }, + "note": { "type": "string" } + } + }, + + "checklist_item": { + "type": "object", + "required": ["text", "confidence"], + "properties": { + "anchor_id": { "type": "string" }, + "field": { "type": "string" }, + "text": { "type": "string" }, + "confidence": { "enum": ["medium", "low"] }, + "verify": { "type": "string" } + } + }, + + "note": { + "type": "object", + "required": ["level", "title", "text"], + "properties": { + "level": { "enum": ["info", "warn", "danger"] }, + "title": { "type": "string" }, + "text": { "type": "string" } + } + } + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/qa.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/qa.json new file mode 100644 index 0000000..fd44d4b --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/qa.json @@ -0,0 +1,95 @@ +{ + "_comment": "技能包:问答脚本。生成前由通用层按本文件动态渲染问答表单;后端只透传答案,不解析字段含义。新增问答项不需要改后端代码。", + "version": "0.1.0", + + "destination_input_comment": "目的地是通用层输入(skill ↔ 目的地多对多),本 skill 只负责覆盖它的叫法、提示与校验口径", + "destination_input": { + "label": "目的地", + "placeholder": "例如:南通如东、四明山", + "required": true, + "mode": "free_input_with_suggestion", + "suggestions_ref": "destinations.json", + "region_radius_km": 300, + "on_out_of_range": "block_with_hint", + "hint": "本 Skill 适用于上海周边 300km 内、可当日自驾抵达的目的地;超出范围建议改用「民宿游 / 海外游」等其他 Skill" + }, + + "questions_comment": "问答项:type 支持 date / time / select / number / text;affects 说明该项会影响哪些产出,便于页面提示用户", + "questions": [ + { + "key": "departure_origin", + "label": "出发地点", + "type": "text", + "required": true, + "default": "上海宝山富锦路地铁站", + "affects": [ + "去程与返程的总里程 / 时长 / 过路费", + "出发时间建议(路程变长必须提前出门)", + "地图上的起点位置" + ], + "hint": "默认从宝山富锦路地铁站出发,可改成任意出发点;改点后去返程的里程、时长、过路费都会重算" + }, + { + "key": "departure_date", + "label": "出行日期(周六)", + "type": "date", + "required": true, + "default_rule": "nearest_saturday", + "affects": [ + "日出与天亮时间", + "季节穿着与蚊虫提示", + "浴场是否旺季", + "周日下午返程高峰时段", + "公园夜间人流与照明" + ], + "hint": "本 Skill 固定周六出发、周日返程;日期只影响季节类信息" + }, + { + "key": "departure_time", + "label": "出发时刻", + "type": "time", + "required": false, + "default": "07:00", + "affects": [ + "抵达目的地时间", + "第一段徒步开始时刻", + "17:00 抵达浴场是否可达", + "22:00 抵达车宿点是否可达" + ], + "hint": "默认 07:00 出门;若目的地车程偏长,会自动建议提前出发并在报告中说明" + }, + { + "key": "vehicle_type", + "label": "车型", + "type": "select", + "required": true, + "default": "fuel", + "options": [ + { "value": "fuel", "label": "油车" }, + { "value": "ev", "label": "电车" } + ], + "affects": [ + "里程成本算法", + "沿途补能点(电车必须给充电站位置与数量)", + "非高速版必须提示充电桩密度与补能风险" + ] + }, + { + "key": "headcount", + "label": "出行人数", + "type": "number", + "required": true, + "default": 1, + "min": 1, + "max": 4, + "affects": [ + "餐食推荐份量", + "单人能否点半份", + "车内睡眠空间安排" + ] + } + ], + + "not_asked_comment": "按用户要求,口味与忌口不收集;如将来需要,直接往 questions 里追加即可,无需改后端", + "not_asked": ["taste_preference", "dietary_restriction"] +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/repair.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/repair.js new file mode 100644 index 0000000..135cf2f --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/repair.js @@ -0,0 +1,121 @@ +// ============================================================ +// travel_citytour/repair.js —— 生成结果的无损修复(游山玩水 lab 本地) +// +// 设计前提:AI 一定会变形。所以我们不指望它 100% 守规矩,而是在服务端做 +// 「可解释、无损、不改业务语义」的自动修复,把能修的修好,修不了的留给校验报出来。 +// +// 当前修复项(全部与业务字段无关,换 skill 同样适用): +// 1. 地图路线引用了不存在点位 id → 先做归一名匹配(marker_x / x / Marker-X 视为同一个), +// 仍找不到的丢弃;剩余点位不足 2 个的路线整条丢弃(画不出来) +// 2. 待核实清单漏收槽位 → 为该槽位补一条通用条目(文案只用卡片标题,不编造事实) +// ============================================================ + +const isNonHighFact = (node) => { + if (Array.isArray(node)) return node.some(isNonHighFact) + if (node && typeof node === 'object') { + if ('confidence' in node) return node.confidence !== 'high' + return Object.keys(node).some((k) => isNonHighFact(node[k])) + } + return false +} + +// marker_hike_1_start / Hike1Start / hike-1-start → hike1start +const normId = (s) => String(s || '') + .toLowerCase() + .replace(/^marker[_\-]?/, '') + .replace(/[^a-z0-9]/g, '') + +const repairMarkerRefs = (map, fixes) => { + const markers = Array.isArray(map.markers) ? map.markers : [] + const routes = Array.isArray(map.routes) ? map.routes : [] + if (!markers.length || !routes.length) return + + const exists = new Set(markers.map((m) => m && m.id)) + const byNorm = {} + markers.forEach((m) => { if (m && m.id) byNorm[normId(m.id)] = m.id }) + + map.routes = routes.filter((r) => { + if (!r) return false + const raw = Array.isArray(r.marker_ids) ? r.marker_ids : [] + const out = [] + raw.forEach((id) => { + if (exists.has(id)) { out.push(id); return } + const hit = byNorm[normId(id)] + if (hit) { out.push(hit); fixes.push(`路线 ${r.id}:点位 ${id} → ${hit}(id 归一匹配)`); return } + fixes.push(`路线 ${r.id}:丢弃未定义点位 ${id}`) + }) + const dedup = [] + out.forEach((id) => { if (dedup.indexOf(id) < 0) dedup.push(id) }) + r.marker_ids = dedup + if (dedup.length < 2) { fixes.push(`路线 ${r.id}:有效点位不足 2 个,已整条丢弃`); return false } + return true + }) +} + +const fillChecklistGaps = (payload, bundle, fixes) => { + const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : [] + const anchorIds = anchors.map((a) => a.id) + const cards = Array.isArray(payload.cards) ? payload.cards : [] + const checklist = Array.isArray(payload.checklist) ? payload.checklist : [] + payload.checklist = checklist + + const covered = new Set(checklist.map((c) => c && c.anchor_id).filter(Boolean)) + + // 清掉引用了未定义槽位的条目(AI 偶尔会造新槽位名) + payload.checklist = checklist.filter((c) => { + if (!c || !c.anchor_id) return true + if (anchorIds.indexOf(c.anchor_id) >= 0) return true + fixes.push(`清单:移除引用未定义槽位的条目(${c.anchor_id})`) + return false + }) + const covered2 = new Set(payload.checklist.map((c) => c && c.anchor_id).filter(Boolean)) + + cards.forEach((c) => { + if (!c || !c.anchor_id) return + if (covered2.has(c.anchor_id)) return + if (!isNonHighFact(c)) return + payload.checklist.push({ + anchor_id: c.anchor_id, + field: 'auto_added', + text: `「${String(c.title || c.anchor_id)}」含低置信信息,出行前需核实`, + confidence: 'low', + verify: '见该节点卡片中的「核实」说明;本条由服务端自动补齐(原清单漏收该槽位)' + }) + fixes.push(`清单:为槽位 ${c.anchor_id} 自动补一条待核实项`) + }) +} + +// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐 +// 骨架已声明该槽位是硬时间点,但 AI 可能把活动类型(sleep/eat/hike…)填进 kind; +// 这里把 kind 对齐为 hard,并把原活动名搬进 activity(该行还没有 activity 时), +// 不改任何事实、也不丢 AI 原本表达的语义。 +const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk'] +const alignHardKind = (payload, bundle, fixes) => { + const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : [] + const hardIds = anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id) + if (!hardIds.length) return + const days = (payload.timeline && Array.isArray(payload.timeline.days)) ? payload.timeline.days : [] + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (!r || hardIds.indexOf(r.anchor_id) < 0 || r.kind === 'hard') return + const old = String(r.kind || '未填') + const isActivity = ACTIVITIES.indexOf(r.kind) >= 0 + if (isActivity && !r.activity) r.activity = r.kind + r.kind = 'hard' + fixes.push(`时序:槽位 ${r.anchor_id} 的 kind「${old}」对齐为 hard(骨架已声明该槽位为硬时间点` + + (isActivity ? `,原值 ${old} 已搬入 activity` : '') + ')') + }) + }) +} + +// 入口:返回修复后的 payload 与修复说明清单 +const repairPayload = (payload, bundle) => { + const fixes = [] + if (!payload || typeof payload !== 'object') return { payload, fixes } + try { repairMarkerRefs(payload.map || {}, fixes) } catch (e) { fixes.push('地图修复异常:' + String(e.message || e)) } + try { fillChecklistGaps(payload, bundle || {}, fixes) } catch (e) { fixes.push('清单修复异常:' + String(e.message || e)) } + try { alignHardKind(payload, bundle || {}, fixes) } catch (e) { fixes.push('硬时间点对齐异常:' + String(e.message || e)) } + return { payload, fixes } +} + +module.exports = { repairPayload, normId } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/report_spec.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/report_spec.json new file mode 100644 index 0000000..d3fc6a0 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/report_spec.json @@ -0,0 +1,38 @@ +{ + "_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端一行不改。", + "version": "0.1.0", + + "block_types_comment": "通用渲染器支持的 8 种 block;skill 只能用这些类型组合页面,不得要求前端为某个 skill 写专用代码", + "block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"], + + "layout_comment": "页面骨架:单列纵向", + "layout": "single_column", + "show_index_comment": "是否在页首显示区块锚点导航", + "show_index": true, + + "sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项", + "sections": [ + { "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" }, + { "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] }, + { "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" }, + { "id": "sec_cards", "type": "cards", "title": "节点详情", "data": "$.cards", "group_by": "day" }, + { "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" }, + { "id": "sec_drive_out", "type": "table", "title": "去程 · 双版本对比", "data": "$.tables", "pick_id": "drive_out_compare" }, + { "id": "sec_drive_back", "type": "table", "title": "返程 · 双版本对比", "data": "$.tables", "pick_id": "drive_back_compare" }, + { "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" }, + { "id": "sec_parking", "type": "table", "title": "各节点停车一览", "data": "$.tables", "pick_id": "parking_summary" }, + { "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" }, + { "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" }, + { "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" } + ], + + "confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)", + "confidence_badge": { + "high": "已核实", + "medium": "待确认", + "low": "需核实" + }, + "image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景", + "image_disclaimer_default": "AI 示意图,非实景", + "map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹" +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/rules.md b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/rules.md new file mode 100644 index 0000000..2052623 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/rules.md @@ -0,0 +1,167 @@ +# 上海周边2日车宿徒步游 · 生成规则 + +> 本文件是 skill #1 的**行为规则**,也是我(Trae)以后迭代这个技能的唯一入口。 +> 改动本文件必须同步升 `skill.json` 的 `version`,并在改动说明里写清改了什么。 +> 本 skill 与其他 skill 完全独立,不引用任何外部规则文件。 + +--- + +## 一、角色与目标 + +你是一名**华东自驾路线规划师**,专门为单人自驾 + 车内过夜的周末行程做规划。 + +产出目标:给出一份**可直接照着执行的两天一夜行程**,并且**如实标注不确定性**。 +宁可标"这条信息置信度低、请电话确认",也不许编一个看起来很像真的店名和价格。 + +固定前提(不随目的地变): + +- 出发地:**默认上海宝山富锦路地铁站**(问答项 `departure_origin` 可改成任意出发点);出行方式:自驾 + 车内过夜(不订酒店) +- 天数:周六出发、周日返程,两天一夜 +- 两次徒步,**各 1.5 小时** +- 一个人出行,餐食按单人设计 + +可变的只有:**目的地** + **问答结果**(出行时间 / 车型 / 人数)。 + +--- + +## 二、问答参数怎么用 + +| 参数 | 必须影响的产出 | +|---|---| +| 出发地点(默认宝山富锦路地铁站) | 去返程的**总里程 / 时长 / 过路费必须按新出发点重算**,不得沿用旧址;路程变长必须提前出发时间建议;地图起点位置随之改变 | +| 出行时间(日期) | 日出/天亮时间、浴场是否旺季、季节穿着与蚊虫提示、周日下午返程高峰时段、公园夜间是否有人流 | +| 车型(油车/电车) | 里程成本算法;电车必须给沿途**充电站**位置与数量,非高速版必须提示充电桩密度;油车给油价口径 | +| 人数(默认 1) | 餐食按人数设计;1 人时明确给"单人份是否有、能否点半份" | + +时间口径统一按**北京时间**(Asia/Shanghai)展示,不得出现 UTC 裸时间。 + +--- + +## 三、时序硬点(不可漂移) + +| 时间 | 动作 | 强度 | +|---|---|---| +| 周六 06:30-07:30 | 上海出发 | 软 | +| 周六 09:30 前后 | 抵达目的地,**直接徒步** | 软 | +| 周六 09:30-11:00 | 第一段徒步 1.5h | 含硬约束(时长) | +| 周六 11:15 前后 | 到街区,先解决停车 | 软 | +| 周六 11:30-12:40 | 午饭 | 软 | +| 周六 12:40-15:40 | 街区打卡动线约 3h | 含硬约束(时长) | +| **周六 17:00 前后** | **抵达浴场** | **硬** | +| 周六 19:40-20:15 | 浴场旁超市补给(与浴场一起完成) | 软 | +| **周六 ≤22:00** | **抵达车宿点(提前到不算漂移)** | **硬** | +| **周日 05:00** | **起床散步** | **硬** | +| 周日 06:15-07:15 | 早饭 + 洗漱 | 软 | +| 周日 08:15-09:45 | 第二段徒步 1.5h | 含硬约束(时长) | +| 周日 10:00-11:20 | 打卡点 1-2 个 | 软 | +| 周日 11:30-12:40 | 午饭 | 软 | +| 周日 13:00 后 | 返程 | 软 | + +**硬点漂移即为不合格。** 若目的地车程过长导致 17:00 浴场 / 22:00 车宿不可达,必须调整出发时间建议并显式说明,而不是悄悄改硬点。 + +--- + +## 四、逐槽位规则 + +槽位的硬约束以 `skill.json` 的 `anchors` 为准,以下是执行时必须遵守的判定口径。 + +### drive_out / drive_back(双版本路线) + +- 两套方案**必须并列展示**:`非高速版` / `高速版`。 +- 每套必须含:总里程、预计时长、过路费、沿途服务区/加油站(电车为充电站)数量、拥堵风险。 +- 必须给一句**取舍建议**:时间优先选哪套、省钱选哪套、风景选哪套。 +- 非高速版的里程与时长**不得低于**高速版(如出现,说明数据有问题,需复核)。 +- 出发点默认「上海宝山富锦路地铁站」,由问答项 `departure_origin` 覆盖;**换出发点后这一组数字必须重算**。 +- 路线几何**只作为示意**:地图上只连"起点 → 主要拐点 → 目的地",不描真实道路。 + +### hike_1 / hike_2(两次徒步) + +- 时长口径:1.5 小时 = 按 4-5 km/h 常规步速折算,**必须写出折算里程**(约 6-7km 平路,山地约 4-5km)。 +- 必须写:起点、终点、环线或折返、累计爬升、路面类型、难度、补水点、卫生间。 +- 停车:免费优先;无免费则按 `degradation_rules.no_free_parking` 处理。 +- 两段徒步**必须在地形/景观上有区别**(一段城郊水岸 + 一段林地或山海)。 +- 无山的目的地按 `terrain_flat` 降级,**必须写"平路徒步、爬升≈0"**,并补一条"想出汗"的做法。 + +### district_arrive / district_parking / district_walk(街区) + +- 街区必须先定性:历史街区 / 文化街区 / 闹市街区,三选一,给理由。 +- 停车必须先查免费,给出**免费判定依据**(景区停车场免费 / 路边限时泊位 / 单位对外开放);不得默认写成免费。 +- 动线必须给**逐段时刻表 + ≥4 个具体打卡点 + 点线顺序 + 全程步行距离 + 休憩点**。 +- 动线书写顺序即为地图连线顺序。 + +### lunch_d1 / lunch_d2(两顿午饭) + +- 各给 3 家(第二天可给 2-3 家),并给"本地人常去"的**判断依据**: + - 加分:不在游客主街、有本地招牌菜、评价里本地口音/本地标签占比高、老店开得久 + - 减分:门口拉客、菜单中英双显、团餐大巴停靠、纯网红装修 +- 人均目标 100 元左右;超出要解释。 +- 必须给推荐菜、能否停车、单人是否方便。 +- 两家店(D1/D2)菜系或店型要有区分,不能都是同类海鲜馆。 + +### bathhouse(浴场) + +- 硬门槛:**规模较大 + 档次中上**,**排除社区小浴室**。 +- 必须给可核实依据:营业面积区间、开业年限、本地评价量级、是否有泡池/桑拿/休息区。 +- 卫生不能空口说"干净",要给依据(本地评价中的卫生关键词、翻新时间),并**必须标注置信度 + 建议出发前电话确认**。 +- 无大型浴场的按 `no_large_bathhouse` 降级为温泉/大型洗浴中心,**显式标注降级**。 +- 必须给:免费停车可行性、人均、建议时长、自带物品清单。 + +### grocery(浴场旁超市 · 与浴场一起完成) + +- **不跑大卖场**:补给必须在**浴场同一商圈**内完成(步行 5 分钟或车程 3 分钟内),**禁止为了买东西额外跑一趟县城或卖场**。 +- 必须核对"泡完汤约 20 点"时超市是否还在营业;同商圈确实没有超市时,才允许就近找并标注"未能在浴场商圈解决"。 +- 与浴场**共用同一个停车点**,不挪车。 +- 购物清单:常温奶、面包 / 包子、水果、瓶装水。 +- 车内过夜无冷藏,牛奶优先**常温奶或小包装**,并给保质期提示。 + +> 本条是用户口径对第 8 条原意的覆盖(原为「找大卖场,大卖场最好」),已写入 `skill.json` 的 `override_note`。 + +### car_sleep(车宿) + +- 优先**市中心公园 / 空气好 / 有卫生间**。 +- 必须给:卫生间夜间是否可用、照明情况、是否有早锻炼人群、监控情况。 +- 必须给**禁停风险等级(低/中/高)+ 判断依据 + 1-2 个备选点**。 +- 必须给车宿安全提示:靠路灯停、避开主通道、留通风缝、不长时间怠速开空调、贵重物品不外露、垃圾带走。 + +### morning_walk / breakfast_wash(晨间) + +- 晨走路线从车宿点出发,**环线优先、距离控制在 2-4km**。 +- 必须给**当日日出时间**(按出行日期),说明 5 点是否天已亮。 +- 早饭二选一或都给:店内吃(必须给开门时间,5-6 点是否营业)/ 车内吃(昨晚补给)。 +- 洗漱必须**可落地**:公厕 / 加油站卫生间 / 健身房单次卡 / 公园水房,四选一并给位置与开放时间,不许含糊带过。 + +### photo_spots(打卡点) + +- 1-2 个,必须写清典故或走红原因(故居类给生平要点,网红类说明为什么火、最佳机位与时段)。 +- 必须给开放时间、门票、停车。 +- 必须与前一天街区打卡点去重。 + +--- + +## 五、置信度使用规则 + +`skill.json` 的 `unverified_fields`(免费停车、本地人常去、浴场档次、卫生、车宿合法性)**默认不许标 high**,且必须带 `verify` 字段(怎么写核实方式)。 + +- `high`:官方公告 / 连锁品牌 / 明确门票营业信息 +- `medium`:常见认知或口碑,大体可靠但需现场确认 +- `low`:推测或信息不足,仅方向性参考 + +标注方式:在字段位置写成 `{"text": "...", "confidence": "low", "verify": "出发前打 XX 电话确认"}`。 +**不确定不是问题,假装确定才是问题。** + +--- + +## 六、输出纪律 + +1. **只输出 JSON**,结构严格符合 `output_schema.json`,不夹带解释性散文。 +2. **一次成卷**,不拆成多次子调用拼接。 +3. 每个槽位都要落进 `blocks`,不得省略;不可得的写降级说明,不许留空。 +4. 禁止行为: + - 编造具体店名与价格(不确定就给业态+位置描述,并标 low) + - 把收费停车写成免费 + - 把社区浴室写成大型浴场 + - 省略 `must_declare` 要求的标注 + - 硬点(17:00 浴场 / ≤22:00 车宿 / 05:00 晨起)漂移而不声明 + - 为了买补给单独安排大卖场行程(与第 8 条的用户口径冲突) +5. 金额统一写人民币,口径写清(人均 / 单次 / 总价)。 +6. 报告末尾必须自动带出"**待核实清单**":**凡是含非 high 字段的槽位,都必须至少有一条清单项**;同一槽位内的多个低置信项可以归并成一条,但**不许整槽位漏收**。 \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/skill.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/skill.json new file mode 100644 index 0000000..1bf00b8 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/skill.json @@ -0,0 +1,328 @@ +{ + "_comment": "技能包:上海周边2日车宿徒步游(skill #1)。一个 skill = 一整套行程打法,骨架固定、目的地可变;同一 skill 可套多个目的地(如东/四明山…),同一目的地也可被别的 skill 覆盖。本文件是本 skill 的机读总纲:骨架槽位(15 条要求的机读版)+ 地形降级规则 + 置信度定义。本 skill 与其他 skill 完全独立,不引用任何外部模块。", + "id_comment": "skill 唯一标识(snake_case),与文件夹名一致", + "id": "sh_2d_car_hike", + "name_comment": "技能显示名(《游山玩水》卡片内的 skill 选择项)", + "name": "上海周边2日车宿徒步游", + "version_comment": "技能版本号;rules/骨架/输出契约任一改动都必须升版本,库里的定稿快照会记录生成时的版本", + "version": "0.3.0", + "schema_version_comment": "输出契约版本,与 output_schema.json 的 version 保持一致", + "schema_version": "0.2.0", + "status_comment": "技能状态:draft=骨架打磨中;ready=可对外生成", + "status": "draft", + "days_comment": "行程天数:周六出发、周日返程,两天一夜", + "days": 2, + "nights": 1, + "base_city_comment": "固定出发城市(去程与返程均以此为起终点)", + "base_city": "上海", + "base_origin_comment": "默认出发点(具体到点位);由问答项 departure_origin 输入,用户可改成任意出发点,改点后去返程里程与时长需重算", + "base_origin": "上海宝山富锦路地铁站", + "travel_mode_comment": "出行方式:自驾 + 车内过夜(不订酒店)", + "travel_mode": "self_drive_car_sleep", + "region_comment": "适用区域:上海周边 300km 内、可当日自驾直达的目的地;超出范围的应提示用户换 skill 或换目的地", + "region": { + "label": "上海周边", + "radius_km": 300, + "terrain_preference_comment": "偏好地形(如东无山,由 degradation_rules.terrain_flat 处理)", + "terrain_preference": ["山地", "丘陵", "水系", "海岸"] + }, + "refs_comment": "本技能包的其他文件(相对本文件夹路径),运行时按引用读取", + "refs": { + "rules": "rules.md", + "qa": "qa.json", + "output_schema": "output_schema.json", + "report_spec": "report_spec.json", + "destinations": "destinations.json" + }, + + "anchors_comment": "骨架槽位 = 用户 15 条要求的机读版。每个槽位含:id / 天 / 顺序 / 对应原始要求编号 / 时间锚点 / 硬约束 / 软偏好 / 地图角色。换目的地时槽位不变,只换值", + "anchors": [ + { + "id": "drive_out", + "day": 1, + "order": 10, + "title": "去程·双版本自驾", + "from_requirements": [2], + "time_anchor": { "type": "soft", "label": "周六早上", "suggest": "06:30-07:30 出发" }, + "hard_constraints": [ + "必须同时给出高速版与非高速版两套方案", + "每套方案必须含:总里程(km)、预计时长、过路费、沿途服务区/加油站数量、拥堵风险提示", + "两套方案必须给出取舍建议(时间优先 or 省钱优先)" + ], + "soft_preferences": ["非高速版优先沿江/沿海风景路线"], + "map_role": "路线起点段(只标起点与拐点,不描真实道路)" + }, + { + "id": "hike_1", + "day": 1, + "order": 20, + "title": "第一段徒步(1.5h)", + "from_requirements": [1, 2], + "time_anchor": { "type": "soft", "label": "到达目的地后立即进行", "suggest": "09:30-11:00" }, + "hard_constraints": [ + "时长必须为 1.5 小时(按常规步速折算里程,并写出折算口径)", + "必须给出起点/终点/环线或折返方式", + "必须给出停车方案,免费停车优先", + "必须给出爬升、路面类型、难度、补水点" + ], + "soft_preferences": ["有山有水优先;平原地区走海堤/河道/林道"], + "map_role": "徒步轨迹(示意图,非真实轨迹)" + }, + { + "id": "district_arrive", + "day": 1, + "order": 30, + "title": "抵达历史/文化/闹市街区", + "from_requirements": [3], + "time_anchor": { "type": "soft", "suggest": "11:15 前后" }, + "hard_constraints": [ + "必须明确是历史街区 / 文化街区 / 闹市街区中的哪一类", + "必须给出街区名称、范围、可逛时长评估", + "若目的地同时存在多类街区,必须给出选择理由并列出备选" + ], + "soft_preferences": ["历史街区优先于商业街区"], + "map_role": "街区中心点" + }, + { + "id": "district_parking", + "day": 1, + "order": 40, + "title": "街区停车(免费优先)", + "from_requirements": [4], + "time_anchor": { "type": "none" }, + "hard_constraints": [ + "必须优先查免费停车场,并给出免费判定依据(路边泊位/景区停车场/单位开放)", + "免费不可得时必须给出收费方案与预估费用,不得留空", + "必须给出停车点步行到街区的距离", + "必须标注置信度与‘建议出发前电话确认’" + ], + "soft_preferences": ["离街区入口步行 5 分钟内"], + "map_role": "停车点(与街区中心点区分显示)" + }, + { + "id": "lunch_d1", + "day": 1, + "order": 50, + "title": "第一天午饭(本地人常去·人均≈100)", + "from_requirements": [5], + "time_anchor": { "type": "soft", "suggest": "11:30-12:40" }, + "hard_constraints": [ + "必须推荐 3 家,且按‘走路可达/需挪车’标注", + "人均目标 100 元左右,超出必须说明理由", + "必须给出判断‘本地人常去’的依据(非游客街主街、本地招牌菜、大众点评本地评价比例等)", + "必须给出推荐菜与停车可行性", + "按单人出行设计(不按多人桌餐推荐)" + ], + "soft_preferences": ["本地时令菜优先(如东:海鲜/文蛤/河鲜)"], + "map_role": "3 个候选餐馆点" + }, + { + "id": "district_walk", + "day": 1, + "order": 60, + "title": "街区打卡动线(约 3 小时)", + "from_requirements": [6], + "time_anchor": { "type": "soft", "suggest": "12:40-15:40" }, + "hard_constraints": [ + "总时长约 3 小时,必须给出逐段时刻表", + "必须给出具体打卡点序列(≥4 个),每个点含看点与停留时长", + "必须给出点线顺序(用于地图连线)与全程步行距离", + "必须给出中途休憩点(可坐可喝水)" + ], + "soft_preferences": ["具有历史/人文叙事线索的动线优先于纯拍照点堆砌"], + "map_role": "街区动线(按 order 连线)" + }, + { + "id": "bathhouse", + "day": 1, + "order": 70, + "title": "下午浴场(档次·规模·免费停车)", + "from_requirements": [7], + "time_anchor": { "type": "hard", "label": "下午 5 点左右抵达", "suggest": "16:45-17:15 到达" }, + "hard_constraints": [ + "必须排除社区小浴室,要求规模较大、档次中上", + "必须有免费停车(不可得时给出收费方案与费用)", + "必须给出卫生与规模的可核实依据(营业面积区间、开业年限、本地评价量级)", + "必须给出泡汤/淋浴时长建议与自带物品清单", + "在无合规大型浴场的目的地,按 degradation_rules.no_bathhouse 降级为温泉/大型洗浴中心,并显式标注降级" + ], + "soft_preferences": ["本地人复购率高的优先"], + "map_role": "浴场点" + }, + { + "id": "grocery", + "day": 1, + "order": 80, + "title": "浴场旁超市补给(与浴场一起完成)", + "from_requirements": [8], + "override_note": "用户口径覆盖第 8 条原意(原为『找大卖场』):不单独跑大卖场,改在浴场旁的超市顺手买完", + "time_anchor": { "type": "soft", "label": "泡汤前后顺路完成", "suggest": "19:40-20:15" }, + "hard_constraints": [ + "不单独安排大卖场行程:必须在浴场同一商圈内(步行 5 分钟或车程 3 分钟内)的超市完成", + "禁止为了买补给额外跑一趟县城或大卖场", + "必须给出营业时间,并核对泡完汤后的时间点是否还在营业", + "购物清单必须含次日早餐所需(牛奶、面包/包子、水果、水)", + "牛奶类必须给出保质期提示(车内过夜无冷藏,优先常温奶/小包装)" + ], + "soft_preferences": ["与浴场共用同一个停车点,不挪车"], + "map_role": "浴场旁超市点(与浴场点位相邻)" + }, + { + "id": "car_sleep", + "day": 1, + "order": 90, + "title": "车宿过夜停车点", + "from_requirements": [9], + "time_anchor": { "type": "hard", "label": "不晚于 22:00 抵达(提前到不算漂移)", "suggest": "21:00-22:00 到达" }, + "hard_constraints": [ + "必须优先市中心公园、空气好、有卫生间", + "必须给出卫生间开放时间(夜间是否可用)", + "必须标注‘禁停/劝离风险’等级,并给出备选点(高速服务区/加油站/营地)", + "必须给出车宿安全建议(靠路灯、远离通道、留缝通风、不怠速开空调)", + "必须给出次日 5 点起床的现场条件说明(照明、是否有早锻炼人群)" + ], + "soft_preferences": ["有早锻炼人群、有监控的市政公园优先"], + "map_role": "车宿点(夜间终点)" + }, + { + "id": "morning_walk", + "day": 2, + "order": 100, + "title": "晨间散步(05:00)", + "from_requirements": [10], + "time_anchor": { "type": "hard", "label": "05:00 起床散步", "suggest": "05:00-06:15" }, + "hard_constraints": [ + "必须给出从车宿点出发的散步路线(环线优先,不走远)", + "必须给出现场条件(路灯、公厕位置)", + "日出时间必须按出行日期给出(季节差异必须体现)" + ], + "soft_preferences": ["公园内环湖/环河步道优先"], + "map_role": "晨走路线" + }, + { + "id": "breakfast_wash", + "day": 2, + "order": 110, + "title": "早饭 + 洗漱", + "from_requirements": [11], + "time_anchor": { "type": "soft", "suggest": "06:15-07:15" }, + "hard_constraints": [ + "必须给出早饭方案:店内吃(本地早点铺)或车内吃(昨晚补给),二选一或都给", + "店内吃必须给出开门时间(5-6 点是否已营业)", + "洗漱必须给出可落地方式(公厕/加油站卫生间/健身房单次卡/公园水房),不得含糊" + ], + "soft_preferences": ["本地早点(如东:面条/汤包/烧饼)优先"], + "map_role": "早点铺/洗漱点" + }, + { + "id": "hike_2", + "day": 2, + "order": 120, + "title": "第二段徒步(1.5h·山水优先)", + "from_requirements": [1, 12], + "time_anchor": { "type": "soft", "suggest": "08:15-09:45" }, + "hard_constraints": [ + "时长必须为 1.5 小时,且有实际运动量(写清爬升与折算口径)", + "必须有山有水(不可得时按 degradation_rules.terrain_flat 降级并明确标注)", + "必须给出免费停车优先的停车方案", + "必须与第一段徒步在地形/景观上有所区别,避免重复" + ], + "soft_preferences": ["山水结合、有制高点视野"], + "map_role": "徒步轨迹(示意图)" + }, + { + "id": "photo_spots", + "day": 2, + "order": 130, + "title": "打卡点 1-2 个", + "from_requirements": [13], + "time_anchor": { "type": "soft", "suggest": "10:00-11:20" }, + "hard_constraints": [ + "数量 1-2 个,典故/成因必须写清(故居类要有生平要点,网红类要说明为什么火)", + "必须给出开放时间、门票、停车", + "必须与前一天街区打卡点去重,不得重复" + ], + "soft_preferences": ["故居/人文类优先于纯拍照点"], + "map_role": "打卡点" + }, + { + "id": "lunch_d2", + "day": 2, + "order": 140, + "title": "第二天午饭(本地人常去·最好能停车)", + "from_requirements": [14], + "time_anchor": { "type": "soft", "suggest": "11:30-12:40" }, + "hard_constraints": [ + "必须是本地人常去的店,并给出判断依据", + "必须给出停车方案(能停优先,不能停要给就近停车场)", + "必须与第一天午饭的菜系/店型有所区别", + "按单人出行设计" + ], + "soft_preferences": ["本地家常菜/老字号优先"], + "map_role": "午餐点" + }, + { + "id": "drive_back", + "day": 2, + "order": 150, + "title": "返程·双版本自驾", + "from_requirements": [15], + "time_anchor": { "type": "soft", "suggest": "13:00 后出发" }, + "hard_constraints": [ + "必须同时给出高速版与非高速版两套方案", + "字段与去程一致:里程、时长、过路费、服务区、拥堵风险", + "必须提示返程高峰时段(周日下午进城方向)" + ], + "soft_preferences": [], + "map_role": "路线终点段" + } + ], + + "degradation_rules_comment": "地形/业态不支持时的降级规则:降级不是省略,而是显式替换 + 明确标注,绝不允许静默降低要求", + "degradation_rules": [ + { + "id": "terrain_flat", + "trigger": "目的地无山(如如东、启东等平原沿海地区)", + "action": "徒步改走海堤 / 河道绿廊 / 林场林道,保留 1.5 小时时长", + "must_declare": "必须写明‘该段为平路徒步,累计爬升约 0,运动量按时长与步数计’,不得冒充山地徒步效果", + "extra": "必须补一条‘想出汗’的建议(配速、折返爬坡、快走替代)" + }, + { + "id": "no_large_bathhouse", + "trigger": "目的地无合规大型浴场", + "action": "降级为温泉度假区 / 大型洗浴中心(营业面积大、本地评价量级高)", + "must_declare": "必须标注‘本地无传统大浴场,本条为温泉/洗浴中心替代’", + "extra": "必须给出单次人均与时长建议" + }, + { + "id": "no_free_parking", + "trigger": "目标节点确实无免费停车", + "action": "给出最近的收费停车场 + 预估费用 + 步行距离", + "must_declare": "标注‘本节点无免费停车’,不得把收费点写成免费点", + "extra": "同时给出‘若愿意多走 5-10 分钟’的免费替代点(如有)" + }, + { + "id": "car_sleep_risk", + "trigger": "车宿点存在禁停/劝离风险", + "action": "保留该点但标注风险等级,并给出 1-2 个备选(服务区/加油站/营地)", + "must_declare": "风险等级必须写明(低/中/高),并说明判断依据", + "extra": "必须提示‘以现场标识与执法人员口径为准’" + } + ], + + "confidence_levels_comment": "置信度三档定义。主观项(免费停车、本地人常去、浴场档次、卫生)必须给置信度,不得默认 high", + "confidence_levels": { + "high": "有明确可核实来源(官方公告、连锁品牌、明确门票/营业信息)", + "medium": "常见认知或口碑信息,大体可靠但需现场确认", + "low": "推测或信息不足,仅作方向性参考,必须附核实方式" + }, + "unverified_fields_comment": "这些字段默认不允许标 high,必须附 verify(核实方式)", + "unverified_fields": ["free_parking", "locals_favorite", "bathhouse_grade", "hygiene", "car_sleep_legality"], + + "output_discipline_comment": "生成纪律:只输出结构化 JSON(符合 output_schema.json),禁止夹带散文;不确定就降置信度并写进 verify,不许编造", + "output_discipline": { + "format": "json_only", + "one_call": "一次成卷,不拆成多次子调用拼接", + "forbid": ["编造店名与价格", "把收费停车写成免费", "把社区浴室写成大型浴场", "省略 must_declare 标注"] + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/validate.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/validate.js new file mode 100644 index 0000000..02f792a --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/validate.js @@ -0,0 +1,154 @@ +// ============================================================ +// travel_citytour/validate.js —— 生成结果的结构校验(游山玩水 lab 本地) +// +// 为什么要服务端校验:AI 一定会变形。与其相信它,不如生成后按「skill 骨架 + 契约」 +// 硬校验一遍,把不合格的地方作为 warnings 回传,前端在交互弹窗里显示出来。 +// +// 校验口径与 output_schema.json 保持一致;不写死任何业务字段名, +// 全部从 skill bundle 里推导(换 skill 不用改本文件)。 +// ============================================================ +const ROOT_KEYS = ['meta', 'summary', 'timeline', 'cards', 'map', 'tables', 'cost', 'checklist', 'notes'] + +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 validatePayload = (payload, bundle) => { + const errors = [] + const warnings = [] + const p = payload || {} + const skill = bundle.skill || {} + const anchors = Array.isArray(skill.anchors) ? skill.anchors : [] + const anchorIds = anchors.map((a) => a.id) + + // 1) 根字段 + ROOT_KEYS.forEach((k) => { + if (!(k in p)) errors.push('缺少根字段:' + k) + }) + + // 2) 卡片必须覆盖全部骨架槽位 + const cards = Array.isArray(p.cards) ? p.cards : [] + const cardAnchors = cards.map((c) => c && c.anchor_id) + anchorIds.forEach((a) => { + if (cardAnchors.indexOf(a) < 0) errors.push('卡片缺槽位:' + a) + }) + cardAnchors.forEach((a) => { + if (anchorIds.indexOf(a) < 0) warnings.push('卡片引用了未定义槽位:' + a) + }) + + // 3) 时序只能引用合法槽位,硬时间点必须以 kind=hard 出现 + const days = (p.timeline && Array.isArray(p.timeline.days)) ? p.timeline.days : [] + if (!days.length) errors.push('timeline.days 为空') + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (anchorIds.indexOf(r.anchor_id) < 0) warnings.push('时序引用未知槽位:' + r.anchor_id) + }) + }) + const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk'] + anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').forEach((a) => { + const rows = [] + days.forEach((d) => ((d && d.rows) || []).forEach((r) => { if (r && r.anchor_id === a.id) rows.push(r) })) + const label = String((a.time_anchor && a.time_anchor.label) || '') + if (!rows.length) { + warnings.push('硬时间点槽位未出现在时序:' + a.id + '(期望:' + label + ')') + return + } + if (!rows.some((r) => r.kind === 'hard')) { + const kinds = rows.map((r) => String(r.kind || '未填')).join(' / ') + const misled = rows.some((r) => ACTIVITIES.indexOf(r.kind) >= 0) + warnings.push('硬时间点槽位在时序中 kind 非 hard(实际:' + kinds + '):' + a.id + + (misled ? ' —— 疑似把活动类型填进了 kind(kind 只能是 hard/soft,活动类型应填 activity)' : '')) + } + }) + + // 3b) 时序行的 kind / activity 取值合法性(kind 与活动类型是正交字段,不允许混用) + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (!r) return + if (['hard', 'soft'].indexOf(r.kind) < 0) { + warnings.push('时序行 kind 取值非法(只能 hard/soft):' + String(r.kind) + ' @ ' + String(r.anchor_id)) + } + if (r.activity && ACTIVITIES.indexOf(r.activity) < 0) { + warnings.push('时序行 activity 取值非法:' + String(r.activity) + ' @ ' + String(r.anchor_id)) + } + }) + }) + + // 4) 契约要求的表格 + const tables = Array.isArray(p.tables) ? p.tables : [] + const requiredTableIds = (bundle.schema && bundle.schema.required_table_ids) || [] + requiredTableIds.forEach((id) => { + if (!tables.some((t) => t && t.id === id)) errors.push('缺表格:' + id) + }) + // 表格列定义与行数据要对得上 + tables.forEach((t) => { + if (!t || !Array.isArray(t.columns) || !t.columns.length) { warnings.push('表格缺 columns:' + (t && t.id)); return } + const keys = t.columns.map((c) => c.key) + ;((t.rows) || []).forEach((r, i) => { + keys.forEach((k) => { + if (r && !(k in r)) warnings.push(`表格 ${t.id} 第 ${i + 1} 行缺列 ${k}`) + }) + }) + }) + + // 5) 地图:路线引用的点位必须存在 + const map = p.map || {} + const markers = Array.isArray(map.markers) ? map.markers : [] + if (!markers.length) errors.push('map.markers 为空') + const markerIds = markers.map((m) => m && m.id) + ;(Array.isArray(map.routes) ? map.routes : []).forEach((r) => { + const ids = (r && r.marker_ids) || [] + ids.forEach((id) => { + if (markerIds.indexOf(id) < 0) warnings.push('路线 ' + (r && r.id) + ' 引用未知点位:' + id) + }) + if (ids.length < 2) warnings.push('路线点位少于 2 个:' + (r && r.id)) + if (!r || !r.note) warnings.push('路线缺示意图免责说明:' + (r && r.id)) + }) + + // 6) 置信度:非 high 必须给核实方式 + 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) errors.push('置信度取值非法:' + c + ' @ ' + trail) + if (c !== 'high') { + confNonHigh++ + if (!node.verify) warnings.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24)) + } + return + } + Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k)) + } + })(p, '') + + // 7) 待核实清单必须覆盖每个含非 high 字段的槽位 + const checklist = Array.isArray(p.checklist) ? p.checklist : [] + const checklistAnchors = checklist.map((c) => c && c.anchor_id).filter(Boolean) + cards.forEach((c) => { + if (c && hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) { + warnings.push('槽位含非 high 字段但清单未收录:' + c.anchor_id) + } + }) + + // 8) AI 配图必须带免责说明 + ;(function walkImg(node) { + if (Array.isArray(node)) return node.forEach(walkImg) + if (node && typeof node === 'object') { + if (node.kind === 'ai_illustration' && !node.disclaimer) warnings.push('AI 示意图缺免责说明') + Object.keys(node).forEach((k) => walkImg(node[k])) + } + })(p, '') + + return { errors, warnings, stats: { cards: cards.length, anchors: anchorIds.length, markers: markers.length, tables: tables.length, confTotal, confNonHigh, checklist: checklist.length } } +} + +module.exports = { validatePayload, ROOT_KEYS } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/ai.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/ai.js new file mode 100644 index 0000000..9fc8f57 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/ai.js @@ -0,0 +1,186 @@ +// ============================================================ +// sh_2d_rail_hike/ai.js —— 本技能的 AI 生成链路(技能私有) +// +// 职责:把「本技能包 + 问答答案 + 目的地覆盖」组装成一次成卷的 prompt, +// 调 lab 层 ctx.callLLM 拿回结构化 JSON,做健壮解析,失败重试一次。 +// +// 边界(红线): +// - 不接触密钥、不发 HTTP、不管超时:全部由 lab 层 ctx.callLLM 负责。 +// - prompt 内容 100% 归本技能所有。 +// +// 【隔离用意 · 请后续开发者先读这段】 +// 本文件是【本技能私有的 prompt 组装器】,不是全 lab 的公共 ai 模块。 +// 它与 sh_2d_car_hike/ai.js 长得很像(都是 slimSkill + buildMessages + generate), +// 但**刻意不共用**,因为两者的硬性要求完全不同: +// · 自驾 skill 要求「双版本路线对比、停车方案、车内过夜安全」 +// · 本 skill 要求「禁止给车次号、21:00 前抵沪、市内单车/网约车、每处可导航」 +// 如果抽成一个「通用 travel ai 模块」,就必须在里面塞满 if (skill === 'xxx') 分支, +// 那正是本 lab 明确禁止的方向。要新增 skill,请复制本文件后在副本里改。 +// ============================================================ + +// 只把「会进 prompt」的骨架字段投出去,避免把一堆 _comment 也塞进模型 +const slimSkill = (skill) => ({ + id: skill.id, + name: skill.name, + version: skill.version, + days: skill.days, + nights: skill.nights, + base_city: skill.base_city, + base_origin: skill.base_origin, + travel_mode: skill.travel_mode, + region: skill.region, + anchors: skill.anchors, + degradation_rules: skill.degradation_rules, + confidence_levels: skill.confidence_levels, + unverified_fields: skill.unverified_fields, + output_discipline: skill.output_discipline +}) + +// 按出发时段给出「行程结构」提示,避免模型把两种模式混着排 +const windowHint = (answers) => { + const w = String((answers && answers.departure_window) || 'sat_morning') + if (w === 'fri_night') { + return [ + '【本次为模式 B:周五晚出发】', + '- 周五晚乘车出发,**车上过一夜**,周六早上抵达目的地。', + '- 因此目的地只需住 1 晚,但总晚数为 2(一在车上、一在目的地)。报告里必须写清楚,避免用户误订两晚酒店。', + '- 必须说明车上的过夜方式(卧铺/硬座、是否需自带颈枕眼罩),并提示「周五晚班次需提前订票,热门线路卧铺紧张」。', + '- 第一天从「周六早上抵达」开始排,不要出现上午还在上海的内容。' + ].join('\n') + } + return [ + '【本次为模式 A:周六出发(' + w + ')】', + '- 周六乘车出发 → 目的地住 1 晚 → 周日返程。**目的地住宿 1 晚**。', + '- 若目的地单程车程超过 3 小时,必须显式提示「当日可用时间仅约 X 小时,行程已按此压缩」,', + ' 并建议改选更早班次或周五晚出发,说明两种模式的差别。' + ].join('\n') +} + +const buildMessages = (bundle, answers, dest) => { + const system = [ + '你是一个严格按「技能包」执行行程生成的引擎。下面给你一个技能包(骨架、规则、输出契约)与本次问答答案,请产出唯一一份行程 JSON。', + '本技能的场景是:上海出发、高铁往返、周末两天一晚、市内靠共享单车与网约车。用户最看重「每个地点都能直接导航」。', + '', + '【硬性要求】', + '1. 只输出 JSON 本身,不要任何解释、前后缀、Markdown 代码块标记。', + '2. 结构必须严格符合下方「输出契约」,根字段一个都不能少。', + '3. skill.json 的 anchors 是骨架:每一张 card 必须对应一个 anchor_id,一个都不能少、不能多。', + '4. 契约里的 required_table_ids 是必须存在的表格 id。', + '5. **绝对禁止编造高铁车次号**(如 G1234):你无法查询 12306,写出的车次号一定是错的,会直接误导用户赶错车。只允许给「出发站 / 到达站 / 建议时段 / 历时区间 / 二等座参考票价区间」,并写明「车次与时刻以 12306 实时查询为准」。', + '6. **返程是最高优先级硬约束**:周日必须下午出发,且最晚 21:00 抵达上海(用户周一要上班,晚于地铁末班无法回家)。必须给出「最后一个景点→车站」的交通方式与耗时,并倒推留出至少 40 分钟安检缓冲;必须提示抵沪后回家的方式与末班地铁。', + '7. **每个地点都必须可导航**:map.markers[] 每条必须有 address 字段,值必须是「城市 + 区 + 具体地点」形式(如「苏州市姑苏区平江路历史街区」),禁止只写泛指名称。卡片建议带一条 label 为「导航」的字段。', + '8. 主观/易变信息(车次时段、票价、共享单车禁停区、网约车上车点、营业时间、洗浴档次与能否过夜、是否需预约、本地人常去、卫生)一律不允许标 high,且必须带 verify 说明核实方式。', + '9. 不确定就降置信度并写进 checklist,绝对不许编造店名、酒店名与价格。票价必须写区间,不得写单一精确数字。', + '10. 触发降级规则时必须显式写出降级说明,不许静默降低要求。', + '11. 市内交通必须落到实处:city_transport 表格要分段给出「步行 / 共享单车 / 网约车」的选择与理由、预估费用,并提示景区禁停区与网约车具体上车点。若用户带拉杆箱(luggage=suitcase),禁止安排骑车,且必须先寄存行李。', + '12. timeline 行的 kind 只表示「时间点性质」,只能取 "hard" 或 "soft":凡骨架里 time_anchor.type 为 "hard" 的槽位(本技能为 lodging_night 与 train_back),其对应时间行必须写 kind="hard";其余行写 "soft"。另外,train_back 的时间行请在标题或备注里写明返程约束(例如「乘高铁返沪,最晚 21:00 抵沪」),不要只写「前往车站」——返程死线是本技能最硬的约束,要在时序里一眼可见。', + '13. timeline 行的 activity 表示「活动类型」,可选,只能取 walk / stroll / spot / bike / taxi / metro / train / eat / hike / shop / checkin / rest 之一。其中 stroll 专指「闲逛 / 夜逛」(与 walk 的点对点步行通勤区分),夜逛行请用 stroll;spot 专指「游览景点 / 展览」(如博物馆、园林、故居),看景点的行用 spot。kind 与 activity 是正交字段:「21:00 入住」应写 kind="hard" 且 activity="checkin",绝不能因为它是入住就把 kind 写成 "checkin"。', + '14. checklist:必须覆盖每一个在卡片里出现过非 high 字段的 anchor_id,一个槽位至少一条;且必须至少包含「往返高铁车次时刻票价(12306 核对)」「住宿能否订/能否过夜/是否需预约」「景区是否需预约」「共享单车禁停区与还车点」四项,kind 填 "must"。', + '15. map.markers 要覆盖当天所有落脚点(出发站、到达站、住宿点、徒步起终点、景点、餐馆、夜逛区域、单车还车点、网约车上车点),点位过少地图会显得很空。', + '16. map.routes[].marker_ids 里的每个 id 必须能在 map.markers 里找到,禁止引用未定义的点位 id。每条 route 的 note 必须说明「点位顺序示意,非真实道路/轨迹」。', + '17. card.day 只能填 1 或 2;第一天所有节点填 1,第二天填 2。', + '18. 两段徒步(hike_1 / hike_2)时长必须落在 1-2 小时,且按问答里的 hike_intensity 取值(easy≈1h / medium≈1.5h / strong≈2h),并且两段在地形或景观上必须有区别,不得雷同。', + '19. 目的地无山时按 terrain_flat 降级为河岸/绿道/环湖步道,并明确写「平路徒步,累计爬升约 0」,不得冒充山地徒步效果。', + '20.【餐馆双筛选,午饭晚饭都要】lunch_d1 与 dinner_d1 必须同时满足两条:① 本地人爱去(给出依据:非游客主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好(给出该店在主流点评平台的评分区间与评价条数量级)。两条缺一不可:评分高但明显是游客店的要排除;本地人常去但评分低于 4.0 的要说明原因并给替代。评分与评价数一律标非 high 并写 verify,绝对不许编造评分数字。', + '21.【周六晚必须安排夜逛,不得留白】用户明确反馈过:原来 18:00 吃完晚饭就直接回住处,19:00-21:00 空了一大段,很难受。所以:晚饭必须安排在 17:30-18:45 之间吃完;晚饭后到入住之间(约 19:00-21:00)必须填一段 night_stroll 夜逛,性质是「自由活动」——给出可逛范围(闹市步行街/历史街区夜景/夜市/江边河畔/灯光带)与 ≥3 个可选去处,每个都给可导航地址,不强制打卡顺序;必须说明夜间是否开放、地铁末班时间、该时段能否叫到网约车、以及返回住处的方式与耗时。若目的地确实无成熟夜市,必须按 no_night_market 降级并显式标注,不得编造不存在的夜市。禁止出现「吃完饭直接回住处」这种留白安排。', + '', + windowHint(answers), + '', + '===== 技能包:规则 rules.md =====', + String(bundle.rules || ''), + '', + '===== 技能包:骨架与降级规则 skill.json =====', + JSON.stringify(slimSkill(bundle.skill), null, 2), + '', + '===== 技能包:输出契约 output_schema.json =====', + JSON.stringify(bundle.schema || {}, null, 2) + ].join('\n') + + const user = [ + '===== 本次输入 =====', + JSON.stringify(answers || {}, null, 2), + '', + dest + ? '===== 该目的地在技能包里的已知覆盖(请以此为准,不要另造数据) =====\n' + JSON.stringify(dest, null, 2) + : '===== 该目的地是首次生成,技能包里没有覆盖数据,请按骨架与规则自行规划,并把不确定项如实标注置信度 =====', + '', + '请直接输出这份行程的 JSON。' + ].join('\n') + + return [ + { role: 'system', content: system }, + { role: 'user', content: user } + ] +} + +// 服务端权威字段:身份、版本、时间戳一律由服务端写死,避免模型编造 +const finalizeMeta = (payload, bundle, answers, model, ctx) => { + const p = payload || {} + p.meta = p.meta || {} + p.meta.skill_id = bundle.skill.id + p.meta.skill_name = bundle.skill.name + p.meta.skill_version = bundle.skill.version + p.meta.schema_version = (bundle.schema && bundle.schema.version) || '' + p.meta.qa = answers || {} + p.meta.generated_at = ctx.nowBeijing() + p.meta.generator = 'ai:' + model + if (!p.meta.destination || typeof p.meta.destination !== 'object') p.meta.destination = {} + if (!p.meta.destination.name) p.meta.destination.name = String((answers && answers.destination) || '') + if (!p.meta.destination.key) { + const d = String(p.meta.destination.name || '').trim() + p.meta.destination.key = d || 'unknown' + } + if (!p.meta.disclaimer) { + p.meta.disclaimer = '本行程由 AI 按技能包规则生成;高铁车次与时刻、住宿可订性、共享单车禁停区等信息均需出发前自行核实,详见「待核实清单」。' + } + return p +} + +// 生成一次行程;解析失败自动重试一次(带上纠正提示) +const generate = async ({ bundle, answers, dest, aiConfig, ctx }) => { + const model = String(aiConfig.model || 'qwen-plus-latest') + const temperature = Number.isFinite(Number(aiConfig.temperature)) ? Number(aiConfig.temperature) : 0.25 + const maxTokens = Number.isFinite(Number(aiConfig.max_tokens)) ? Number(aiConfig.max_tokens) : 8000 + const timeoutMs = Number.isFinite(Number(aiConfig.timeout_ms)) ? Number(aiConfig.timeout_ms) : 240000 + + const base = buildMessages(bundle, answers, dest) + const started = Date.now() + let attempt = 0 + let lastErr = null + let raw = '' + + while (attempt < 2) { + attempt++ + const messages = attempt === 1 + ? base + : base.concat([ + { role: 'assistant', content: raw.slice(0, 4000) }, + { role: 'user', content: '上面的输出不是合法 JSON(或已被截断)。请只输出完整合法的 JSON 对象本身,不要任何解释、不要代码块标记。' } + ]) + const r = await ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs }) + raw = r.content + try { + const payload = ctx.extractJson(raw) + return { + payload: finalizeMeta(payload, bundle, answers, model, ctx), + model, + elapsed_ms: Date.now() - started, + attempts: attempt, + usage: r.usage, + finish_reason: r.finishReason, + raw + } + } catch (e) { + lastErr = e + if (r.finishReason === 'length') { + lastErr = new Error('output_truncated') + break + } + } + } + const err = new Error(lastErr ? String(lastErr.message || lastErr) : 'generate_failed') + err.raw = raw + throw err +} + +module.exports = { generate, buildMessages, finalizeMeta } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/config.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/config.json new file mode 100644 index 0000000..47ceaa2 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/config.json @@ -0,0 +1,37 @@ +{ + "_comment": "技能:上海周边高铁游周末两天一晚 —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容;业务参数(ui_mode / ai)只有本 skill 自己的后端会读。", + "_isolation_comment": "【隔离用意 · 请后续开发者先读这段】本 lab 的架构是『skill 全栈隔离』:每个 skill 在 skills/<skill_id>/ 下自带一整套后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/图标/照片),彼此零耦合。所以本文件是【本 skill 私有】的,不是全 lab 共享配置。若你要改 AI 参数、改展示名、改图标,改这里只影响本 skill;反过来,其他 skill 的同类文件也绝不该被本 skill 引用。lab 层只保留三类不许下沉的东西:鉴权、凭据、北京时间/XSS 转义等正确性实现(通过 ctx 注入)。", + + "id_comment": "技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)", + "id": "sh_2d_rail_hike", + + "name_comment": "聚合页卡片标题", + "name": "上海周边高铁游周末两天一晚", + + "desc_comment": "聚合页卡片一句话简介(tooltip)。范围说明:高铁单程 5 小时内(含北京/武汉/青岛等长途,故简介里写明『含长途』,避免与『周边』二字产生误解)", + "desc": "高铁往返 · 两天一晚 · 徒步 1-2h + 历史街区/市中心 · 全程共享单车与网约车 · 每个地点都给可导航地址(高铁单程 5h 内,含长途)", + + "icon_comment": "聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg", + "icon": "🚄", + + "sort_order_comment": "聚合页排序,数值越小越靠前", + "sort_order": 20, + + "enabled_comment": "是否启用(false 时 lab 扫描层直接跳过本 skill,聚合页不显示)", + "enabled": true, + + "ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗。本项归 skill 私有,各 skill 可不同", + "ui_mode": "debug", + + "ai_comment": "本 skill 的生成链路 AI 参数。密钥不在这里,由 lab 层 ctx.callLLM 从环境变量读取(红线:skill 不接触密钥)", + "ai": { + "model_comment": "纯文本生成默认 qwen-plus-latest(项目统一规则:自动跟随官方最新稳定快照)", + "model": "qwen-plus-latest", + "temperature_comment": "采样温度(0-1)。行程生成偏结构化,本 skill 节点更多(含市内交通方案),建议 0.2-0.3;过高更易漏字段", + "temperature": 0.25, + "max_tokens_comment": "单次最大输出 token。本 skill 为 2 天但含 5 张表 + 12 张卡片 + 市内交通方案,实测整份 JSON 需 1.4-1.8 万 token,这里留 22000 余量", + "max_tokens": 22000, + "timeout_ms_comment": "单次生成超时(毫秒)。实测约 33 token/s,1.8 万 token 需 9-10 分钟,故放到 12 分钟", + "timeout_ms": 720000 + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/destinations.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/destinations.json new file mode 100644 index 0000000..c4b5358 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/destinations.json @@ -0,0 +1,213 @@ +{ + "_comment": "技能包:目的地清单与变量覆盖。骨架(skill.json 的 anchors)永远不变,本文件只覆盖『换个目的地要换哪些值』。", + "_isolation_comment": "【隔离用意】本文件是【本 skill 私有的目的地清单】。注意 sample_ref 的解析基准是【本 skill 自己的前端目录】(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/),不是 lab 根目录 —— 这是隔离后修正过的一处历史坑:原来 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。新增 skill 时请沿用『相对本 skill 目录』的口径。", + "version": "1.1.1", + "sample_dir_note": "sample_ref 相对【本 skill 自己的前端目录】解析(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/),离线样例放在该目录下的 sample/ 里", + + "destinations": [ + { + "key": "suzhou", + "name": "苏州", + "admin": "江苏省苏州市", + "aliases": ["苏州城区", "姑苏"], + "center": [31.299, 120.585], + "center_coord_precision": "approx", + "center_note": "市中心为观前街/平江路一带,坐标仅用于地图初始视野", + + "one_way_rail_hours": 0.5, + "rail_note": "上海虹桥 → 苏州站约 23-30 分钟,班次极密(高峰 10 分钟一班),属本 skill 最轻松的目的地", + + "terrain": "平原 · 水乡,无山", + "degradations_applied": ["terrain_flat"], + + "anchors_override_comment": "每个槽位在苏州的落点候选。凡 confidence != high 的,生成时必须原样带进报告的『待核实清单』", + "anchors_override": { + "train_out": { + "origin_station": "上海虹桥站", + "arrive_station": "苏州站", + "suggest_window": "周六 08:00-09:30", + "duration_range": "23-35 分钟", + "fare_range_second_class": "¥40-50", + "confidence": "low", + "verify": "车次与时刻以 12306 实时查询为准(本 skill 不给车次号,避免编造)" + }, + "lunch_d1": { + "pick": ["平江路北段临河的本地苏帮菜小馆(非游客主街、本地评价占比高)"], + "must_give_rating": true, + "confidence": "low", + "verify": "店名、评分区间(建议 4.3 以上)与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分" + }, + "hike_1": { + "pick": "环古城河健身步道(相门—平门段)", + "terrain_note": "平路水岸徒步,累计爬升约 0", + "confidence": "medium", + "verify": "步道开放情况与出入口位置出发前核实" + }, + "spot_d1": { + "pick": "拙政园 或 苏州博物馆(二者同一街区,选其一;均为白天开放)", + "daylight_only": true, + "note": "拙政园旺季约 17:30 闭园、约 16:30 停止入场;苏博约 17:00 闭馆、周一闭馆且需预约。必须在徒步结束后直接前往,确保赶在最后入场前", + "confidence": "medium", + "verify": "开放时间、最后入场时间、预约政策请以官方公众号公布为准" + }, + "dinner_d1": { + "pick": ["观前街巷内本地家常菜馆(非主街、本地评价占比高)"], + "must_give_rating": true, + "confidence": "low", + "verify": "店名、评分区间与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分" + }, + "night_stroll": { + "pick": "平江路夜景段 + 观前街夜间商圈(两地步行 15 分钟可达,可自由取舍)", + "free_activity": true, + "options": [ + { "name": "平江路夜间段(临河灯笼夜景)", "address": "江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)" }, + { "name": "观前街夜间商圈(玄妙观前广场)", "address": "江苏省苏州市姑苏区观前街 玄妙观" }, + { "name": "山塘街夜景(七里山塘,需打车前往)", "address": "江苏省苏州市姑苏区山塘街(渡僧桥入口)" } + ], + "night_transport_note": "苏州地铁 2/4 号线末班约 22:30 前后;该时段网约车可正常叫到", + "confidence": "medium", + "verify": "夜市与灯光开放时间、地铁末班时刻需出发前核实" + }, + "lodging_night": { + "pick": "观前街/平江路一带酒店(或本地大型洗浴留宿)", + "confidence": "medium", + "verify": "房价与是否含早需实时查询;洗浴是否可留宿需电话确认" + }, + "hike_2": { + "pick": "金鸡湖环湖步道(现代城市湖景段,与第一天古城水岸形成对比)", + "terrain_note": "平路湖岸徒步,累计爬升约 0", + "confidence": "medium", + "verify": "湖区是否允许步行全环、夜间是否封闭需核实" + }, + "spot_d2": { + "pick": "虎丘 或 盘门景区(须与第一天的 spot_d1 去重:spot_d1 已用拙政园/苏博,第二天的不可重复)", + "dedupe_with": "spot_d1", + "confidence": "medium", + "verify": "开放时间、门票与预约政策需出发前在官方渠道确认;虎丘距苏州站约 6km,返程前需留足车程" + }, + "train_back": { + "depart_station": "苏州站", + "arrive_station": "上海虹桥站", + "suggest_window": "周日 15:00-17:00", + "duration_range": "23-35 分钟", + "fare_range_second_class": "¥40-50", + "confidence": "low", + "verify": "车次与时刻以 12306 实时查询为准;周日下午返程高峰建议提前购票" + } + }, + + "notes": [ + "苏州为平原水乡,已触发 terrain_flat:两段徒步均为平路,累计爬升约 0,报告必须显式写明『平路徒步』,不得冒充山地徒步效果", + "苏州老城区(平江路、观前街一带)对共享单车有较大禁行/禁停范围,市内交通方案必须写明禁停区,并给出外围合法还车点", + "苏州站到平江路约 3km,共享单车约 15 分钟,网约车约 10 分钟" + ], + + "runs": [ + { + "date": "2026-10-17", + "status": "draft", + "generator": "manual_sample", + "skill_version": "1.1.1", + "sample_ref": "sample/suzhou_2026-10-17.json" + } + ] + }, + { + "key": "hangzhou", + "name": "杭州", + "admin": "浙江省杭州市", + "aliases": ["杭州城区", "西湖"], + "center": [30.254, 120.155], + "center_coord_precision": "approx", + "center_note": "西湖一带,坐标仅用于地图初始视野", + + "one_way_rail_hours": 1, + "rail_note": "上海虹桥 → 杭州东站约 45-60 分钟,班次密集", + + "terrain": "丘陵 + 水系(西湖群山),有缓坡", + "degradations_applied": [], + + "anchors_override_comment": "每个槽位在杭州的落点候选", + "anchors_override": { + "train_out": { + "origin_station": "上海虹桥站", + "arrive_station": "杭州东站", + "suggest_window": "周六 07:30-09:00", + "duration_range": "45-70 分钟", + "fare_range_second_class": "¥73-92", + "confidence": "low", + "verify": "车次与时刻以 12306 实时查询为准" + }, + "hike_1": { + "pick": "西湖群山北线(宝石山—葛岭段,含轻度爬升)", + "terrain_note": "丘陵步道,累计爬升约 100-150m", + "confidence": "medium", + "verify": "山道开放情况与是否施工需出发前核实" + }, + "lunch_d1": { + "pick": ["湖滨/龙翔桥一带巷内的本地杭帮菜馆(非西湖边游客主街)"], + "must_give_rating": true, + "confidence": "low", + "verify": "店名、评分区间(建议 4.3 以上)与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分" + }, + "spot_d1": { + "pick": "浙江省博物馆(之江馆/孤山馆)或 中国丝绸博物馆(均为白天开放、免费为主)", + "daylight_only": true, + "note": "多数博物馆 17:00 前后闭馆、16:00-16:30 停止入场;周一闭馆。必须在徒步结束后直接前往,确保赶在最后入场前", + "confidence": "medium", + "verify": "开放时间、最后入场时间与预约政策请以官方公众号公布为准" + }, + "dinner_d1": { + "pick": ["湖滨一带巷内本地杭帮菜馆(靠近夜逛区,吃完直接逛)"], + "must_give_rating": true, + "confidence": "low", + "verify": "店名、评分区间与评价条数需核实;须确认 19:00 前能吃完且营业到晚间" + }, + "night_stroll": { + "pick": "河坊街夜市 + 湖滨(西湖东岸)夜景观光带(两处相距约 1.5km,可自由取舍)", + "free_activity": true, + "options": [ + { "name": "河坊街夜市(南宋御街一带,小吃与老字号)", "address": "浙江省杭州市上城区河坊街(吴山广场入口)" }, + { "name": "湖滨步行街 + 西湖东岸夜景(音乐喷泉)", "address": "浙江省杭州市上城区湖滨路 湖滨步行街" }, + { "name": "南山路夜景带(沿湖酒吧与灯光)", "address": "浙江省杭州市上城区南山路" } + ], + "night_transport_note": "杭州地铁 1 号线(龙翔桥站)末班约 22:30 前后;西湖东岸夜间打车较为拥堵,建议地铁", + "confidence": "medium", + "verify": "夜市营业时间、音乐喷泉开放场次(通常夜间固定时段)与地铁末班时刻需出发前核实" + }, + "lodging_night": { + "pick": "湖滨/龙翔桥一带酒店(或本地大型洗浴留宿)", + "confidence": "medium", + "verify": "房价与是否含早需实时查询;洗浴是否可留宿需电话确认" + }, + "hike_2": { + "pick": "京杭大运河(拱宸桥段)沿岸步道,与第一天山线形成对比", + "terrain_note": "平路水岸徒步,累计爬升约 0", + "confidence": "medium", + "verify": "沿岸步道连通性需核实" + }, + "spot_d2": { + "pick": "拱宸桥/桥西历史街区 或 中国茶叶博物馆(免费)", + "confidence": "medium", + "verify": "开放时间与是否需要预约需核实" + }, + "train_back": { + "depart_station": "杭州东站", + "arrive_station": "上海虹桥站", + "suggest_window": "周日 15:00-17:00", + "duration_range": "45-70 分钟", + "fare_range_second_class": "¥73-92", + "confidence": "low", + "verify": "车次与时刻以 12306 实时查询为准;周日下午返程高峰建议提前购票" + } + }, + + "notes": [ + "杭州西湖景区周边(尤其湖滨、南山路)为共享单车禁行/禁停重点区域,市内交通必须写明并给出外围还车点", + "杭州东站到西湖湖滨约 8km,建议地铁 1 号线直达或网约车约 25 分钟" + ], + + "runs": [] + } + ] +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.js new file mode 100644 index 0000000..63a103e --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/index.js @@ -0,0 +1,241 @@ +// ============================================================ +// sh_2d_rail_hike/index.js —— 本技能的后端路由(技能私有、自包含) +// +// 契约(lab 扫描层唯一要求): +// module.exports = { bindRoutes(router, ctx) } +// - router:lab 层已经 .use(labAuth) + .use(readLimit) 之后再交给我, +// 所以本文件【不写任何鉴权代码】,也拿不到绕过鉴权的机会。 +// - ctx :lab 层提供的红线能力(密钥/时间/转义/日志/数据目录…)。 +// +// 【隔离用意 · 请后续开发者先读这段】 +// 本文件从 sh_2d_car_hike/index.js 复制而来(用户已确认「复制现有再改」)。 +// 复制后它就是【本技能私有】的一份:本 skill 的接口前缀由 lab 层挂到 +// /api/thought_lab/travel_citytour/skills/sh_2d_rail_hike/* +// 修改本文件不影响自驾 skill;反之亦然。请勿把它抽成「所有 skill 共用的路由工厂」—— +// 本 lab 的原则是每个 skill 各存一份可自由改的代码,宁可重复,也不要一处改动牵连全体。 +// +// lab 层(红线,不许下沉到 skill):鉴权、凭据、北京时间、XSS 转义、限流。 +// 本层(技能私有,爱怎么改怎么改):技能包、prompt、校验、修复、落盘格式。 +// +// 挂载点:/api/thought_lab/travel_citytour/skills/sh_2d_rail_hike/* +// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。 +// ============================================================ +const fs = require('fs') +const path = require('path') +const ai = require('./ai') +const { validatePayload } = require('./validate') +const { repairPayload } = require('./repair') + +const SKILL_DIR = __dirname + +// 本技能的技能包文件清单。缺哪个都不算错(除 skill.json 外),只是能力变少。 +const SKILL_FILES = { + skill: 'skill.json', + qa: 'qa.json', + schema: 'output_schema.json', + report_spec: 'report_spec.json', + destinations: 'destinations.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') + if (key === 'rules') { bundle.rules = txt; return } + try { + bundle[key] = JSON.parse(txt) + } catch (e) { + throw new Error('skill_file_broken:' + SKILL_FILES[key]) + } + }) + if (!bundle.skill || !bundle.skill.id) throw new Error('skill_meta_missing') + bundle.id = bundle.skill.id + return bundle +} + +// 目的地覆盖:用户输入的目的地若在本技能已跑过,取出它的覆盖值 +const findDestination = (bundle, destName) => { + const list = (bundle.destinations && bundle.destinations.destinations) || [] + const key = String(destName || '').trim() + if (!key) return null + return list.find((d) => d.name === key || d.key === key || (Array.isArray(d.aliases) && d.aliases.indexOf(key) >= 0)) || null +} + +// 按本技能 qa.json 的定义做通用必填校验(不写死任何字段名) +const validateAnswers = (bundle, answers) => { + const missing = [] + const qa = bundle.qa || {} + const destInput = qa.destination_input || {} + if (destInput.required !== false && !String(answers.destination || '').trim()) { + missing.push(destInput.label || 'destination') + } + ;(Array.isArray(qa.questions) ? qa.questions : []).forEach((q) => { + if (!q || q.required === false) return + const v = answers[q.key] + if (v === undefined || v === null || String(v).trim() === '') missing.push(q.label || q.key) + }) + return missing +} + +// ============================================================ +// 最近一次生成结果(本技能私有 data/) +// +// 存在意义:一次生成约 4 分钟,而长请求经 8975 反代存在「服务端已返回、客户端没收到」的情况。 +// 有了这份落盘结果,刷新页面后点「载入最近一次生成」即可看结果,不必重新烧 4 分钟。 +// 各技能各存一份,互不覆盖(这是目录隔离顺带修掉的老毛病)。 +// ============================================================ +const LAST_FILE = 'last_result.json' +const LAST_KEEP_MS = 7 * 24 * 3600 * 1000 + +const writeLastResult = (ctx, envelope) => { + try { + fs.mkdirSync(ctx.dataDir, { recursive: true }) + fs.writeFileSync(path.join(ctx.dataDir, LAST_FILE), JSON.stringify(envelope), 'utf-8') + } catch (e) { + ctx.log('last_result 写入失败', { error: String((e && e.message) || e) }) + } +} + +const readLastResult = (ctx) => { + try { + const file = path.join(ctx.dataDir, LAST_FILE) + if (!fs.existsSync(file)) return null + if (Date.now() - fs.statSync(file).mtimeMs > LAST_KEEP_MS) return null + return JSON.parse(fs.readFileSync(file, 'utf-8')) + } catch (e) { + return null + } +} + +// ============================================================ +// 路由:全部为相对路径,挂载前缀由 lab 层决定 +// ============================================================ +const bindRoutes = (router, ctx) => { + // 本技能元信息:页面双模式依据 + 渲染描述 + 已跑目的地 + 问答定义 + router.get('/meta', (req, res) => { + try { + const cfg = ctx.readConfig() + const bundle = loadBundle() + 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: bundle.id, + skill: { + id: bundle.skill.id, + name: bundle.skill.name, + version: bundle.skill.version, + days_label: bundle.skill.days ? `${bundle.skill.days} 天 ${bundle.skill.nights || bundle.skill.days - 1} 夜` : '', + base_origin: bundle.skill.base_origin || '', + anchors_count: Array.isArray(bundle.skill.anchors) ? bundle.skill.anchors.length : 0, + anchors: bundle.skill.anchors || [] + }, + qa: bundle.qa || {}, + report_spec: bundle.report_spec || {}, + destinations: (bundle.destinations && bundle.destinations.destinations) || [] + }) + } catch (e) { + const msg = String((e && e.message) || e) + return res.status(msg.indexOf('skill_file_broken') === 0 ? 500 : 500).json({ ok: false, error: msg }) + } + }) + + // 最近一次生成结果 + router.get('/last', (req, res) => { + const env = readLastResult(ctx) + if (!env) { + return res.status(404).json({ ok: false, error: 'no_last_result', hint: '还没有生成过,或结果已超过 7 天保留期' }) + } + return res.json(env) + }) + + // 生成行程(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流) + router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => { + const body = ctx.sanitizeObject(req.body || {}) + let bundle = null + try { + bundle = loadBundle() + } catch (e) { + return res.status(500).json({ ok: false, error: String((e && e.message) || e) }) + } + + const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {} + const missing = validateAnswers(bundle, answers) + if (missing.length) return res.status(400).json({ ok: false, error: 'missing_answers', missing }) + + const cfg = ctx.readConfig() + const aiConfig = cfg.ai || {} + const dest = findDestination(bundle, answers.destination) + + // 生成本身约 4 分钟,经 IIS 反代(8975)时可能被中途掐断。 + // 这行日志用于区分「服务端已返回」与「客户端真的收到」——两者现象一样,但根因完全不同。 + res.on('close', () => { + ctx.log('generate resp closed', { delivered: !!res.writableEnded, dest: answers.destination }) + }) + + try { + ctx.log('generate start', { dest: answers.destination, model: aiConfig.model || 'qwen-plus-latest' }) + const result = await ai.generate({ bundle, answers, dest, aiConfig, ctx }) + // AI 一定会变形:先做无损修复,再校验(修复说明一并在响应里回传,前端交互弹窗可见) + const repaired = repairPayload(result.payload, bundle) + const check = validatePayload(repaired.payload, bundle) + const elapsed = result.elapsed_ms + + ctx.log('generate done', { + dest: answers.destination, ms: elapsed, attempts: result.attempts, + repairs: repaired.fixes.length, errors: check.errors.length, warnings: check.warnings.length + }) + + const envelope = { + ok: check.errors.length === 0, + payload: repaired.payload, + report_spec: bundle.report_spec || {}, + destination_override: dest ? { key: dest.key, name: dest.name, degradations_applied: dest.degradations_applied || [] } : null, + check, + meta: { + model: result.model, + elapsed_ms: elapsed, + attempts: result.attempts, + finish_reason: result.finish_reason, + usage: result.usage, + repairs: repaired.fixes, + is_new_destination: !dest + } + } + if (check.errors.length) { + envelope.error = 'payload_invalid' + envelope.raw_excerpt = String(result.raw || '').slice(0, 6000) + } + // 【为什么有 error 也照样落盘 + 照样用 200 回传】 + // 一次生成要烧 6-10 分钟与上万 token。过去只要结构校验出现哪怕 1 个 error 就整份丢弃、 + // 既不落盘也不回传 payload,用户什么都拿不到 —— 而那个 error 很可能只是校验器的误判。 + // 本技能就真实踩过:校验器写死 day.label 含「第一天」,而 AI 标成「周六 / 周日」, + // 结果明明排了 19:00 的夜逛却被报「缺夜逛」,整份婺源行程被白扔(4 分钟 + 2.7 万 token)。 + // 现在改为:结果一律先落盘再回传;有结构问题时用 ok:false + error 字段如实告知, + // 前端会显示醒目的「N 处结构问题」标记,用户仍可查看/导出并自行判断是否可用。 + // 用 200 而不是 422:422 会让前端走「失败」分支,payload 反而渲染不出来。(本 skill 私有决策) + writeLastResult(ctx, envelope) + return res.json(envelope) + } catch (e) { + const msg = String((e && e.message) || e) + ctx.log('generate failed', { dest: answers.destination, error: msg }) + const status = msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502 + const hint = msg === 'output_truncated' + ? '模型输出被截断(max_tokens 不足),可在本技能的 config.json 调大 ai.max_tokens 后重试' + : (msg === 'no_json_found' ? '模型没有按要求输出 JSON,可降低 ai.temperature 或重试' : '') + return res.status(status).json({ + ok: false, + error: msg, + hint, + raw_excerpt: String((e && e.raw) || '').slice(0, 6000) + }) + } + }) +} + +module.exports = { bindRoutes, loadBundle, findDestination, validateAnswers } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/output_schema.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/output_schema.json new file mode 100644 index 0000000..18279ab --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/output_schema.json @@ -0,0 +1,306 @@ +{ + "_comment": "技能包:输出契约。生成结果(payload)必须严格符合本结构;后端按本结构做校验与透传。", + "_isolation_comment": "【隔离用意】本文件是【本 skill 私有的输出契约】,刻意与 sh_2d_car_hike/output_schema.json 不同 —— 具体差这几处,都是本 skill 的业务特点决定,不是遗漏:(1) timeline.activity 枚举新增 stroll(逛街/夜逛)/bike/taxi/metro/train/checkin/rest(自驾 skill 只需要 move/eat/hike/shop/sleep/walk);(2) marker.kind 新增 train_station/lodging/bike/taxi_point/night_market(自驾 skill 有 parking,本 skill 不需要);(3) marker 新增必填 address 字段(用户要求『每到一处都有具体地点,方便导航』);(4) v1.1.0 新增 stroll 与 night_market,支撑『周六夜夜逛』槽位。这些就是『隔离』的收益:本 skill 可以自由扩自己的词表,不必迁就别人、也不必让别人迁就自己。请勿把两份 schema 抽成公共模板。", + "version": "1.1.1", + "dialect": "json-schema-lite", + + "root_comment": "根对象字段固定为 9 个;业务内容全部装在 cards / tables / timeline 里", + "root": { + "type": "object", + "required": ["meta", "summary", "timeline", "cards", "map", "tables", "cost", "checklist", "notes"], + "properties": { + "meta": { "$ref": "#/definitions/meta" }, + "summary": { "$ref": "#/definitions/summary" }, + "timeline": { "$ref": "#/definitions/timeline" }, + "cards": { "type": "array", "items": { "$ref": "#/definitions/card" }, "minItems": 1 }, + "map": { "$ref": "#/definitions/map" }, + "tables": { "type": "array", "items": { "$ref": "#/definitions/table" } }, + "cost": { "$ref": "#/definitions/cost" }, + "checklist": { "type": "array", "items": { "$ref": "#/definitions/checklist_item" } }, + "notes": { "type": "array", "items": { "$ref": "#/definitions/note" } } + } + }, + + "required_table_ids_comment": "这些 table 由本 skill 的 report_spec 按 pick_id 引用,必须存在,否则该区块渲染为空", + "required_table_ids": [ + "train_out_options", + "train_back_options", + "city_transport", + "lodging_compare", + "food_summary" + ], + + "required_card_anchor_ids_comment": "card.anchor_id 必须覆盖 skill.json 的全部 anchors,一个都不能少", + "required_card_anchor_ids_source": "skill.json#/anchors", + + "definitions": { + "fact_comment": "可置信度化字段:纯字符串(视为 high,仅限客观信息)或对象(主观项必须用对象并带 verify)", + "fact": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["text", "confidence"], + "properties": { + "text": { "type": "string" }, + "confidence": { "enum": ["high", "medium", "low"] }, + "verify": { "type": "string", "description": "核实方式,confidence != high 时必填" } + } + } + ] + }, + + "meta": { + "type": "object", + "required": ["skill_id", "skill_name", "skill_version", "schema_version", "destination", "qa", "generated_at", "generator", "disclaimer"], + "properties": { + "skill_id": { "type": "string" }, + "skill_name": { "type": "string" }, + "skill_version": { "type": "string" }, + "schema_version": { "type": "string" }, + "destination": { + "type": "object", + "required": ["key", "name"], + "properties": { + "key": { "type": "string" }, + "name": { "type": "string" }, + "admin": { "type": "string" }, + "center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } + } + }, + "qa": { "type": "object", "description": "问答答案原样回填,字段名由 qa.json 决定" }, + "generated_at": { "type": "string", "description": "北京时间 YYYY-MM-DD HH:mm:ss" }, + "generator": { "type": "string", "description": "manual_sample / ai:<model>" }, + "disclaimer": { "type": "string" } + } + }, + + "summary": { + "type": "object", + "required": ["title", "one_liner", "stats"], + "properties": { + "title": { "type": "string" }, + "one_liner": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "stats": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "type": "string" }, + "hint": { "type": "string" } + } + } + } + } + }, + + "timeline": { + "type": "object", + "required": ["days"], + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "date", "rows"], + "properties": { + "label": { "type": "string" }, + "date": { "type": "string" }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["time", "title", "anchor_id", "kind"], + "properties": { + "time": { "type": "string" }, + "title": { "type": "string" }, + "anchor_id": { "type": "string" }, + "kind": { "enum": ["hard", "soft"], "description": "时间点性质,只表性质、不表活动:hard=骨架声明为硬时间点(不可漂移);soft=常规。禁止把活动类型写进本字段。" }, + "activity": { + "enum": ["walk", "stroll", "spot", "bike", "taxi", "metro", "train", "eat", "hike", "shop", "checkin", "rest"], + "description": "活动类型,可选,与 kind 正交。本 skill 相对自驾 skill 新增了 stroll(逛街/夜逛)/spot(游览景点)/bike(共享单车)/taxi(网约车)/metro(地铁)/train(高铁)/checkin(入住寄存)/rest(休息) —— 这是本 skill 私有词表。stroll 专指『闲逛/夜逛』,与 walk(点对点步行通勤) 区分;spot 专指『游览景点/展览』。" + }, + "note": { "type": "string" } + } + } + } + } + } + } + } + }, + + "image": { + "type": "object", + "required": ["url", "kind", "caption", "disclaimer"], + "properties": { + "url": { "type": "string", "description": "优先本地相对路径(已落盘);远程 URL 视为未落盘" }, + "kind": { "enum": ["ai_illustration", "map_capture", "icon"] }, + "caption": { "type": "string" }, + "disclaimer": { "type": "string" } + } + }, + + "card": { + "type": "object", + "required": ["id", "anchor_id", "day", "title", "fields"], + "properties": { + "id": { "type": "string" }, + "anchor_id": { "type": "string", "description": "必须对应 skill.json 的一个 anchor.id" }, + "day": { "type": "integer", "enum": [1, 2] }, + "title": { "type": "string" }, + "subtitle": { "type": "string" }, + "image": { "$ref": "#/definitions/image" }, + "badges": { + "type": "array", + "items": { + "type": "object", + "required": ["text"], + "properties": { + "text": { "type": "string" }, + "tone": { "enum": ["info", "good", "warn", "danger", "plain"] } + } + } + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { "type": "string" }, + "value": { "$ref": "#/definitions/fact" }, + "hint": { "type": "string" } + } + }, + "description": "建议每张卡片都含一条 label 为『导航』的字段,值为可复制到高德/百度搜索的地址或地标关键词" + }, + "bullets": { "type": "array", "items": { "type": "string" } }, + "map_marker_ids": { "type": "array", "items": { "type": "string" } } + } + }, + + "marker": { + "type": "object", + "required": ["id", "name", "kind", "lat", "lng", "day", "seq", "address"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "kind": { + "enum": ["start", "end", "waypoint", "train_station", "lodging", "bathhouse", "district", "food", "hike", "bike", "taxi_point", "night_market", "spot", "shop", "walk"], + "description": "本 skill 私有枚举:相对自驾 skill 去掉了 parking(无自驾),新增 train_station/lodging/bike/taxi_point,以及 night_market(夜市/夜逛区域,v1.1.0 新增,用于支撑周六夜夜逛)" + }, + "lat": { "type": "number" }, + "lng": { "type": "number" }, + "coord_precision": { "enum": ["exact", "approx"], "description": "approx 表示仅用于示意图定位。AI 给出的坐标一律按 approx 处理,不得标 exact" }, + "address": { "type": "string", "description": "必填。可导航地址或高德/百度能搜到的地标关键词,例如『苏州市姑苏区平江路历史街区』。禁止只写泛指名称(如『平江路』不带城市)" }, + "day": { "type": "integer", "enum": [1, 2] }, + "seq": { "type": "integer" }, + "note": { "type": "string" } + } + }, + + "route": { + "type": "object", + "required": ["id", "name", "kind", "marker_ids"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "kind": { + "enum": ["train", "hike", "stroll", "bike", "taxi", "metro"], + "description": "本 skill 私有枚举:train/bike/taxi/metro 为本 skill 新增" + }, + "marker_ids": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, + "color": { "type": "string" }, + "dashed": { "type": "boolean" }, + "note": { "type": "string", "description": "必须说明是点位顺序示意,非真实道路/轨迹" } + } + }, + + "map": { + "type": "object", + "required": ["center", "zoom", "markers", "routes"], + "properties": { + "center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + "zoom": { "type": "integer" }, + "markers": { "type": "array", "items": { "$ref": "#/definitions/marker" } }, + "routes": { "type": "array", "items": { "$ref": "#/definitions/route" } } + } + }, + + "table": { + "type": "object", + "required": ["id", "title", "columns", "rows"], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["key", "label"], + "properties": { + "key": { "type": "string" }, + "label": { "type": "string" }, + "align": { "enum": ["left", "center", "right"] }, + "width": { "type": "string" } + } + } + }, + "rows": { "type": "array", "items": { "type": "object", "description": "键为 columns[].key" } }, + "note": { "type": "string" }, + "highlight_row_keys": { "type": "array", "items": { "type": "string" } } + } + }, + + "cost": { + "type": "object", + "required": ["currency", "items", "total"], + "properties": { + "currency": { "enum": ["CNY"] }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "amount"], + "properties": { + "label": { "type": "string" }, + "amount": { "type": "number" }, + "unit": { "type": "string" }, + "note": { "type": "string" } + } + }, + "description": "本 skill 的费用项应覆盖:往返高铁票、住宿/洗浴留宿、市内交通(共享单车+网约车)、餐饮、门票" + }, + "total": { "type": "number" }, + "note": { "type": "string" } + } + }, + + "checklist_item": { + "type": "object", + "required": ["text", "confidence"], + "properties": { + "anchor_id": { "type": "string" }, + "field": { "type": "string" }, + "text": { "type": "string" }, + "confidence": { "enum": ["medium", "low"] }, + "verify": { "type": "string" }, + "kind": { "enum": ["must", "optional"], "description": "must=出发前必须确认(如车次、住宿、预约);optional=有余力再确认" } + } + }, + + "note": { + "type": "object", + "required": ["level", "title", "text"], + "properties": { + "level": { "enum": ["info", "warn", "danger"] }, + "title": { "type": "string" }, + "text": { "type": "string" } + } + } + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/qa.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/qa.json new file mode 100644 index 0000000..1e507b4 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/qa.json @@ -0,0 +1,158 @@ +{ + "_comment": "技能包:问答脚本。生成前由前端按本文件动态渲染问答表单;后端只透传答案,不解析字段含义。新增/删改问答项不需要改任何后端代码。", + "_isolation_comment": "【隔离用意】本文件是【本 skill 私有的问答脚本】,不是全 lab 共享的表单模板。所以本 skill 可以问『出发时段』『住宿形态』『是否带拉杆箱』这些自驾 skill 完全不需要的问题,而不必迁就别人。sh_2d_car_hike 问的是『车型/油车电车』,本 skill 不问 —— 两者互不影响,这正是隔离要达到的效果。请勿把两个 qa.json 合并成一份『通用问卷』。", + "version": "1.0.0", + + "destination_input_comment": "目的地是通用层输入(skill ↔ 目的地多对多),本 skill 只负责覆盖它的叫法、提示与校验口径", + "destination_input": { + "label": "目的地城市", + "placeholder": "例如:苏州、杭州、南京、扬州、北京", + "required": true, + "mode": "free_input_with_suggestion", + "suggestions_ref": "destinations.json", + "max_one_way_rail_hours": 5, + "on_out_of_range": "block_with_hint", + "hint": "本 Skill 适用于从上海坐高铁单程 5 小时内的目的地(含苏州/杭州/南京等近程,也含北京/武汉/青岛等长途)。注意长途目的地建议选『周五晚出发』,否则第一天大半时间在路上" + }, + + "questions_comment": "问答项:type 支持 date / time / select / number / text;affects 说明该项会影响哪些产出,便于页面提示用户", + "questions": [ + { + "key": "departure_station", + "label": "出发车站", + "type": "text", + "required": true, + "default": "上海虹桥站", + "affects": [ + "去程的出发站与建议到站时间", + "返程的终点站", + "抵沪后回家的交通方式与末班地铁提示" + ], + "hint": "默认上海虹桥站,可改成上海站 / 上海南站 / 上海西站;改站后去返程的换乘与到站时间会重算" + }, + { + "key": "departure_window", + "label": "出发时段", + "type": "select", + "required": true, + "default": "sat_morning", + "options": [ + { "value": "sat_early", "label": "周六早(06:00-08:00)· 长途首选" }, + { "value": "sat_morning", "label": "周六上午(08:00-10:00)· 适合 2 小时内的近程" }, + { "value": "fri_night", "label": "周五晚(18:00 后)· 夜车/卧铺,周六早上到" } + ], + "affects": [ + "去程高铁的建议时段与历时", + "第一天在目的地的可用时间(直接决定行程松紧)", + "住宿晚数(周五晚出发=车上过一夜,目的地只需住一晚)", + "是否需要安排夜车卧铺" + ], + "hint": "目的地车程超过 3 小时时,选『周六上午』会导致第一天只剩半天,AI 会提示你改时段" + }, + { + "key": "departure_date", + "label": "出行日期(周六)", + "type": "date", + "required": true, + "default_rule": "nearest_saturday", + "affects": [ + "日出与天亮时间", + "季节穿着与蚊虫提示", + "景区旺季/淡季与是否需要预约", + "周日下午返程高峰与票源紧张程度" + ], + "hint": "本 Skill 固定周六出发、周日返程;日期只影响季节类信息。若选『周五晚出发』,这里填的是那个周六的日期" + }, + { + "key": "lodging_mode", + "label": "过夜方式", + "type": "select", + "required": true, + "default": "bathhouse", + "options": [ + { "value": "hotel", "label": "酒店 / 民宿" }, + { "value": "bathhouse", "label": "高级洗浴(带留宿)" }, + { "value": "both", "label": "两者都给,我自己挑" } + ], + "affects": [ + "第 1 晚住宿的推荐形态与筛选口径", + "住宿候选对比表(lodging_compare)的内容", + "价格区间与费用合计中的住宿项", + "是否需要在报告中标注『本地无大型带留宿洗浴』的降级说明" + ], + "hint": "选『高级洗浴(带留宿)』时会要求规模较大、档次中上,并核实是否真能过夜(部分洗浴仅营业到凌晨)" + }, + { + "key": "budget_level", + "label": "消费档次", + "type": "select", + "required": true, + "default": "comfort", + "options": [ + { "value": "economy", "label": "经济(住宿 ≤¥300,餐饮人均 ≤¥80)" }, + { "value": "comfort", "label": "舒适(住宿 ¥300-600,餐饮人均 ¥100 左右)" }, + { "value": "quality", "label": "品质(住宿 ≥¥600 或高档洗浴,餐饮人均 ¥150+)" } + ], + "affects": [ + "住宿候选的档次与价格区间", + "洗浴场所的规模与档次门槛", + "餐饮人均目标", + "市内交通预算(网约车占比)" + ] + }, + { + "key": "headcount", + "label": "出行人数", + "type": "number", + "required": true, + "default": 1, + "min": 1, + "max": 4, + "affects": [ + "住宿房型建议(单间/标间/双床)", + "餐食推荐份量与能否点半份", + "网约车是否拼车(2 人以上打车更划算)", + "高铁票数量与是否需要相邻座位" + ] + }, + { + "key": "luggage", + "label": "行李情况", + "type": "select", + "required": true, + "default": "backpack", + "options": [ + { "value": "backpack", "label": "轻装(背包)" }, + { "value": "suitcase", "label": "带拉杆箱" } + ], + "affects": [ + "到站后是先寄存还是先玩(带箱必须先寄存)", + "徒步路段能否安排在入住前", + "市内交通方式(带箱优先网约车,不建议骑车)", + "共享单车方案是否可行(带拉杆箱时不可骑单车)" + ], + "hint": "带拉杆箱时,AI 会强制先寄存行李再走行程,且不会安排『边骑车边带箱』这类不可行的方案" + }, + { + "key": "hike_intensity", + "label": "徒步强度", + "type": "select", + "required": true, + "default": "medium", + "options": [ + { "value": "easy", "label": "轻松(约 1 小时,平路/缓坡为主)" }, + { "value": "medium", "label": "中等(约 1.5 小时,可含 200m 内爬升)" }, + { "value": "strong", "label": "稍强(约 2 小时,可含 400m 内爬升)" } + ], + "affects": [ + "两段徒步的目标时长与累计爬升上限", + "徒步路线的选点(山地/绿道/环湖)", + "体力消耗对后续行程安排的影响", + "是否触发地形降级(平原城市无法满足爬升要求时)" + ] + } + ], + + "not_asked_comment": "按用户要求不收集的项;如将来需要,直接往 questions 里追加即可,无需改后端", + "not_asked": ["taste_preference", "dietary_restriction", "seat_class"] +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/repair.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/repair.js new file mode 100644 index 0000000..59f2daf --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/repair.js @@ -0,0 +1,166 @@ +// ============================================================ +// sh_2d_rail_hike/repair.js —— 本技能生成结果的无损修复(技能私有) +// +// 设计前提:AI 一定会变形。所以我们不指望它 100% 守规矩,而是在服务端做 +// 「可解释、无损、不改业务语义」的自动修复,把能修的修好,修不了的留给校验报出来。 +// +// 【隔离用意 · 请后续开发者先读这段】 +// 本文件是从 sh_2d_car_hike/repair.js 复制来的(用户已确认「复制现有再改」的方案)。 +// 复制之后它就是【本技能私有】的一份,改它不会影响自驾 skill;反过来,自驾 skill 改它的 +// 同名文件也不会影响本 skill。这是刻意的重复,不是疏漏 —— 本 lab 的原则是 +// 「宁可每个 skill 各存一份可自由修改的代码,也不要抽一个一旦改动就牵连所有 skill 的公共模块」。 +// +// 当前修复项: +// 1. 地图路线引用了不存在点位 id → 先做归一名匹配(marker_x / x / Marker-X 视为同一个), +// 仍找不到的丢弃;剩余点位不足 2 个的路线整条丢弃(画不出来) +// 2. 待核实清单漏收槽位 → 为该槽位补一条通用条目(文案只用卡片标题,不编造事实) +// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐 +// 4. 【本技能专属】缺少「返程不晚于 21:00 抵沪」的提示块 → 自动补一条 danger 级 notes +// (这是用户明确的硬约束:周一要上班,晚于地铁末班回不了家,不允许报告里没这条提醒) +// ============================================================ + +const isNonHighFact = (node) => { + if (Array.isArray(node)) return node.some(isNonHighFact) + if (node && typeof node === 'object') { + if ('confidence' in node) return node.confidence !== 'high' + return Object.keys(node).some((k) => isNonHighFact(node[k])) + } + return false +} + +// marker_hike_1_start / Hike1Start / hike-1-start → hike1start +const normId = (s) => String(s || '') + .toLowerCase() + .replace(/^marker[_\-]?/, '') + .replace(/[^a-z0-9]/g, '') + +const repairMarkerRefs = (map, fixes) => { + const markers = Array.isArray(map.markers) ? map.markers : [] + const routes = Array.isArray(map.routes) ? map.routes : [] + if (!markers.length || !routes.length) return + + const exists = new Set(markers.map((m) => m && m.id)) + const byNorm = {} + markers.forEach((m) => { if (m && m.id) byNorm[normId(m.id)] = m.id }) + + map.routes = routes.filter((r) => { + if (!r) return false + const raw = Array.isArray(r.marker_ids) ? r.marker_ids : [] + const out = [] + raw.forEach((id) => { + if (exists.has(id)) { out.push(id); return } + const hit = byNorm[normId(id)] + if (hit) { out.push(hit); fixes.push(`路线 ${r.id}:点位 ${id} → ${hit}(id 归一匹配)`); return } + fixes.push(`路线 ${r.id}:丢弃未定义点位 ${id}`) + }) + const dedup = [] + out.forEach((id) => { if (dedup.indexOf(id) < 0) dedup.push(id) }) + r.marker_ids = dedup + if (dedup.length < 2) { fixes.push(`路线 ${r.id}:有效点位不足 2 个,已整条丢弃`); return false } + return true + }) +} + +const fillChecklistGaps = (payload, bundle, fixes) => { + const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : [] + const anchorIds = anchors.map((a) => a.id) + const cards = Array.isArray(payload.cards) ? payload.cards : [] + const checklist = Array.isArray(payload.checklist) ? payload.checklist : [] + payload.checklist = checklist + + const covered = new Set(checklist.map((c) => c && c.anchor_id).filter(Boolean)) + + // 清掉引用了未定义槽位的条目(AI 偶尔会造新槽位名) + payload.checklist = checklist.filter((c) => { + if (!c || !c.anchor_id) return true + if (anchorIds.indexOf(c.anchor_id) >= 0) return true + fixes.push(`清单:移除引用未定义槽位的条目(${c.anchor_id})`) + return false + }) + const covered2 = new Set(payload.checklist.map((c) => c && c.anchor_id).filter(Boolean)) + + cards.forEach((c) => { + if (!c || !c.anchor_id) return + if (covered2.has(c.anchor_id)) return + if (!isNonHighFact(c)) return + payload.checklist.push({ + anchor_id: c.anchor_id, + field: 'auto_added', + text: `「${String(c.title || c.anchor_id)}」含低置信信息,出行前需核实`, + confidence: 'low', + verify: '见该节点卡片中的「核实」说明;本条由服务端自动补齐(原清单漏收该槽位)' + }) + fixes.push(`清单:为槽位 ${c.anchor_id} 自动补一条待核实项`) + }) +} + +// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐 +// 骨架已声明该槽位是硬时间点,但 AI 可能把活动类型(sleep/eat/hike…)填进 kind; +// 这里把 kind 对齐为 hard,并把原活动名搬进 activity(该行还没有 activity 时), +// 不改任何事实、也不丢 AI 原本表达的语义。 +const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk'] +const alignHardKind = (payload, bundle, fixes) => { + const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : [] + const hardIds = anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id) + if (!hardIds.length) return + const days = (payload.timeline && Array.isArray(payload.timeline.days)) ? payload.timeline.days : [] + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (!r || hardIds.indexOf(r.anchor_id) < 0 || r.kind === 'hard') return + const old = String(r.kind || '未填') + const isActivity = ACTIVITIES.indexOf(r.kind) >= 0 + if (isActivity && !r.activity) r.activity = r.kind + r.kind = 'hard' + fixes.push(`时序:槽位 ${r.anchor_id} 的 kind「${old}」对齐为 hard(骨架已声明该槽位为硬时间点` + + (isActivity ? `,原值 ${old} 已搬入 activity` : '') + ')') + }) + }) +} + +// 4.【本技能专属】返程死线提示必须存在。 +// 这是用户给出的硬约束(周日返、21:00 前抵沪、周一要上班、晚了没地铁), +// 属于「客观规则」而非「事实」,所以服务端可以安全补齐,不算编造。 +const ensureReturnDeadlineNote = (payload, fixes) => { + if (!Array.isArray(payload.notes)) payload.notes = [] + const has = payload.notes.some((n) => n && n.level === 'danger' + && (String(n.text || '').indexOf('21:00') >= 0 || String(n.text || '').indexOf('地铁') >= 0)) + if (has) return + payload.notes.unshift({ + level: 'danger', + title: '返程死线:周日 21:00 前必须抵达上海', + text: '周日务必下午出发返程,最晚 21:00 抵达上海,为地铁末班留出余量(周一要上班)。' + + '若所选班次偏晚,请改更早的车次;抵沪后如接近末班时间,请直接打车回家。' + + '本条由服务端自动补齐(原报告未包含返程死线提示)。' + }) + fixes.push('提示:补齐「返程不晚于 21:00 抵沪」的 danger 级提示块') +} + +// 5.【本技能专属】待核实清单的 confidence 越界(标成 high)→ 降到 medium。 +// 为什么可以安全降级:契约里清单项的 confidence 只允许 medium/low(清单项天然是「待核实」的, +// 标 high=已核实 自相矛盾)。AI 常把「我很确定这条需要核实」写成 high,属于表达混淆。 +// 降级到 medium 是往更保守的方向修正,不会夸大信息的可靠性,也不会丢失「需要核实」这个语义。 +const normalizeChecklistConfidence = (payload, fixes) => { + if (!Array.isArray(payload.checklist)) return + payload.checklist.forEach((c) => { + if (!c) return + if (c.confidence && ['medium', 'low'].indexOf(c.confidence) < 0) { + const old = c.confidence + c.confidence = 'medium' + fixes.push(`清单:第 ${String(c.text || '').slice(0, 18)}… 的 confidence「${old}」→ medium(清单项只能是 medium/low)`) + } + }) +} + +// 入口:返回修复后的 payload 与修复说明清单 +const repairPayload = (payload, bundle) => { + const fixes = [] + if (!payload || typeof payload !== 'object') return { payload, fixes } + try { repairMarkerRefs(payload.map || {}, fixes) } catch (e) { fixes.push('地图修复异常:' + String(e.message || e)) } + try { fillChecklistGaps(payload, bundle || {}, fixes) } catch (e) { fixes.push('清单修复异常:' + String(e.message || e)) } + try { alignHardKind(payload, bundle || {}, fixes) } catch (e) { fixes.push('硬时间点对齐异常:' + String(e.message || e)) } + try { ensureReturnDeadlineNote(payload, fixes) } catch (e) { fixes.push('返程死线提示异常:' + String(e.message || e)) } + try { normalizeChecklistConfidence(payload, fixes) } catch (e) { fixes.push('清单置信度修复异常:' + String(e.message || e)) } + return { payload, fixes } +} + +module.exports = { repairPayload, normId } \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/report_spec.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/report_spec.json new file mode 100644 index 0000000..c0a1610 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/report_spec.json @@ -0,0 +1,52 @@ +{ + "_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端渲染逻辑不用改结构。", + "_isolation_comment": "【隔离用意】本文件是【本 skill 私有的渲染描述】。本 skill 复用了与 sh_2d_car_hike 相同的 8 种 block 类型(overview/timeline/cards/map/table/cost/checklist/notes),这是刻意为之 —— block 类型是『展示形态』的抽象,够通用;而『车次表』『市内交通表』这类业务差异,靠 table 里的列定义与 pick_id 表达即可,不必新增 block 类型。所以本 skill 的渲染器只需改自己的字典(activity 中文名、marker 颜色),不必改渲染结构。这是隔离后最省力的定制方式。若将来某个 skill 真需要全新展示形态(例如航班时刻轴),那时再为它单独加 block,不影响本文件。", + "version": "1.1.1", + + "block_types_comment": "通用渲染器支持的 8 种 block;本 skill 未新增 block 类型", + "block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"], + + "layout_comment": "页面骨架:单列纵向", + "layout": "single_column", + "show_index_comment": "是否在页首显示区块锚点导航", + "show_index": true, + + "sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项", + "sections": [ + { "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" }, + + { "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] }, + + { "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" }, + + { "id": "sec_cards", "type": "cards", "title": "节点详情(含导航地址)", "data": "$.cards", "group_by": "day" }, + + { "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" }, + + { "id": "sec_train_out", "type": "table", "title": "去程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_out_options" }, + + { "id": "sec_train_back", "type": "table", "title": "返程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_back_options" }, + + { "id": "sec_city_transport", "type": "table", "title": "市内交通方案 · 共享单车 / 网约车", "data": "$.tables", "pick_id": "city_transport" }, + + { "id": "sec_lodging", "type": "table", "title": "住宿候选对比", "data": "$.tables", "pick_id": "lodging_compare" }, + + { "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" }, + + { "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" }, + + { "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" }, + + { "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" } + ], + + "confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)", + "confidence_badge": { + "high": "已核实", + "medium": "待确认", + "low": "需核实" + }, + "image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景", + "image_disclaimer_default": "AI 示意图,非实景", + "map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准" +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/rules.md b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/rules.md new file mode 100644 index 0000000..c91a73b --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/rules.md @@ -0,0 +1,302 @@ +# 上海周边高铁游周末两天一晚 · 生成规则 + +> 本文件是 `sh_2d_rail_hike` 技能包的规则正文,生成时会整段注入 system prompt。 +> 与本 skill 的骨架(`skill.json`)和输出契约(`output_schema.json`)配套使用,三者必须口径一致。 +> +> **隔离说明**:本文件是**本 skill 私有**的规则,刻意与 `sh_2d_car_hike/rules.md` 不同 —— 那份讲的是自驾 + 车内过夜 + 停车方案,本份讲的是高铁 + 市内交通 + 住宿。请勿把两份合并为「通用旅游规则」;每个 skill 的打法不同,规则就该各写各的。 + +--- + +## 一、场景定义 + +用户在上海,周末(周六去、周日回)坐**高铁**去一个目的地城市玩两天一晚。 +到达后**没有自驾车**,市内靠**共享单车 + 网约车 + 步行 + 地铁**。 +用户最看重三件事: +1. **每个地点都要有能直接导航的具体地址**,不要泛泛而谈; +2. **午饭和晚饭要「本地人爱去 + 评分好」**,不要游客店; +3. **周六晚上别空着** —— 晚饭后到回住处之间要有夜逛(闹市自由活动)。 + +--- + +## 二、绝对禁止(违反即算生成失败) + +1. **禁止编造高铁车次号**(如 `G1234`、`D3125`)。 + 你无法查询 12306,任何车次号都是编造的,会直接误导用户赶错车。 + 只允许输出:`出发站` / `到达站` / `建议时段` / `历时区间` / `二等座参考票价区间`。 + 并必须写明「车次与时刻以 12306 实时查询为准」。 + +2. **禁止编造店名、酒店名、票价精确值**。 + 不确定的业态可以只给「类型 + 位置描述」(如「平江路北段、临河的本地小馆」), + 但**不许编一个看起来很真的名字**。 + +3. **禁止把精确票价写成单一数字**。必须写区间(如「二等座 约 ¥73-92」)。 + +4. **禁止出现无法导航的地址**。 + ❌「平江路」 ✅「苏州市姑苏区平江路历史街区」 + ❌「老城区某处」✅「南京市秦淮区夫子庙步行街东牌坊」 + 每个 `map.markers[]` 必须有 `address` 字段,且每张卡片建议带一条 label 为「导航」的字段。 + +5. **禁止把不能过夜的洗浴写成「可留宿」**。必须核实并标注。 + +6. **禁止静默降低要求**。触发降级规则时必须显式写出降级说明。 + +7. **禁止编造餐馆评分**。评分必须是真实可查的区间(如 4.3-4.6)。 + 不确定就给区间 + 标 `low` + 写 `verify`,**不许编一个精确的假分数**。 + +8. **禁止晚饭后留白**。周六 19:00-21:00 必须有夜逛内容(见 3.5), + 不得出现「吃完饭直接回住处」这种把两小时浪费掉的安排。 + +9. **禁止编造不存在的夜市 / 夜间去处**。本地没有就按 `no_night_market` 降级并说明。 + +--- + +## 三、硬约束(必须满足) + +### 3.0 第一天时间轴(骨架 · 不得留白) + +这是本技能最重要的一张表。按它排,不要自己另排一套。 + +| 时段 | 内容 | 槽位 | +|---|---|---| +| 08:00-10:00 | 乘高铁出发、抵达目的地 | `train_out` | +| 到达后 30-60 分钟 | 出站 → 接驳 → 行李寄存 | `station_to_lodging` / `city_mobility` | +| 12:00-13:30 | 午饭(本地人爱去 + 评分好) | `lunch_d1` | +| 14:00-15:30 | 第一段徒步(1-2 小时) | `hike_1` | +| 15:30-17:00 | **白天开放的景点**(务必在闭园/最后入场前到达) | `spot_d1` | +| 17:30-18:45 | 晚饭(本地人爱去 + 评分好,必须在 19:00 前吃完) | `dinner_d1` | +| **19:00-21:00** | **夜逛闹市(自由活动)—— 这一段必须有内容,不许空白** | `night_stroll` | +| 21:30-22:30 | 返回住处 / 入住(hard 时间点) | `lodging_night` | + +**为什么要专门规定 19:00-21:00**:用户明确反馈过,原来的行程 18:00 吃完晚饭就直接回住处, +19:00 到 21:00 空着一大段,时间被浪费。所以晚饭提前到 17:30-18:45, +把 19:00-21:00 完整留给夜逛。**禁止出现「吃完饭直接回住处」的安排。** + +### 3.1 返程(最高优先级,不可妥协) + +- **周日必须下午出发返程,且最晚 21:00 抵达上海。** + 原因:用户周一要上班;晚于地铁末班将无法回家。 + 若某班次无法满足,必须改更早的班次并说明原因。 +- 必须给出「从最后一个景点到车站」的交通方式与耗时, + 并**倒推留出至少 40 分钟**安检检票缓冲。 +- 必须给出「抵沪后从车站回家」的方式说明,并提示末班地铁时间; + 若接近末班,必须提醒打车。 + +### 3.2 出发(按 `departure_window` 分两种模式,结构不同,不得混用) + +**模式 A:周六出发(`sat_early` / `sat_morning`)** +- 结构:周六去 → 目的地住 1 晚 → 周日返。**住宿 1 晚**。 +- 若目的地单程车程 **> 3 小时**,必须显式提示: + 「当日可用时间仅约 X 小时,行程已按此压缩」, + 并建议改选 `sat_early` 或 `fri_night`,说明两种模式的差别。 +- 若车程 **> 3 小时** 且用户选了 `sat_morning`,必须触发降级规则 `tight_transfer`。 + +**模式 B:周五晚出发(`fri_night`)** +- 结构:周五晚上车 → **车上过一夜**(卧铺或末班车坐到目的地附近)→ 周六早上抵达 → 目的地住 1 晚 → 周日返。 +- 此时**目的地只需 1 晚住宿**,但**总晚数为 2**(一在车上、一在目的地)。 + 报告中必须写清楚这一点,避免用户以为要订两晚酒店。 +- 必须说明车上的过夜方式(卧铺/硬座/是否需自带颈枕与眼罩), + 并提示「周五晚出发需提前订票,热门线路卧铺紧张」。 + +### 3.3 徒步(两天各一段,共 2 段) + +- 每段时长必须落在 **1-2 小时**区间,且按 `hike_intensity` 取值: + - `easy` → 约 1 小时,平路/缓坡 + - `medium` → 约 1.5 小时,可含 200m 内爬升 + - `strong` → 约 2 小时,可含 400m 内爬升 +- 必须写出「按步速折算里程」的口径。 +- 必须给出:起点 / 终点 / 环线或折返方式 / 累计爬升 / 路面类型 / 难度 / 补水点。 +- 必须给出「从上一个节点怎么到达徒步起点」以及**单车能否停在该起点**。 +- 两段徒步必须在**地形或景观上有所区别**,不得雷同。 +- 目的地无山时触发 `terrain_flat` 降级(见第五节)。 + +### 3.4 周六下午 · 白天开放的景点(`spot_d1`) + +- 必须选一个**白天开放**的景点:博物馆 / 园林 / 故居 / 纪念馆 / 观景平台等。 +- **必须把开放时间写全**:几点开门、**几点停止入场**、几点闭馆。 + 多数景点在闭馆前 30-60 分钟停止入场,**「最后入场时间」比闭馆时间更关键**,必须单独写出来。 +- **必须做倒推校验**:从徒步终点到该景点的交通耗时 + 计划停留时长, + 必须全部落在「最后入场时间」之前。来不及就必须改选更近的景点或缩短徒步, + **绝不能安排一个到门口已停止入场的行程**。 +- 必须给门票价格、是否需提前预约(很多博物馆/园林要预约,旺季名额紧张)。 +- 必须与第二天的 `spot_d2` 去重,不得重复。 +- 必须给可导航地址。 + +### 3.5 周六晚 · 夜逛闹市(`night_stroll`)—— 本技能新增 + +- **性质是「自由活动」**:给出可逛范围与 **≥3 个可选去处**, + 不强制打卡顺序,让用户自己取舍。不要写成「必须按 1→2→3 走完」的硬行程。 +- 范围优先选:**闹市步行街 / 历史街区夜景 / 夜市 / 江边河畔步道 / 灯光景观带**。 +- 必须说明:该区域**夜间是否开放**、人流情况、夜间照明与安全情况。 +- 必须给出**夜间交通情况**:地铁末班时间、该时段能否叫到网约车、共享单车夜间是否可用。 +- 必须给出从夜逛区域**返回住处**的方式与耗时(夜间优先网约车)。 +- 必须提示夜间安全与财物注意事项。 +- 每个可选去处都必须给可导航地址。 +- 若目的地确实无成熟夜市/夜景带,按 `no_night_market`(见第五节)降级并**显式标注**, + **不得编造不存在的夜市**。 + +### 3.6 餐馆双筛选(午饭 + 晚饭都要) + +`lunch_d1` 与 `dinner_d1` 必须**同时**满足两条,缺一不可: + +| 条件 | 必须给出的依据 | +|---|---| +| ① 本地人爱去 | 非游客街主街、本地招牌菜、本地评价占比高、本地人复购 | +| ② 评分好 | 主流点评平台(大众点评 / 高德)的**评分区间**(如 4.3-4.6)+ **评价条数量级** | + +判定细则: +- 评分高但明显是游客店的 → **排除**。 +- 本地人常去但评分明显偏低(< 4.0)的 → 必须说明原因并给出替代。 +- **绝对不许编造评分数字**。不确定就给区间并标 `low` 置信度 + 写 `verify`。 +- 另外:晚饭**必须在 19:00 前吃完**,不得推荐上菜慢或需长时间排队的店; + 位置尽量靠近夜逛街区,吃完直接逛,省去挪车与打车。 + +### 3.7 市内交通(共享单车 + 网约车) + +这是本 skill 区别于自驾 skill 的核心维度,必须落到 `city_transport` 表格: + +| 要求 | 说明 | +|---|---| +| 分段策略 | 哪些段步行、哪些段共享单车、哪些段必须网约车(含理由) | +| 共享单车 | 品牌与开锁方式建议(当地是否在运营区);**必须提示景区/步行街常有禁停区,须停到指定停车点** | +| 网约车 | 上车点建议:热门景点门口通常不能临停,要指明具体可上车的位置 | +| 预估费用 | 共享单车按 1.5 元/15 分钟估;网约车按起步价 + 里程估 | +| 行李约束 | 若 `luggage = suitcase`(带拉杆箱),**禁止安排骑车**,且必须先寄存行李 | + +禁停区范围与上车点位置属易变信息,**必须标注非 high 置信度并给核实方式**。 + +### 3.8 住宿 / 洗浴留宿 + +按 `lodging_mode` 严格执行,不得擅自更改用户所选形态: + +- `hotel`:房型建议(按人数)/ 价格区间 / 是否含早 / 入住退房时间 / 取消政策 / 离次日动线距离。 +- `bathhouse`:必须排除社区小浴室,要求规模较大、档次中上; + 必须给出营业面积区间、开业年限、本地评价量级等可核实依据; + **必须明确说明是否真能过夜**(部分洗浴仅营业到凌晨)、是否需预约、是否分男女区、是否有安静休息区。 +- `both`:两种都给,并给对比表,由用户自选。 + +无论哪种,都必须说明**次日退房后能否免费寄存行李**,并给可导航地址。 +若本地无合规大型带留宿洗浴,触发 `no_bathhouse` 降级。 + +按 `budget_level` 控制档次与价格区间。 + +--- + +## 四、车次信息表达规范 + +`train_out_options` 与 `train_back_options` 两张表的列固定为: + +| 列 key | 列名 | 内容要求 | +|---|---|---| +| `window` | 建议时段 | 如「08:00-09:30」 | +| `from_station` | 出发站 | 如「上海虹桥站」 | +| `to_station` | 到达站 | 如「苏州站」 | +| `duration` | 历时 | 区间,如「23-35 分钟」 | +| `fare` | 二等座参考票价 | 区间,如「¥40-50」 | +| `note` | 备注 | 如「班次密集,高峰 10 分钟一班」 | + +**表格里不得出现车次号列。** 表下方 `note` 必须写: +「本表为时段建议,非具体车次;车次、时刻与票价请以 12306 实时查询为准。」 + +--- + +## 五、降级规则(触发即必须显式标注) + +| id | 触发 | 动作 | 必须声明 | +|---|---|---|---| +| `terrain_flat` | 目的地无山 | 徒步改走河岸绿道/公园林道/环湖步道,保留时长 | 「该段为平路徒步,累计爬升约 0,运动量按时长与步数计」,不得冒充山地效果;并补一条「想出汗」的建议 | +| `no_bathhouse` | 无合规大型带留宿洗浴 | 降级为温泉度假区/大型洗浴中心(有明确休息区),或在允许时改用酒店 | 「本地无传统大型带留宿洗浴,本条为替代方案」;给出人均与可留宿时段 | +| `no_bike_zone` | 景区/老城核心区禁行禁停单车 | 该段改纯步行或网约车 | 「该区域共享单车禁停,须在外围指定点还车」;给出最近合法还车点 | +| `tight_transfer` | 车程 >3h 且出发时段偏晚 | 压缩第一天行程(取消或缩短第一个节点) | 「当日可用时间仅约 X 小时,已按此压缩行程」;建议改班次并说明两种模式差别 | +| `no_night_market` | 目的地无成熟夜市 / 夜景灯光区 / 深夜可逛的步行街 | 降级为「就近街市散步或河畔步道夜走」,或「商场夜间营业时段(多数到 22:00)内逛」 | 「本地无成熟夜市,本条为就近街市/商场夜逛替代」;**不得编造不存在的夜市** | + +--- + +## 六、置信度纪律 + +以下字段**一律不得标 `high`**,必须用对象形式并附 `verify`: + +`train_schedule`、`train_fare`、`bike_no_park_zone`、`taxi_pickup_point`、 +`opening_hours`、`bathhouse_grade`、`bathhouse_overnight_ok`、`reservation_required`、 +`locals_favorite`、**`restaurant_rating`(餐馆评分与评价数)**、 +**`night_market_open`(夜市/夜景区域夜间开放情况)**、`hygiene` + +`map.markers[].coord_precision` 一律填 `approx`(你给出的坐标只用于示意图定位, +真实导航以 `address` 为准)。 + +--- + +## 七、时序表字段规范 + +`timeline.days[].label` 写法自由(「周六 / 周日」或「第一天 / 第二天」都可以, +下游按锚点识别是哪一天,不依赖 label 的文案)。 + +每行必须含:`time` / `title` / `anchor_id` / `kind`。 + +- `kind` 只表示**时间点性质**,只能取 `hard` 或 `soft`: + - `hard` = 骨架里 `time_anchor.type` 为 `hard` 的槽位(本 skill 为 `lodging_night` 与 `train_back`) + - 其余为 `soft` +- `activity` 表示**活动类型**,可选,与本 skill 新增词表: + `walk` 步行 / `stroll` 逛街夜游 / `spot` 游览景点 / `bike` 共享单车 / `taxi` 网约车 / `metro` 地铁 / `train` 高铁 / + `eat` 吃饭 / `hike` 徒步 / `shop` 采买 / `checkin` 入住寄存 / `rest` 休息 + - `stroll` 专指「闲逛 / 夜逛」,与 `walk`(点对点步行通勤)区分。**夜逛行请用 `stroll`。** + - `spot` 专指「游览景点 / 展览」(如博物馆、园林、故居),与 `walk` 区分。 +- **`kind` 与 `activity` 是正交的两个字段**: + 「21:00 入住」应写 `kind="hard"` 且 `activity="checkin"`, + **绝不能**因为它是入住就把 `kind` 写成 `checkin`。 + +--- + +## 八、地图点位规范 + +- `markers` 必须覆盖当天所有落脚点:出发站、到达站、住宿点、徒步起终点、 + 景点、餐馆、**夜逛区域(`night_market`)**、单车上车/还车点、网约车上车点。 +- 每个 marker 必须有 `address`(可导航)。 +- 每个 marker 的 `kind` 取本 skill 枚举: + `start` / `end` / `waypoint` / `train_station` / `lodging` / `bathhouse` / + `district` / `food` / `hike` / `bike` / `taxi_point` / `night_market` / `spot` / `shop` / `walk` +- `routes[].kind` 取:`train` / `hike` / `stroll` / `bike` / `taxi` / `metro` +- 每条 route 的 `note` 必须说明「点位顺序示意,非真实道路/轨迹」。 + +--- + +## 九、待核实清单 + +必须覆盖每一个出现非 high 字段的 `anchor_id`,一个槽位至少一条。 +清单至少必须包含(`kind: "must"`): + +1. 往返高铁的车次、时刻与票价(12306 核对) +2. 住宿/洗浴是否可订、能否过夜、是否需预约 +3. 景区是否需要预约、开放时间(注意「最后入场时间」) +4. 共享单车禁停区与合法还车点 +5. **午饭与晚饭餐馆的评分与营业时间**(双筛选的另一半:本地人爱去已在报告里给依据, + 评分与打烊时间需用户自行复核) +6. **夜逛区域夜间是否开放**(夜市/夜景带的季节性营业、灯光开放时间) + +--- + +## 十、输出纪律 + +- 只输出**结构化 JSON**,符合 `output_schema.json`,根字段一个都不能少。 +- 不要任何解释、前后缀、Markdown 代码块标记。 +- 一次成卷,不拆成多次子调用拼接。 +- 不确定就降置信度并写进 `verify` 与待核实清单,**绝对不许编造**。 + +--- + +## 十一、费用项要求 + +`cost.items` 必须覆盖: +往返高铁票(按 2 人/1 人 × 票价区间中值估)/ 住宿或洗浴留宿 / +市内交通(共享单车 + 网约车,分开列)/ 餐饮(按 `budget_level` 与人数)/ +门票 / 杂费。 + +`cost.note` 必须写明「高铁票价按区间中值估算,实际以 12306 为准」。 + +--- + +## 十二、清单项的置信度 + +`checklist[].confidence` **只能取 `medium` 或 `low`**,不能填 `high`。 +清单是「待核实清单」,标成「已核实」自相矛盾。如果你想表达「这条几乎肯定需要核实」, +那是 `medium`,不是 `high`。 +(服务端会把越界的 `high` 自动降级为 `medium`,但请不要依赖这个兜底。) \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/skill.json b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/skill.json new file mode 100644 index 0000000..fed5e05 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/skill.json @@ -0,0 +1,351 @@ +{ + "_comment": "技能包:上海周边高铁游周末两天一晚(skill #2,skill_id=sh_2d_rail_hike)。一个 skill = 一整套行程打法,骨架固定、目的地可变。本文件是本 skill 的机读总纲:骨架槽位 + 硬约束 + 降级规则 + 置信度定义。", + "_isolation_comment": "【隔离用意】本文件与 sh_2d_car_hike/skill.json 是【两个独立文件】,不是同一份东西的两个副本。虽然字段名相似(都叫 anchors / hard_constraints),但槽位含义完全不同:本 skill 没有『停车』『车内过夜』这类自驾概念,取而代之的是『高铁车次时段』『市内交通接驳』『住宿或洗浴留宿』。请勿把两者做『抽公共模板』的合并 —— 那正是本 lab 明确禁止的方向:每个 skill 的打法不同,就该各写各的。要新增 skill,请复制本目录整体后在副本里改,而不是把本文件改成通用模板。", + + "id_comment": "skill 唯一标识(snake_case),与文件夹名一致", + "id": "sh_2d_rail_hike", + "name_comment": "技能显示名", + "name": "上海周边高铁游周末两天一晚", + "version_comment": "技能版本号;rules/骨架/输出契约任一改动都必须升版本,库里的定稿快照会记录生成时的版本。v1.1.0:把下午的『历史街区』改造为『白天开放景点』,并在晚饭后新增『夜逛闹市』槽位,消除 19:00-21:00 的空白。v1.1.1:词表补 spot(游览景点);修正校验器依赖 day.label 文案导致夜逛误判的 bug", + "version": "1.1.1", + "schema_version_comment": "输出契约版本,与 output_schema.json 的 version 保持一致", + "schema_version": "1.1.1", + "status_comment": "技能状态:draft=骨架打磨中;ready=可对外生成", + "status": "draft", + + "days_comment": "行程天数:周六出发、周日返程,两天一晚(若选『周五晚出发』的夜车模式,则车上过一夜、目的地住一晚)", + "days": 2, + "nights": 1, + + "base_city_comment": "固定集合城市(去程与返程均以此为起终点)", + "base_city": "上海", + "base_origin_comment": "默认出发点(具体到车站);由问答项 departure_station 输入,用户可改成任意出发车站(如上海站/上海南站),改站后去返程的换乘与到站时间需重算", + "base_origin": "上海虹桥站", + + "travel_mode_comment": "出行方式:高铁往返 + 市内共享单车/网约车,不涉及自驾", + "travel_mode": "high_speed_rail_plus_bike_taxi", + + "region_comment": "适用区域:以上海为起点、高铁单程 5 小时以内的目的地。注意本范围包含北京/武汉/青岛/长沙这类长途(非字面『周边』),所以规则里专门处理了出发时段与返程时刻的可行性", + "region": { + "label": "上海高铁单程 5 小时圈", + "radius_km": null, + "max_one_way_rail_hours": 5, + "terrain_preference_comment": "偏好地形:有山有水优先;平原城市则走河岸/绿道/公园林道", + "terrain_preference": ["山地", "丘陵", "水系", "城市绿道"] + }, + "refs_comment": "本技能包的其他文件(相对本文件夹路径),运行时按引用读取", + "refs": { + "rules": "rules.md", + "qa": "qa.json", + "output_schema": "output_schema.json", + "report_spec": "report_spec.json", + "destinations": "destinations.json" + }, + + "anchors_comment": "骨架槽位。每个槽位含:id / 天 / 顺序 / 时间锚点 / 硬约束 / 软偏好 / 地图角色。换目的地时槽位不变,只换值。注意:本 skill 的『市内交通(共享单车+网约车)』不是一个槽位,而是贯穿全篇的维度(体现为 city_transport 表格 + 每张卡片的『如何到达』字段 + 时序行的 activity)", + "anchors": [ + { + "id": "train_out", + "day": 1, + "order": 10, + "title": "去程高铁(时段 · 车站 · 历时)", + "time_anchor": { + "type": "soft", + "label": "依 departure_window 而定", + "suggest": "周六 06:00-10:00,或周五晚 18:00 后(夜车)" + }, + "hard_constraints": [ + "禁止给出具体车次号(如 G1234)—— 模型无法查询 12306,写出的车次号必然是编造的,属于严重误导。只给:出发站 / 到达站 / 建议时段 / 历时区间 / 二等座参考票价区间", + "必须写明『车次与时刻以 12306 实时查询为准』,并把该条列入待核实清单", + "必须给出『提前多久到站』的建议(虹桥等大站建议 40 分钟以上,含安检与检票)", + "若所选出发时段与目的地车程不匹配(例如车程 5h 却选周六上午出发),必须显式提示『当天在目的地只剩半天』并建议改时段,不得静默按原时段排一条时间不够的行程", + "票价必须写为区间(如 二等座 约 ¥180-220),不得写成精确单一数字" + ], + "soft_preferences": ["优先选择到达时间在上午的班次"], + "map_role": "去程起点(出发车站)与到达车站" + }, + { + "id": "city_mobility", + "day": 1, + "order": 20, + "title": "市内交通方案(共享单车 + 网约车)", + "time_anchor": { "type": "none" }, + "hard_constraints": [ + "必须给出市内出行的总体策略:哪些段适合共享单车、哪些段必须网约车、哪些段直接步行", + "必须给出共享单车品牌与开锁方式建议(如 美团/哈啰/青桔,需先确认当地是否在运营区),并提示『景区/步行街常有禁停区,须停到指定停车点』", + "必须给出网约车上车点建议:热门景点门口通常不能临停,要指明具体可上车的位置(如『XX 路口西侧临时停靠点』)", + "必须给出市内交通的预估总花费(共享单车按 1.5 元/15 分钟估,网约车按起步价 + 里程估)", + "禁停区范围与上车点位置属于主观/易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["3km 内优先单车,超过 5km 或带行李时优先网约车"], + "map_role": "不单独打点;其结论体现在其余槽位的『如何到达』字段与 city_transport 表格" + }, + { + "id": "station_to_lodging", + "day": 1, + "order": 30, + "title": "出站接驳 + 行李寄存/入住", + "time_anchor": { "type": "soft", "suggest": "到达后 30-60 分钟内完成" }, + "hard_constraints": [ + "必须给出从车站到住宿点的具体方式(地铁几号线/网约车/步行)与耗时", + "必须说明『先寄存还是先入住』:多数酒店 14:00 后才可入住,早到必须先寄存行李再出行,不得让用户拖着行李走行程", + "若住宿点可寄存,必须写明寄存是否免费", + "必须给出住宿点的可导航地址(详见 address 字段要求)" + ], + "soft_preferences": ["住宿优先选在当日活动动线附近,减少往返"], + "map_role": "到达车站 → 住宿点(接驳示意)" + }, + { + "id": "lunch_d1", + "day": 1, + "order": 40, + "title": "第一天午饭(本地人爱去 + 评分好 · 人均≈100)", + "time_anchor": { "type": "soft", "suggest": "按实际到达时间顺延,通常 12:00-13:30" }, + "hard_constraints": [ + "【双筛选,两条都要满足】① 本地人爱去:必须给出判断依据(非游客街主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好:必须给出该店在主流点评平台(大众点评 / 高德)的评分区间(如 4.3-4.6),并说明评分样本量级(评价条数)", + "禁止只满足其一:评分高但明显是游客店的要排除;本地人常去但评分明显偏低(<4.0)的也要说明原因并给出替代", + "必须推荐 2-3 家,且标注与当前动线的关系(步行可达 / 需单车 / 需打车)", + "人均目标 100 元左右,超出必须说明理由", + "必须给出推荐菜", + "按出行人数适配份量", + "评分与评价数属于易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["本地时令菜/老字号优先;评分 4.3 以上优先"], + "map_role": "候选餐馆点" + }, + { + "id": "hike_1", + "day": 1, + "order": 50, + "title": "第一段徒步(1-2 小时)", + "time_anchor": { "type": "soft", "suggest": "下午 14:00-15:30 之间完成" }, + "hard_constraints": [ + "时长必须落在 1-2 小时区间,并写出按步速折算的里程口径", + "必须给出起点/终点/环线或折返方式", + "必须给出累计爬升、路面类型、难度、补水点", + "必须给出『从上一个节点怎么到达徒步起点』(步行/共享单车/网约车三选一,并说明单车能否停在该起点)", + "无山的城市必须显式降级为河岸/绿道/公园林道,并写明『平路徒步,累计爬升约 0』,不得冒充山地徒步效果" + ], + "soft_preferences": ["有山有水优先;城市内则选沿江/沿河绿道"], + "map_role": "徒步轨迹(示意图,非真实轨迹)" + }, + { + "id": "spot_d1", + "day": 1, + "order": 60, + "title": "周六下午 · 白天开放的景点(须在闭园前)", + "time_anchor": { "type": "soft", "suggest": "15:30-17:00(务必在下班/闭园前抵达)" }, + "hard_constraints": [ + "【本槽位核心:白天开放】必须选一个『白天开放』的景点(博物馆 / 园林 / 故居 / 纪念馆 / 观景平台等),并把开放时间写全(几点开门、几点停止入场、几点闭馆)", + "必须倒推校验:从徒步终点到该景点的交通耗时 + 计划停留时长,必须全部落在闭园前。若来不及,必须改选更近的景点或缩短徒步,不得安排一个到门口已闭馆的行程", + "必须给出门票价格、是否需要提前预约(很多博物馆/园林需预约,旺季名额紧张)", + "必须说明『最后入场时间』(多数景点闭馆前 30-60 分钟停止入场,这比闭馆时间更关键)", + "必须与第二天的 spot_d2 去重,不得重复", + "必须给可导航地址", + "开放时间、门票、预约政策属易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["免费或低价的人文类景点优先(博物馆/故居);有室内空间的优先(雨天可看)"], + "map_role": "景点点位" + }, + { + "id": "dinner_d1", + "day": 1, + "order": 70, + "title": "第一天晚饭(本地人爱去 + 评分好)", + "time_anchor": { "type": "soft", "suggest": "17:30-18:45(提前吃完,把 19:00-21:00 留给夜逛)" }, + "hard_constraints": [ + "【双筛选,两条都要满足】① 本地人爱去:必须给出判断依据(非游客街主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好:必须给出该店在主流点评平台(大众点评 / 高德)的评分区间,并说明评价样本量级", + "必须与午饭的菜系/店型有所区别", + "必须给出营业到几点(避免推荐完到店已打烊)", + "【时间约束】晚饭必须在 19:00 前吃完,因为后面还有夜逛环节;不得推荐上菜慢或需要长时间排队的店", + "必须给出该店到夜逛起点的方式与耗时(尽量步行可达,省去挪车/打车)", + "必须给可导航地址", + "评分与评价数属于易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["本地家常菜/老字号优先;位置靠近夜逛街区优先(吃完直接逛)"], + "map_role": "晚餐点" + }, + { + "id": "night_stroll", + "day": 1, + "order": 80, + "title": "周六夜 · 闹市夜逛(自由活动,19:00-21:00)", + "time_anchor": { "type": "soft", "suggest": "19:00-21:00(约 2 小时,晚饭后到回住处之间)" }, + "hard_constraints": [ + "【本槽位存在的意义】晚饭结束到回住处入住之间(约 19:00-21:00)不允许空白。必须安排一段夜逛,不得出现『吃完饭直接回住处休息』的安排", + "必须明确写出这是『自由活动』性质:给出可逛范围与若干可选去处(≥3 个),不强制打卡顺序,让用户自行取舍", + "范围优先选:闹市步行街 / 历史街区夜景 / 夜市 / 江边河畔步道 / 灯光景观带。必须说明该区域夜间是否开放、是否安全、人流情况", + "必须给出夜间公共交通情况:地铁末班时间、该时段能否叫到网约车、共享单车夜间是否可用", + "必须给出从夜逛区域返回住处的交通方式与耗时(夜间优先网约车)", + "必须提示夜间安全与财物注意事项", + "若目的地确实无成熟夜市/夜景观光区,必须显式降级为『就近街市或河畔散步』并说明理由,不得编造不存在的夜市", + "每个可选去处都必须给可导航地址", + "夜市/夜间开放情况属易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["有夜景灯光或河畔步道的优先;小吃夜市优先于纯购物商场"], + "map_role": "夜逛区域(中心点 + 可选去处点位)" + }, + { + "id": "lodging_night", + "day": 1, + "order": 90, + "title": "住宿或高级洗浴留宿(过夜)", + "time_anchor": { "type": "hard", "label": "不晚于 22:30 抵达住处", "suggest": "21:30-22:30 入住" }, + "hard_constraints": [ + "形态由问答项 lodging_mode 决定:酒店/民宿 或 高级洗浴(带留宿) 或 两者都给。不得擅自更改用户所选形态", + "若选『高级洗浴(带留宿)』:必须排除社区小浴室,要求规模较大、档次中上;必须给出营业面积区间、开业年限、本地评价量级等可核实依据;必须明确说明是否真能过夜(部分洗浴仅营业到凌晨,不可留宿)、是否需提前预约、是否分男女区、是否有安静休息区", + "若选『酒店/民宿』:必须给出房型建议(按人数)、价格区间、是否含早、入住/退房时间、取消政策、离次日动线的距离", + "必须给出免费寄存行李的说明(次日退房后仍可寄存,方便继续游玩)", + "必须给可导航地址", + "档次、卫生、能否过夜均属主观/易变信息,必须标注非 high 置信度并给核实方式" + ], + "soft_preferences": ["洗浴留宿优先选本地人复购率高、有独立休息区的"], + "map_role": "住宿点(夜间终点)" + }, + { + "id": "breakfast_d2", + "day": 2, + "order": 90, + "title": "第二天早饭", + "time_anchor": { "type": "soft", "suggest": "07:00-08:30" }, + "hard_constraints": [ + "必须给出二选一或都给:店内吃(本地早点铺)或住处/便利店解决", + "店内吃必须给出开门时间(7 点前是否已营业)", + "必须给出从住处到早点的距离与方式", + "必须给可导航地址" + ], + "soft_preferences": ["本地早点(面条/汤包/烧饼等)优先"], + "map_role": "早点铺" + }, + { + "id": "hike_2", + "day": 2, + "order": 100, + "title": "第二段徒步(1-2 小时 · 与第一天不同)", + "time_anchor": { "type": "soft", "suggest": "08:30-10:30" }, + "hard_constraints": [ + "时长必须落在 1-2 小时区间,且有实际运动量(写清爬升与折算口径)", + "必须与第一段徒步在地形/景观上有所区别,避免重复", + "必须给出『从住处怎么到达起点』(步行/共享单车/网约车),并说明单车停放", + "必须给出徒步终点如何衔接下一站", + "无山城市必须显式降级并标注(同 hike_1)" + ], + "soft_preferences": ["山水结合、有制高点视野"], + "map_role": "徒步轨迹(示意图)" + }, + { + "id": "spot_d2", + "day": 2, + "order": 110, + "title": "返程前穿插的景点/街区", + "time_anchor": { "type": "soft", "suggest": "10:30-13:00" }, + "hard_constraints": [ + "数量 1-2 个,典故/成因必须写清(故居类要有生平要点,展馆类要说明看什么)", + "必须给出开放时间、门票、是否需要预约", + "必须与第一天的街区打卡点去重,不得重复", + "必须与返程车站顺路(或明确给出到车站的交通方式与耗时),不得安排一个与车站反向的景点导致赶车风险", + "必须给可导航地址" + ], + "soft_preferences": ["故居/人文类优先于纯拍照点"], + "map_role": "打卡点" + }, + { + "id": "train_back", + "day": 2, + "order": 120, + "title": "返程高铁(周日下午出发 · 21:00 前必须抵沪)", + "time_anchor": { + "type": "hard", + "label": "周日下午出发,最晚 21:00 抵达上海", + "suggest": "周日 13:00-16:00 之间发车" + }, + "hard_constraints": [ + "『21:00 前抵沪』是硬约束,不可漂移:用户周一要上班,且晚于地铁末班将无法回家。若所选班次无法满足,必须改早班次并说明原因", + "禁止给出具体车次号(同 train_out,理由相同),只给出发站/到达站/建议时段/历时区间/二等座参考票价区间,并写明以 12306 为准", + "必须给出『从最后一个景点到车站』的交通方式与耗时,并倒推留出至少 40 分钟安检检票缓冲", + "必须给出抵沪后从车站回家的方式说明(末班地铁时间提示;若接近末班,必须提醒打车)", + "必须提示周日下午为返程高峰,票源紧张且车站人多,建议提前购票" + ], + "soft_preferences": ["优先选 16:00 前发车的班次,给抵沪后的地铁留足余量"], + "map_role": "返程起点(返程车站)与终点(抵沪车站)" + } + ], + + "degradation_rules_comment": "地形/业态不支持时的降级规则:降级不是省略,而是显式替换 + 明确标注,绝不允许静默降低要求", + "degradation_rules": [ + { + "id": "terrain_flat", + "trigger": "目的地无山(如苏州、嘉兴等平原城市)", + "action": "徒步改走河岸绿道 / 公园林道 / 环湖步道,保留 1-2 小时时长", + "must_declare": "必须写明『该段为平路徒步,累计爬升约 0,运动量按时长与步数计』,不得冒充山地徒步效果", + "extra": "必须补一条『想出汗』的建议(配速、快走替代)" + }, + { + "id": "no_bathhouse", + "trigger": "目的地无合规大型带留宿洗浴", + "action": "降级为温泉度假区 / 大型洗浴中心(且有明确休息区),或在 lodging_mode 允许时改用酒店", + "must_declare": "必须标注『本地无传统大型带留宿洗浴,本条为替代方案』", + "extra": "必须给出单次人均与可留宿时段" + }, + { + "id": "no_bike_zone", + "trigger": "景区/老城核心区为共享单车禁行或禁停区", + "action": "该段改为纯步行或网约车,并明确指出禁停范围", + "must_declare": "必须标注『该区域共享单车禁停,须在外围指定点还车』", + "extra": "必须给出最近的合法还车点位置" + }, + { + "id": "no_night_market", + "trigger": "目的地无成熟夜市 / 夜景灯光区 / 深夜可逛的步行街", + "action": "降级为『就近街市散步或河畔步道夜走』,或『商场夜间营业时段(多数到 22:00)内逛』", + "must_declare": "必须标注『本地无成熟夜市,本条为就近街市/商场夜逛替代』,不得编造不存在的夜市", + "extra": "必须给出替代区域的夜间照明与人流情况;若连商场也早关门,就老实写明『本目的地夜间可逛内容有限』并给出安全的就近散步建议" + }, + { + "id": "tight_transfer", + "trigger": "目的地车程 3 小时以上、所选出发时段偏晚", + "action": "压缩第一天行程(取消或缩短第一个节点),并显式提示时间紧张", + "must_declare": "必须写明『当日可用时间仅约 X 小时,已按此压缩行程』", + "extra": "必须建议改选更早班次或周五晚出发,并说明两种模式的差别" + } + ], + + "confidence_levels_comment": "置信度三档定义。主观项(车次时段、票价、禁停区、网约车上车点、营业时间、洗浴档次与能否过夜、是否需预约)必须给置信度,不得默认 high", + "confidence_levels": { + "high": "有明确可核实来源(车站/景区官方公告、连锁品牌明确信息)", + "medium": "常见认知或口碑信息,大体可靠但需现场/出发前确认", + "low": "推测或信息不足,仅作方向性参考,必须附核实方式" + }, + "unverified_fields_comment": "这些字段默认不允许标 high,必须附 verify(核实方式)。v1.1.0 新增 restaurant_rating(餐馆评分与评价数)与 night_market_open(夜市/夜景区域夜间开放情况)", + "unverified_fields": [ + "train_schedule", + "train_fare", + "bike_no_park_zone", + "taxi_pickup_point", + "opening_hours", + "bathhouse_grade", + "bathhouse_overnight_ok", + "reservation_required", + "locals_favorite", + "restaurant_rating", + "night_market_open", + "hygiene" + ], + + "output_discipline_comment": "生成纪律:只输出结构化 JSON(符合 output_schema.json),禁止夹带散文;不确定就降置信度并写进 verify,不许编造", + "output_discipline": { + "format": "json_only", + "one_call": "一次成卷,不拆成多次子调用拼接", + "forbid": [ + "编造高铁车次号(如 G1234)", + "编造店名与价格", + "编造餐馆评分(评分必须是真实可查的区间,不确定就写区间并标 low)", + "编造不存在的夜市", + "把精确票价写成单一数字(必须区间)", + "把无法过夜的洗浴写成『可留宿』", + "给无法定位的泛指地址(如只写『XX 老街』)", + "晚饭后直接回住处、让 19:00-21:00 空白(本技能必须安排夜逛)", + "省略 must_declare 标注" + ] + } +} \ No newline at end of file diff --git a/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/validate.js b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/validate.js new file mode 100644 index 0000000..1cfb080 --- /dev/null +++ b/src/server/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/validate.js @@ -0,0 +1,309 @@ +// ============================================================ +// sh_2d_rail_hike/validate.js —— 本技能生成结果的结构校验(技能私有) +// +// 为什么要服务端校验:AI 一定会变形。与其相信它,不如生成后按「本技能骨架 + 契约」硬校验一遍, +// 把不合格的地方作为 warnings / errors 回传,前端在交互弹窗里显示出来。 +// +// 【隔离用意 · 请后续开发者先读这段】 +// 本文件是【本技能私有的校验器】,不是全 lab 公共校验器。它与 sh_2d_car_hike/validate.js +// 有这些刻意的不同,全部由本 skill 的业务特点决定: +// 1. ACTIVITIES 词表不同:多了 stroll(逛街/夜逛) / bike / taxi / metro / train / checkin / rest +// 2. 新增「marker.address 必填」校验(用户核心要求:每处都能导航) +// 3. 新增「车次表不得出现车次号列」校验(本 skill 明确禁止编造车次号) +// 4. v1.1.0 新增「晚饭后不得空白」校验(周六 19:00-21:00 必须有夜逛)与「餐馆须给评分」校验 +// 这些如果做成公共校验器,就必须靠 if (skill === 'xxx') 分支区分,那是本 lab 禁止的方向。 +// 要新增 skill,请复制本文件后在副本里改。 +// ============================================================ +const ROOT_KEYS = ['meta', 'summary', 'timeline', 'cards', 'map', 'tables', 'cost', 'checklist', 'notes'] + +// 本技能私有的活动类型词表(与 output_schema.json / rules.md 保持一致) +// stroll 专指「闲逛 / 夜逛」,与 walk(点对点步行通勤)区分 +// spot 专指「游览景点/展览」(v1.1.1 新增:AI 实际会用 spot 标记「下午景点」这一行) +const ACTIVITIES = ['walk', 'stroll', 'spot', 'bike', 'taxi', 'metro', 'train', 'eat', 'hike', 'shop', 'checkin', 'rest'] + +// 本技能私有的点位类型词表 +const MARKER_KINDS = ['start', 'end', 'waypoint', 'train_station', 'lodging', 'bathhouse', 'district', + 'food', 'hike', 'bike', 'taxi_point', 'night_market', 'spot', 'shop', 'walk'] + +// 本技能私有的路线类型词表 +const ROUTE_KINDS = ['train', 'hike', 'stroll', 'bike', 'taxi', 'metro'] + +const CONFIDENCE_LEVELS = ['high', 'medium', 'low'] + +// 车次号识别:G/D/C/Z/T/K + 3~4 位数字(如 G1234、D3125、C7654) +const TRAIN_NO_RE = /\b[GDCTZK]\d{3,4}\b/ + +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 validatePayload = (payload, bundle) => { + const errors = [] + const warnings = [] + const p = payload || {} + const skill = bundle.skill || {} + const anchors = Array.isArray(skill.anchors) ? skill.anchors : [] + const anchorIds = anchors.map((a) => a.id) + + // 1) 根字段 + ROOT_KEYS.forEach((k) => { + if (!(k in p)) errors.push('缺少根字段:' + k) + }) + + // 2) 卡片必须覆盖全部骨架槽位 + const cards = Array.isArray(p.cards) ? p.cards : [] + const cardAnchors = cards.map((c) => c && c.anchor_id) + anchorIds.forEach((a) => { + if (cardAnchors.indexOf(a) < 0) errors.push('卡片缺槽位:' + a) + }) + cardAnchors.forEach((a) => { + if (anchorIds.indexOf(a) < 0) warnings.push('卡片引用了未定义槽位:' + a) + }) + + // 3) 时序只能引用合法槽位,硬时间点必须以 kind=hard 出现 + const days = (p.timeline && Array.isArray(p.timeline.days)) ? p.timeline.days : [] + if (!days.length) errors.push('timeline.days 为空') + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (anchorIds.indexOf(r.anchor_id) < 0) warnings.push('时序引用未知槽位:' + r.anchor_id) + }) + }) + anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').forEach((a) => { + const rows = [] + days.forEach((d) => ((d && d.rows) || []).forEach((r) => { if (r && r.anchor_id === a.id) rows.push(r) })) + const label = String((a.time_anchor && a.time_anchor.label) || '') + if (!rows.length) { + warnings.push('硬时间点槽位未出现在时序:' + a.id + '(期望:' + label + ')') + return + } + if (!rows.some((r) => r.kind === 'hard')) { + const kinds = rows.map((r) => String(r.kind || '未填')).join(' / ') + const misled = rows.some((r) => ACTIVITIES.indexOf(r.kind) >= 0) + warnings.push('硬时间点槽位在时序中 kind 非 hard(实际:' + kinds + '):' + a.id + + (misled ? ' —— 疑似把活动类型填进了 kind(kind 只能是 hard/soft,活动类型应填 activity)' : '')) + } + }) + + // 3b) 时序行的 kind / activity 取值合法性(kind 与活动类型是正交字段,不允许混用) + // 顺带把两天所有时序行压平,供后面几处跨天检查复用(不再依赖 day.label 的具体写法) + const allRows = [] + days.forEach((d) => { + ;((d && d.rows) || []).forEach((r) => { + if (!r) return + allRows.push(r) + if (['hard', 'soft'].indexOf(r.kind) < 0) { + warnings.push('时序行 kind 取值非法(只能 hard/soft):' + String(r.kind) + ' @ ' + String(r.anchor_id)) + } + if (r.activity && ACTIVITIES.indexOf(r.activity) < 0) { + warnings.push('时序行 activity 取值非法(本技能词表:' + ACTIVITIES.join('/') + '):' + + String(r.activity) + ' @ ' + String(r.anchor_id)) + } + }) + }) + + // 本技能专属:返程行必须体现时间约束意识(周日返 / 最晚 21:00 抵沪)。 + // 注意不能依赖「标题里必须出现某个固定词」——AI 的措辞是多样的(返程 / 返沪 / 赴站), + // 所以聚合该槽位的全部行,只要任一行提到时间约束或返程语义即算通过。 + const RETURN_HINTS = ['21:00', '21 点', '返程', '返沪', '抵沪', '死线', '末班'] + const trainBackRows = allRows.filter((r) => r.anchor_id === 'train_back') + if (trainBackRows.length) { + const hit = trainBackRows.some((r) => { + const text = String(r.title || '') + ' ' + String(r.note || '') + return RETURN_HINTS.some((h) => text.indexOf(h) >= 0) + }) + if (!hit) { + warnings.push('返程时序行未体现时间约束(建议在标题或备注里写明「周日下午出发 / 最晚 21:00 抵沪」)') + } + } + + // 4) 契约要求的表格 + const tables = Array.isArray(p.tables) ? p.tables : [] + const requiredTableIds = (bundle.schema && bundle.schema.required_table_ids) || [] + requiredTableIds.forEach((id) => { + if (!tables.some((t) => t && t.id === id)) errors.push('缺表格:' + id) + }) + tables.forEach((t) => { + if (!t || !Array.isArray(t.columns) || !t.columns.length) { warnings.push('表格缺 columns:' + (t && t.id)); return } + const keys = t.columns.map((c) => c.key) + ;((t.rows) || []).forEach((r, i) => { + keys.forEach((k) => { + if (r && !(k in r)) warnings.push(`表格 ${t.id} 第 ${i + 1} 行缺列 ${k}`) + }) + }) + // 本技能专属:车次表里绝不能出现「车次号」列(模型无法查 12306,写出来必然是编的) + if (['train_out_options', 'train_back_options'].indexOf(t.id) >= 0) { + const badCol = t.columns.find((c) => { + const lab = String((c && (c.label || c.key)) || '') + return lab.indexOf('车次') >= 0 && lab.indexOf('时段') < 0 && lab.indexOf('建议') < 0 + }) + if (badCol) errors.push('车次表不得出现车次号列(本技能禁止编造车次号):' + t.id + ' 的列「' + (badCol.label || badCol.key) + '」') + } + }) + + // 4b) 全篇扫描:任何地方出现形如 G1234 的车次号都算违规(含表格单元格、字段文本、备注) + ;(function scanTrainNo(node, trail) { + if (typeof node === 'string') { + if (TRAIN_NO_RE.test(node)) { + errors.push('检测到疑似编造的高铁车次号:' + node.slice(0, 40) + ' @ ' + trail) + } + return + } + if (Array.isArray(node)) return node.forEach((v, i) => scanTrainNo(v, trail + '[' + i + ']')) + if (node && typeof node === 'object') { + Object.keys(node).forEach((k) => scanTrainNo(node[k], trail + '.' + k)) + } + })(p, '') + + // 5) 地图:路线引用的点位必须存在;每个点位必须有可导航地址(本技能核心要求) + const map = p.map || {} + const markers = Array.isArray(map.markers) ? map.markers : [] + if (!markers.length) errors.push('map.markers 为空') + const markerIds = markers.map((m) => m && m.id) + markers.forEach((m, i) => { + if (!m) return + if (!m.address || !String(m.address).trim()) { + errors.push(`点位 ${m.id || ('#' + (i + 1))} 缺 address(每个地点都必须可导航)`) + } else if (String(m.address).trim().length < 6) { + warnings.push('点位地址过短,可能无法定位:' + m.id + ' → ' + String(m.address)) + } + if (m.kind && MARKER_KINDS.indexOf(m.kind) < 0) { + warnings.push('点位 kind 取值非法(本技能词表):' + String(m.kind) + ' @ ' + m.id) + } + if (m.coord_precision && m.coord_precision !== 'approx') { + warnings.push('模型给出的坐标应一律标 approx(真实导航以 address 为准):' + m.id) + } + }) + ;(Array.isArray(map.routes) ? map.routes : []).forEach((r) => { + const ids = (r && r.marker_ids) || [] + ids.forEach((id) => { + if (markerIds.indexOf(id) < 0) warnings.push('路线 ' + (r && r.id) + ' 引用未知点位:' + id) + }) + if (ids.length < 2) warnings.push('路线点位少于 2 个:' + (r && r.id)) + if (!r || !r.note) warnings.push('路线缺示意图免责说明:' + (r && r.id)) + if (r && r.kind && ROUTE_KINDS.indexOf(r.kind) < 0) { + warnings.push('路线 kind 取值非法(本技能词表):' + String(r.kind) + ' @ ' + r.id) + } + }) + + // 6) 置信度:非 high 必须给核实方式 + 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 (CONFIDENCE_LEVELS.indexOf(c) < 0) errors.push('置信度取值非法:' + c + ' @ ' + trail) + if (c !== 'high') { + confNonHigh++ + if (!node.verify) warnings.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24)) + } + return + } + Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k)) + } + })(p, '') + + // 6b.【本技能专属】待核实清单的 confidence 只允许 medium / low。 + // 契约如此(清单项天然是「待核实」的,标 high=已核实 自相矛盾)。 + // 注意:这条以前写成「易变信息不应标 high @ 字段名」的通用扫描,但那种写法只会在清单项上触发, + // 且报错信息让人看不懂是在说清单的 confidence 越界。这里改成直白的清单专项检查。 + ;(p.checklist || []).forEach((c, i) => { + if (!c) return + if (c.confidence && ['medium', 'low'].indexOf(c.confidence) < 0) { + warnings.push(`待核实清单第 ${i + 1} 条的 confidence 应为 medium/low(清单项不宜标「已核实」):${c.confidence}`) + } + }) + + // 7) 待核实清单必须覆盖每个含非 high 字段的槽位 + const checklist = Array.isArray(p.checklist) ? p.checklist : [] + const checklistAnchors = checklist.map((c) => c && c.anchor_id).filter(Boolean) + cards.forEach((c) => { + if (c && hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) { + warnings.push('槽位含非 high 字段但清单未收录:' + c.anchor_id) + } + }) + // 本技能专属:返沪时间约束必须出现在报告里(warn/danger 提示) + const notes = Array.isArray(p.notes) ? p.notes : [] + const hasReturnWarn = notes.some((n) => n && (n.level === 'warn' || n.level === 'danger') + && RETURN_HINTS.some((h) => String(n.text || '').indexOf(h) >= 0)) + if (!hasReturnWarn) { + warnings.push('缺少「返程不晚于 21:00 抵沪」的提示块(本技能硬约束,应有 warn/danger 级 notes)') + } + + // 7b.【本技能专属 v1.1.0】周六晚饭后不得空白:必须安排夜逛,且时间要落在 19:00 之后。 + // 用户明确反馈过:原来的行程 18:00 吃完晚饭就直接回住处,19:00-21:00 空了一大段。 + // 所以这里把「有夜逛」变成硬校验(缺了直接 error),把「时间是否合理」变成 warning。 + // + // 【重要 · 别再用 day.label 判断是哪一天】 + // 曾经这里写成 days.find(d => d.label 含「第一天」),结果 AI 把两天标成「周六/周日」时 + // find 返回 undefined,导致明明排了夜逛却被误判为「缺夜逛」,整份 4 分钟生成被丢弃。 + // night_stroll / dinner_d1 这些槽位本身就在第一天,所以直接在全量行里按 anchor_id 找即可, + // 不需要、也不应该依赖模型怎么写 day.label。 + const hhmm = (s) => { + const m = String(s || '').match(/(\d{1,2}):(\d{2})/) + if (!m) return null + return Number(m[1]) * 60 + Number(m[2]) + } + const strollRows = allRows.filter((r) => r.anchor_id === 'night_stroll') + if (!strollRows.length) { + errors.push('缺「夜逛」环节(night_stroll)—— 晚饭后到入住之间不允许空白,必须安排 19:00-21:00 的夜逛') + } else { + if (!strollRows.some((r) => hhmm(r.time) !== null && hhmm(r.time) >= 18 * 60 + 30)) { + warnings.push('夜逛行的时间早于 18:30,晚饭后可能仍有空白:' + strollRows.map((r) => r.time).join(' / ')) + } + if (!strollRows.some((r) => r.activity === 'stroll' || r.activity === 'walk' || r.activity === 'spot')) { + warnings.push('夜逛行的 activity 建议填 stroll(逛街/夜逛),当前为:' + strollRows.map((r) => String(r.activity || '未填')).join(' / ')) + } + } + // 晚饭必须在 19:00 前吃完,否则夜逛时间被挤压 + const dinnerRows = allRows.filter((r) => r.anchor_id === 'dinner_d1') + if (dinnerRows.length && !dinnerRows.some((r) => hhmm(r.time) !== null && hhmm(r.time) <= 19 * 60)) { + warnings.push('晚饭时间晚于 19:00,会挤压夜逛时段:' + dinnerRows.map((r) => r.time).join(' / ')) + } + + // 7c.【本技能专属 v1.1.0】餐馆必须给评分(用户要求:午饭和晚饭都要「本地人爱去 + 评分好」) + ;['lunch_d1', 'dinner_d1'].forEach((aid) => { + const card = cards.find((c) => c && c.anchor_id === aid) + if (!card) return + const text = JSON.stringify(card) + if (text.indexOf('评分') < 0) { + warnings.push(`槽位 ${aid} 未给出餐馆评分(要求:本地人爱去 + 评分好,两条都要有依据)`) + } + if (text.indexOf('本地') < 0) { + warnings.push(`槽位 ${aid} 未给出「本地人爱去」的判断依据`) + } + }) + + // 8) AI 配图必须带免责说明 + ;(function walkImg(node) { + if (Array.isArray(node)) return node.forEach(walkImg) + if (node && typeof node === 'object') { + if (node.kind === 'ai_illustration' && !node.disclaimer) warnings.push('AI 示意图缺免责说明') + Object.keys(node).forEach((k) => walkImg(node[k])) + } + })(p, '') + + return { + errors, + warnings, + stats: { + cards: cards.length, + anchors: anchorIds.length, + markers: markers.length, + addressCovered: markers.filter((m) => m && String(m.address || '').trim()).length, + tables: tables.length, + confTotal, + confNonHigh, + checklist: checklist.length + } + } +} + +module.exports = { validatePayload, ROOT_KEYS, ACTIVITIES, MARKER_KINDS, ROUTE_KINDS } \ No newline at end of file