@danceiny/gotry 0.0.1-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/src/loop.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Stage 1 对话循环(S2 段1,docs/stage1-top-down-design.md §2.3)。
3
+ *
4
+ * 自顶向下:循环只依赖两个端口——LlmPort(mock/真)与确定性工具(interview_next)。
5
+ * 求解/渲染接线是 S2 后续段;本段验收:重放用户开场白,系统一轮内完成
6
+ * 日历断言 + 访谈补全启动(问出工作窗口与已订资源——Kimi 第 6 轮才问的事)。
7
+ */
8
+
9
+ import type { InterviewQuestion, TripState, TravelerProfile, Turn, CalendarState, SolveResult } from './contracts.ts'
10
+ import type { JourneySpecTS } from './unified.ts'
11
+ import { anythingSearch } from '../capabilities/anything.ts'
12
+
13
+ /** LLM 端口:S2 mock(确定性剧本) / S4 真(dsh 运行时)。循环不感知差异。 */
14
+ export interface LlmPort {
15
+ /** 从对话抽取事实:日历(年/星期)与 profile 字段,带 evidence */
16
+ extractFacts(history: Turn[], state: TripState): Promise<{
17
+ calendar?: Partial<CalendarState>
18
+ profile?: Partial<TravelerProfile>
19
+ assumptions: Array<{ field: string; source: 'user-verbatim' | 'inferred' | 'default' }>
20
+ }>
21
+ /** 翻译:对话历史 → 统一模型 spec;约束未齐时返回 null(循环继续访谈) */
22
+ extractSpec(history: Turn[], state: TripState): Promise<JourneySpecTS | null>
23
+ /** 问句润色(ADR-9:确定性驱动,LLM 只管语气) */
24
+ polishQuestion(q: InterviewQuestion): Promise<string>
25
+ /** 结果解释(S3 接 solve 后启用) */
26
+ render?(state: TripState): Promise<string>
27
+ }
28
+
29
+ export function newState(year = 2026): TripState {
30
+ return {
31
+ calendar: { year, assertedWeekdays: {} },
32
+ profile: {},
33
+ gates: [],
34
+ wishes: [],
35
+ }
36
+ }
37
+
38
+ /** ADR-9:访谈由缺失字段驱动(确定性,无 LLM 即兴) */
39
+ export function interviewNext(state: TripState): { questions: InterviewQuestion[]; missing: string[] } {
40
+ const p = state.profile
41
+ const questions: InterviewQuestion[] = []
42
+ const ww = p.workWindow as unknown as { vacation?: boolean } | undefined
43
+ if (!p.workWindow && !ww?.vacation) {
44
+ questions.push({
45
+ key: 'workWindow',
46
+ text: '你这两周要远程办公——工作时间是几点到几点、按哪个时区?这决定每天的可玩时段。',
47
+ why: '工作窗口直接决定每日节奏(普吉 13:00-22:00 与 9:00-18:00 是两种人生)',
48
+ })
49
+ }
50
+ if (!p.bookedResources) {
51
+ questions.push({
52
+ key: 'bookedResources',
53
+ text: '已经有订好的航班或酒店吗?(哪怕只是意向)——已订资源是硬锚点,先告诉我再规划。',
54
+ why: '已订资源决定哪些段不可动,Kimi 式规划会绕开它们重排',
55
+ })
56
+ }
57
+ if (!p.budgetTier) {
58
+ questions.push({
59
+ key: 'budgetTier',
60
+ text: '预算档位:经济(¥12.6k 级)/ 舒适(¥16.3k 级)/ 便利优先?',
61
+ why: '分层预算决定班次与住宿的选型口径',
62
+ options: [
63
+ { label: '经济', tradeOff: '省钱,接驳多走路' },
64
+ { label: '舒适', tradeOff: 'workation 办公质量优先' },
65
+ { label: '便利优先', tradeOff: '时间最省' },
66
+ ],
67
+ })
68
+ }
69
+ return { questions, missing: questions.map(q => q.key) }
70
+ }
71
+
72
+ /** spec 校验闸:LLM 翻译产物进求解器前的确定性形状检查(责任边界的执行点)。 */
73
+ export function validateSpec(spec: JourneySpecTS): string | null {
74
+ if (!Array.isArray(spec.segments) || spec.segments.length === 0) return '没有段(segments)'
75
+ for (const seg of spec.segments) {
76
+ if (!seg.id) return '存在缺少 id 的段'
77
+ if (!Array.isArray(seg.options) || seg.options.length === 0) return `段 ${seg.id} 没有可选方案`
78
+ for (const opt of seg.options) {
79
+ const svcs = opt.move?.services
80
+ if (!Array.isArray(svcs) || svcs.length === 0) return `段 ${seg.id} 的方案 ${opt.id} 缺少班次(services)`
81
+ for (const s of svcs) {
82
+ if (typeof s.depMin !== 'number' || typeof s.arrMin !== 'number') {
83
+ return `段 ${seg.id}/${opt.id} 的班次 ${s.id ?? '?'} 缺少时刻(depMin/arrMin)`
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return null
89
+ }
90
+
91
+ /** 求解端口:S3 起由 unified 求解器实现;循环只认此签名(确定性责任)。 */
92
+ export type SolvePort = (spec: JourneySpecTS) => Promise<SolveResult>
93
+
94
+ // ---- 异步深度规划(S5 架构段,ADR-8:编排架构先于智能) ----------------------------
95
+ // 产品形态:复杂规划 → 「我后台做,约一小时后回来看看」→ 回访时交付
96
+ // 已验证方案 + 选择题。不失望四条是交付物的自检契约。
97
+
98
+ export interface AsyncTicket {
99
+ id: string
100
+ objective: string
101
+ requestedAt: string
102
+ /** 剧本/mock 期用分钟级;真实化(S5 后半段)由 loopx tick 驱动 */
103
+ etaLabel: string
104
+ }
105
+
106
+ export function isComplex(state: TripState): boolean {
107
+ /** 复杂度判据(架构占位,后续可细化):多段或多约束即深规划 */
108
+ const p = state.profile
109
+ return Boolean(p.workWindow && p.bookedResources) && (state.gates.length > 0 || Boolean(state.spec))
110
+ }
111
+
112
+ export async function requestDeepPlanning(state: TripState): Promise<{ reply: string; ticket: AsyncTicket }> {
113
+ const ticket: AsyncTicket = {
114
+ id: `dp-${Date.now().toString(36)}`,
115
+ objective: '生成已验证的行程方案:全成本核算 + 动机/约束匹配 + 不失望四条',
116
+ requestedAt: new Date().toISOString(),
117
+ etaLabel: '约 1 小时(mock 期:秒级)',
118
+ }
119
+ state.gates.push({ id: `async-${ticket.id}`, question: `深度规划已启动(${ticket.etaLabel})`, options: [] })
120
+ const reply = '这趟行程跨度大(两周 workation + 三城 + 红眼返程),我切到**深度规划模式**:后台做多轮校验'
121
+ + '(锚点/工作窗口/全成本/负例排查),做好后通知你。**先留一道选择题给你**,回来直接定:'
122
+ + '\n1. 预算档位?(经济/舒适/便利优先)'
123
+ return { reply, ticket }
124
+ }
125
+
126
+ export async function collectDeepPlanning(
127
+ state: TripState,
128
+ ticket: AsyncTicket,
129
+ solve: SolvePort,
130
+ ): Promise<{ reply: string; state: TripState }> {
131
+ const spec = state.spec
132
+ if (!spec) return { reply: '(内部状态缺失 spec——深度规划未就绪,这是不应发生的路径)', state }
133
+ state.solve = await solve(spec)
134
+ state.gates = state.gates.filter(g => !g.id.startsWith('async-'))
135
+
136
+ // 不失望四条自检(交付物自带,总纲 3.6)
137
+ const checks = {
138
+ '1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
139
+ '2_产物通过自检清单': state.solve.feasible ? (state.solve.legs?.every(l => (l as Record<string, unknown>)['energy_pct'] !== undefined) ?? false) : Boolean(state.solve.unsat_core?.length),
140
+ '3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
141
+ '4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
142
+ }
143
+ const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${Object.values(checks).every(Boolean) ? '4/4 ✅' : '有未达项 ❌'})`
144
+ return { reply: `${head}\n\n${renderSolve(state)}`, state }
145
+ }
146
+
147
+ /** 求解结果 → 人话(模板;S4 可由 LLM 润色,数字与判定不可改) */
148
+ export function renderSolve(state: TripState): string {
149
+ const s = state.solve
150
+ if (!s) return '(无求解结果)'
151
+ const lines: string[] = []
152
+ if (s.feasible) {
153
+ lines.push(`**方案可行,机票合计 ¥${s.money_cny}**`)
154
+ for (const lg of s.legs ?? []) {
155
+ const l = lg as Record<string, string | number>
156
+ lines.push(`- ${l['leg']} ${l['service']}:${l['dep']} 起飞,${l['wake']} 出发,${l['arrive_stay']} 到,`
157
+ + `门到门 ${l['door_to_door']},落地精力 ${l['energy_pct']}%,¥${l['price_cny']}`)
158
+ }
159
+ for (const e of s.work_window_exclusions ?? []) {
160
+ lines.push(`- 已排除 ${e.option}(工作窗口):${e.reason}`)
161
+ }
162
+ for (const n of s.skeleton_notes ?? []) lines.push(`- ${n}`)
163
+ for (const f of s.red_flags ?? []) lines.push(`- ⚠️ ${f}`)
164
+ } else {
165
+ lines.push(`**当前约束下不可行——冲突:${(s.unsat_core ?? []).join('、')}`)
166
+ for (const sg of s.suggestions ?? []) lines.push(`- 放宽「${sg.relax}」可解(约 ¥${sg.money_cny})`)
167
+ }
168
+ if (state.gates.length) {
169
+ lines.push('', '**待你决定(选择题)**')
170
+ for (const g of state.gates) {
171
+ lines.push(`- ${g.question} → ${g.options.map(o => o.label + (o.tradeOff ? `(${o.tradeOff})` : '')).join(' / ')}`)
172
+ }
173
+ }
174
+ return lines.join('\n')
175
+ }
176
+
177
+ /** 一次对话回合:抽取事实(冲突即指出)→ 增量访谈 → 约束齐备则求解+渲染。 */
178
+ export async function runTurn(
179
+ state: TripState,
180
+ userMsg: string,
181
+ llm: LlmPort,
182
+ history: Turn[] = [],
183
+ solve: SolvePort | null = null,
184
+ ): Promise<{ reply: string; state: TripState }> {
185
+ const facts = await llm.extractFacts([...history, { role: 'user', text: userMsg }], state)
186
+
187
+ const conflicts: string[] = []
188
+ if (facts.calendar?.assertedWeekdays) {
189
+ for (const [date, wd] of Object.entries(facts.calendar.assertedWeekdays)) {
190
+ const prev = state.calendar.assertedWeekdays[date]
191
+ if (prev && prev !== wd) {
192
+ conflicts.push(`${date}:已有断言 ${prev},新说法 ${wd}——以先断言为准,如需改请明确说`)
193
+ } else {
194
+ state.calendar.assertedWeekdays[date] = wd
195
+ }
196
+ }
197
+ }
198
+ Object.assign(state.profile, facts.profile ?? {})
199
+
200
+ // 访谈:workWindow/bookedResources 是求解前置;budgetTier 转为 gate(不阻塞规划)
201
+ const { questions } = interviewNext(state)
202
+ const blocking = questions.filter(q => q.key !== 'budgetTier')
203
+ const budgetQ = questions.find(q => q.key === 'budgetTier')
204
+ if (budgetQ && !state.gates.some(g => g.id === 'budget')) {
205
+ state.gates.push({
206
+ id: 'budget',
207
+ question: budgetQ.text,
208
+ options: budgetQ.options ?? [{ label: '经济' }, { label: '舒适' }, { label: '便利优先' }],
209
+ })
210
+ }
211
+
212
+ const parts: string[] = []
213
+ if (conflicts.length) parts.push(`⚠️ 日历冲突:\n${conflicts.map(c => `- ${c}`).join('\n')}`)
214
+
215
+ // D-7a 例外: blocking>0 时,用户消息含"查+地名/酒店/天气"信号时,直接调 anything_search
216
+ // 走 PoI 真相(datasources 层主动调,不动 polling 状态机,不改求解器)
217
+ const poiProbe = probePoi(userMsg)
218
+ if (poiProbe) {
219
+ const ar = await anythingSearch({ keyword: poiProbe })
220
+ if (ar.verdict === 'hit' && ar.hits && ar.hits.length > 0) {
221
+ const top = ar.hits.slice(0, 5)
222
+ const lines = top.map((h, i) => {
223
+ const latlng = h.latitude !== undefined && h.longitude !== undefined
224
+ ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})`
225
+ : ''
226
+ return ` ${i + 1}. [${h.type}] ${h.name}${latlng}`
227
+ }).join('\n')
228
+ parts.push(`**${poiProbe} → hit (${ar.hits.length} 候选项)**\n${lines}\n${ar.evidence}`)
229
+ } else if (ar.verdict === 'miss') {
230
+ parts.push(`**${poiProbe} → miss** (酒店-be Anything 一切正常但无候选)\n${ar.evidence}`)
231
+ } else {
232
+ parts.push(`**${poiProbe} → unavailable** (${ar.error ?? 'hbcli 不可达'})\n${ar.evidence}`)
233
+ }
234
+ }
235
+
236
+ for (const q of blocking) parts.push(await llm.polishQuestion(q))
237
+
238
+ // 约束齐备(无阻塞问题)→ 翻译 spec → **校验闸** → 求解 → 渲染
239
+ if (blocking.length === 0 && solve) {
240
+ const spec = await llm.extractSpec([...history, { role: 'user', text: userMsg }], state)
241
+ // 场景路由:erhai 候选标记 → 候选求解(洱海金标准的引擎判定)——纯 TS unify 路径
242
+ if (spec && (spec as unknown as { note?: string }).note === 'erhai-candidates') {
243
+ const { readFile } = await import('node:fs/promises')
244
+ const { join } = await import('node:path')
245
+ try {
246
+ const raw = JSON.parse(await readFile(join(import.meta.dirname, '..', '..', 'data', 'golden_erhai.json'), 'utf-8'))
247
+ const { parseCandidate, parseRequest } = await import('./model.ts')
248
+ const { segmentsFromCandidate, solveChoiceSegment } = await import('./unified.ts')
249
+ const req = parseRequest(raw['request'] as Record<string, unknown>)
250
+ const cands = (raw['candidates'] as Record<string, unknown>[]).map(parseCandidate)
251
+ const erhaiSpec = segmentsFromCandidate(req, cands)
252
+ const result = solveChoiceSegment(erhaiSpec, req) as { answer_md?: string; recommended?: string }
253
+ state.solve = result as never
254
+ parts.push(result.answer_md ?? '(候选求解完成)')
255
+ } catch {
256
+ parts.push('(洱海候选求解暂不可用——退回访谈)')
257
+ }
258
+ } else {
259
+ const invalid = spec ? validateSpec(spec) : '翻译器未产出 spec'
260
+ if (spec && !invalid) {
261
+ state.spec = spec
262
+ state.solve = await solve(spec)
263
+ parts.push(llm.render ? await llm.render(state) : renderSolve(state))
264
+ } else if (blocking.length === 0 && invalid) {
265
+ parts.push(`(行程骨架还不完整:${invalid}——请补充对应信息,我不猜)`)
266
+ }
267
+ }
268
+ } else if (blocking.length === 0) {
269
+ parts.push('(约束齐备——进入规划,S3 接线后此处产出方案与选择题)')
270
+ }
271
+ return { reply: parts.join('\n\n'), state }
272
+ }
273
+
274
+ // ---- 异步工单持久化(S5 编排半段):真正的「一小时后」必须跨进程存续 ----
275
+ // 请求时落盘(ticket+state 快照);任意后续进程(如 loopx 驱动的 tick)执行
276
+ // async-collect 加载、求解、写回交付物。状态目录属用户数据(红线 6)。
277
+
278
+ import { join } from 'node:path'
279
+ import { mkdir } from 'node:fs/promises'
280
+ import { readFile, writeFile } from 'node:fs/promises'
281
+
282
+ const ASYNC_DIR = 'gotry-state/async'
283
+
284
+ async function asyncPath(name: string): Promise<string> {
285
+ await mkdir(ASYNC_DIR, { recursive: true })
286
+ return join(ASYNC_DIR, name)
287
+ }
288
+
289
+ export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState): Promise<string> {
290
+ const p = await asyncPath(`${ticket.id}.json`)
291
+ await writeFile(p, JSON.stringify({ ticket, state }, null, 2), 'utf-8')
292
+ return p
293
+ }
294
+
295
+ export async function loadAsyncTicket(ticketId: string): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
296
+ try {
297
+ const p = await asyncPath(`${ticketId}.json`)
298
+ return JSON.parse(await readFile(p, 'utf-8')) as { ticket: AsyncTicket; state: TripState }
299
+ } catch {
300
+ return null
301
+ }
302
+ }
303
+
304
+ export async function settleAsyncTicket(ticketId: string, reply: string): Promise<string> {
305
+ const p = await asyncPath(`${ticketId}.deliverable.md`)
306
+ await writeFile(p, reply, 'utf-8')
307
+ return p
308
+ }
309
+
310
+ /**
311
+ * probePoi:从 user msg 探测"查 POI/酒店" 信号,返回关键词。
312
+ * 触发的不是关键词,是**结构**:句首含"查/搜/找" + 后接地理/酒店类名词。
313
+ * 不是意图(改求解器),只是 datasources 编排层的"提早调 anything"提示。
314
+ */
315
+ export function probePoi(msg: string): string | null {
316
+ const trimmed = msg.trim()
317
+ if (!trimmed) return null
318
+ // 短查询(<=24 字符 且非问句): 优先剥 trigger 词,再 fall-through 让内容触发也跑
319
+ const isShort = trimmed.length <= 24 && !/[??!!。.,,]/.test(trimmed)
320
+ if (isShort) {
321
+ const stripped = trimmed.replace(/^(查一下|查|搜|找|看看|推荐|告诉我|检索)\s*/, '').trim()
322
+ // 即使没 trigger 词,只要 stripped 仍是 ≥2 字符的非空串就当关键词
323
+ if (stripped.length >= 2) return stripped.slice(0, 24)
324
+ }
325
+ // 触发模式:查/搜/找/看看/推荐/告诉我/检索 + 地理/酒店类名词(贪吃但只切前 24 字符)
326
+ const m = trimmed.match(/(查一下|查|搜|找|看看|推荐|告诉我|检索)\s*(.{2,24})/)
327
+ if (m) return m[2].trim().slice(0, 24)
328
+ // 另一模式:含"酒店"/"民宿"/"客栈" 触发酒店查
329
+ if (/(酒店|民宿|客栈|饭店|有什么|玩什么|有哪些)/.test(trimmed)) {
330
+ // 尝试提取前面的地名关键词
331
+ const place = trimmed.match(/([一-龥a-zA-Z]{2,12})\s*(?:酒店|民宿|客栈|饭店|有什么|玩什么|有哪些)/)
332
+ if (place) return place[1]
333
+ return trimmed.replace(/(酒店|民宿|客栈|饭店|有什么|玩什么|有哪些)/, '').trim().slice(0, 24) || trimmed.slice(0, 24)
334
+ }
335
+ return null
336
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Mock LLM(S2,ADR-8):确定性剧本,重放真实对话的智能侧。
3
+ * 它不做任何「聪明事」——只按剧本返回抽取结果;循环架构的正确性由此验证,
4
+ * 与智能质量解耦。S4 接真 LLM 后,本文件留作回归夹具。
5
+ */
6
+
7
+ import type { LlmPort } from './loop.ts'
8
+ import type { InterviewQuestion, TravelerProfile, TripState, Turn, CalendarState } from './contracts.ts'
9
+ import type { JourneySpecTS } from './unified.ts'
10
+ import { parseFlightPackToSpec } from './unified.ts'
11
+
12
+ interface ScriptStep {
13
+ /** 命中条件:用户消息包含此关键词(第一条匹配生效) */
14
+ when: string
15
+ calendar?: Partial<CalendarState>
16
+ profile?: Partial<TravelerProfile>
17
+ }
18
+
19
+ /** 剧本:来自真实 Kimi 对话里用户给出关键事实的时刻 */
20
+ const SCRIPT: ScriptStep[] = [
21
+ {
22
+ when: '请给我做机票和酒店的行程规划和推荐',
23
+ calendar: {
24
+ year: 2026,
25
+ assertedWeekdays: {
26
+ '2026-07-17': 'fri', '2026-07-18': 'sat', '2026-07-31': 'fri',
27
+ '2026-08-01': 'sat', '2026-08-09': 'sun', '2026-08-10': 'mon',
28
+ },
29
+ },
30
+ profile: { companions: ['girlfriend(先在普吉)'] },
31
+ },
32
+ {
33
+ when: '工作时间',
34
+ profile: {
35
+ workWindow: {
36
+ homeTzOffsetMin: 240, startMin: 600, endMin: 1140, workdays: [0, 1, 2, 3, 4],
37
+ evidence: '用户原话:我的工作时间是UTC+4的早上10点到下午7点',
38
+ },
39
+ },
40
+ },
41
+ {
42
+ when: '订了酒店',
43
+ profile: {
44
+ bookedResources: [
45
+ { kind: 'hotel', ref: 'The Title East Wing Rawai(7.18-23, 5 晚)', window: '2026-07-18~07-23' },
46
+ ],
47
+ },
48
+ },
49
+ ]
50
+
51
+ export function createMockLlm(flightPackPath?: string): LlmPort {
52
+ let stepIdx = 0
53
+ return {
54
+ async extractFacts(history: Turn[], _state: TripState) {
55
+ const last = history[history.length - 1]?.text ?? ''
56
+ // 剧本顺序消费:真实对话里事实按此顺序浮出;未命中则无新事实
57
+ const step = SCRIPT[stepIdx]
58
+ if (step && last.includes(step.when)) {
59
+ stepIdx++
60
+ return {
61
+ calendar: step.calendar,
62
+ profile: step.profile,
63
+ assumptions: Object.keys({ ...step.profile }).map(f => ({ field: f, source: 'user-verbatim' as const })),
64
+ }
65
+ }
66
+ return { assumptions: [] }
67
+ },
68
+ async extractSpec(history: Turn[], state: TripState): Promise<JourneySpecTS | null> {
69
+ // mock 翻译:工作窗口与已订资源齐备后,「翻译」= 装载航班包并挂上真实工作窗口
70
+ if (!state.profile.workWindow || !state.profile.bookedResources) return null
71
+ if (!flightPackPath) return null
72
+ const { readFile } = await import('node:fs/promises')
73
+ const pack = JSON.parse(await readFile(flightPackPath, 'utf-8'))
74
+ const spec = parseFlightPackToSpec(pack)
75
+ spec.workWindow = {
76
+ homeTzOffsetMin: state.profile.workWindow.homeTzOffsetMin,
77
+ startMin: state.profile.workWindow.startMin,
78
+ endMin: state.profile.workWindow.endMin,
79
+ workdays: state.profile.workWindow.workdays,
80
+ }
81
+ spec.budgetCny = 9000
82
+ return spec
83
+ },
84
+ async polishQuestion(q: InterviewQuestion) {
85
+ return `【${q.key}】${q.text}\n(为什么问:${q.why})`
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Gotry 可行性引擎领域模型(TS 版,与 py/gotry_feasibility/model.py 逐行对齐)。
3
+ *
4
+ * 双实现纪律:Python 版是**对照实现(oracle)**,TS 版是产品运行时实现;
5
+ * 同一输入两版必须给出相同判定(ts/scripts/diff-test.ts 做差分验证)。
6
+ * 算术(门到门全成本)在此层与求解完全分离,两边各自可测。
7
+ */
8
+
9
+ export function hhmmToMin(s: string): number {
10
+ const [h, m] = s.split(':')
11
+ return Number(h) * 60 + Number(m)
12
+ }
13
+
14
+ export function minToHhmm(m: number): string {
15
+ return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
16
+ }
17
+
18
+ export interface TransferMode {
19
+ mode: string
20
+ minutes: number
21
+ priceCny: number
22
+ }
23
+
24
+ export interface Service {
25
+ id: string
26
+ depMin: number
27
+ arrMin: number
28
+ priceCny: number
29
+ }
30
+
31
+ export interface HubAccess {
32
+ hub: string
33
+ toHubMin: number
34
+ }
35
+
36
+ export interface MotivationProfile {
37
+ /** 动机谱系,如 { escape_rest: 0.7, curiosity: 0.3 } */
38
+ weights: Record<string, number>
39
+ /** 起床不早于(当日分钟)——由动机推出的硬约束 */
40
+ wakeFloorMin: number
41
+ minArrivalEnergyPct: number
42
+ baseUsableHours: number
43
+ escapeHoursPerWeight: number
44
+ }
45
+
46
+ export interface Candidate {
47
+ id: string
48
+ name: string
49
+ hub: string
50
+ bufferOutMin: number
51
+ bufferRetMin: number
52
+ servicesOut: Service[]
53
+ servicesRet: Service[]
54
+ destTransfers: TransferMode[]
55
+ stayCnyPerNight: number
56
+ localDailyCny: number
57
+ minDaysForPurpose: number
58
+ imageryMatch: number
59
+ bestMonths: number[]
60
+ }
61
+
62
+ export interface TravelRequest {
63
+ note: string
64
+ motivation: MotivationProfile
65
+ windowDays: number
66
+ budgetCny: number
67
+ homeHubAccess: Record<string, HubAccess>
68
+ }
69
+
70
+ export interface Choice {
71
+ outService: Service
72
+ outTransfer: TransferMode
73
+ retService: Service
74
+ retTransfer: TransferMode
75
+ days: number
76
+ }
77
+
78
+ export interface TrueCost {
79
+ moneyCny: number
80
+ wakeMin: number
81
+ arriveStayMin: number
82
+ doorToDoorOutMin: number
83
+ energyArrivalPct: number
84
+ usableHours: number
85
+ usableDay1Hours: number
86
+ usableDay2Hours: number
87
+ departHomeRetMin: number
88
+ arriveHomeRetMin: number
89
+ }
90
+
91
+ export function requiredUsableHours(mot: MotivationProfile): number {
92
+ return mot.baseUsableHours + mot.escapeHoursPerWeight * (mot.weights['escape_rest'] ?? 0)
93
+ }
94
+
95
+ // ---- 精力模型(可校准参数,与 Python 版一致) --------------------------------
96
+ export const WAKE_PENALTY_BEFORE_5 = 30
97
+ export const WAKE_PENALTY_BEFORE_6 = 25
98
+ export const WAKE_PENALTY_BEFORE_630 = 15
99
+ export const TRANSFER_PENALTY = 8
100
+ export const LATE_ARRIVAL_PENALTY = 10
101
+ export const LONG_D2D_PENALTY = 10
102
+ export const DAY_END_MIN = 21 * 60
103
+ export const DAY2_START_MIN = 9 * 60
104
+ export const DAY2_QUALITY = 0.9
105
+ export const LATEST_ARRIVE_STAY_MIN = 18 * 60
106
+
107
+ export function parseMotivation(d: Record<string, unknown>): MotivationProfile {
108
+ const hard = (d['hard'] ?? {}) as Record<string, unknown>
109
+ const rawWeights = (d['weights'] ?? d) as Record<string, unknown>
110
+ const weights: Record<string, number> = {}
111
+ for (const [k, v] of Object.entries(rawWeights)) {
112
+ if (typeof v === 'number') weights[k] = v
113
+ }
114
+ return {
115
+ weights,
116
+ wakeFloorMin: hhmmToMin(String(hard['wake_not_before'] ?? '06:30')),
117
+ minArrivalEnergyPct: Number(hard['min_arrival_energy_pct'] ?? 40),
118
+ baseUsableHours: 4.0,
119
+ escapeHoursPerWeight: 2.0,
120
+ }
121
+ }
122
+
123
+ export function parseRequest(d: Record<string, unknown>): TravelRequest {
124
+ const home = ((d['home'] ?? {}) as Record<string, unknown>)['hubs'] as Record<string, Record<string, unknown>> | undefined ?? {}
125
+ const homeHubAccess: Record<string, HubAccess> = {}
126
+ for (const [hub, acc] of Object.entries(home)) {
127
+ homeHubAccess[hub] = { hub, toHubMin: Number(acc['to_hub_min']) }
128
+ }
129
+ return {
130
+ note: String(d['note'] ?? ''),
131
+ motivation: parseMotivation(d['motivation'] as Record<string, unknown>),
132
+ windowDays: Number(d['window_days']),
133
+ budgetCny: Number(d['budget_cny']),
134
+ homeHubAccess,
135
+ }
136
+ }
137
+
138
+ function parseService(d: Record<string, unknown>): Service {
139
+ return { id: String(d['id']), depMin: hhmmToMin(String(d['dep'])), arrMin: hhmmToMin(String(d['arr'])), priceCny: Number(d['price_cny']) }
140
+ }
141
+
142
+ function parseTransfer(d: Record<string, unknown>): TransferMode {
143
+ return { mode: String(d['mode']), minutes: Number(d['min']), priceCny: Number(d['price_cny']) }
144
+ }
145
+
146
+ export function parseCandidate(d: Record<string, unknown>): Candidate {
147
+ return {
148
+ id: String(d['id']),
149
+ name: String(d['name']),
150
+ hub: String(d['hub']),
151
+ bufferOutMin: Number(d['buffer_out_min'] ?? 60),
152
+ bufferRetMin: Number(d['buffer_ret_min'] ?? 60),
153
+ servicesOut: (d['services_out'] as Record<string, unknown>[]).map(parseService),
154
+ servicesRet: (d['services_ret'] as Record<string, unknown>[]).map(parseService),
155
+ destTransfers: (d['dest_transfers'] as Record<string, unknown>[]).map(parseTransfer),
156
+ stayCnyPerNight: Number(d['stay_cny_per_night']),
157
+ localDailyCny: Number(d['local_daily_cny']),
158
+ minDaysForPurpose: Number(d['min_days_for_purpose']),
159
+ imageryMatch: Number(d['imagery_match']),
160
+ bestMonths: ((d['best_months'] as number[]) ?? []).map(Number),
161
+ }
162
+ }
163
+
164
+ export function evaluateChoice(cand: Candidate, req: TravelRequest, ch: Choice): TrueCost {
165
+ /** 门到门全成本核算(与 Python evaluate_choice 完全一致)。 */
166
+ const access = req.homeHubAccess[cand.hub]
167
+ const wake = ch.outService.depMin - cand.bufferOutMin - access.toHubMin
168
+ const arriveStay = ch.outService.arrMin + ch.outTransfer.minutes
169
+ const d2dOut = arriveStay - wake
170
+
171
+ let energy = 100
172
+ if (wake < 5 * 60) energy -= WAKE_PENALTY_BEFORE_5
173
+ else if (wake < 6 * 60) energy -= WAKE_PENALTY_BEFORE_6
174
+ else if (wake < hhmmToMin('06:30')) energy -= WAKE_PENALTY_BEFORE_630
175
+ energy -= 2 * TRANSFER_PENALTY
176
+ if (arriveStay > 21 * 60) energy -= LATE_ARRIVAL_PENALTY
177
+ if (d2dOut > 6 * 60) energy -= LONG_D2D_PENALTY
178
+ energy = Math.max(0, energy)
179
+
180
+ const day1Raw = Math.max(0, DAY_END_MIN - arriveStay) / 60
181
+ const day1 = day1Raw * (0.5 + energy / 200)
182
+ const leaveStayRet = ch.retService.depMin - cand.bufferRetMin - ch.retTransfer.minutes
183
+ const day2 = Math.max(0, leaveStayRet - DAY2_START_MIN) / 60 * DAY2_QUALITY
184
+ const midDays = Math.max(0, ch.days - 2)
185
+ const usable = day1 + day2 + midDays * 8.0 * DAY2_QUALITY
186
+
187
+ const money =
188
+ ch.outService.priceCny + ch.retService.priceCny
189
+ + ch.outTransfer.priceCny + ch.retTransfer.priceCny
190
+ + cand.stayCnyPerNight * (ch.days - 1)
191
+ + cand.localDailyCny * ch.days
192
+
193
+ return {
194
+ moneyCny: money,
195
+ wakeMin: wake,
196
+ arriveStayMin: arriveStay,
197
+ doorToDoorOutMin: d2dOut,
198
+ energyArrivalPct: energy,
199
+ usableHours: usable,
200
+ usableDay1Hours: day1,
201
+ usableDay2Hours: day2,
202
+ departHomeRetMin: leaveStayRet,
203
+ arriveHomeRetMin: ch.retService.arrMin + access.toHubMin,
204
+ }
205
+ }
206
+
207
+ export function trueCostToDict(t: TrueCost): Record<string, unknown> {
208
+ return {
209
+ money_cny: t.moneyCny,
210
+ wake: minToHhmm(t.wakeMin),
211
+ arrive_stay: minToHhmm(t.arriveStayMin),
212
+ door_to_door_out: `${Math.floor(t.doorToDoorOutMin / 60)}h${String(t.doorToDoorOutMin % 60).padStart(2, '0')}m`,
213
+ energy_arrival_pct: t.energyArrivalPct,
214
+ usable_hours: Math.round(t.usableHours * 10) / 10,
215
+ usable_day1_hours: Math.round(t.usableDay1Hours * 10) / 10,
216
+ usable_day2_hours: Math.round(t.usableDay2Hours * 10) / 10,
217
+ leave_stay_return: minToHhmm(t.departHomeRetMin),
218
+ arrive_home_return: minToHhmm(Math.min(1440, t.arriveHomeRetMin)),
219
+ }
220
+ }