@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.
@@ -0,0 +1,577 @@
1
+ /**
2
+ * 统一行程模型 TS 版(与 py/gotry_feasibility/unified.py 对齐;Python 为 oracle)。
3
+ * 行程 = Segment 序列;选择单元是 SegmentOption(目的地或具体班次)。
4
+ * 本文件实现:类型 + 双旧输入适配 + 航班链形态求解(Z3)。
5
+ * 候选形态 TS 求解(枚举过滤)与 engine 等价对账在下一迁移段。
6
+ */
7
+
8
+ import { checkConnectivity } from '../scripts/skeleton-check.ts'
9
+ import type { Candidate, Choice, MotivationProfile, Service, TransferMode, TravelRequest, TrueCost } from './model.ts'
10
+ import { evaluateChoice, minToHhmm, hhmmToMin, requiredUsableHours, trueCostToDict, LATEST_ARRIVE_STAY_MIN } from './model.ts'
11
+ import type { LegReport } from './journey.ts'
12
+
13
+ export interface AnchorsSpec {
14
+ arriveByMin?: number
15
+ departAfterMin?: number
16
+ minDays?: number
17
+ wakeFloorMin?: number
18
+ }
19
+
20
+ export interface MoveSpecTS {
21
+ hub: string
22
+ services: Service[]
23
+ retServices?: Service[]
24
+ transfers?: Array<{ mode: string; minutes: number; priceCny: number }>
25
+ bufferMin: number
26
+ bufferRetMin?: number
27
+ originTransferMin: number
28
+ destTransferMin: number
29
+ redEye?: boolean
30
+ redEyeDurationMin?: number
31
+ /** D-5:目的地相对出发地的时差(KMG-BKK=+60,DXB-SZX=-240) */
32
+ tzOffsetMin?: number
33
+ /** M-1:出发地 UTC 偏移(工作窗口换算用) */
34
+ originTzOffsetMin?: number
35
+ }
36
+
37
+ /** M-1:旅行者工作窗口(家时区) */
38
+ export interface WorkWindowSpec {
39
+ homeTzOffsetMin: number
40
+ startMin: number
41
+ endMin: number
42
+ workdays?: number[]
43
+ }
44
+
45
+ export interface StaySpecTS {
46
+ nights: number
47
+ stayCnyPerNight?: number
48
+ localDailyCny?: number
49
+ /** M-1 预留:{ tz: 'UTC+4', start: '10:00', end: '19:00' } */
50
+ workWindow?: { tz: string; start: string; end: string }
51
+ }
52
+
53
+ export interface SegmentOptionTS {
54
+ id: string
55
+ label: string
56
+ move?: MoveSpecTS
57
+ stay?: StaySpecTS
58
+ score?: number
59
+ bestMonths?: number[]
60
+ minDays?: number
61
+ /** M-1:班次星期标注(mon/tue/.../sun),工作窗口过滤用 */
62
+ depWeekday?: string
63
+ }
64
+
65
+ export interface SegmentTS {
66
+ id: string
67
+ role: 'choice' | 'fixed'
68
+ note?: string
69
+ date?: string
70
+ /** 城市对提示("HKG->HKT"),骨架层通航校验用 */
71
+ route?: string
72
+ anchors?: AnchorsSpec
73
+ options: SegmentOptionTS[]
74
+ }
75
+
76
+ export interface JourneySpecTS {
77
+ note?: string
78
+ segments: SegmentTS[]
79
+ budgetCny?: number
80
+ defaultWakeFloorMin?: number
81
+ workWindow?: WorkWindowSpec
82
+ /** 骨架层开关(§7-1):true 时对带 route 提示的段做通航性三值标注 */
83
+ skeletonHub?: boolean
84
+ }
85
+
86
+ /* eslint-disable @typescript-eslint/no-explicit-any */
87
+ let z3Promise: Promise<any> | null = null
88
+
89
+ async function getZ3(): Promise<any> {
90
+ // 延迟到首次实际调用 solveUnified 才加载:避免 dsh 加载 GoTry 模块时启动
91
+ // WASM worker,引起 worker 线程内存冲突(z3-built.wasm 多线程 unsafe)。
92
+ if (!z3Promise) {
93
+ const { init } = await import('z3-solver')
94
+ z3Promise = (async () => (await init()).Context('main'))()
95
+ }
96
+ return z3Promise
97
+ }
98
+
99
+ /** 航班数据包(data/flights_2026.json 形态)→ 段链 */
100
+ const SEGMENT_ROUTES: Record<string, string> = {
101
+ f1: 'HKG->HKT', f2: 'HKT->BKK', f3: 'BKK->KMG', f4: 'KMG->SZX', f5: 'SZX->DXB',
102
+ yn1: 'SZX->LJG', yn2: 'LJG->DLU', yn3: 'DLU->LJG', yn4: 'LJG->SZX',
103
+ }
104
+ function routeHint(id: string): string | undefined {
105
+ return SEGMENT_ROUTES[id]
106
+ }
107
+
108
+ // ---- 适配器:旧候选用例(洱海形态:request + candidates)→ 单 choice 段 -------------
109
+ // 与 py unified.py segments_from_candidate 逐行对齐
110
+
111
+ export function segmentsFromCandidate(req: TravelRequest, candidates: Candidate[]): JourneySpecTS {
112
+ const motHard = req.motivation
113
+ const wakeFloor = motHard.wakeFloorMin
114
+ const options: SegmentOptionTS[] = candidates.map(c => ({
115
+ id: c.id,
116
+ label: c.name,
117
+ score: c.imageryMatch,
118
+ bestMonths: c.bestMonths,
119
+ minDays: c.minDaysForPurpose,
120
+ move: {
121
+ hub: c.hub,
122
+ services: [...c.servicesOut],
123
+ retServices: [...c.servicesRet],
124
+ transfers: [...c.destTransfers],
125
+ bufferMin: c.bufferOutMin,
126
+ bufferRetMin: c.bufferRetMin,
127
+ originTransferMin: 0, // 候选形态无独立接驳(并入 destTransfers)
128
+ destTransferMin: 0,
129
+ },
130
+ stay: {
131
+ nights: req.windowDays - 1,
132
+ stayCnyPerNight: c.stayCnyPerNight,
133
+ localDailyCny: c.localDailyCny,
134
+ },
135
+ }))
136
+ const escape = req.motivation.weights['escape_rest'] ?? 0
137
+ return {
138
+ note: req.note,
139
+ segments: [{
140
+ id: 'dest',
141
+ role: 'choice',
142
+ note: '目的地选择',
143
+ anchors: { wakeFloorMin: wakeFloor },
144
+ options,
145
+ }],
146
+ budgetCny: req.budgetCny,
147
+ defaultWakeFloorMin: wakeFloor,
148
+ }
149
+ }
150
+
151
+ export function parseFlightPackToSpec(pack: Record<string, unknown>): JourneySpecTS {
152
+ const legs = (pack['legs'] as Array<Record<string, unknown>>).map(l => ({
153
+ id: String(l['id']),
154
+ role: ((l['services'] as unknown[]).length === 1 ? 'fixed' : 'choice') as 'fixed' | 'choice',
155
+ note: l['note'] ? String(l['note']) : undefined,
156
+ date: l['date'] ? String(l['date']) : undefined,
157
+ anchors: { arriveByMin: l['arrive_by'] ? hhmmToMin(String(l['arrive_by'])) : undefined },
158
+ route: routeHint(l['id'] as string),
159
+ options: (l['services'] as Array<Record<string, unknown>>).map(sv => ({
160
+ id: String(sv['id']),
161
+ label: `${String(sv['id'])} ${String(l['note'] ?? '').slice(0, 18)}`,
162
+ move: {
163
+ hub: String(l['hub'] ?? ''),
164
+ services: [{ id: String(sv['id']), depMin: hhmmToMin(String(sv['dep'])), arrMin: hhmmToMin(String(sv['arr'])), priceCny: Number(sv['price_cny']) }],
165
+ bufferMin: Number(l['buffer_min']),
166
+ originTransferMin: Number(l['origin_transfer_min']),
167
+ destTransferMin: Number(l['dest_transfer_min']),
168
+ redEye: Boolean(l['red_eye']),
169
+ redEyeDurationMin: Number(l['red_eye_duration_min'] ?? 0),
170
+ tzOffsetMin: Number(l['tz_offset_min'] ?? 0),
171
+ originTzOffsetMin: Number(l['origin_tz_offset_min'] ?? 480),
172
+ },
173
+ })),
174
+ }))
175
+ const meta = (pack['meta'] ?? {}) as Record<string, unknown>
176
+ const ww = meta['work_window'] as Record<string, unknown> | undefined
177
+ const spec: JourneySpecTS = {
178
+ segments: legs,
179
+ workWindow: ww ? {
180
+ homeTzOffsetMin: Number(ww['home_tz_offset_min']),
181
+ startMin: Number(ww['start_min']),
182
+ endMin: Number(ww['end_min']),
183
+ workdays: (ww['workdays'] as number[] | undefined) ?? [0, 1, 2, 3, 4],
184
+ } : undefined,
185
+ }
186
+ // M-1:班次的星期标注挂到 Option(缺省=不受工作窗口约束)
187
+ for (const l of pack['legs'] as Array<Record<string, unknown>>) {
188
+ const seg = spec.segments.find(s => s.id === l['id'])!
189
+ for (const svc of l['services'] as Array<Record<string, unknown>>) {
190
+ if (svc['weekday']) {
191
+ const opt = seg.options.find(o => o.id === svc['id'])
192
+ if (opt) opt.depWeekday = String(svc['weekday'])
193
+ }
194
+ }
195
+ }
196
+ return spec
197
+ }
198
+
199
+ const WEEKDAY_IDX: Record<string, number> = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 }
200
+ const WEEKDAY_CN: Record<string, string> = { mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六', sun: '日' }
201
+
202
+ /** M-1:工作日的工作窗口内起飞 → 排除理由;否则 null(与 py _work_window_blocks 对齐)。 */
203
+ function workWindowBlocks(spec: JourneySpecTS, option: SegmentOptionTS): string | null {
204
+ const ww = spec.workWindow
205
+ if (!ww || !option.move || !option.depWeekday) return null
206
+ if (!((ww.workdays ?? [0, 1, 2, 3, 4]).includes(WEEKDAY_IDX[option.depWeekday]))) return null
207
+ const dep = option.move.services[0].depMin
208
+ const shift = (option.move.originTzOffsetMin ?? 480) - ww.homeTzOffsetMin
209
+ const start = ww.startMin + shift, end = ww.endMin + shift
210
+ if (start <= dep && dep <= end) {
211
+ const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
212
+ return `周${WEEKDAY_CN[option.depWeekday]} ${hhmm(dep)} 起飞落在工作窗口(当地 ${hhmm(start)}-${hhmm(end)})内`
213
+ }
214
+ return null
215
+ }
216
+
217
+ /** D-5:时区感知的段核算(与 py unified._evaluate_option_move 对齐)。 */
218
+ function evaluateOptionMove(segId: string, mv: MoveSpecTS): LegReport & { d2d_min?: number } {
219
+ const svc = mv.services[0]
220
+ const wake = svc.depMin - mv.bufferMin - mv.originTransferMin
221
+ // 真实时长 = (到达−出发) − 时差;EK329: 215−(−240)=455min=7h35m
222
+ const trueFlight = (svc.arrMin - svc.depMin) - (mv.tzOffsetMin ?? 0)
223
+ const d2d = mv.bufferMin + mv.originTransferMin + trueFlight + mv.destTransferMin
224
+ const wakeDisplay = wake < 0 ? `${minToHhmm(wake + 1440)}(前一日)` : minToHhmm(wake)
225
+ const arriveStay = svc.arrMin + mv.destTransferMin
226
+
227
+ let energy: number
228
+ if (mv.redEye && (mv.redEyeDurationMin ?? 0) > 0) {
229
+ const sleepH = ((mv.redEyeDurationMin ?? 0) - 60) / 60
230
+ energy = Math.max(30, Math.min(75, 30 + 8 * sleepH))
231
+ } else {
232
+ energy = 100 - 2 * 8
233
+ if (wake < 5 * 60) energy -= 30
234
+ else if (wake < 6 * 60) energy -= 25
235
+ if (arriveStay > 21 * 60) energy -= 10
236
+ if (d2d > 6 * 60) energy -= 10
237
+ energy = Math.max(0, energy)
238
+ }
239
+ return {
240
+ service: svc.id,
241
+ dep: minToHhmm(svc.depMin),
242
+ wake: wakeDisplay,
243
+ wakeMin: wake,
244
+ arrive_stay: minToHhmm(arriveStay),
245
+ door_to_door: `${Math.floor(d2d / 60)}h${String(d2d % 60).padStart(2, '0')}m`,
246
+ d2d_min: d2d,
247
+ energy_pct: Math.round(energy),
248
+ price_cny: svc.priceCny,
249
+ }
250
+ }
251
+
252
+ /** 航班链形态求解:按 Option 选择,锚点命名约束,core 剥竖线(D-2 修复) */
253
+ export async function solveUnified(spec: JourneySpecTS): Promise<{
254
+ feasible: boolean
255
+ money_cny?: number
256
+ legs?: Array<LegReport & { leg: string }>
257
+ red_flags?: string[]
258
+ unsat_core?: string[]
259
+ suggestions?: Array<{ relax: string; money_cny: number }>
260
+ work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
261
+ skeleton_notes?: string[]
262
+ }> {
263
+ // WASM 防护:如果 z3-solver 加载或求解触发 memory access 错误,不让异常穿透到进程层把 dsh 杀掉。
264
+ // 候选形态走 solveChoiceSegment 不经过这里——这里是显式航班链路径,用户量较少。
265
+ try {
266
+ return await solveUnifiedInner(spec)
267
+ } catch (e) {
268
+ console.error('[gotry] solveUnified failed (likely wasm thread race):', (e as Error).message?.slice(0, 200))
269
+ return { feasible: false, unsat_core: ['wasm_runtime_error'], red_flags: ['WASM 求解器异常,建议下次用候选形态(枚举)重试'] }
270
+ }
271
+ }
272
+
273
+ async function solveUnifiedInner(spec: JourneySpecTS): Promise<{
274
+ feasible: boolean
275
+ money_cny?: number
276
+ legs?: Array<LegReport & { leg: string }>
277
+ red_flags?: string[]
278
+ unsat_core?: string[]
279
+ suggestions?: Array<{ relax: string; money_cny: number }>
280
+ work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
281
+ skeleton_notes?: string[]
282
+ }> {
283
+ // M-1:求解前的工作窗口确定性预过滤(与 py 对齐),排除理由入记录
284
+ // 骨架层(§7-1):三值语义标注——枢纽间否定只降权不排除(骨架滞后会错杀 EK329)
285
+ const skeletonNotes: string[] = []
286
+ const exclusions: Array<{ segment: string; option: string; reason: string }> = []
287
+ for (const seg of spec.segments) {
288
+ if (spec.skeletonHub) {
289
+ // 段级骨架查询:route 提示(如 "HKG->HKT")优先,否则跳过
290
+ const route = (seg as SegmentTS & { route?: string }).route
291
+ if (route) {
292
+ const [a, b] = route.split('->').map(s => s.trim())
293
+ const verdict = await checkConnectivity(a, b)
294
+ skeletonNotes.push(`${seg.id}: ${verdict.evidence}`)
295
+ }
296
+ }
297
+ const kept = seg.options.filter(o => {
298
+ const reason = workWindowBlocks(spec, o)
299
+ if (reason) exclusions.push({ segment: seg.id, option: o.id, reason })
300
+ return !reason
301
+ })
302
+ seg.options = kept
303
+ if (kept.length === 0) {
304
+ return { feasible: false, unsat_core: [`${seg.id}:work_window`], work_window_exclusions: exclusions }
305
+ }
306
+ }
307
+
308
+ const z3 = await getZ3()
309
+ const { Bool, If, Int, Solver, Sum } = z3
310
+ const wakeFloor = spec.defaultWakeFloorMin ?? hhmmToMin('06:00')
311
+
312
+ const allSels: Record<string, any[]> = {}
313
+ const assertions: Record<string, any> = {}
314
+ const svcOf: Record<string, Service[]> = {}
315
+
316
+ for (const seg of spec.segments) {
317
+ const sels = seg.options.map((_, i) => Bool.const(`${seg.id}_o${i}`))
318
+ allSels[seg.id] = sels
319
+ const svcs = seg.options.map(o => o.move!.services[0])
320
+ svcOf[seg.id] = svcs
321
+ const pick = (attr: 'depMin' | 'arrMin' | 'priceCny') =>
322
+ Sum(...sels.map((s: any, i: number) => If(s, Int.val(svcs[i][attr]), Int.val(0))))
323
+ const dep = pick('depMin'), arr = pick('arrMin')
324
+ const o0 = seg.options[0].move!
325
+ const wake = dep.sub(Int.val(o0.bufferMin + o0.originTransferMin))
326
+ const arriveStay = arr.add(Int.val(o0.destTransferMin))
327
+ if (seg.anchors?.arriveByMin !== undefined) assertions[`${seg.id}:arrive_by`] = arriveStay.le(Int.val(seg.anchors.arriveByMin))
328
+ if (seg.anchors?.departAfterMin !== undefined) assertions[`${seg.id}:depart_after`] = dep.ge(Int.val(seg.anchors.departAfterMin))
329
+ if (!seg.options.some(o => o.move?.redEye)) assertions[`${seg.id}:wake_floor`] = wake.ge(Int.val(wakeFloor))
330
+ }
331
+
332
+ const total = Sum(...spec.segments.flatMap(seg =>
333
+ allSels[seg.id].map((s: any, i: number) => If(s, Int.val(svcOf[seg.id][i].priceCny), Int.val(0)))))
334
+ if (spec.budgetCny !== undefined) assertions['total:budget'] = total.le(Int.val(spec.budgetCny))
335
+
336
+ const exactlyOne = (sels: any[]) =>
337
+ Sum(...sels.map((s: any) => If(s, Int.val(1), Int.val(0)))).eq(Int.val(1))
338
+ const coreOf = (s: any): string[] => {
339
+ const v = s.unsatCore()
340
+ const out: string[] = []
341
+ for (let i = 0; i < v.length(); i++) out.push(String(v.get(i)).replace(/^\|/, '').replace(/\|$/, ''))
342
+ return out
343
+ }
344
+
345
+ const s = new Solver()
346
+ for (const sel of Object.values(allSels)) s.add(exactlyOne(sel))
347
+ for (const [name, expr] of Object.entries(assertions)) s.addAndTrack(expr, name)
348
+
349
+ const report = async (model: any): Promise<Array<LegReport & { leg: string }>> => {
350
+ const out: Array<LegReport & { leg: string }> = []
351
+ for (const seg of spec.segments) {
352
+ for (let i = 0; i < allSels[seg.id].length; i++) {
353
+ const v = await maybeAwait(model.eval(allSels[seg.id][i], true))
354
+ if (String(v) !== 'true') continue
355
+ const o = seg.options[i]
356
+ out.push({ leg: seg.id, ...evaluateOptionMove(seg.id, o.move!) })
357
+ break
358
+ }
359
+ }
360
+ return out
361
+ }
362
+
363
+ async function maybeAwait<T>(v: T | Promise<T>): Promise<T> {
364
+ return v instanceof Promise ? await v : v
365
+ }
366
+
367
+ if (String(await s.check()) !== 'unsat') {
368
+ const legs = await report(s.model())
369
+ const money = legs.reduce((a, l) => a + l.price_cny, 0)
370
+ const redFlags = legs
371
+ .filter(l => spec.segments.find(sg => sg.id === l.leg)?.options.some(o => o.move?.redEye) && l.energy_pct < 50)
372
+ .map(l => `${l.leg} 落地精力仅 ${l.energy_pct}%(红眼后直奔事务,当日不宜安排重要会议)`)
373
+ return { feasible: true, money_cny: money, legs, red_flags: redFlags, work_window_exclusions: exclusions, skeleton_notes: skeletonNotes.length ? skeletonNotes : undefined }
374
+ }
375
+
376
+ const core = coreOf(s).sort()
377
+ const suggestions: Array<{ relax: string; money_cny: number }> = []
378
+ for (const name of core) {
379
+ const s2 = new Solver()
380
+ for (const sel of Object.values(allSels)) s2.add(exactlyOne(sel))
381
+ for (const [n2, expr] of Object.entries(assertions)) {
382
+ if (n2 !== name) s2.addAndTrack(expr, n2)
383
+ }
384
+ if (String(await s2.check()) !== 'unsat') {
385
+ const legs = await report(s2.model())
386
+ suggestions.push({ relax: name, money_cny: legs.reduce((a, l) => a + l.price_cny, 0) })
387
+ }
388
+ }
389
+ return { feasible: false, unsat_core: core, suggestions }
390
+ }
391
+
392
+ // ---- 候选形态求解(枚举过滤,与 py solve_choice_segment 对齐) --------------------
393
+
394
+ interface CandidateChecks {
395
+ wake_floor?: boolean
396
+ energy_floor?: boolean
397
+ usable_hours?: boolean
398
+ budget?: boolean
399
+ arrival_before_evening?: boolean
400
+ }
401
+
402
+ function checkTrueCost(t: TrueCost, spec: {
403
+ wakeFloorMin: number; minArrivalEnergyPct: number; requiredUsableHours: number;
404
+ budgetCny?: number; latestArriveStayMin: number;
405
+ }): string[] {
406
+ const fails: string[] = []
407
+ if (t.wakeMin < spec.wakeFloorMin) fails.push('wake_floor')
408
+ if (t.energyArrivalPct < spec.minArrivalEnergyPct) fails.push('energy_floor')
409
+ if (t.usableHours < spec.requiredUsableHours) fails.push('usable_hours')
410
+ if (spec.budgetCny !== undefined && t.moneyCny > spec.budgetCny) fails.push('budget')
411
+ if (t.arriveStayMin > spec.latestArriveStayMin) fails.push('arrival_before_evening')
412
+ return fails
413
+ }
414
+
415
+ function enumerateCombos(opt: SegmentOptionTS, cand: Candidate, req: TravelRequest, days: number):
416
+ Array<{ choice: Choice; cost: TrueCost; fails: string[] }> {
417
+ const mv = opt.move!
418
+ const out: Array<{ choice: Choice; cost: TrueCost; fails: string[] }> = []
419
+ const spec = {
420
+ wakeFloorMin: req.motivation.wakeFloorMin,
421
+ minArrivalEnergyPct: req.motivation.minArrivalEnergyPct,
422
+ requiredUsableHours: requiredUsableHours(req.motivation),
423
+ budgetCny: req.budgetCny,
424
+ latestArriveStayMin: LATEST_ARRIVE_STAY_MIN,
425
+ }
426
+ for (const o of mv.services) {
427
+ for (const tr of mv.transfers ?? []) {
428
+ for (const r of (mv.retServices ?? mv.services)) {
429
+ for (const trr of mv.transfers ?? []) {
430
+ const ch: Choice = { outService: o, outTransfer: tr, retService: r, retTransfer: trr, days }
431
+ const t = evaluateChoice(cand, req, ch)
432
+ out.push({ choice: ch, cost: t, fails: checkTrueCost(t, spec) })
433
+ }
434
+ }
435
+ }
436
+ }
437
+ return out
438
+ }
439
+
440
+ function optionToCandidate(opt: SegmentOptionTS): Candidate {
441
+ const mv = opt.move!, st = opt.stay!
442
+ return {
443
+ id: opt.id, name: opt.label, hub: mv.hub,
444
+ bufferOutMin: mv.bufferMin, bufferRetMin: mv.bufferRetMin ?? mv.bufferMin,
445
+ servicesOut: [...mv.services], servicesRet: [...(mv.retServices ?? mv.services)],
446
+ destTransfers: [...(mv.transfers ?? [])],
447
+ stayCnyPerNight: st.stayCnyPerNight ?? 0, localDailyCny: st.localDailyCny ?? 0,
448
+ minDaysForPurpose: opt.minDays ?? 1,
449
+ imageryMatch: opt.score ?? 0, bestMonths: opt.bestMonths ?? [],
450
+ }
451
+ }
452
+
453
+ /** 单 choice 段(目的地选项):逐 Option 枚举过滤,按 score 排序。与 py solve_choice_segment 等价。 */
454
+ export function solveChoiceSegment(spec: JourneySpecTS, req: TravelRequest): Record<string, unknown> {
455
+ const seg = spec.segments.find(s => s.role === 'choice' && s.options.length > 0 && s.options[0].stay)
456
+ if (!seg) throw new Error('solveChoiceSegment: no choice segment with stay found')
457
+ const windowDays = req.windowDays
458
+ const verdicts: Array<Record<string, unknown>> = []
459
+
460
+ for (const opt of seg.options) {
461
+ const cand = optionToCandidate(opt)
462
+ const minDays = cand.minDaysForPurpose
463
+ const durationOk = windowDays >= minDays
464
+ const combos = durationOk ? enumerateCombos(opt, cand, req, windowDays) : []
465
+ const good = combos.filter(c => c.fails.length === 0)
466
+
467
+ if (good.length > 0) {
468
+ const best = good.reduce((a, b) => a.cost.moneyCny <= b.cost.moneyCny ? a : b)
469
+ verdicts.push({
470
+ candidate_id: opt.id, name: opt.label, feasible: true, imagery_match: opt.score ?? 0,
471
+ chosen: {
472
+ out_service: best.choice.outService.id, out_transfer: best.choice.outTransfer.mode,
473
+ ret_service: best.choice.retService.id, ret_transfer: best.choice.retTransfer.mode,
474
+ days: best.choice.days,
475
+ },
476
+ true_cost: trueCostToDict(best.cost),
477
+ })
478
+ continue
479
+ }
480
+
481
+ // 不可行:归因 + duration 换长口径的 wish pool
482
+ const blocked = durationOk
483
+ ? Array.from(new Set(combos.flatMap(c => c.fails))).sort()
484
+ : ['duration']
485
+ const suggestions: Array<{ relax: string; resulting_money_cny?: number }> = []
486
+ for (const name of blocked) {
487
+ const opened = combos.filter(c => !c.fails.includes(name))
488
+ if (opened.length > 0) {
489
+ const cheapest = opened.reduce((a, b) => a.cost.moneyCny <= b.cost.moneyCny ? a : b)
490
+ suggestions.push({ relax: name, resulting_money_cny: cheapest.cost.moneyCny })
491
+ }
492
+ }
493
+ let wish: Record<string, unknown> | null = null
494
+ if (!durationOk) {
495
+ const longCombos = enumerateCombos(opt, cand, req, minDays)
496
+ const budgetDropped = longCombos.filter(c => c.fails.length === 0 || (c.fails.length === 1 && c.fails[0] === 'budget'))
497
+ const conds: Record<string, unknown> = { days: minDays }
498
+ if (budgetDropped.length > 0) {
499
+ conds['budget_cny'] = Math.min(...budgetDropped.map(c => c.cost.moneyCny))
500
+ }
501
+ if (opt.bestMonths?.length) conds['best_months'] = opt.bestMonths
502
+ wish = { name: opt.label, conditions: conds, reason: `${windowDays} 天窗口装不下(目的需 ${minDays} 天)` }
503
+ }
504
+ verdicts.push({
505
+ candidate_id: opt.id, name: opt.label, feasible: false, imagery_match: opt.score ?? 0,
506
+ unsat_core: blocked, suggestions, wish_pool: wish,
507
+ })
508
+ }
509
+
510
+ const feasible = verdicts.filter(v => v.feasible).sort((a, b) => (b.imagery_match as number) - (a.imagery_match as number))
511
+ return {
512
+ verdicts,
513
+ recommended: feasible.length > 0 ? feasible[0]['candidate_id'] : null,
514
+ answer_md: renderCandidateMarkdown(req, verdicts, feasible.length > 0 ? String(feasible[0]['candidate_id']) : null),
515
+ }
516
+ }
517
+
518
+ function renderCandidateMarkdown(req: TravelRequest, verdicts: Array<Record<string, unknown>>, recommended: string | null): string {
519
+ const feasible = verdicts.filter(v => v['feasible']).sort((a, b) => (b['imagery_match'] as number) - (a['imagery_match'] as number))
520
+ const parked = verdicts.filter(v => !v['feasible'])
521
+ const lines: string[] = []
522
+ lines.push(`> 憧憬:${req.note}`)
523
+ lines.push(`> 已识别约束:窗口 ${req.windowDays} 天 | 预算 ¥${req.budgetCny} | `
524
+ + `动机(休整改写需求 ${requiredUsableHours(req.motivation).toFixed(1)}h 有效休整)`)
525
+ lines.push('')
526
+ for (const v of parked) {
527
+ const core = (v['unsat_core'] as string[]) ?? []
528
+ lines.push(`**${v['name']}:现在不行**——冲突约束:${core.join('、')}。`)
529
+ const sug = ((v['suggestions'] as Array<Record<string, unknown>>) ?? [])[0]
530
+ if (sug) lines.push(`- 放宽 ${sug['relax']}:约 ¥${sug['resulting_money_cny']}`)
531
+ const wish = v['wish_pool'] as Record<string, unknown> | null
532
+ if (wish) {
533
+ const conds = wish['conditions'] as Record<string, unknown>
534
+ const budgetNote = conds['budget_cny'] ? `、约 ¥${conds['budget_cny']}` : ''
535
+ const months = conds['best_months'] as number[] | undefined
536
+ const season = months ? `,${months} 月最佳` : ''
537
+ lines.push(`- 已放入「下一次出发」清单:需要 ${conds['days']} 天${budgetNote}${season}`)
538
+ }
539
+ }
540
+ lines.push('')
541
+ for (const v of feasible) {
542
+ const ch = v['chosen'] as Record<string, unknown>
543
+ const t = v['true_cost'] as Record<string, unknown>
544
+ lines.push(`**${v['name']}:可行**`
545
+ + `(${ch['out_service']} 出发,${ch['out_transfer']} 接驳,`
546
+ + `起床 ${t['wake']},到达精力 ${t['energy_arrival_pct']}%,`
547
+ + `门到门 ${t['door_to_door_out']},有效休整 ${t['usable_hours']}h,共 ¥${t['money_cny']})`)
548
+ }
549
+ if (feasible.length) {
550
+ lines.push('')
551
+ const best = feasible[0]
552
+ const alt = feasible[1]
553
+ lines.push(`**建议:${best['name']}**(意象匹配 ${((best['imagery_match'] as number) * 100).toFixed(0)}%)。`
554
+ + (alt ? `备选:${alt['name']}(¥${(alt['true_cost'] as Record<string, unknown>)['money_cny']},匹配 ${((alt['imagery_match'] as number) * 100).toFixed(0)}%)。` : ''))
555
+ }
556
+ lines.push('')
557
+ lines.push('**待你决定的两个问题**:')
558
+ if (feasible.length >= 2) {
559
+ lines.push(`1. ${feasible[0]['name']} 还是 ${feasible[1]['name']}?(前者更贴意象,后者更省)`)
560
+ } else if (feasible.length === 1) {
561
+ lines.push(`1. 就去 ${feasible[0]['name']} 吗?`)
562
+ } else {
563
+ lines.push('1. 所有候选都不可行——考虑放宽哪条约束?')
564
+ }
565
+ const p0 = parked[0]
566
+ const p0wish = p0?.['wish_pool'] as Record<string, unknown> | null
567
+ if (p0wish) {
568
+ const conds = p0wish['conditions'] as Record<string, unknown>
569
+ lines.push(`2. 把 ${p0!['name']} 留给「下一次出发」(${conds['days']} 天起),这次先去可行的?`)
570
+ } else if (feasible.length) {
571
+ const ch = feasible[0]['chosen'] as Record<string, unknown>
572
+ lines.push(`2. 出发班次选 ${ch['out_service']} 还是更晚的?`)
573
+ }
574
+ return lines.join('\n')
575
+ }
576
+
577
+ export { minToHhmm }