@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
package/ts/src/loop.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1 对话循环(S2 段1,docs/stage1-top-down-design.md §2.3)。
|
|
3
|
+
*
|
|
4
|
+
* 自顶向下:循环只依赖两个端口——LlmPort(mock/真)与确定性工具(interview_next)。
|
|
5
|
+
* 求解/渲染接线是 S2 后续段;本段验收:重放用户开场白,系统一轮内完成
|
|
6
|
+
* 日历断言 + 访谈补全启动(问出工作窗口与已订资源——Kimi 第 6 轮才问的事)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { InterviewQuestion, TripState, TravelerProfile, Turn, CalendarState, SolveResult } from './contracts.ts'
|
|
10
|
+
import type { JourneySpecTS } from './unified.ts'
|
|
11
|
+
import type { TravelSlotExtraction } from './travel-slots.ts'
|
|
12
|
+
import { anythingSearch } from '../capabilities/anything.ts'
|
|
13
|
+
|
|
14
|
+
/** LLM 端口:S2 mock(确定性剧本) / S4 真(dsh 运行时)。循环不感知差异。 */
|
|
15
|
+
export interface LlmPort {
|
|
16
|
+
/** 从对话抽取事实:日历(年/星期)与 profile 字段,带 evidence */
|
|
17
|
+
extractFacts(history: Turn[], state: TripState): Promise<{
|
|
18
|
+
calendar?: Partial<CalendarState>
|
|
19
|
+
profile?: Partial<TravelerProfile>
|
|
20
|
+
assumptions: Array<{ field: string; source: 'user-verbatim' | 'inferred' | 'default' }>
|
|
21
|
+
}>
|
|
22
|
+
/** 翻译:对话历史 → 统一模型 spec;约束未齐时返回 null(循环继续访谈) */
|
|
23
|
+
extractSpec(history: Turn[], state: TripState): Promise<JourneySpecTS | null>
|
|
24
|
+
/**
|
|
25
|
+
* 槽位抽取(travel_slot_extraction.v1):差旅 intake 面。时间表达逐字保留,
|
|
26
|
+
* 过期判定在确定性层(flagExpiredSlots),实现方负责注入时间锚点卡。
|
|
27
|
+
* now 可注入(评测用固定锚点保证确定性;生产省略取系统时钟)。
|
|
28
|
+
*/
|
|
29
|
+
extractSlots(history: Turn[], now?: Date): Promise<TravelSlotExtraction | null>
|
|
30
|
+
/** 问句润色(ADR-9:确定性驱动,LLM 只管语气) */
|
|
31
|
+
polishQuestion(q: InterviewQuestion): Promise<string>
|
|
32
|
+
/** 结果解释(S3 接 solve 后启用) */
|
|
33
|
+
render?(state: TripState): Promise<string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function newState(year = 2026): TripState {
|
|
37
|
+
return {
|
|
38
|
+
calendar: { year, assertedWeekdays: {} },
|
|
39
|
+
profile: {},
|
|
40
|
+
gates: [],
|
|
41
|
+
wishes: [],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ADR-9:访谈由缺失字段驱动(确定性,无 LLM 即兴) */
|
|
46
|
+
export function interviewNext(state: TripState): { questions: InterviewQuestion[]; missing: string[] } {
|
|
47
|
+
const p = state.profile
|
|
48
|
+
const questions: InterviewQuestion[] = []
|
|
49
|
+
const ww = p.workWindow as unknown as { vacation?: boolean } | undefined
|
|
50
|
+
if (!p.workWindow && !ww?.vacation) {
|
|
51
|
+
questions.push({
|
|
52
|
+
key: 'workWindow',
|
|
53
|
+
text: '你这两周要远程办公——工作时间是几点到几点、按哪个时区?这决定每天的可玩时段。',
|
|
54
|
+
why: '工作窗口直接决定每日节奏(普吉 13:00-22:00 与 9:00-18:00 是两种人生)',
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
if (!p.bookedResources) {
|
|
58
|
+
questions.push({
|
|
59
|
+
key: 'bookedResources',
|
|
60
|
+
text: '已经有订好的航班或酒店吗?(哪怕只是意向)——已订资源是硬锚点,先告诉我再规划。',
|
|
61
|
+
why: '已订资源决定哪些段不可动,Kimi 式规划会绕开它们重排',
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
if (!p.budgetTier) {
|
|
65
|
+
questions.push({
|
|
66
|
+
key: 'budgetTier',
|
|
67
|
+
text: '预算档位:经济(¥12.6k 级)/ 舒适(¥16.3k 级)/ 便利优先?',
|
|
68
|
+
why: '分层预算决定班次与住宿的选型口径',
|
|
69
|
+
options: [
|
|
70
|
+
{ label: '经济', tradeOff: '省钱,接驳多走路' },
|
|
71
|
+
{ label: '舒适', tradeOff: 'workation 办公质量优先' },
|
|
72
|
+
{ label: '便利优先', tradeOff: '时间最省' },
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return { questions, missing: questions.map(q => q.key) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** spec 校验闸:LLM 翻译产物进求解器前的确定性形状检查(责任边界的执行点)。 */
|
|
80
|
+
export function validateSpec(spec: JourneySpecTS): string | null {
|
|
81
|
+
if (!Array.isArray(spec.segments) || spec.segments.length === 0) return '没有段(segments)'
|
|
82
|
+
for (const seg of spec.segments) {
|
|
83
|
+
if (!seg.id) return '存在缺少 id 的段'
|
|
84
|
+
if (!Array.isArray(seg.options) || seg.options.length === 0) return `段 ${seg.id} 没有可选方案`
|
|
85
|
+
for (const opt of seg.options) {
|
|
86
|
+
const svcs = opt.move?.services
|
|
87
|
+
if (!Array.isArray(svcs) || svcs.length === 0) return `段 ${seg.id} 的方案 ${opt.id} 缺少班次(services)`
|
|
88
|
+
for (const s of svcs) {
|
|
89
|
+
if (typeof s.depMin !== 'number' || typeof s.arrMin !== 'number') {
|
|
90
|
+
return `段 ${seg.id}/${opt.id} 的班次 ${s.id ?? '?'} 缺少时刻(depMin/arrMin)`
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 求解端口:S3 起由 unified 求解器实现;循环只认此签名(确定性责任)。 */
|
|
99
|
+
export type SolvePort = (spec: JourneySpecTS) => Promise<SolveResult>
|
|
100
|
+
|
|
101
|
+
// ---- 异步深度规划(S5 架构段,ADR-8:编排架构先于智能) ----------------------------
|
|
102
|
+
// 产品形态:复杂规划 → 「我后台做,约一小时后回来看看」→ 回访时交付
|
|
103
|
+
// 已验证方案 + 选择题。不失望四条是交付物的自检契约。
|
|
104
|
+
|
|
105
|
+
export interface AsyncTicket {
|
|
106
|
+
id: string
|
|
107
|
+
objective: string
|
|
108
|
+
requestedAt: string
|
|
109
|
+
/** 剧本/mock 期用分钟级;真实化(S5 后半段)由 loopx tick 驱动 */
|
|
110
|
+
etaLabel: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isComplex(state: TripState): boolean {
|
|
114
|
+
/** 复杂度判据(架构占位,后续可细化):多段或多约束即深规划 */
|
|
115
|
+
const p = state.profile
|
|
116
|
+
return Boolean(p.workWindow && p.bookedResources) && (state.gates.length > 0 || Boolean(state.spec))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function requestDeepPlanning(state: TripState): Promise<{ reply: string; ticket: AsyncTicket }> {
|
|
120
|
+
const ticket: AsyncTicket = {
|
|
121
|
+
id: `dp-${Date.now().toString(36)}`,
|
|
122
|
+
objective: '生成已验证的行程方案:全成本核算 + 动机/约束匹配 + 不失望四条',
|
|
123
|
+
requestedAt: new Date().toISOString(),
|
|
124
|
+
etaLabel: '约 1 小时(mock 期:秒级)',
|
|
125
|
+
}
|
|
126
|
+
state.gates.push({ id: `async-${ticket.id}`, question: `深度规划已启动(${ticket.etaLabel})`, options: [] })
|
|
127
|
+
const reply = '这趟行程跨度大(两周 workation + 三城 + 红眼返程),我切到**深度规划模式**:后台做多轮校验'
|
|
128
|
+
+ '(锚点/工作窗口/全成本/负例排查),做好后通知你。**先留一道选择题给你**,回来直接定:'
|
|
129
|
+
+ '\n1. 预算档位?(经济/舒适/便利优先)'
|
|
130
|
+
return { reply, ticket }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function collectDeepPlanning(
|
|
134
|
+
state: TripState,
|
|
135
|
+
ticket: AsyncTicket,
|
|
136
|
+
solve: SolvePort,
|
|
137
|
+
): Promise<{ reply: string; state: TripState }> {
|
|
138
|
+
const spec = state.spec
|
|
139
|
+
if (!spec) return { reply: '(内部状态缺失 spec——深度规划未就绪,这是不应发生的路径)', state }
|
|
140
|
+
state.solve = await solve(spec)
|
|
141
|
+
state.gates = state.gates.filter(g => !g.id.startsWith('async-'))
|
|
142
|
+
|
|
143
|
+
// 不失望四条自检(交付物自带,总纲 3.6)
|
|
144
|
+
const checks = {
|
|
145
|
+
'1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
|
|
146
|
+
'2_产物通过自检清单': state.solve.feasible ? (state.solve.legs?.every(l => (l as Record<string, unknown>)['energy_pct'] !== undefined) ?? false) : Boolean(state.solve.unsat_core?.length),
|
|
147
|
+
'3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
|
|
148
|
+
'4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
|
|
149
|
+
}
|
|
150
|
+
const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${Object.values(checks).every(Boolean) ? '4/4 ✅' : '有未达项 ❌'})`
|
|
151
|
+
return { reply: `${head}\n\n${renderSolve(state)}`, state }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 求解结果 → 人话(模板;S4 可由 LLM 润色,数字与判定不可改) */
|
|
155
|
+
export function renderSolve(state: TripState): string {
|
|
156
|
+
const s = state.solve
|
|
157
|
+
if (!s) return '(无求解结果)'
|
|
158
|
+
const lines: string[] = []
|
|
159
|
+
if (s.feasible) {
|
|
160
|
+
lines.push(`**方案可行,机票合计 ¥${s.money_cny}**`)
|
|
161
|
+
for (const lg of s.legs ?? []) {
|
|
162
|
+
const l = lg as Record<string, string | number>
|
|
163
|
+
lines.push(`- ${l['leg']} ${l['service']}:${l['dep']} 起飞,${l['wake']} 出发,${l['arrive_stay']} 到,`
|
|
164
|
+
+ `门到门 ${l['door_to_door']},落地精力 ${l['energy_pct']}%,¥${l['price_cny']}`)
|
|
165
|
+
}
|
|
166
|
+
for (const e of s.work_window_exclusions ?? []) {
|
|
167
|
+
lines.push(`- 已排除 ${e.option}(工作窗口):${e.reason}`)
|
|
168
|
+
}
|
|
169
|
+
for (const n of s.skeleton_notes ?? []) lines.push(`- ${n}`)
|
|
170
|
+
for (const f of s.red_flags ?? []) lines.push(`- ⚠️ ${f}`)
|
|
171
|
+
} else {
|
|
172
|
+
lines.push(`**当前约束下不可行——冲突:${(s.unsat_core ?? []).join('、')}`)
|
|
173
|
+
for (const sg of s.suggestions ?? []) lines.push(`- 放宽「${sg.relax}」可解(约 ¥${sg.money_cny})`)
|
|
174
|
+
}
|
|
175
|
+
if (state.gates.length) {
|
|
176
|
+
lines.push('', '**待你决定(选择题)**')
|
|
177
|
+
for (const g of state.gates) {
|
|
178
|
+
lines.push(`- ${g.question} → ${g.options.map(o => o.label + (o.tradeOff ? `(${o.tradeOff})` : '')).join(' / ')}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return lines.join('\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 一次对话回合:抽取事实(冲突即指出)→ 增量访谈 → 约束齐备则求解+渲染。 */
|
|
185
|
+
export async function runTurn(
|
|
186
|
+
state: TripState,
|
|
187
|
+
userMsg: string,
|
|
188
|
+
llm: LlmPort,
|
|
189
|
+
history: Turn[] = [],
|
|
190
|
+
solve: SolvePort | null = null,
|
|
191
|
+
): Promise<{ reply: string; state: TripState }> {
|
|
192
|
+
const facts = await llm.extractFacts([...history, { role: 'user', text: userMsg }], state)
|
|
193
|
+
|
|
194
|
+
const conflicts: string[] = []
|
|
195
|
+
if (facts.calendar?.assertedWeekdays) {
|
|
196
|
+
for (const [date, wd] of Object.entries(facts.calendar.assertedWeekdays)) {
|
|
197
|
+
const prev = state.calendar.assertedWeekdays[date]
|
|
198
|
+
if (prev && prev !== wd) {
|
|
199
|
+
conflicts.push(`${date}:已有断言 ${prev},新说法 ${wd}——以先断言为准,如需改请明确说`)
|
|
200
|
+
} else {
|
|
201
|
+
state.calendar.assertedWeekdays[date] = wd
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
Object.assign(state.profile, facts.profile ?? {})
|
|
206
|
+
|
|
207
|
+
// 访谈:workWindow/bookedResources 是求解前置;budgetTier 转为 gate(不阻塞规划)
|
|
208
|
+
const { questions } = interviewNext(state)
|
|
209
|
+
const blocking = questions.filter(q => q.key !== 'budgetTier')
|
|
210
|
+
const budgetQ = questions.find(q => q.key === 'budgetTier')
|
|
211
|
+
if (budgetQ && !state.gates.some(g => g.id === 'budget')) {
|
|
212
|
+
state.gates.push({
|
|
213
|
+
id: 'budget',
|
|
214
|
+
question: budgetQ.text,
|
|
215
|
+
options: budgetQ.options ?? [{ label: '经济' }, { label: '舒适' }, { label: '便利优先' }],
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const parts: string[] = []
|
|
220
|
+
if (conflicts.length) parts.push(`⚠️ 日历冲突:\n${conflicts.map(c => `- ${c}`).join('\n')}`)
|
|
221
|
+
|
|
222
|
+
// D-7a 例外: blocking>0 时,用户消息含"查+地名/酒店/天气"信号时,直接调 anything_search
|
|
223
|
+
// 走 PoI 真相(datasources 层主动调,不动 polling 状态机,不改求解器)
|
|
224
|
+
const poiProbe = probePoi(userMsg)
|
|
225
|
+
if (poiProbe) {
|
|
226
|
+
const ar = await anythingSearch({ keyword: poiProbe })
|
|
227
|
+
if (ar.verdict === 'hit' && ar.hits && ar.hits.length > 0) {
|
|
228
|
+
const top = ar.hits.slice(0, 5)
|
|
229
|
+
const lines = top.map((h, i) => {
|
|
230
|
+
const latlng = h.latitude !== undefined && h.longitude !== undefined
|
|
231
|
+
? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})`
|
|
232
|
+
: ''
|
|
233
|
+
return ` ${i + 1}. [${h.type}] ${h.name}${latlng}`
|
|
234
|
+
}).join('\n')
|
|
235
|
+
parts.push(`**${poiProbe} → hit (${ar.hits.length} 候选项)**\n${lines}\n${ar.evidence}`)
|
|
236
|
+
} else if (ar.verdict === 'miss') {
|
|
237
|
+
parts.push(`**${poiProbe} → miss** (酒店-be Anything 一切正常但无候选)\n${ar.evidence}`)
|
|
238
|
+
} else {
|
|
239
|
+
parts.push(`**${poiProbe} → unavailable** (${ar.error ?? 'hbcli 不可达'})\n${ar.evidence}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const q of blocking) parts.push(await llm.polishQuestion(q))
|
|
244
|
+
|
|
245
|
+
// 约束齐备(无阻塞问题)→ 翻译 spec → **校验闸 + 日期一致性闸** → 求解 → 渲染
|
|
246
|
+
if (blocking.length === 0 && solve) {
|
|
247
|
+
const spec = await llm.extractSpec([...history, { role: 'user', text: userMsg }], state)
|
|
248
|
+
// 场景路由:erhai 候选标记 → 候选求解(洱海金标准的引擎判定)——纯 TS unify 路径
|
|
249
|
+
if (spec && (spec as unknown as { note?: string }).note === 'erhai-candidates') {
|
|
250
|
+
const { readFile } = await import('node:fs/promises')
|
|
251
|
+
const { join } = await import('node:path')
|
|
252
|
+
try {
|
|
253
|
+
const raw = JSON.parse(await readFile(join(import.meta.dirname, '..', '..', 'data', 'golden_erhai.json'), 'utf-8'))
|
|
254
|
+
const { parseCandidate, parseRequest } = await import('./model.ts')
|
|
255
|
+
const { segmentsFromCandidate, solveChoiceSegment } = await import('./unified.ts')
|
|
256
|
+
const req = parseRequest(raw['request'] as Record<string, unknown>)
|
|
257
|
+
const cands = (raw['candidates'] as Record<string, unknown>[]).map(parseCandidate)
|
|
258
|
+
const erhaiSpec = segmentsFromCandidate(req, cands)
|
|
259
|
+
const result = solveChoiceSegment(erhaiSpec, req) as { answer_md?: string; recommended?: string }
|
|
260
|
+
state.solve = result as never
|
|
261
|
+
parts.push(result.answer_md ?? '(候选求解完成)')
|
|
262
|
+
} catch {
|
|
263
|
+
parts.push('(洱海候选求解暂不可用——退回访谈)')
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
const invalid = spec ? validateSpec(spec) : '翻译器未产出 spec'
|
|
267
|
+
if (spec && !invalid) {
|
|
268
|
+
// D-10 切片 C:spec↔槽位日期一致性闸(ADR-10 翻译≠造数的执行点)。
|
|
269
|
+
// LLM 翻译的 spec 日期与代码层换算的槽位日期分歧时不求解、不猜,追问确认——
|
|
270
|
+
// 日期是求解关键输入,错日期 = 错判决。槽位缺失/unresolved 不参与(不造假阳性)。
|
|
271
|
+
const ext = await llm.extractSlots([...history, { role: 'user', text: userMsg }])
|
|
272
|
+
if (ext) {
|
|
273
|
+
const { resolveSlots, specDateMismatches } = await import('./slot-spec.ts')
|
|
274
|
+
const { buildTimeAnchor } = await import('./time-anchor.ts')
|
|
275
|
+
const mismatches = specDateMismatches(spec, resolveSlots(ext, buildTimeAnchor()))
|
|
276
|
+
if (mismatches.length > 0) {
|
|
277
|
+
const lines = mismatches.map(m => `- ${m.specAt}:翻译说 ${m.specDate},你的原话换算是 ${m.slotDate}(${m.field})`)
|
|
278
|
+
parts.push(`⚠️ 日期分歧,确认后我再算(以你为准):\n${lines.join('\n')}`)
|
|
279
|
+
return { reply: parts.join('\n\n'), state }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
state.spec = spec
|
|
283
|
+
state.solve = await solve(spec)
|
|
284
|
+
parts.push(llm.render ? await llm.render(state) : renderSolve(state))
|
|
285
|
+
} else if (blocking.length === 0 && invalid) {
|
|
286
|
+
parts.push(`(行程骨架还不完整:${invalid}——请补充对应信息,我不猜)`)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} else if (blocking.length === 0) {
|
|
290
|
+
parts.push('(约束齐备——进入规划,S3 接线后此处产出方案与选择题)')
|
|
291
|
+
}
|
|
292
|
+
return { reply: parts.join('\n\n'), state }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---- 异步工单持久化(S5 编排半段 → ADR-15 durable 工单):真正的「一小时后」必须跨进程存续 ----
|
|
296
|
+
// 权威 = 账本 workflow_runs/workflow_steps(单事务;崩溃后 done 步骤不重执行);
|
|
297
|
+
// {id}.json / {id}.deliverable.md 降级为视图(AGENTS.md 清扫合同 + 人工检视),
|
|
298
|
+
// tmp+rename 原子写——此前裸 writeFile 的非原子缺口(RFC §1.1)就此关闭。
|
|
299
|
+
|
|
300
|
+
import { join } from 'node:path'
|
|
301
|
+
import { mkdir, rename, writeFile, readFile } from 'node:fs/promises'
|
|
302
|
+
import { ensureLedger, openLedgerIfExists, type StateLedger } from './state-ledger.ts'
|
|
303
|
+
|
|
304
|
+
async function asyncDir(stateRoot: string): Promise<string> {
|
|
305
|
+
const root = stateRoot === '.' ? process.cwd() : stateRoot
|
|
306
|
+
const dir = join(root, 'gotry-state', 'async')
|
|
307
|
+
await mkdir(dir, { recursive: true })
|
|
308
|
+
return dir
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function atomicWrite(path: string, text: string): Promise<void> {
|
|
312
|
+
const tmp = `${path}.tmp`
|
|
313
|
+
await writeFile(tmp, text, 'utf-8')
|
|
314
|
+
await rename(tmp, path)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState, stateRoot = '.'): Promise<string> {
|
|
318
|
+
const ledger = ensureLedger(stateRoot)
|
|
319
|
+
ledger.createWorkflowRun({ id: ticket.id, goal: ticket.objective, ticket, state })
|
|
320
|
+
const dir = await asyncDir(stateRoot)
|
|
321
|
+
const p = join(dir, `${ticket.id}.json`)
|
|
322
|
+
await atomicWrite(p, JSON.stringify({ ticket, state }, null, 2))
|
|
323
|
+
return p
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function loadAsyncTicket(ticketId: string, stateRoot = '.'): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
|
|
327
|
+
// 账本优先;无账本的 root 回退旧 json 文件(只读,兼容存量已交付工单)
|
|
328
|
+
const ledger = openLedgerIfExists(stateRoot)
|
|
329
|
+
const run = ledger?.getWorkflowRun(ticketId)
|
|
330
|
+
if (run) {
|
|
331
|
+
return {
|
|
332
|
+
ticket: JSON.parse(run.ticket_json) as AsyncTicket,
|
|
333
|
+
state: JSON.parse(run.state_json) as TripState,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const p = join(await asyncDir(stateRoot), `${ticketId}.json`)
|
|
338
|
+
return JSON.parse(await readFile(p, 'utf-8')) as { ticket: AsyncTicket; state: TripState }
|
|
339
|
+
} catch {
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function settleAsyncTicket(ticketId: string, reply: string, stateRoot = '.'): Promise<string> {
|
|
345
|
+
openLedgerIfExists(stateRoot)?.settleWorkflowRun(ticketId, reply)
|
|
346
|
+
const dir = await asyncDir(stateRoot)
|
|
347
|
+
const p = join(dir, `${ticketId}.deliverable.md`)
|
|
348
|
+
await atomicWrite(p, reply)
|
|
349
|
+
return p
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 可日志化 solve 端口(ADR-15 步骤日志,intent-before-execute):
|
|
354
|
+
* 先记 intent 再执行,done 后结果落账本——任意进程恢复时 done 的步骤直接复用
|
|
355
|
+
* 结果不重执行(exactly-once:LLM/求解调用不重复花钱),intent 悬挂的重试。
|
|
356
|
+
*/
|
|
357
|
+
export function makeJournaledSolvePort(ledger: StateLedger, runId: string, solve: SolvePort, opts?: { onRealSolve?: () => void }): SolvePort {
|
|
358
|
+
return async spec => {
|
|
359
|
+
const step = ledger.getWorkflowStep(runId, 'solve')
|
|
360
|
+
if (step?.status === 'done' && step.result) {
|
|
361
|
+
return JSON.parse(step.result) as Awaited<ReturnType<SolvePort>>
|
|
362
|
+
}
|
|
363
|
+
ledger.markStepIntent(runId, 'solve')
|
|
364
|
+
opts?.onRealSolve?.()
|
|
365
|
+
const r = await solve(spec)
|
|
366
|
+
ledger.markStepDone(runId, 'solve', r)
|
|
367
|
+
return r
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* probePoi:从 user msg 探测"查 POI/酒店" 信号,返回关键词。
|
|
373
|
+
* 触发的不是关键词,是**结构**;关键词抓取有方向性——
|
|
374
|
+
* 1) 显式搜索动词(查/搜/找/看看/推荐/告诉我/检索)+ 宾语在动词后;
|
|
375
|
+
* 2) 住宿名词(酒店/民宿/客栈/饭店):优先取名词**之后**的名称段(须含拉丁/数字,
|
|
376
|
+
* 「我订了酒店:The Title…」→ The Title…);退路是名词前 2-6 字紧邻地名
|
|
377
|
+
* (「大理酒店」→ 大理;「机票和」类垃圾后缀拒);
|
|
378
|
+
* 3) 开放问(有什么/玩什么/有哪些):取名词前的地名;
|
|
379
|
+
* 4) 短裸地名:≤12 字、无标点、无陈述动词(「大理」✓;「明天有空」「我的工作时间是…」✗——
|
|
380
|
+
* 2026-08-28 巡检收紧:旧版 ≤24 直通把访谈答案整句当关键词,垃圾 hbcli 调用+证据噪音)。
|
|
381
|
+
* 不是意图(改求解器),只是 datasources 编排层的"提早调 anything"提示。
|
|
382
|
+
*/
|
|
383
|
+
export function probePoi(msg: string): string | null {
|
|
384
|
+
const trimmed = msg.trim()
|
|
385
|
+
if (!trimmed) return null
|
|
386
|
+
const cutAtPunct = (s: string): string => s.split(/[,,。.;;!!??]/)[0]?.trim() ?? ''
|
|
387
|
+
|
|
388
|
+
// 1) 显式搜索动词 + 宾语
|
|
389
|
+
const m = trimmed.match(/(查一下|查|搜|找|看看|推荐|告诉我|检索)\s*(.{2,24})/)
|
|
390
|
+
if (m?.[2]) {
|
|
391
|
+
const obj = cutAtPunct(m[2])
|
|
392
|
+
if (obj.length >= 2) return obj.slice(0, 24)
|
|
393
|
+
}
|
|
394
|
+
// 2) 住宿名词:名称段在名词后
|
|
395
|
+
if (/(酒店|民宿|客栈|饭店)/.test(trimmed)) {
|
|
396
|
+
const parts = trimmed.split(/(酒店|民宿|客栈|饭店)/)
|
|
397
|
+
const after = cutAtPunct((parts[2] ?? '').replace(/^[的::\s]+/, ''))
|
|
398
|
+
if (after.length >= 2 && /[A-Za-z0-9]/.test(after)) return after.slice(0, 24)
|
|
399
|
+
const nounAt = trimmed.search(/(酒店|民宿|客栈|饭店)/)
|
|
400
|
+
const pre = trimmed.slice(Math.max(0, nounAt - 6), nounAt).match(/[一-龥]{2,6}$/)
|
|
401
|
+
if (pre && !/(机票|车票|和|做|订|的)$/.test(pre[0])) return pre[0]
|
|
402
|
+
}
|
|
403
|
+
// 3) 开放问:地名在疑问词前
|
|
404
|
+
const q = trimmed.match(/([一-龥a-zA-Z]{2,12})\s*(?:有什么|玩什么|有哪些)/)
|
|
405
|
+
if (q?.[1]) return q[1]
|
|
406
|
+
// 4) 短裸地名(≤12 字,无标点,无陈述动词)
|
|
407
|
+
if (trimmed.length <= 12 && !/[,,。.;;!!??::]/.test(trimmed) && !/[是有在要想得到说订吧呢]/.test(trimmed)) {
|
|
408
|
+
return trimmed.slice(0, 24)
|
|
409
|
+
}
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T1 动态记忆的合并守门层(M4 记忆域;DeerFlow 研究 T1 落地):
|
|
3
|
+
*
|
|
4
|
+
* 分工(founder 校正「正则匹配 rules 不对」后重写):
|
|
5
|
+
* - 事实提取 = LLM 的活。契约 (18) 已要求模型当轮把对话透露的新事实
|
|
6
|
+
* (预算/日期/窗口/出发地/同伴/实际经历)经 gotry_motivation_save 并入
|
|
7
|
+
* 画像(evidence=用户原话)——语言理解交给模型,不做第二套正则引擎。
|
|
8
|
+
* - 合并守门 = 代码的活。本文件只做三件代码才可靠的事:
|
|
9
|
+
* ① 追加不删史(evidence 永不移除,P0 反幻觉:历史依据不可篡改)
|
|
10
|
+
* ② 幂等(同 evidence 不重复入池)
|
|
11
|
+
* ③ 结构守卫(weights 存在性校验、空补丁拒绝、权重归一)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ProfilePatch {
|
|
15
|
+
weights?: Record<string, number>
|
|
16
|
+
/** 新增依据(用户原话或工具证据);只增不改不删 */
|
|
17
|
+
evidence?: string[]
|
|
18
|
+
hard?: Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MergedProfile {
|
|
22
|
+
weights: Record<string, number>
|
|
23
|
+
evidence: string[]
|
|
24
|
+
hard: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 与现有画像合并;无可并入内容返回 null(调用方跳过落盘)。纯函数。 */
|
|
28
|
+
export function mergeProfile(
|
|
29
|
+
current: { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> } | null,
|
|
30
|
+
patch: ProfilePatch | null,
|
|
31
|
+
): MergedProfile | null {
|
|
32
|
+
if (!patch) return null
|
|
33
|
+
const base = {
|
|
34
|
+
weights: (current?.weights ?? {}) as Record<string, number>,
|
|
35
|
+
evidence: (current?.evidence ?? []) as string[],
|
|
36
|
+
hard: (current?.hard ?? {}) as Record<string, unknown>,
|
|
37
|
+
} // 首次保存:无档案即空档案
|
|
38
|
+
const newEvidence = (patch.evidence ?? []).filter(Boolean).filter(e => !base.evidence.includes(e))
|
|
39
|
+
const patchWeights = patch.weights
|
|
40
|
+
const weightsChanged = !!patchWeights && Object.keys(patchWeights).length > 0 &&
|
|
41
|
+
JSON.stringify(patchWeights) !== JSON.stringify(base.weights)
|
|
42
|
+
const hardChanged = !!patch.hard && Object.keys(patch.hard).length > 0 &&
|
|
43
|
+
JSON.stringify(patch.hard) !== JSON.stringify(base.hard)
|
|
44
|
+
if (newEvidence.length === 0 && !weightsChanged && !hardChanged) return null
|
|
45
|
+
|
|
46
|
+
// 权重变更必须伴至少一条新 evidence(P0:改画像要有依据),否则拒该部分
|
|
47
|
+
let weights = { ...base.weights }
|
|
48
|
+
if (weightsChanged && newEvidence.length > 0 && patchWeights) {
|
|
49
|
+
weights = { ...weights, ...patchWeights }
|
|
50
|
+
const sum = Object.values(weights).reduce((a, b) => a + b, 0)
|
|
51
|
+
if (sum > 0) for (const k of Object.keys(weights)) weights[k] = Math.round((weights[k]! / sum) * 100) / 100
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
weights,
|
|
56
|
+
evidence: [...base.evidence, ...newEvidence],
|
|
57
|
+
hard: hardChanged ? { ...base.hard, ...(patch.hard ?? {}) } : { ...base.hard },
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 时间窗衰减原语(memory-design P3):行为偏好的新鲜度置信度——
|
|
3
|
+
* 30/90/180/365d 分级窗口,确定性纯函数;**只降不删**(历史事件永远在,
|
|
4
|
+
* 衰减只作用于读时置信度);地板 0.1(记忆永不因旧而消失)。
|
|
5
|
+
*
|
|
6
|
+
* 边界(memory-design §4 P3):衰减只作用于**行为事件**(效用 sidecar 等);
|
|
7
|
+
* 动机权重层零衰减——动机跨年稳定是产品设计 §4 的明文(个性化的锚点),
|
|
8
|
+
* 本模块不提供任何作用于 motivation-profile 的 API(构造性保证)。
|
|
9
|
+
*
|
|
10
|
+
* 未来消费方(行为偏好层落地时):同原语直接复用,窗口即配置。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DecayWindow = '30d' | '90d' | '180d' | '365d' | 'stale'
|
|
14
|
+
|
|
15
|
+
/** 分级窗口:age(天)→ 因子;单调不增,地板 0.1(只降不删) */
|
|
16
|
+
export function windowFactor(ageDays: number): number {
|
|
17
|
+
if (ageDays <= 30) return 1.0
|
|
18
|
+
if (ageDays <= 90) return 0.75
|
|
19
|
+
if (ageDays <= 180) return 0.5
|
|
20
|
+
if (ageDays <= 365) return 0.25
|
|
21
|
+
return 0.1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 事件种类权重:verified_outcome 最强,recalled 最弱(自称被召回 ≠ 有用) */
|
|
25
|
+
export const KIND_WEIGHT = { recalled: 0.25, applied: 0.5, verified_outcome: 1.0 } as const
|
|
26
|
+
|
|
27
|
+
export interface DecayableEvent {
|
|
28
|
+
ts: string
|
|
29
|
+
kind: keyof typeof KIND_WEIGHT
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ageDaysOf(ts: string, now: Date): number {
|
|
33
|
+
const then = new Date(ts).getTime()
|
|
34
|
+
if (Number.isNaN(then)) return Number.POSITIVE_INFINITY
|
|
35
|
+
return Math.max(0, (now.getTime() - then) / 86_400_000)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 单事件衰减分:KIND_WEIGHT × windowFactor;未知时间戳按地板(不猜) */
|
|
39
|
+
export function eventDecayScore(ev: DecayableEvent, now: Date): number {
|
|
40
|
+
const w = KIND_WEIGHT[ev.kind] ?? 0
|
|
41
|
+
return round4(w * windowFactor(ageDaysOf(ev.ts, now)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 单条记忆的新鲜置信度 ∈ [0,1]:Σ 事件衰减分,上界 1。
|
|
46
|
+
* 同 wish 同窗口内事件越多置信越高(有界);旧事件只做地板贡献。
|
|
47
|
+
*/
|
|
48
|
+
export function decayedConfidence(events: DecayableEvent[], now: Date): number {
|
|
49
|
+
const sum = events.reduce((acc, ev) => acc + eventDecayScore(ev, now), 0)
|
|
50
|
+
return round4(Math.min(1, sum))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function round4(x: number): number {
|
|
54
|
+
return Math.round(x * 10_000) / 10_000
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆效用 sidecar(RFC S2,post-outcome-memory-utility-attribution 映射):
|
|
3
|
+
* 「被召回 ≠ 有用,自称用了 ≠ 让结果变好」——召回/使用/结果/归因四件语义独立记录,
|
|
4
|
+
* 确定性 reducer 只做投影;verified_outcome 只能来自 owner 确认(owner_correction tier),
|
|
5
|
+
* 模型永远不能自称「有用」。默认 fail-open:sidecar 缺失/损坏不阻塞主路径。
|
|
6
|
+
*
|
|
7
|
+
* 多用户 AaaS 前向兼容(RFC §6.5):append-only 事件流 + 稳定 wish_id,
|
|
8
|
+
* 未来账本化(CAS/receipt)是存储面替换,语义层零改造。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type UtilityEventKind = 'recalled' | 'applied' | 'verified_outcome'
|
|
12
|
+
/** 归因只能来自 verified_outcome 事件,且必须 owner 确认(用户原话/显式选择) */
|
|
13
|
+
export type UtilityAttribution = 'helpful' | 'harmful' | 'neutral'
|
|
14
|
+
|
|
15
|
+
export interface MemoryUtilityEvent {
|
|
16
|
+
schema: 'memory_utility_observation.v0'
|
|
17
|
+
/** 幂等键:wish_id|kind|ctx|attribution 派生(语义派生,不用墙钟) */
|
|
18
|
+
event_id: string
|
|
19
|
+
wish_id: string
|
|
20
|
+
kind: UtilityEventKind
|
|
21
|
+
ts: string
|
|
22
|
+
/** 召回/确认发生的表面(turn id / 工具调用标识) */
|
|
23
|
+
ctx?: string
|
|
24
|
+
detail?: string
|
|
25
|
+
/** 仅 verified_outcome 携带;owner_correction tier */
|
|
26
|
+
attribution?: UtilityAttribution
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UtilityStatus = 'unknown' | UtilityAttribution
|
|
30
|
+
|
|
31
|
+
export interface WishUtility {
|
|
32
|
+
wish_id: string
|
|
33
|
+
status: UtilityStatus
|
|
34
|
+
recalled: number
|
|
35
|
+
applied: number
|
|
36
|
+
verified: number
|
|
37
|
+
last_event_ts?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 事件幂等键:同 wish 同类同 ctx 同归因 = 同一事件(重放 no-op) */
|
|
41
|
+
export function makeEventId(ev: Omit<MemoryUtilityEvent, 'schema' | 'event_id'>): string {
|
|
42
|
+
return [ev.wish_id, ev.kind, ev.ctx ?? '', ev.attribution ?? ''].join('|')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 追加不重复(纯函数,不改入参):同 event_id 已存在 → no-op */
|
|
46
|
+
export function appendEvent(events: MemoryUtilityEvent[], ev: Omit<MemoryUtilityEvent, 'schema' | 'event_id'>): { events: MemoryUtilityEvent[]; appended: boolean } {
|
|
47
|
+
const full: MemoryUtilityEvent = { schema: 'memory_utility_observation.v0', event_id: makeEventId(ev), ...ev }
|
|
48
|
+
if (events.some(e => e.event_id === full.event_id)) return { events, appended: false }
|
|
49
|
+
if (ev.kind === 'verified_outcome' && !ev.attribution) {
|
|
50
|
+
// 无归因的结果不算归因证据:降级为 applied(六件语义不越界)
|
|
51
|
+
return appendEvent(events, { ...ev, kind: 'applied' })
|
|
52
|
+
}
|
|
53
|
+
return { events: [...events, full], appended: true }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 只读投影:status = 最后一次 verified_outcome 的归因;无 verified_outcome 永远 unknown */
|
|
57
|
+
export function projectUtility(events: MemoryUtilityEvent[]): Record<string, WishUtility> {
|
|
58
|
+
const out: Record<string, WishUtility> = {}
|
|
59
|
+
for (const ev of events) {
|
|
60
|
+
const w = out[ev.wish_id] ??= { wish_id: ev.wish_id, status: 'unknown', recalled: 0, applied: 0, verified: 0 }
|
|
61
|
+
if (ev.kind === 'recalled') w.recalled++
|
|
62
|
+
if (ev.kind === 'applied') w.applied++
|
|
63
|
+
if (ev.kind === 'verified_outcome') {
|
|
64
|
+
w.verified++
|
|
65
|
+
if (ev.attribution) w.status = ev.attribution
|
|
66
|
+
}
|
|
67
|
+
w.last_event_ts = ev.ts
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|