@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.
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/bin/gotry-inner.js +214 -0
- package/bin/gotry-stdio-ask.js +59 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +86 -0
- package/data/flights_2026.json +218 -0
- package/data/golden_erhai.json +92 -0
- package/data/golden_trip_2026.json +75 -0
- package/data/hotels_2026.json +151 -0
- package/data/openflights-skeleton.json +895 -0
- package/data/time-slot-eval.json +384 -0
- package/data/yunnan-pack.json +202 -0
- package/dist/capabilities/agent-reach-bridge.py +90 -0
- package/dist/capabilities/agent-reach-deep.js +172 -0
- package/dist/capabilities/agent-reach.js +204 -0
- package/dist/capabilities/anything.js +139 -0
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/hbcli.js +143 -0
- package/dist/capabilities/incident-log.js +100 -0
- package/dist/capabilities/opensky.js +74 -0
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +99 -0
- package/dist/capabilities/session/transport.js +101 -0
- package/dist/capabilities/session-search.js +93 -0
- package/dist/capabilities/weather.js +180 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/agent-reach-deep-tests.js +54 -0
- package/dist/scripts/agent-reach-tests.js +38 -0
- package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
- package/dist/scripts/anything-tests.js +95 -0
- package/dist/scripts/async-collect.js +37 -0
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/diff-test.js +39 -0
- package/dist/scripts/engine-run.js +14 -0
- package/dist/scripts/engine-tests.js +45 -0
- package/dist/scripts/hbcli-tests.js +101 -0
- package/dist/scripts/incident-tests.js +96 -0
- package/dist/scripts/journey-tests.js +45 -0
- package/dist/scripts/ledger-tests.js +365 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +24 -0
- package/dist/scripts/nudge-digest.js +83 -0
- package/dist/scripts/opensky-check.js +20 -0
- package/dist/scripts/opensky-tests.js +52 -0
- package/dist/scripts/probe-poi-tests.js +88 -0
- package/dist/scripts/publish-preverify.js +68 -0
- package/dist/scripts/replay-async.js +44 -0
- package/dist/scripts/replay-real.js +38 -0
- package/dist/scripts/replay.js +116 -0
- package/dist/scripts/session-attach-diagnose.js +27 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/skeleton-check.js +37 -0
- package/dist/scripts/skeleton-integration-test.js +22 -0
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +328 -0
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +248 -0
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +49 -0
- package/dist/scripts/weather-tests.js +48 -0
- package/dist/src/bridge.js +34 -0
- package/dist/src/companions.js +112 -0
- package/dist/src/contracts.js +64 -0
- package/dist/src/dsh-llm.js +174 -0
- package/dist/src/engine.js +331 -0
- package/dist/src/index.js +1239 -0
- package/dist/src/journey.js +147 -0
- package/dist/src/loop.js +360 -0
- package/dist/src/memory-capture.js +40 -0
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/memory-utility.js +55 -0
- package/dist/src/mock-llm.js +103 -0
- package/dist/src/model.js +134 -0
- package/dist/src/slot-spec.js +165 -0
- package/dist/src/state-ledger.js +901 -0
- package/dist/src/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +538 -0
- package/dist/src/wish-pool.js +27 -0
- package/package.json +84 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +158 -0
- package/ts/capabilities/anything.ts +201 -0
- package/ts/capabilities/flyai.ts +151 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +148 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/transport.ts +114 -0
- package/ts/capabilities/session-search.ts +129 -0
- package/ts/capabilities/weather.ts +176 -0
- package/ts/package.json +27 -0
- package/ts/scripts/async-collect.ts +50 -0
- package/ts/scripts/skeleton-check.ts +49 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/scripts/state-cli.ts +201 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +155 -0
- package/ts/src/index.ts +968 -0
- package/ts/src/loop.ts +411 -0
- package/ts/src/memory-capture.ts +59 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +100 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +849 -0
- package/ts/src/time-anchor.ts +134 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/unified.ts +582 -0
- 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
|
+
}
|