@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/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/gotry-inner.js +124 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +28 -0
- package/package.json +53 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +145 -0
- package/ts/cordis.gotry-patch.yml +36 -0
- package/ts/package.json +23 -0
- package/ts/scripts/skeleton-check.ts +45 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +135 -0
- package/ts/src/index.ts +620 -0
- package/ts/src/loop.ts +336 -0
- package/ts/src/mock-llm.ts +88 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/unified.ts +577 -0
|
@@ -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 }
|