@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.20
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/README.md +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +309 -30
- package/bin/gotry-inner.js +74 -23
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +22 -8
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/anything.js +62 -29
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +307 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/hbcli.js +28 -2
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +305 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/anything-tests.js +70 -21
- package/dist/scripts/benchmark-environment-bridge-e2e.js +139 -37
- package/dist/scripts/benchmark-environment-bridge-tests.js +1099 -306
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +113 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +175 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/hbcli-tests.js +38 -1
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +39 -16
- package/dist/src/benchmark-environment-bridge.js +316 -52
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +739 -150
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +7 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/anything.ts +89 -38
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/hbcli.ts +53 -4
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/dsh-runtime/vendor/README.md +60 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +200 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +202 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +216 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +371 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +56 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +126 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +109 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +27 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +170 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +53 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +20 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +91 -0
- package/ts/package.json +9 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +54 -15
- package/ts/src/benchmark-environment-bridge.ts +234 -46
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +492 -172
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +365 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
package/ts/src/loop.ts
CHANGED
package/ts/src/state-ledger.ts
CHANGED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent, per-turn wall-clock boundary driven by routing (ADR-24 v2).
|
|
3
|
+
*
|
|
4
|
+
* 职责只有一件:保证每一轮终结在三态之一——当面答完(converge)、转后台
|
|
5
|
+
* 并给出回访承诺(handoff)、或明确暂停。绝不允许第四态:流死掉、什么
|
|
6
|
+
* 都没交付(2026-09-02 轨迹的失败形态)。
|
|
7
|
+
*
|
|
8
|
+
* - 阈值不再固定:每次 user/message 由 `turn-policy.ts` 的确定性分类器
|
|
9
|
+
* 解析 TurnPolicy(softMs/hardMs/exit);benchmark opt-in 用 fixedPolicy
|
|
10
|
+
* 钉死以保证可复现。
|
|
11
|
+
* - 硬阈拒绝时**同步**抑制工具 schema(防同 step 回环),并按 exit 语义
|
|
12
|
+
* 返回两种结构化结果:converge=用已有证据作答;handoff=落
|
|
13
|
+
* `gotry_turn_handoff.v1` 工单并指令模型告知用户 ETA 与回访方式。
|
|
14
|
+
* - handoff 工单是**新的独立格式**,落在 `<stateRoot>/gotry-state/
|
|
15
|
+
* turn-handoffs/`,不与 `async/` 的 loop 求解工单混用——async-collect
|
|
16
|
+
* 对无 spec 的 state 会立即结算 failed(假失败),收集器是未来的
|
|
17
|
+
* agent tick(loopx),不是 async-collect。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from 'node:crypto'
|
|
21
|
+
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
24
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
25
|
+
// 类型面显式依赖:session/event、session/disposed 的事件表声明在 dsh-session 的
|
|
26
|
+
// cordis Events augmentation 里——不显式导入时,该声明只在 peer 恰好物化进
|
|
27
|
+
// ts/node_modules 的机器上可见(legacy-peer-deps 安装则缺,5 个 TS2345/TS7006)。
|
|
28
|
+
import type {} from '@deepseek-ai/dsh-session'
|
|
29
|
+
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
|
30
|
+
import { classifyTurn, TURN_HANDOFF_ETA_LABEL, turnPolicyFor, type TurnPolicy } from './turn-policy.ts'
|
|
31
|
+
|
|
32
|
+
export const TURN_DEADLINE_SOFT = 'TURN_DEADLINE_SOFT'
|
|
33
|
+
export const TURN_DEADLINE_EXHAUSTED = 'TURN_DEADLINE_EXHAUSTED'
|
|
34
|
+
export const TURN_DEADLINE_HANDOFF = 'TURN_DEADLINE_HANDOFF'
|
|
35
|
+
export const TURN_HANDOFF_SCHEMA = 'gotry_turn_handoff.v1' as const
|
|
36
|
+
|
|
37
|
+
export interface TurnHandoffTicket {
|
|
38
|
+
schema: typeof TURN_HANDOFF_SCHEMA
|
|
39
|
+
id: string
|
|
40
|
+
objective: string
|
|
41
|
+
userMessage: string
|
|
42
|
+
requestedAt: string
|
|
43
|
+
etaLabel: string
|
|
44
|
+
status: 'open' | 'settled' | 'failed'
|
|
45
|
+
/** 结算时间(ISO);open 时缺省。 */
|
|
46
|
+
settledAt?: string
|
|
47
|
+
/** 结算交付物文件名(与工单同目录的 .deliverable.md)。 */
|
|
48
|
+
deliverableFile?: string
|
|
49
|
+
/** failed 时的稳定错误描述。 */
|
|
50
|
+
error?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 复访视图:工单事实 + 已结算交付物摘录(有界)。 */
|
|
54
|
+
export interface TurnHandoffTicketView {
|
|
55
|
+
id: string
|
|
56
|
+
status: TurnHandoffTicket['status']
|
|
57
|
+
objective: string
|
|
58
|
+
requestedAt: string
|
|
59
|
+
etaLabel: string
|
|
60
|
+
settledAt?: string
|
|
61
|
+
error?: string
|
|
62
|
+
deliverableExcerpt?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TurnDeadlineOptions {
|
|
66
|
+
/** When false the install call returns immediately with zero side effects. */
|
|
67
|
+
enabled?: boolean
|
|
68
|
+
/** Benchmark pin: routing is bypassed, every turn gets this exact policy. */
|
|
69
|
+
fixedPolicy?: TurnPolicy
|
|
70
|
+
/** Product: handoff ticket target root. Handoff degrades to converge when absent. */
|
|
71
|
+
stateRoot?: string
|
|
72
|
+
/** Wall-clock source — overridden by tests for determinism. */
|
|
73
|
+
now?: () => number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writeTurnHandoffTicket(stateRoot: string, userMessage: string): Promise<TurnHandoffTicket> {
|
|
77
|
+
const ticket: TurnHandoffTicket = {
|
|
78
|
+
schema: TURN_HANDOFF_SCHEMA,
|
|
79
|
+
id: `th-${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`,
|
|
80
|
+
objective: userMessage.length > 120 ? `${userMessage.slice(0, 120)}…` : userMessage,
|
|
81
|
+
userMessage,
|
|
82
|
+
requestedAt: new Date().toISOString(),
|
|
83
|
+
etaLabel: TURN_HANDOFF_ETA_LABEL,
|
|
84
|
+
status: 'open',
|
|
85
|
+
}
|
|
86
|
+
await writeTicketJson(stateRoot, ticket)
|
|
87
|
+
return ticket
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handoffDir(stateRoot: string): string {
|
|
91
|
+
const root = stateRoot === '.' ? process.cwd() : stateRoot
|
|
92
|
+
return join(root, 'gotry-state', 'turn-handoffs')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function writeTicketJson(stateRoot: string, ticket: TurnHandoffTicket): Promise<string> {
|
|
96
|
+
const dir = handoffDir(stateRoot)
|
|
97
|
+
await mkdir(dir, { recursive: true })
|
|
98
|
+
const path = join(dir, `${ticket.id}.json`)
|
|
99
|
+
const tmp = `${path}.tmp`
|
|
100
|
+
await writeFile(tmp, JSON.stringify(ticket, null, 2), 'utf-8')
|
|
101
|
+
await rename(tmp, path)
|
|
102
|
+
return path
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 结算工单:交付物落 `<id>.deliverable.md`(settled=规划产物;failed=诚实
|
|
107
|
+
* 失败说明),工单 json 原子更新 status/settledAt/deliverableFile/error。
|
|
108
|
+
*/
|
|
109
|
+
export async function settleTurnHandoffTicket(
|
|
110
|
+
stateRoot: string,
|
|
111
|
+
ticket: TurnHandoffTicket,
|
|
112
|
+
status: 'settled' | 'failed',
|
|
113
|
+
deliverable: string,
|
|
114
|
+
error?: string,
|
|
115
|
+
): Promise<{ ticketPath: string; deliverablePath: string }> {
|
|
116
|
+
const dir = handoffDir(stateRoot)
|
|
117
|
+
await mkdir(dir, { recursive: true })
|
|
118
|
+
const deliverablePath = join(dir, `${ticket.id}.deliverable.md`)
|
|
119
|
+
const deliverableTmp = `${deliverablePath}.tmp`
|
|
120
|
+
await writeFile(deliverableTmp, deliverable, 'utf-8')
|
|
121
|
+
await rename(deliverableTmp, deliverablePath)
|
|
122
|
+
const settled: TurnHandoffTicket = {
|
|
123
|
+
...ticket,
|
|
124
|
+
status,
|
|
125
|
+
settledAt: new Date().toISOString(),
|
|
126
|
+
deliverableFile: `${ticket.id}.deliverable.md`,
|
|
127
|
+
...(error ? { error } : {}),
|
|
128
|
+
}
|
|
129
|
+
const ticketPath = await writeTicketJson(stateRoot, settled)
|
|
130
|
+
return { ticketPath, deliverablePath }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 复访查询:按请求时间倒序列出工单;settled 附交付物摘录(≤600 字)。 */
|
|
134
|
+
export async function listTurnHandoffTickets(stateRoot: string): Promise<TurnHandoffTicketView[]> {
|
|
135
|
+
const dir = handoffDir(stateRoot)
|
|
136
|
+
let names: string[]
|
|
137
|
+
try {
|
|
138
|
+
names = await readdir(dir)
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
const views: TurnHandoffTicketView[] = []
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
if (!name.endsWith('.json')) continue
|
|
145
|
+
try {
|
|
146
|
+
const raw = JSON.parse(await readFile(join(dir, name), 'utf-8')) as TurnHandoffTicket
|
|
147
|
+
if (raw?.schema !== TURN_HANDOFF_SCHEMA) continue
|
|
148
|
+
const view: TurnHandoffTicketView = {
|
|
149
|
+
id: raw.id,
|
|
150
|
+
status: raw.status,
|
|
151
|
+
objective: raw.objective,
|
|
152
|
+
requestedAt: raw.requestedAt,
|
|
153
|
+
etaLabel: raw.etaLabel,
|
|
154
|
+
...(raw.settledAt ? { settledAt: raw.settledAt } : {}),
|
|
155
|
+
...(raw.error ? { error: raw.error } : {}),
|
|
156
|
+
}
|
|
157
|
+
if (raw.status !== 'open' && raw.deliverableFile) {
|
|
158
|
+
try {
|
|
159
|
+
const text = await readFile(join(dir, raw.deliverableFile), 'utf-8')
|
|
160
|
+
view.deliverableExcerpt = text.length > 600 ? `${text.slice(0, 600)}…` : text
|
|
161
|
+
} catch { /* 交付物缺失时视图降级,不阻塞列表 */ }
|
|
162
|
+
}
|
|
163
|
+
views.push(view)
|
|
164
|
+
} catch { /* 单个坏工单不阻塞列表 */ }
|
|
165
|
+
}
|
|
166
|
+
views.sort((a, b) => (a.requestedAt < b.requestedAt ? 1 : -1))
|
|
167
|
+
return views
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolveEnvPin(name: string): number | null {
|
|
171
|
+
const raw = process.env[name]
|
|
172
|
+
if (!raw) return null
|
|
173
|
+
const parsed = Number.parseInt(raw, 10)
|
|
174
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function convergenceContext(code: string, text: string) {
|
|
178
|
+
return {
|
|
179
|
+
id: randomUUID(),
|
|
180
|
+
role: 'user' as const,
|
|
181
|
+
content: [{ type: 'text' as const, text: `${code}: ${text}` }],
|
|
182
|
+
source: { kind: 'plugin' as const, plugin: 'gotry-turn-deadline' },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function exhaustionResult(reason: string): ToolExecutionResult {
|
|
187
|
+
return {
|
|
188
|
+
isError: true,
|
|
189
|
+
error: {
|
|
190
|
+
message: reason,
|
|
191
|
+
info: { name: 'TurnDeadlineError', code: TURN_DEADLINE_EXHAUSTED },
|
|
192
|
+
},
|
|
193
|
+
content: [{ type: 'text', text: reason }],
|
|
194
|
+
additionalContexts: [convergenceContext(
|
|
195
|
+
TURN_DEADLINE_EXHAUSTED,
|
|
196
|
+
'No more tool dispatches are permitted in this turn. Produce the final answer from existing results.',
|
|
197
|
+
) as never],
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handoffResult(ticket: TurnHandoffTicket): ToolExecutionResult {
|
|
202
|
+
const instruction = `Turn handed off to background planning. Async ticket ${ticket.id} created (ETA ${ticket.etaLabel}). Stop dispatching tools and tell the user in one short message: the deep planning continues in the background, the expected time, and to check back later (mention ticket ${ticket.id}).`
|
|
203
|
+
return {
|
|
204
|
+
isError: true,
|
|
205
|
+
error: {
|
|
206
|
+
message: `${TURN_DEADLINE_HANDOFF}: sync window exhausted for a deep-planning turn; ticket ${ticket.id}`,
|
|
207
|
+
info: { name: 'TurnDeadlineHandoff', code: TURN_DEADLINE_HANDOFF },
|
|
208
|
+
},
|
|
209
|
+
content: [{ type: 'text', text: `${TURN_DEADLINE_HANDOFF}: ${instruction}` }],
|
|
210
|
+
additionalContexts: [convergenceContext(TURN_DEADLINE_HANDOFF, instruction) as never],
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type RestrictableTools = {
|
|
215
|
+
schemas?: () => Array<{ name: string }>
|
|
216
|
+
restrict?: (filter: { deny: string[] }) => () => void
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type AgentTurnState = {
|
|
220
|
+
startedAt: number
|
|
221
|
+
policy: TurnPolicy
|
|
222
|
+
userMessage?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Install the runtime boundary, kept separate from the GoTry tool catalog so
|
|
227
|
+
* the actual Cordis waterfalls can be exercised without booting every tool.
|
|
228
|
+
*/
|
|
229
|
+
export function installTurnDeadline(ctx: Context, options: TurnDeadlineOptions = {}): void {
|
|
230
|
+
if (options.enabled === false) return
|
|
231
|
+
|
|
232
|
+
// Some offline contract tests intentionally provide a registration-only
|
|
233
|
+
// Context slice. They do not drive an agent loop, so there is no boundary
|
|
234
|
+
// to install and no reason to require an event bus.
|
|
235
|
+
if (typeof (ctx as unknown as { on?: unknown }).on !== 'function') return
|
|
236
|
+
|
|
237
|
+
const now = options.now ?? (() => Date.now())
|
|
238
|
+
// Test/benchmark seam only: pin the resolved numbers without touching the
|
|
239
|
+
// routed exit strategy. Product runs never set these.
|
|
240
|
+
const softPin = resolveEnvPin('GOTRY_TURN_DEADLINE_SOFT_MS')
|
|
241
|
+
const hardPin = resolveEnvPin('GOTRY_TURN_DEADLINE_HARD_MS')
|
|
242
|
+
|
|
243
|
+
const defaultPolicy = options.fixedPolicy ?? turnPolicyFor('sync-planning')
|
|
244
|
+
const routePolicy = (message: string): TurnPolicy => {
|
|
245
|
+
if (options.fixedPolicy) return { ...options.fixedPolicy }
|
|
246
|
+
const policy = turnPolicyFor(classifyTurn(message))
|
|
247
|
+
return { ...policy, softMs: softPin ?? policy.softMs, hardMs: hardPin ?? policy.hardMs }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const stateByAgent = new Map<string, AgentTurnState>()
|
|
251
|
+
const softExecutions = new Map<ToolExecutionToken, { agentId: string; exit: string }>()
|
|
252
|
+
const finalOnlyDisposers = new Map<string, () => void>()
|
|
253
|
+
|
|
254
|
+
const clearAgent = (agentId: string) => {
|
|
255
|
+
stateByAgent.delete(agentId)
|
|
256
|
+
for (const [token, owner] of softExecutions) {
|
|
257
|
+
if (owner.agentId === agentId) softExecutions.delete(token)
|
|
258
|
+
}
|
|
259
|
+
finalOnlyDisposers.get(agentId)?.()
|
|
260
|
+
finalOnlyDisposers.delete(agentId)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const enterFinalOnly = (agent: Agent) => {
|
|
264
|
+
const agentId = String(agent.id)
|
|
265
|
+
if (finalOnlyDisposers.has(agentId)) return
|
|
266
|
+
const globalTools = ctx.tools as unknown as RestrictableTools
|
|
267
|
+
const scopedTools = agent.ctx?.tools as unknown as RestrictableTools | undefined
|
|
268
|
+
if (typeof globalTools.schemas !== 'function' || typeof scopedTools?.restrict !== 'function') {
|
|
269
|
+
// No registry to suppress: subsequent tool calls in the same turn hit the
|
|
270
|
+
// same per-call denial branch and stay refused until the next turn.
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const inheritedNames = globalTools.schemas()
|
|
274
|
+
.map(schema => schema.name)
|
|
275
|
+
.filter(name => name !== 'run_code')
|
|
276
|
+
if (inheritedNames.length === 0) return
|
|
277
|
+
finalOnlyDisposers.set(agentId, scopedTools.restrict({ deny: inheritedNames }))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 事件主体是 session 对象;agent 归属同一 session 时共用同一预算键。
|
|
281
|
+
const keyOfAgent = (agent: Agent) => {
|
|
282
|
+
const session = (agent as unknown as { session?: { id?: unknown } }).session
|
|
283
|
+
return String(session?.id ?? agent.id)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
ctx.on('session/event', (subject, event) => {
|
|
287
|
+
const agentId = String(subject.id)
|
|
288
|
+
if (event.type === 'turn/end') {
|
|
289
|
+
clearAgent(agentId)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (event.type === 'turn/start') {
|
|
293
|
+
finalOnlyDisposers.get(agentId)?.()
|
|
294
|
+
finalOnlyDisposers.delete(agentId)
|
|
295
|
+
if (!stateByAgent.has(agentId)) {
|
|
296
|
+
stateByAgent.set(agentId, {
|
|
297
|
+
startedAt: now(),
|
|
298
|
+
policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs },
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (event.type === 'user/message') {
|
|
304
|
+
// dsh 事件载荷在 event.data 下(benchmark-agent-conformance 同型读法)。
|
|
305
|
+
const message = (event as {
|
|
306
|
+
data?: { source?: { kind?: string }; content?: Array<{ type?: string; text?: string }> }
|
|
307
|
+
}).data
|
|
308
|
+
if (message?.source?.kind !== 'user') return
|
|
309
|
+
const text = (message.content ?? []).find(block => block.type === 'text')?.text ?? ''
|
|
310
|
+
if (!text.trim()) return
|
|
311
|
+
// 每个 user 消息 = 新的预算:路由分类 + 时钟重置。plugin/skill 注入
|
|
312
|
+
// 的 user/message(source.kind !== 'user')不参与路由。
|
|
313
|
+
stateByAgent.set(agentId, { startedAt: now(), policy: routePolicy(text), userMessage: text })
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
ctx.on('session/disposed', subject => clearAgent(String(subject.id)))
|
|
317
|
+
|
|
318
|
+
ctx.on('tools/execute', (exec, next) => {
|
|
319
|
+
// Programmatic/direct calls are not part of a model planner turn.
|
|
320
|
+
if (!exec.agent) return next()
|
|
321
|
+
const agentId = keyOfAgent(exec.agent)
|
|
322
|
+
const state = stateByAgent.get(agentId)
|
|
323
|
+
?? { startedAt: now(), policy: { ...defaultPolicy, softMs: softPin ?? defaultPolicy.softMs, hardMs: hardPin ?? defaultPolicy.hardMs } }
|
|
324
|
+
stateByAgent.set(agentId, state)
|
|
325
|
+
|
|
326
|
+
const elapsed = now() - state.startedAt
|
|
327
|
+
if (elapsed > state.policy.hardMs) {
|
|
328
|
+
// Synchronous schema suppression so same-step follow-up calls cannot
|
|
329
|
+
// loop on refusal results (the 2026-09-02 trajectory failure).
|
|
330
|
+
enterFinalOnly(exec.agent)
|
|
331
|
+
if (state.policy.exit === 'handoff' && state.userMessage && options.stateRoot) {
|
|
332
|
+
return writeTurnHandoffTicket(options.stateRoot, state.userMessage)
|
|
333
|
+
.then(ticket => handoffResult(ticket))
|
|
334
|
+
.catch(() => exhaustionResult(
|
|
335
|
+
`${TURN_DEADLINE_EXHAUSTED}: hard deadline ${state.policy.hardMs}ms reached (ticket persistence failed; degraded to converge)`,
|
|
336
|
+
))
|
|
337
|
+
}
|
|
338
|
+
return Promise.resolve(exhaustionResult(
|
|
339
|
+
`${TURN_DEADLINE_EXHAUSTED}: turn exceeded hard deadline of ${state.policy.hardMs}ms (elapsed ${elapsed}ms); provide a final answer using existing results`,
|
|
340
|
+
))
|
|
341
|
+
}
|
|
342
|
+
if (elapsed > state.policy.softMs) {
|
|
343
|
+
softExecutions.set(exec.token, { agentId, exit: state.policy.exit })
|
|
344
|
+
}
|
|
345
|
+
return next()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
ctx.on('tools/post-execute', (exec, _result, next) => {
|
|
349
|
+
return next().then(decision => {
|
|
350
|
+
const owner = softExecutions.get(exec.token)
|
|
351
|
+
if (!owner) return decision
|
|
352
|
+
softExecutions.delete(exec.token)
|
|
353
|
+
const text = owner.exit === 'handoff'
|
|
354
|
+
? 'Turn has crossed the soft deadline of a deep-planning turn. Wrap up context gathering now; the turn will hand off to background planning at the hard deadline.'
|
|
355
|
+
: 'Turn has crossed the soft deadline. Stop dispatching new tools and produce the final answer from gathered evidence.'
|
|
356
|
+
return {
|
|
357
|
+
...decision,
|
|
358
|
+
additionalContexts: [
|
|
359
|
+
...(decision.additionalContexts ?? []),
|
|
360
|
+
convergenceContext(TURN_DEADLINE_SOFT, text) as never,
|
|
361
|
+
],
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn routing & time allocation (ADR-24 v2).
|
|
3
|
+
*
|
|
4
|
+
* 用户主观时间是产品路径唯一的预算:复杂度决定**出口结构**(converge /
|
|
5
|
+
* handoff),时间只是在所选结构内的资源分配。路由在 user/message 落地时
|
|
6
|
+
* 一次完成,纯函数、零 LLM、零 IO——控制面判定必须是确定性组件
|
|
7
|
+
* (stage1 责任铁律;ADR-9 同型先例),且不能花它要分配的资源。
|
|
8
|
+
*
|
|
9
|
+
* Tier 0(消息内禀)是 v1 的全部规则输入;Tier 1(账本读回)只在
|
|
10
|
+
* handoff 落单时用于工单上下文,不参与分类。`loop.ts` 的 isComplex 是
|
|
11
|
+
* S5 循环架构内的访谈后复核,不在产品路径上,不进本层。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TaskClass = 'quick' | 'sync-planning' | 'deep-planning'
|
|
15
|
+
|
|
16
|
+
export type TurnExit = 'converge' | 'handoff'
|
|
17
|
+
|
|
18
|
+
export interface TurnPolicy {
|
|
19
|
+
/** 软阈:注入收敛/转出提示,不拦截。 */
|
|
20
|
+
softMs: number
|
|
21
|
+
/** 硬阈:拒绝后续工具派发,按 exit 语义出口。 */
|
|
22
|
+
hardMs: number
|
|
23
|
+
/** 硬阈出口:converge=用已有证据作答;handoff=落工单转后台并告知用户。 */
|
|
24
|
+
exit: TurnExit
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** v1 阈值表:数据不是代码;评测/部署换表不换执行器。 */
|
|
28
|
+
export const TURN_POLICIES: Record<TaskClass, TurnPolicy> = {
|
|
29
|
+
// quick:实际上限只是安全网——quick 类历史上没有发散记录。
|
|
30
|
+
quick: { softMs: 120_000, hardMs: 180_000, exit: 'converge' },
|
|
31
|
+
'sync-planning': { softMs: 300_000, hardMs: 600_000, exit: 'converge' },
|
|
32
|
+
// deep-planning:同步窗口只够"摸底 + 落单承诺",到点必须转后台。
|
|
33
|
+
'deep-planning': { softMs: 120_000, hardMs: 240_000, exit: 'handoff' },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const TURN_HANDOFF_ETA_LABEL = '约 1 小时'
|
|
37
|
+
|
|
38
|
+
/** 约束词表:只作弱信号(命中≥2 才可能推 deep);新增词=加一行+一测。 */
|
|
39
|
+
export const TURN_CONSTRAINT_LEXICON = [
|
|
40
|
+
'婚礼', '婚宴', '回门', '订婚',
|
|
41
|
+
'IRW', 'irw',
|
|
42
|
+
'请假', '年假', '调休', '额度',
|
|
43
|
+
'签证', '同行', '伴侣', '亲子', '带娃', '父母', '团建', '出差',
|
|
44
|
+
'红眼', '中转', '联程', '多城',
|
|
45
|
+
] as const
|
|
46
|
+
|
|
47
|
+
export interface TurnSignals {
|
|
48
|
+
messageLength: number
|
|
49
|
+
/** 消息内的绝对日期(已过 1-12/1-31 校验)。 */
|
|
50
|
+
absoluteDates: Array<{ year: number | null; month: number; day: number }>
|
|
51
|
+
/** 绝对日期≥2 时为极差天数;否则为跨度词天数;都没有则 null。 */
|
|
52
|
+
spanDays: number | null
|
|
53
|
+
/** 命中的约束词(去重,保留词表序)。 */
|
|
54
|
+
constraintHits: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CN_DIGITS: Record<string, number> = {
|
|
58
|
+
一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dayCount(year: number | null, month: number, day: number): number {
|
|
62
|
+
// 无年份时按平年锚定:跨度计算只需要差值,月份跨年场景 v1 不覆盖。
|
|
63
|
+
const y = year ?? 1970
|
|
64
|
+
return Date.UTC(y, month - 1, day) / 86_400_000
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 跨度词 → 估算天数;按声明强度取第一个命中,v1 不做组合。 */
|
|
68
|
+
function spanWordDays(message: string): number | null {
|
|
69
|
+
if (/十几天/.test(message)) return 12
|
|
70
|
+
if (/半个月/.test(message)) return 15
|
|
71
|
+
if (/一个月/.test(message)) return 30
|
|
72
|
+
const week = message.match(/[一两]个?(?:星期|周)/)
|
|
73
|
+
if (week) return week[0].startsWith('两') ? 14 : 7
|
|
74
|
+
const dayMatch = message.match(/([0-9一二两三四五六七八九十]+)\s*天/)
|
|
75
|
+
if (dayMatch) {
|
|
76
|
+
const raw = dayMatch[1]
|
|
77
|
+
if (/^[0-9]+$/.test(raw)) return Math.min(Number.parseInt(raw, 10), 60)
|
|
78
|
+
if (CN_DIGITS[raw] !== undefined) return Math.min(CN_DIGITS[raw], 60)
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractTurnSignals(message: string): TurnSignals {
|
|
84
|
+
const absoluteDates: TurnSignals['absoluteDates'] = []
|
|
85
|
+
|
|
86
|
+
// 完整日期先摘除,避免 2026-10-03 再被月/日模式重复计数。
|
|
87
|
+
let rest = message.replace(
|
|
88
|
+
/(?<!\d)(\d{4})[年\-.\/](\d{1,2})[月\-.\/](\d{1,2})日?(?!\d)/g,
|
|
89
|
+
(_all, y: string, m: string, d: string) => {
|
|
90
|
+
const month = Number.parseInt(m, 10)
|
|
91
|
+
const day = Number.parseInt(d, 10)
|
|
92
|
+
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
|
93
|
+
absoluteDates.push({ year: Number.parseInt(y, 10), month, day })
|
|
94
|
+
}
|
|
95
|
+
return ' '
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
rest = rest.replace(
|
|
99
|
+
/(?<!\d)(\d{1,2})月(\d{1,2})日?(?!\d)/g,
|
|
100
|
+
(_all, m: string, d: string) => {
|
|
101
|
+
const month = Number.parseInt(m, 10)
|
|
102
|
+
const day = Number.parseInt(d, 10)
|
|
103
|
+
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
|
104
|
+
absoluteDates.push({ year: null, month, day })
|
|
105
|
+
}
|
|
106
|
+
return ' '
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
rest = rest.replace(
|
|
110
|
+
/(?<!\d)(\d{1,2})[.\/](\d{1,2})(?!\d)/g,
|
|
111
|
+
(_all, m: string, d: string) => {
|
|
112
|
+
const month = Number.parseInt(m, 10)
|
|
113
|
+
const day = Number.parseInt(d, 10)
|
|
114
|
+
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
|
|
115
|
+
absoluteDates.push({ year: null, month, day })
|
|
116
|
+
}
|
|
117
|
+
return ' '
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const constraintHits = TURN_CONSTRAINT_LEXICON.filter(word => message.includes(word))
|
|
122
|
+
|
|
123
|
+
let spanDays: number | null = null
|
|
124
|
+
if (absoluteDates.length >= 2) {
|
|
125
|
+
const counts = absoluteDates.map(d => dayCount(d.year, d.month, d.day))
|
|
126
|
+
spanDays = Math.max(...counts) - Math.min(...counts)
|
|
127
|
+
} else {
|
|
128
|
+
spanDays = spanWordDays(message)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { messageLength: message.length, absoluteDates, spanDays, constraintHits }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 分类规则(有序,可表测):长跨度或多约束 → deep(转后台);无日期无约束
|
|
136
|
+
* 且短 → quick;其余落默认中间档 sync-planning。误分有界:三态出口保证
|
|
137
|
+
* 最坏结局是"本可当面答的被转了后台"(下一轮可救),而非流死掉。
|
|
138
|
+
*/
|
|
139
|
+
export function classifyTurn(message: string): TaskClass {
|
|
140
|
+
const s = extractTurnSignals(message)
|
|
141
|
+
if (s.spanDays !== null && s.spanDays >= 7) return 'deep-planning'
|
|
142
|
+
if (s.absoluteDates.length >= 2 && s.constraintHits.length >= 1) return 'deep-planning'
|
|
143
|
+
if (s.constraintHits.length >= 2 && (s.absoluteDates.length >= 1 || s.spanDays !== null)) {
|
|
144
|
+
return 'deep-planning'
|
|
145
|
+
}
|
|
146
|
+
if (s.absoluteDates.length === 0 && s.constraintHits.length === 0 && s.spanDays === null && s.messageLength <= 60) {
|
|
147
|
+
return 'quick'
|
|
148
|
+
}
|
|
149
|
+
return 'sync-planning'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function turnPolicyFor(taskClass: TaskClass): TurnPolicy {
|
|
153
|
+
return { ...TURN_POLICIES[taskClass] }
|
|
154
|
+
}
|
package/ts/src/wish-pool.ts
CHANGED
|
@@ -10,16 +10,19 @@ export interface WishPoolEntry {
|
|
|
10
10
|
wish_id?: string | unknown
|
|
11
11
|
name?: string | unknown
|
|
12
12
|
reason?: string | unknown
|
|
13
|
-
conditions?: { days?: number; budget_cny?: number; best_months?: number[] } | unknown
|
|
13
|
+
conditions?: { days?: number; budget_cny?: number; best_months?: number[]; channels?: string[] } | unknown
|
|
14
14
|
muted?: boolean | unknown
|
|
15
15
|
added_at?: string | unknown
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** 召回窗口上下文:用户当前可支配的天数/预算 +
|
|
18
|
+
/** 召回窗口上下文:用户当前可支配的天数/预算 + 目标月份;
|
|
19
|
+
* channelDown(外部事件接缝第 2 段,seam 设计 §3.3/§6②)=当前处于 down 的通道名
|
|
20
|
+
* 集合(来自 channel-health.jsonl 持久面;缺省=不启用通道否证,旧调用方零影响)。 */
|
|
19
21
|
export interface WishMatchContext {
|
|
20
22
|
days?: number
|
|
21
23
|
budgetCny?: number
|
|
22
24
|
month?: number
|
|
25
|
+
channelDown?: ReadonlySet<string>
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export interface WishMatch {
|
|
@@ -30,9 +33,19 @@ export interface WishMatch {
|
|
|
30
33
|
hits: string[]
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* 条件评分:days/budget 为「窗口 ≥ 条件」,month 为「命中 best_months」;条件缺失的项不计分。
|
|
38
|
+
* 通道否证(接缝第 2 段):conditions.channels 命名依赖通道,任一处于 down 且
|
|
39
|
+
* ctx.channelDown 已提供 → 否证成行条件,返回 null(当下推这个愿望=体验差);
|
|
40
|
+
* 通道全健康或健康面缺席(未传 channelDown)不加分不否——「佐证」渲染面留后续切片。
|
|
41
|
+
*/
|
|
34
42
|
export function scoreWishMatch(entry: WishPoolEntry, ctx: WishMatchContext): WishMatch | null {
|
|
35
|
-
const c = (entry.conditions ?? {}) as { days?: number; budget_cny?: number; best_months?: number[] }
|
|
43
|
+
const c = (entry.conditions ?? {}) as { days?: number; budget_cny?: number; best_months?: number[]; channels?: string[] }
|
|
44
|
+
if (ctx.channelDown && Array.isArray(c.channels)) {
|
|
45
|
+
for (const ch of c.channels) {
|
|
46
|
+
if (typeof ch === 'string' && ctx.channelDown.has(ch)) return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
36
49
|
const hits: string[] = []
|
|
37
50
|
if (typeof ctx.days === 'number' && typeof c.days === 'number' && ctx.days >= c.days) hits.push(`days≥${c.days}`)
|
|
38
51
|
if (typeof ctx.budgetCny === 'number' && typeof c.budget_cny === 'number' && ctx.budgetCny >= c.budget_cny) hits.push(`budget≥${c.budget_cny}`)
|