@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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock LLM(S2,ADR-8):确定性剧本,重放真实对话的智能侧。
|
|
3
|
+
* 它不做任何「聪明事」——只按剧本返回抽取结果;循环架构的正确性由此验证,
|
|
4
|
+
* 与智能质量解耦。S4 接真 LLM 后,本文件留作回归夹具。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LlmPort } from './loop.ts'
|
|
8
|
+
import type { InterviewQuestion, TravelerProfile, TripState, Turn, CalendarState } from './contracts.ts'
|
|
9
|
+
import type { JourneySpecTS } from './unified.ts'
|
|
10
|
+
import type { TravelSlotExtraction } from './travel-slots.ts'
|
|
11
|
+
import { parseFlightPackToSpec } from './unified.ts'
|
|
12
|
+
|
|
13
|
+
interface ScriptStep {
|
|
14
|
+
/** 命中条件:用户消息包含此关键词(第一条匹配生效) */
|
|
15
|
+
when: string
|
|
16
|
+
calendar?: Partial<CalendarState>
|
|
17
|
+
profile?: Partial<TravelerProfile>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 槽位抽取剧本:评测 harness 把 golden 逐条挂进来,mock 原样回吐(管道自测用) */
|
|
21
|
+
export interface SlotScriptStep {
|
|
22
|
+
when: string
|
|
23
|
+
extraction: TravelSlotExtraction
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 剧本:来自真实 Kimi 对话里用户给出关键事实的时刻 */
|
|
27
|
+
const SCRIPT: ScriptStep[] = [
|
|
28
|
+
{
|
|
29
|
+
when: '请给我做机票和酒店的行程规划和推荐',
|
|
30
|
+
calendar: {
|
|
31
|
+
year: 2026,
|
|
32
|
+
assertedWeekdays: {
|
|
33
|
+
'2026-07-17': 'fri', '2026-07-18': 'sat', '2026-07-31': 'fri',
|
|
34
|
+
'2026-08-01': 'sat', '2026-08-09': 'sun', '2026-08-10': 'mon',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
profile: { companions: ['girlfriend(先在普吉)'] },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
when: '工作时间',
|
|
41
|
+
profile: {
|
|
42
|
+
workWindow: {
|
|
43
|
+
homeTzOffsetMin: 240, startMin: 600, endMin: 1140, workdays: [0, 1, 2, 3, 4],
|
|
44
|
+
evidence: '用户原话:我的工作时间是UTC+4的早上10点到下午7点',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
when: '订了酒店',
|
|
50
|
+
profile: {
|
|
51
|
+
bookedResources: [
|
|
52
|
+
{ kind: 'hotel', ref: 'The Title East Wing Rawai(7.18-23, 5 晚)', window: '2026-07-18~07-23' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
export function createMockLlm(flightPackPath?: string, slotScript: SlotScriptStep[] = []): LlmPort {
|
|
59
|
+
let stepIdx = 0
|
|
60
|
+
return {
|
|
61
|
+
async extractFacts(history: Turn[], _state: TripState) {
|
|
62
|
+
const last = history[history.length - 1]?.text ?? ''
|
|
63
|
+
// 剧本顺序消费:真实对话里事实按此顺序浮出;未命中则无新事实
|
|
64
|
+
const step = SCRIPT[stepIdx]
|
|
65
|
+
if (step && last.includes(step.when)) {
|
|
66
|
+
stepIdx++
|
|
67
|
+
return {
|
|
68
|
+
calendar: step.calendar,
|
|
69
|
+
profile: step.profile,
|
|
70
|
+
assumptions: Object.keys({ ...step.profile }).map(f => ({ field: f, source: 'user-verbatim' as const })),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { assumptions: [] }
|
|
74
|
+
},
|
|
75
|
+
async extractSpec(history: Turn[], state: TripState): Promise<JourneySpecTS | null> {
|
|
76
|
+
// mock 翻译:工作窗口与已订资源齐备后,「翻译」= 装载航班包并挂上真实工作窗口
|
|
77
|
+
if (!state.profile.workWindow || !state.profile.bookedResources) return null
|
|
78
|
+
if (!flightPackPath) return null
|
|
79
|
+
const { readFile } = await import('node:fs/promises')
|
|
80
|
+
const pack = JSON.parse(await readFile(flightPackPath, 'utf-8'))
|
|
81
|
+
const spec = parseFlightPackToSpec(pack)
|
|
82
|
+
spec.workWindow = {
|
|
83
|
+
homeTzOffsetMin: state.profile.workWindow.homeTzOffsetMin,
|
|
84
|
+
startMin: state.profile.workWindow.startMin,
|
|
85
|
+
endMin: state.profile.workWindow.endMin,
|
|
86
|
+
workdays: state.profile.workWindow.workdays,
|
|
87
|
+
}
|
|
88
|
+
spec.budgetCny = 9000
|
|
89
|
+
return spec
|
|
90
|
+
},
|
|
91
|
+
async extractSlots(history: Turn[]): Promise<TravelSlotExtraction | null> {
|
|
92
|
+
const last = history[history.length - 1]?.text ?? ''
|
|
93
|
+
const hit = slotScript.find(s => last.includes(s.when))
|
|
94
|
+
return hit ? structuredClone(hit.extraction) : null
|
|
95
|
+
},
|
|
96
|
+
async polishQuestion(q: InterviewQuestion) {
|
|
97
|
+
return `【${q.key}】${q.text}\n(为什么问:${q.why})`
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
package/ts/src/model.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gotry 可行性引擎领域模型(TS 版,与 py/gotry_feasibility/model.py 逐行对齐)。
|
|
3
|
+
*
|
|
4
|
+
* 双实现纪律:Python 版是**对照实现(oracle)**,TS 版是产品运行时实现;
|
|
5
|
+
* 同一输入两版必须给出相同判定(ts/scripts/diff-test.ts 做差分验证)。
|
|
6
|
+
* 算术(门到门全成本)在此层与求解完全分离,两边各自可测。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function hhmmToMin(s: string): number {
|
|
10
|
+
const [h, m] = s.split(':')
|
|
11
|
+
return Number(h) * 60 + Number(m)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function minToHhmm(m: number): string {
|
|
15
|
+
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TransferMode {
|
|
19
|
+
mode: string
|
|
20
|
+
minutes: number
|
|
21
|
+
priceCny: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Service {
|
|
25
|
+
id: string
|
|
26
|
+
depMin: number
|
|
27
|
+
arrMin: number
|
|
28
|
+
priceCny: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HubAccess {
|
|
32
|
+
hub: string
|
|
33
|
+
toHubMin: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MotivationProfile {
|
|
37
|
+
/** 动机谱系,如 { escape_rest: 0.7, curiosity: 0.3 } */
|
|
38
|
+
weights: Record<string, number>
|
|
39
|
+
/** 起床不早于(当日分钟)——由动机推出的硬约束 */
|
|
40
|
+
wakeFloorMin: number
|
|
41
|
+
minArrivalEnergyPct: number
|
|
42
|
+
baseUsableHours: number
|
|
43
|
+
escapeHoursPerWeight: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Candidate {
|
|
47
|
+
id: string
|
|
48
|
+
name: string
|
|
49
|
+
hub: string
|
|
50
|
+
bufferOutMin: number
|
|
51
|
+
bufferRetMin: number
|
|
52
|
+
servicesOut: Service[]
|
|
53
|
+
servicesRet: Service[]
|
|
54
|
+
destTransfers: TransferMode[]
|
|
55
|
+
stayCnyPerNight: number
|
|
56
|
+
localDailyCny: number
|
|
57
|
+
minDaysForPurpose: number
|
|
58
|
+
imageryMatch: number
|
|
59
|
+
bestMonths: number[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TravelRequest {
|
|
63
|
+
note: string
|
|
64
|
+
motivation: MotivationProfile
|
|
65
|
+
windowDays: number
|
|
66
|
+
budgetCny: number
|
|
67
|
+
homeHubAccess: Record<string, HubAccess>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Choice {
|
|
71
|
+
outService: Service
|
|
72
|
+
outTransfer: TransferMode
|
|
73
|
+
retService: Service
|
|
74
|
+
retTransfer: TransferMode
|
|
75
|
+
days: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TrueCost {
|
|
79
|
+
moneyCny: number
|
|
80
|
+
wakeMin: number
|
|
81
|
+
arriveStayMin: number
|
|
82
|
+
doorToDoorOutMin: number
|
|
83
|
+
energyArrivalPct: number
|
|
84
|
+
usableHours: number
|
|
85
|
+
usableDay1Hours: number
|
|
86
|
+
usableDay2Hours: number
|
|
87
|
+
departHomeRetMin: number
|
|
88
|
+
arriveHomeRetMin: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function requiredUsableHours(mot: MotivationProfile): number {
|
|
92
|
+
return mot.baseUsableHours + mot.escapeHoursPerWeight * (mot.weights['escape_rest'] ?? 0)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- 精力模型(可校准参数,与 Python 版一致) --------------------------------
|
|
96
|
+
export const WAKE_PENALTY_BEFORE_5 = 30
|
|
97
|
+
export const WAKE_PENALTY_BEFORE_6 = 25
|
|
98
|
+
export const WAKE_PENALTY_BEFORE_630 = 15
|
|
99
|
+
export const TRANSFER_PENALTY = 8
|
|
100
|
+
export const LATE_ARRIVAL_PENALTY = 10
|
|
101
|
+
export const LONG_D2D_PENALTY = 10
|
|
102
|
+
export const DAY_END_MIN = 21 * 60
|
|
103
|
+
export const DAY2_START_MIN = 9 * 60
|
|
104
|
+
export const DAY2_QUALITY = 0.9
|
|
105
|
+
export const LATEST_ARRIVE_STAY_MIN = 18 * 60
|
|
106
|
+
|
|
107
|
+
export function parseMotivation(d: Record<string, unknown>): MotivationProfile {
|
|
108
|
+
const hard = (d['hard'] ?? {}) as Record<string, unknown>
|
|
109
|
+
const rawWeights = (d['weights'] ?? d) as Record<string, unknown>
|
|
110
|
+
const weights: Record<string, number> = {}
|
|
111
|
+
for (const [k, v] of Object.entries(rawWeights)) {
|
|
112
|
+
if (typeof v === 'number') weights[k] = v
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
weights,
|
|
116
|
+
wakeFloorMin: hhmmToMin(String(hard['wake_not_before'] ?? '06:30')),
|
|
117
|
+
minArrivalEnergyPct: Number(hard['min_arrival_energy_pct'] ?? 40),
|
|
118
|
+
baseUsableHours: 4.0,
|
|
119
|
+
escapeHoursPerWeight: 2.0,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseRequest(d: Record<string, unknown>): TravelRequest {
|
|
124
|
+
const home = ((d['home'] ?? {}) as Record<string, unknown>)['hubs'] as Record<string, Record<string, unknown>> | undefined ?? {}
|
|
125
|
+
const homeHubAccess: Record<string, HubAccess> = {}
|
|
126
|
+
for (const [hub, acc] of Object.entries(home)) {
|
|
127
|
+
homeHubAccess[hub] = { hub, toHubMin: Number(acc['to_hub_min']) }
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
note: String(d['note'] ?? ''),
|
|
131
|
+
motivation: parseMotivation(d['motivation'] as Record<string, unknown>),
|
|
132
|
+
windowDays: Number(d['window_days']),
|
|
133
|
+
budgetCny: Number(d['budget_cny']),
|
|
134
|
+
homeHubAccess,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseService(d: Record<string, unknown>): Service {
|
|
139
|
+
return { id: String(d['id']), depMin: hhmmToMin(String(d['dep'])), arrMin: hhmmToMin(String(d['arr'])), priceCny: Number(d['price_cny']) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseTransfer(d: Record<string, unknown>): TransferMode {
|
|
143
|
+
return { mode: String(d['mode']), minutes: Number(d['min']), priceCny: Number(d['price_cny']) }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseCandidate(d: Record<string, unknown>): Candidate {
|
|
147
|
+
return {
|
|
148
|
+
id: String(d['id']),
|
|
149
|
+
name: String(d['name']),
|
|
150
|
+
hub: String(d['hub']),
|
|
151
|
+
bufferOutMin: Number(d['buffer_out_min'] ?? 60),
|
|
152
|
+
bufferRetMin: Number(d['buffer_ret_min'] ?? 60),
|
|
153
|
+
servicesOut: (d['services_out'] as Record<string, unknown>[]).map(parseService),
|
|
154
|
+
servicesRet: (d['services_ret'] as Record<string, unknown>[]).map(parseService),
|
|
155
|
+
destTransfers: (d['dest_transfers'] as Record<string, unknown>[]).map(parseTransfer),
|
|
156
|
+
stayCnyPerNight: Number(d['stay_cny_per_night']),
|
|
157
|
+
localDailyCny: Number(d['local_daily_cny']),
|
|
158
|
+
minDaysForPurpose: Number(d['min_days_for_purpose']),
|
|
159
|
+
imageryMatch: Number(d['imagery_match']),
|
|
160
|
+
bestMonths: ((d['best_months'] as number[]) ?? []).map(Number),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function evaluateChoice(cand: Candidate, req: TravelRequest, ch: Choice): TrueCost {
|
|
165
|
+
/** 门到门全成本核算(与 Python evaluate_choice 完全一致)。 */
|
|
166
|
+
const access = req.homeHubAccess[cand.hub]
|
|
167
|
+
const wake = ch.outService.depMin - cand.bufferOutMin - access.toHubMin
|
|
168
|
+
const arriveStay = ch.outService.arrMin + ch.outTransfer.minutes
|
|
169
|
+
const d2dOut = arriveStay - wake
|
|
170
|
+
|
|
171
|
+
let energy = 100
|
|
172
|
+
if (wake < 5 * 60) energy -= WAKE_PENALTY_BEFORE_5
|
|
173
|
+
else if (wake < 6 * 60) energy -= WAKE_PENALTY_BEFORE_6
|
|
174
|
+
else if (wake < hhmmToMin('06:30')) energy -= WAKE_PENALTY_BEFORE_630
|
|
175
|
+
energy -= 2 * TRANSFER_PENALTY
|
|
176
|
+
if (arriveStay > 21 * 60) energy -= LATE_ARRIVAL_PENALTY
|
|
177
|
+
if (d2dOut > 6 * 60) energy -= LONG_D2D_PENALTY
|
|
178
|
+
energy = Math.max(0, energy)
|
|
179
|
+
|
|
180
|
+
const day1Raw = Math.max(0, DAY_END_MIN - arriveStay) / 60
|
|
181
|
+
const day1 = day1Raw * (0.5 + energy / 200)
|
|
182
|
+
const leaveStayRet = ch.retService.depMin - cand.bufferRetMin - ch.retTransfer.minutes
|
|
183
|
+
const day2 = Math.max(0, leaveStayRet - DAY2_START_MIN) / 60 * DAY2_QUALITY
|
|
184
|
+
const midDays = Math.max(0, ch.days - 2)
|
|
185
|
+
const usable = day1 + day2 + midDays * 8.0 * DAY2_QUALITY
|
|
186
|
+
|
|
187
|
+
const money =
|
|
188
|
+
ch.outService.priceCny + ch.retService.priceCny
|
|
189
|
+
+ ch.outTransfer.priceCny + ch.retTransfer.priceCny
|
|
190
|
+
+ cand.stayCnyPerNight * (ch.days - 1)
|
|
191
|
+
+ cand.localDailyCny * ch.days
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
moneyCny: money,
|
|
195
|
+
wakeMin: wake,
|
|
196
|
+
arriveStayMin: arriveStay,
|
|
197
|
+
doorToDoorOutMin: d2dOut,
|
|
198
|
+
energyArrivalPct: energy,
|
|
199
|
+
usableHours: usable,
|
|
200
|
+
usableDay1Hours: day1,
|
|
201
|
+
usableDay2Hours: day2,
|
|
202
|
+
departHomeRetMin: leaveStayRet,
|
|
203
|
+
arriveHomeRetMin: ch.retService.arrMin + access.toHubMin,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function trueCostToDict(t: TrueCost): Record<string, unknown> {
|
|
208
|
+
return {
|
|
209
|
+
money_cny: t.moneyCny,
|
|
210
|
+
wake: minToHhmm(t.wakeMin),
|
|
211
|
+
arrive_stay: minToHhmm(t.arriveStayMin),
|
|
212
|
+
door_to_door_out: `${Math.floor(t.doorToDoorOutMin / 60)}h${String(t.doorToDoorOutMin % 60).padStart(2, '0')}m`,
|
|
213
|
+
energy_arrival_pct: t.energyArrivalPct,
|
|
214
|
+
usable_hours: Math.round(t.usableHours * 10) / 10,
|
|
215
|
+
usable_day1_hours: Math.round(t.usableDay1Hours * 10) / 10,
|
|
216
|
+
usable_day2_hours: Math.round(t.usableDay2Hours * 10) / 10,
|
|
217
|
+
leave_stay_return: minToHhmm(t.departHomeRetMin),
|
|
218
|
+
arrive_home_return: minToHhmm(Math.min(1440, t.arriveHomeRetMin)),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 槽位→日期解析层(D-10 切片 A):把 travel_slot_extraction.v1 的逐字时间表达
|
|
3
|
+
* 在代码层换算为绝对日期——「tool-owned dates」的执行面。
|
|
4
|
+
*
|
|
5
|
+
* ADR-12 复审结论(2026-08-27,D-10 赎回触发):设计成立,但解析范围必须**有界**——
|
|
6
|
+
* 只解析锚点卡词表内的表达(今天/明天/后天/大后天/本周X/下周X/下下周X/下个月初|中旬|下旬)
|
|
7
|
+
* 与绝对表达(ISO/点分/中文月日,parseAbsoluteDate)+ 槽位约定后缀「+N」;
|
|
8
|
+
* 词表外(近期/这阵子/next Monday/英文月名…)一律 unresolved 逐字保留,
|
|
9
|
+
* 不做开放式中文相对日期解析(ADR-12 被拒备选:维护黑洞)。
|
|
10
|
+
*
|
|
11
|
+
* 本层是纯函数:不碰求解器、不改入参;工具查询/spec 一致性闸的接线属后续切片。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseAbsoluteDate, ymd, type TimeAnchor } from './time-anchor.ts'
|
|
15
|
+
import type { TravelSlotExtraction } from './travel-slots.ts'
|
|
16
|
+
|
|
17
|
+
export type SlotDateKind = 'absolute' | 'card' | 'unresolved'
|
|
18
|
+
|
|
19
|
+
export interface SlotDateResolution {
|
|
20
|
+
/** 用户原话(逐字) */
|
|
21
|
+
raw: string
|
|
22
|
+
/** 换算结果 YYYY-MM-DD;unresolved 时 null */
|
|
23
|
+
date: string | null
|
|
24
|
+
kind: SlotDateKind
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const WEEKDAY_ZH: Record<string, number> = { 日: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6 }
|
|
28
|
+
|
|
29
|
+
function addDaysYmd(today: string, n: number): string {
|
|
30
|
+
const [y, m, d] = today.split('-').map(Number)
|
|
31
|
+
return ymd(new Date(y, m - 1, d + n))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 「下周五+3」「8.20+2天」后缀:base + N 天(槽位规则 5/6 的拼接约定) */
|
|
35
|
+
function splitPlusSuffix(expr: string): { base: string; plusDays: number } {
|
|
36
|
+
const m = expr.match(/^(.*?)\+(\d+)天?$/)
|
|
37
|
+
if (m && m[1]) return { base: m[1], plusDays: Number(m[2]) }
|
|
38
|
+
return { base: expr, plusDays: 0 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 锚点卡近邻日词表(今天/明天/后天/大后天)→ 相对今天的偏移天数;词表外 null */
|
|
42
|
+
function neighborDayOffset(expr: string): number | null {
|
|
43
|
+
if (expr === '今天') return 0
|
|
44
|
+
if (expr === '明天') return 1
|
|
45
|
+
if (expr === '后天') return 2
|
|
46
|
+
if (expr === '大后天') return 3
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 锚点卡词表周表达(本周X/下周X/下下周X)→ 相对今天的偏移天数;词表外 null。周一分界,与锚点卡一致 */
|
|
51
|
+
function weekdayOffsetDays(expr: string, today: string): number | null {
|
|
52
|
+
const m = expr.match(/^(本|下|下下)周([日一二三四五六])$/)
|
|
53
|
+
if (!m || !m[2]) return null
|
|
54
|
+
const target = WEEKDAY_ZH[m[2]]
|
|
55
|
+
if (target === undefined) return null
|
|
56
|
+
const [y, mm, d] = today.split('-').map(Number)
|
|
57
|
+
const todayMonOffset = (new Date(y, mm - 1, d).getDay() + 6) % 7
|
|
58
|
+
const targetMonOffset = (target + 6) % 7
|
|
59
|
+
const weekShift = m[1] === '本' ? 0 : m[1] === '下' ? 7 : 14
|
|
60
|
+
return weekShift + targetMonOffset - todayMonOffset
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 锚点卡月分段词表(下个月初/中旬/下旬)→ 该分段首日;词表外 null(保守取首日,宁可早不可晚) */
|
|
64
|
+
function monthFragmentDate(expr: string, today: string): string | null {
|
|
65
|
+
if (!['下个月初', '下个月中旬', '下个月下旬'].includes(expr)) return null
|
|
66
|
+
const [y, m] = today.split('-').map(Number)
|
|
67
|
+
const day = expr === '下个月初' ? 1 : expr === '下个月中旬' ? 11 : 21
|
|
68
|
+
return ymd(new Date(y, m, day))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 逐字时间表达 → 绝对日期。顺序:绝对表达 → 「+N」后缀拆解 → 锚点卡词表 → unresolved。
|
|
73
|
+
* kind 告知下游证据等级:absolute/card 可直接用于工具查询;unresolved 必须原文透传给人/模型。
|
|
74
|
+
*/
|
|
75
|
+
export function resolveSlotDate(expr: string, anchor: TimeAnchor): SlotDateResolution {
|
|
76
|
+
const raw = expr.trim()
|
|
77
|
+
const year = Number(anchor.today.slice(0, 4))
|
|
78
|
+
|
|
79
|
+
const absolute = parseAbsoluteDate(raw, year)
|
|
80
|
+
if (absolute) return { raw, date: absolute, kind: 'absolute' }
|
|
81
|
+
|
|
82
|
+
const { base, plusDays } = splitPlusSuffix(raw)
|
|
83
|
+
const baseAbs = parseAbsoluteDate(base, year)
|
|
84
|
+
if (baseAbs) return { raw, date: addDaysYmd(baseAbs, plusDays), kind: 'absolute' }
|
|
85
|
+
|
|
86
|
+
const month = monthFragmentDate(base, anchor.today)
|
|
87
|
+
if (month) return { raw, date: addDaysYmd(month, plusDays), kind: 'card' }
|
|
88
|
+
|
|
89
|
+
const neighbor = neighborDayOffset(base)
|
|
90
|
+
if (neighbor !== null) return { raw, date: addDaysYmd(anchor.today, neighbor + plusDays), kind: 'card' }
|
|
91
|
+
|
|
92
|
+
const offset = weekdayOffsetDays(base, anchor.today)
|
|
93
|
+
if (offset !== null) return { raw, date: addDaysYmd(anchor.today, offset + plusDays), kind: 'card' }
|
|
94
|
+
|
|
95
|
+
return { raw, date: null, kind: 'unresolved' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 各域的日期槽位字段(解析覆盖面:主日期 + 回程/退房) */
|
|
99
|
+
const DATE_FIELDS: Record<string, string[]> = {
|
|
100
|
+
requisition: ['start_date', 'end_date'],
|
|
101
|
+
flight: ['departure_date', 'return_date'],
|
|
102
|
+
hotel: ['check_in_date', 'check_out_date'],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ResolvedSlots {
|
|
106
|
+
requisition?: { start_date?: SlotDateResolution; end_date?: SlotDateResolution }
|
|
107
|
+
flight?: { departure_date?: SlotDateResolution; return_date?: SlotDateResolution }
|
|
108
|
+
hotel?: { check_in_date?: SlotDateResolution; check_out_date?: SlotDateResolution }
|
|
109
|
+
/** 词表外表达清单(field = "<domain>.<field>"),逐字保留等下游(人/模型)裁决 */
|
|
110
|
+
unresolved: Array<{ field: string; raw: string }>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 整张抽取的日期解析(纯函数,不改入参):只处理日期槽位,目的地/偏好等不在此层 */
|
|
114
|
+
export function resolveSlots(ext: TravelSlotExtraction, anchor: TimeAnchor): ResolvedSlots {
|
|
115
|
+
const out: ResolvedSlots = { unresolved: [] }
|
|
116
|
+
for (const domain of ext.domains) {
|
|
117
|
+
const slots = ext.slots[domain] as Record<string, unknown> | undefined
|
|
118
|
+
if (!slots) continue
|
|
119
|
+
for (const field of DATE_FIELDS[domain] ?? []) {
|
|
120
|
+
const value = slots[field]
|
|
121
|
+
if (typeof value !== 'string') continue
|
|
122
|
+
const res = resolveSlotDate(value, anchor)
|
|
123
|
+
const bucket = (out[domain as keyof Omit<ResolvedSlots, 'unresolved'>] ??= {}) as Record<string, SlotDateResolution>
|
|
124
|
+
bucket[field] = res
|
|
125
|
+
if (res.kind === 'unresolved') out.unresolved.push({ field: `${domain}.${field}`, raw: res.raw })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SpecDateMismatch {
|
|
132
|
+
/** spec 侧位置,如 segments[0].date */
|
|
133
|
+
specAt: string
|
|
134
|
+
field: string
|
|
135
|
+
specDate: string
|
|
136
|
+
slotDate: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* spec ↔ 槽位日期一致性闸(ADR-10 翻译≠造数的执行面):LLM 产出的 JourneySpecTS 日期
|
|
141
|
+
* 与代码层换算的槽位日期逐项比对,分歧即返回 mismatch(供渲染 red-flag/追问),不静默采信。
|
|
142
|
+
*
|
|
143
|
+
* 范围边界(2026-08-28 巡检修正):槽位 v1 只有 trip 级主日期(start/departure/check-in),
|
|
144
|
+
* **只校验恰好一个带日期段的 spec**;多段行程的逐段日期没有槽位真值可比(不比,不造假阳性
|
|
145
|
+
* ——金标准六段行程曾被全段误判分歧,求解被永久拦截)。spec 无日期或槽位 unresolved 不参与。
|
|
146
|
+
*/
|
|
147
|
+
export function specDateMismatches(
|
|
148
|
+
spec: { segments?: Array<{ date?: string }> },
|
|
149
|
+
resolved: ResolvedSlots,
|
|
150
|
+
): SpecDateMismatch[] {
|
|
151
|
+
const mismatches: SpecDateMismatch[] = []
|
|
152
|
+
const primary: Array<{ field: string; res?: SlotDateResolution }> = [
|
|
153
|
+
{ field: 'requisition.start_date', res: resolved.requisition?.start_date },
|
|
154
|
+
{ field: 'flight.departure_date', res: resolved.flight?.departure_date },
|
|
155
|
+
{ field: 'hotel.check_in_date', res: resolved.hotel?.check_in_date },
|
|
156
|
+
]
|
|
157
|
+
const dated = primary.filter((p): p is { field: string; res: SlotDateResolution } => p.res?.date != null)
|
|
158
|
+
if (dated.length === 0) return mismatches
|
|
159
|
+
const datedSegs = (spec.segments ?? []).filter(s => typeof s.date === 'string' && s.date)
|
|
160
|
+
if (datedSegs.length !== 1) return mismatches // 多段(或零段)无逐段真值,闸不判
|
|
161
|
+
const seg = datedSegs[0]!
|
|
162
|
+
const slotDates = new Set(dated.map(s => s.res.date as string))
|
|
163
|
+
if (!slotDates.has(seg.date as string)) {
|
|
164
|
+
mismatches.push({
|
|
165
|
+
specAt: `segments[${(spec.segments ?? []).indexOf(seg)}].date`,
|
|
166
|
+
field: dated.map(s => s.field).join('|'),
|
|
167
|
+
specDate: seg.date as string,
|
|
168
|
+
slotDate: [...slotDates].join('|'),
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
return mismatches
|
|
172
|
+
}
|