@danceiny/gotry 0.0.1-rc.10

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