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

This commit is contained in:
yangxiangyuan
2026-09-30 13:47:51 +08:00
parent bbc7ca6523
commit ac475b6659
66 changed files with 14572 additions and 0 deletions
+17
View File
@@ -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) {
@@ -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
}
}
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 }
@@ -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
}
@@ -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 }
@@ -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
}
}
@@ -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"
}
]
}
]
}
@@ -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 }
@@ -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" }
}
}
}
}
@@ -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"]
}
@@ -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 }
@@ -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": "点位顺序示意,非真实道路/轨迹"
}
@@ -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 字段的槽位,都必须至少有一条清单项**;同一槽位内的多个低置信项可以归并成一条,但**不许整槽位漏收**。
@@ -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 标注"]
}
}
@@ -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 }
@@ -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 }
@@ -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
}
}
@@ -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": []
}
]
}
@@ -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 }
@@ -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" }
}
}
}
}
@@ -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"]
}
@@ -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 }
@@ -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": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准"
}
@@ -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`,但请不要依赖这个兜底。)
@@ -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 标注"
]
}
}
@@ -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 }