@danceiny/gotry 0.0.1-rc.11 → 0.0.1-rc.13

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.
Files changed (37) hide show
  1. package/README.md +111 -146
  2. package/cordis.gotry-patch.yml +14 -7
  3. package/dist/capabilities/flyai.js +135 -30
  4. package/dist/capabilities/hbcli.js +14 -2
  5. package/dist/capabilities/session/benchmark.js +252 -0
  6. package/dist/capabilities/session/transport.js +25 -10
  7. package/dist/capabilities/session-consent.js +82 -0
  8. package/dist/capabilities/session-login.js +119 -0
  9. package/dist/capabilities/session-search.js +7 -4
  10. package/dist/capabilities/weather.js +72 -17
  11. package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
  12. package/dist/scripts/async-collect.js +33 -5
  13. package/dist/scripts/hbcli-tests.js +25 -4
  14. package/dist/scripts/ledger-tests.js +134 -4
  15. package/dist/scripts/memory-value-report.js +379 -0
  16. package/dist/scripts/product-metrics.js +569 -0
  17. package/dist/scripts/session-benchmark.js +261 -0
  18. package/dist/scripts/session-login.js +28 -0
  19. package/dist/scripts/session-tests.js +262 -23
  20. package/dist/scripts/smoke.js +112 -14
  21. package/dist/scripts/weather-tests.js +8 -1
  22. package/dist/src/index.js +124 -5
  23. package/dist/src/loop.js +65 -14
  24. package/dist/src/state-ledger.js +30 -4
  25. package/package.json +4 -2
  26. package/ts/capabilities/flyai.ts +177 -22
  27. package/ts/capabilities/hbcli.ts +16 -4
  28. package/ts/capabilities/session/benchmark.ts +273 -0
  29. package/ts/capabilities/session/transport.ts +41 -9
  30. package/ts/capabilities/session-consent.ts +127 -0
  31. package/ts/capabilities/session-login.ts +146 -0
  32. package/ts/capabilities/session-search.ts +13 -5
  33. package/ts/capabilities/weather.ts +84 -19
  34. package/ts/scripts/async-collect.ts +48 -7
  35. package/ts/src/index.ts +107 -11
  36. package/ts/src/loop.ts +85 -12
  37. package/ts/src/state-ledger.ts +48 -4
@@ -4,10 +4,11 @@
4
4
  * 链路(同构 anything.ts 的 CLI spawn 模式):
5
5
  * gotry capabilities/flyai.ts
6
6
  * → spawn `npx -y @fly-ai/flyai-cli search-flight|search-train --origin X --destination Y --dep-date D`
7
+ * | `search-hotel --dest-name X [--key-words K][--check-in-date A --check-out-date B]`
7
8
  * → 飞猪 MCP API(实时直连官方商品库)
8
9
  *
9
10
  * 契约(与 hbcli/weather/opensky/anything 同构,L4 不变量):
10
- * - 只读:8 工具全只读,交易经 jumpUrl 由人完成(与 WriteGate 哲学同构);
11
+ * - 只读:8 工具全只读,交易经 jumpUrl/detailUrl 由人完成(与 WriteGate 哲学同构);
11
12
  * - 永不抛错:网络/超时/解析失败一律降级返回 verdict='error';
12
13
  * - 证据链:成功 [实时API:flyai@ts];失败 [实时API:flyai@error@ts];
13
14
  * - 无 key 可用;FLYAI_API_KEY 为可选增强(env 透传)。
@@ -15,15 +16,24 @@
15
16
 
16
17
  import { spawn } from 'node:child_process'
17
18
 
18
- export type FlyaiKind = 'flight' | 'train'
19
+ export type FlyaiKind = 'flight' | 'train' | 'hotel'
19
20
 
20
21
  export interface FlyaiQuery {
21
22
  kind: FlyaiKind
22
- /** 城市名(中文),如 上海 / 丽江 */
23
- origin: string
24
- destination: string
25
- /** YYYY-MM-DD */
26
- depDate: string
23
+ /** 城市名(中文),如 上海 / 丽江——flight/train 必填 */
24
+ origin?: string
25
+ /** 目的地城市名(中文);flight/train=到达城市,hotel 未传 destName 时即目的地 */
26
+ destination?: string
27
+ /** YYYY-MM-DD——flight/train 必填(出发日) */
28
+ depDate?: string
29
+ /** hotel 目的地(中文,国家/省/市/区均可),缺省取 destination */
30
+ destName?: string
31
+ /** hotel 入住日 YYYY-MM-DD(与 checkOutDate 成对,可选;未定档期不带日期先摸底) */
32
+ checkInDate?: string
33
+ /** hotel 退房日 YYYY-MM-DD(与 checkInDate 成对) */
34
+ checkOutDate?: string
35
+ /** hotel 可选关键词(商业区/地标/酒店名) */
36
+ keyWords?: string
27
37
  /** 默认 30_000 ms(npx 冷启动 + 远端检索) */
28
38
  timeoutMs?: number
29
39
  /** 显式 CLI bin(默认 npx -y @fly-ai/flyai-cli) */
@@ -48,6 +58,26 @@ export interface FlyaiOption {
48
58
  priceRaw?: string
49
59
  }
50
60
 
61
+ export interface FlyaiHotelOption {
62
+ /** 酒店名 */
63
+ name: string
64
+ /** 档级:舒适型 / 高档型 / 豪华型 …(上游 star) */
65
+ star?: string
66
+ /** 数字价(未鉴权态上游打码,恒为 0) */
67
+ price: number
68
+ /** 打码价格原值(如 "¥7xx"——真实价以 jumpUrl 落地页为准) */
69
+ priceRaw?: string
70
+ /** 评分(未鉴权常缺) */
71
+ rate?: string
72
+ address?: string
73
+ /** 周边地标(上游 interestsPoi) */
74
+ poi?: string
75
+ /** 飞猪酒店 id(上游 shId) */
76
+ hotelId?: string
77
+ /** 飞猪侧预订/详情跳转(由人完成,agent 不碰) */
78
+ jumpUrl?: string
79
+ }
80
+
51
81
  export interface FlyaiResult {
52
82
  ok: boolean
53
83
  via: 'flyai' | 'flyai-error'
@@ -56,6 +86,7 @@ export interface FlyaiResult {
56
86
  verdict: 'hit' | 'miss' | 'error'
57
87
  kind: FlyaiKind
58
88
  options?: FlyaiOption[]
89
+ hotels?: FlyaiHotelOption[]
59
90
  error?: string
60
91
  }
61
92
 
@@ -74,11 +105,76 @@ interface RawItem {
74
105
  }>
75
106
  }>
76
107
  ticketPrice?: string
77
- /** 火车条目用顶层 price(且未鉴权态为打码串如 "1xxx") */
108
+ /** 机/火与酒店条目的顶层 price(未鉴权态为打码串,机/火如 "1xxx",酒店如 "¥7xx") */
78
109
  price?: string
79
110
  jumpUrl?: string
111
+ /** 酒店条目字段(实测 2026-08-29,search-hotel 大理:data.itemList) */
112
+ name?: string
113
+ shId?: string
114
+ star?: string
115
+ rate?: string | null
116
+ address?: string
117
+ interestsPoi?: string
118
+ detailUrl?: string
119
+ }
120
+
121
+ /**
122
+ * 从 CLI stdout 中提取首个完整且含 itemList 的 JSON 对象。
123
+ *
124
+ * npx/CLI 偶发在业务 JSON 前后追加提示;不能从首个 `{` 一直切到 EOF。
125
+ * 扫描器识别字符串与转义,因此 JSON 字符串里的花括号不会破坏深度计数。
126
+ */
127
+ export function parseFlyaiItemList(stdout: string): unknown[] {
128
+ let start = -1
129
+ let depth = 0
130
+ let inString = false
131
+ let escaped = false
132
+ let sawIncompleteObject = false
133
+
134
+ for (let index = 0; index < stdout.length; index += 1) {
135
+ const char = stdout[index]!
136
+ if (start < 0) {
137
+ if (char === '{') {
138
+ start = index
139
+ depth = 1
140
+ inString = false
141
+ escaped = false
142
+ sawIncompleteObject = true
143
+ }
144
+ continue
145
+ }
146
+
147
+ if (inString) {
148
+ if (escaped) escaped = false
149
+ else if (char === '\\') escaped = true
150
+ else if (char === '"') inString = false
151
+ continue
152
+ }
153
+
154
+ if (char === '"') inString = true
155
+ else if (char === '{') depth += 1
156
+ else if (char === '}') {
157
+ depth -= 1
158
+ if (depth !== 0) continue
159
+
160
+ const candidate = stdout.slice(start, index + 1)
161
+ start = -1
162
+ sawIncompleteObject = false
163
+ try {
164
+ const parsed = JSON.parse(candidate) as { data?: { itemList?: unknown } }
165
+ if (Array.isArray(parsed.data?.itemList)) return parsed.data.itemList
166
+ } catch {
167
+ // 前缀日志可能包含成对花括号但不是 JSON;继续找下一个完整对象。
168
+ }
169
+ }
170
+ }
171
+
172
+ throw new Error(sawIncompleteObject
173
+ ? 'incomplete FlyAI JSON object'
174
+ : 'no complete FlyAI itemList JSON object')
80
175
  }
81
176
 
177
+
82
178
  function sh(cmd: string, args: string[], opts: { timeoutMs: number }) {
83
179
  const child = spawn(cmd, args, { env: process.env, cwd: process.cwd() })
84
180
  let stdout = ''
@@ -98,18 +194,40 @@ function sh(cmd: string, args: string[], opts: { timeoutMs: number }) {
98
194
  }).finally(() => clearTimeout(timer))
99
195
  }
100
196
 
101
- /** FlyAI 官方只读检索(机/火车票) — 任何失败走降级;不抛错 */
197
+ const YMD = /^\d{4}-\d{2}-\d{2}$/
198
+
199
+ /** FlyAI 官方只读检索(机票/火车票/酒店) — 任何失败走降级;不抛错 */
102
200
  export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
103
201
  const started = Date.now()
104
202
  const ts = new Date().toISOString()
105
203
  const base = { kind: q.kind, latencyMs: 0 }
106
- const origin = (q.origin ?? '').trim()
107
- const destination = (q.destination ?? '').trim()
108
- if (!origin || !destination || !/^\d{4}-\d{2}-\d{2}$/.test(q.depDate ?? '')) {
109
- return { ...base, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] bad args`, error: 'origin/destination/depDate(YYYY-MM-DD) required' }
204
+ const argErr = (error: string): FlyaiResult =>
205
+ ({ ...base, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] bad args`, error })
206
+
207
+ // per-kind 参数闸:机/火与酒店参数面不同,各自校验,不合法走 error 不静默容错
208
+ let cliArgs: string[]
209
+ if (q.kind === 'hotel') {
210
+ const dest = (q.destName ?? q.destination ?? '').trim()
211
+ if (!dest) return argErr('hotel 需要 to/destName(目的地中文)')
212
+ const badPair = (q.checkInDate ? 1 : 0) !== (q.checkOutDate ? 1 : 0)
213
+ || (q.checkInDate && q.checkOutDate && (!YMD.test(q.checkInDate) || !YMD.test(q.checkOutDate)))
214
+ if (badPair) return argErr('checkInDate/checkOutDate 须成对且为 YYYY-MM-DD(未定档期可不带日期先摸底)')
215
+ cliArgs = [
216
+ 'search-hotel', '--dest-name', dest,
217
+ ...(q.checkInDate && q.checkOutDate ? ['--check-in-date', q.checkInDate, '--check-out-date', q.checkOutDate] : []),
218
+ ...(q.keyWords ? ['--key-words', q.keyWords] : []),
219
+ ]
220
+ } else {
221
+ const origin = (q.origin ?? '').trim()
222
+ const destination = (q.destination ?? '').trim()
223
+ if (!origin || !destination || !YMD.test(q.depDate ?? '')) {
224
+ return argErr('origin/destination/depDate(YYYY-MM-DD) required')
225
+ }
226
+ const sub = q.kind === 'flight' ? 'search-flight' : 'search-train'
227
+ cliArgs = [sub, '--origin', origin, '--destination', destination, '--dep-date', (q.depDate ?? '') as string]
110
228
  }
111
- const sub = q.kind === 'flight' ? 'search-flight' : 'search-train'
112
- const r = await sh(q.cliBin ?? 'npx', ['-y', '@fly-ai/flyai-cli', sub, '--origin', origin, '--destination', destination, '--dep-date', q.depDate], {
229
+
230
+ const r = await sh(q.cliBin ?? 'npx', ['-y', '@fly-ai/flyai-cli', ...cliArgs], {
113
231
  timeoutMs: q.timeoutMs ?? 30_000,
114
232
  })
115
233
  const latencyMs = Date.now() - started
@@ -118,14 +236,28 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
118
236
  }
119
237
  let items: RawItem[]
120
238
  try {
121
- const jStart = r.stdout.indexOf('{')
122
- const parsed = JSON.parse(jStart >= 0 ? r.stdout.slice(jStart) : r.stdout) as { data?: { itemList?: RawItem[] } }
123
- items = parsed.data?.itemList ?? []
124
- } catch {
239
+ // main-lane 加固的扫描器:容忍 npx 前缀日志/不完整对象——data:null 语义失败
240
+ // (issue #24)的「出发日期非法」原话经 raw stdout 片段进 error 终态,语义不丢
241
+ items = parseFlyaiItemList(r.stdout) as RawItem[]
242
+ } catch (e) {
125
243
  // 实测(2026-08-28):Sentinel 限流时 CLI exit=0 但 stdout 是 {"message":"SentinelBlockException..."}
126
244
  const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
127
- return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`, error: `failed to parse flyai output as JSON: ${raw}` }
245
+ const reason = e instanceof Error ? e.message : String(e)
246
+ return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] parse failed(${reason}): ${raw}`, error: `failed to parse flyai output as JSON (${reason}): ${raw}` }
128
247
  }
248
+
249
+ if (q.kind === 'hotel') {
250
+ const hotels = parseHotelItems(items)
251
+ const verdict: FlyaiResult['verdict'] = hotels.length > 0 ? 'hit' : 'miss'
252
+ return { kind: q.kind, latencyMs, ok: true, via: 'flyai', verdict, evidence: `[实时API:flyai@${ts}] ${hotels.length}/${items.length} hotel options`, hotels }
253
+ }
254
+ const options = parseTransportItems(items)
255
+ const verdict: FlyaiResult['verdict'] = options.length > 0 ? 'hit' : 'miss'
256
+ return { kind: q.kind, latencyMs, ok: true, via: 'flyai', verdict, evidence: `[实时API:flyai@${ts}] ${options.length}/${items.length} ${q.kind} options`, options }
257
+ }
258
+
259
+ /** 机/火条目 → FlyaiOption(journeys[0].segments[0];缺航班号/时刻的条目跳过) */
260
+ function parseTransportItems(items: RawItem[]): FlyaiOption[] {
129
261
  const options: FlyaiOption[] = []
130
262
  for (const it of items) {
131
263
  const seg = it.journeys?.[0]?.segments?.[0]
@@ -146,6 +278,29 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
146
278
  jumpUrl: it.jumpUrl,
147
279
  })
148
280
  }
149
- const verdict: FlyaiResult['verdict'] = options.length > 0 ? 'hit' : 'miss'
150
- return { kind: q.kind, latencyMs, ok: true, via: 'flyai', verdict, evidence: `[实时API:flyai@${ts}] ${options.length}/${items.length} ${q.kind} options`, options }
281
+ return options
151
282
  }
283
+
284
+ /** 酒店条目 → FlyaiHotelOption(实测 2026-08-29:price 未鉴权为打码串"¥7xx",rate 常 null) */
285
+ function parseHotelItems(items: RawItem[]): FlyaiHotelOption[] {
286
+ const hotels: FlyaiHotelOption[] = []
287
+ for (const it of items) {
288
+ if (!it.name) continue
289
+ const rawPrice = String(it.price ?? '')
290
+ // 打码价如 "¥7xx" 绝不能截成数字 7(会伪装成真价)——仅全数字串才落 price
291
+ const bare = rawPrice.replace(/^[¥]/, '')
292
+ const numericPrice = Number(bare)
293
+ hotels.push({
294
+ name: it.name,
295
+ star: it.star,
296
+ price: /^\d+(\.\d+)?$/.test(bare) && numericPrice > 0 ? numericPrice : 0,
297
+ priceRaw: rawPrice || undefined,
298
+ rate: it.rate != null ? String(it.rate) : undefined,
299
+ address: it.address,
300
+ poi: it.interestsPoi,
301
+ hotelId: it.shId,
302
+ jumpUrl: it.detailUrl,
303
+ })
304
+ }
305
+ return hotels
306
+ }
@@ -129,16 +129,28 @@ export async function searchHotels(
129
129
  if (live.via === 'hbcli-realtime') {
130
130
  return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
131
131
  }
132
- // 降级:读静态包
132
+ // 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
133
+ // (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
134
+ // 「无数据」而不是伪装成可用结果。
133
135
  const fallback = opts.fallbackPath
134
136
  if (fallback) {
135
137
  try {
136
138
  const { readFile } = await import('node:fs/promises')
137
- const pack = JSON.parse(await readFile(fallback, 'utf-8')) as Record<string, unknown>
139
+ const pack = JSON.parse(await readFile(fallback, 'utf-8')) as { stays?: unknown[] } & Record<string, unknown>
140
+ const kw = query.destination.trim()
141
+ const stays = Array.isArray(pack.stays) ? pack.stays : []
142
+ const matched = kw ? stays.filter(s => JSON.stringify(s).includes(kw)) : stays
143
+ if (matched.length) {
144
+ return {
145
+ ...live,
146
+ hotels: { stays: matched },
147
+ summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`,
148
+ }
149
+ }
138
150
  return {
139
151
  ...live,
140
- hotels: pack,
141
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包`,
152
+ hotels: null,
153
+ summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
142
154
  }
143
155
  } catch { /* 静态包读不到也优雅降级 */ }
144
156
  }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Session 双源字段合同与 fixture 评分器。
3
+ *
4
+ * 只处理已经脱敏、结构化的证据;不打开浏览器、不读登录态,也不执行任何写操作。
5
+ */
6
+
7
+ export type SessionEvidenceVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach'
8
+
9
+ export const SESSION_BENCHMARK_SCHEMA_VERSION = 'session-double-source.v1' as const
10
+ export const SESSION_FIELD_ACCURACY_THRESHOLD = 0.9
11
+ export const REQUIRED_COMPARABLE_FIELDS = [
12
+ 'query_id',
13
+ 'route_segments',
14
+ 'journey_type',
15
+ 'route_segments[].departure_at',
16
+ 'route_segments[].arrival_at',
17
+ 'route_segments[].transport_number',
18
+ 'currency',
19
+ 'price',
20
+ 'source',
21
+ 'fetched_at',
22
+ 'verdict',
23
+ ] as const
24
+
25
+ export interface SessionComparableSegment {
26
+ from: string
27
+ to: string
28
+ departure_at: string
29
+ arrival_at: string
30
+ transport_number: string
31
+ }
32
+
33
+ export interface SessionComparableRecord {
34
+ query_id: string
35
+ route_segments: SessionComparableSegment[]
36
+ journey_type: 'direct' | 'transfer'
37
+ currency: string
38
+ price: number
39
+ /** 具体通道标识,如 flyai / ctrip-flight;official/session 角色由双源输入位置表达。 */
40
+ source: string
41
+ fetched_at: string
42
+ verdict: SessionEvidenceVerdict
43
+ latency_ms: number
44
+ read_guard_blocked: number
45
+ }
46
+
47
+ export interface SessionFixtureScore {
48
+ pass: boolean
49
+ threshold: number
50
+ correct: number
51
+ total: number
52
+ accuracy: number
53
+ missing: string[]
54
+ incorrect: string[]
55
+ fixture_errors: string[]
56
+ }
57
+
58
+ interface ComparableRecordInput {
59
+ query_id?: unknown
60
+ route_segments?: Array<Partial<SessionComparableSegment>>
61
+ journey_type?: unknown
62
+ currency?: unknown
63
+ price?: unknown
64
+ source?: unknown
65
+ fetched_at?: unknown
66
+ verdict?: unknown
67
+ latency_ms?: unknown
68
+ read_guard_blocked?: unknown
69
+ }
70
+
71
+ interface FieldComparison {
72
+ path: string
73
+ expected: unknown
74
+ actual: unknown
75
+ }
76
+
77
+ function isMissing(value: unknown): boolean {
78
+ return value === undefined || value === null || (typeof value === 'string' && value.trim() === '')
79
+ }
80
+
81
+ function sameValue(left: unknown, right: unknown): boolean {
82
+ if (typeof left !== typeof right) return false
83
+ return typeof left === 'number' && typeof right === 'number'
84
+ ? Object.is(left, right)
85
+ : left === right
86
+ }
87
+
88
+ function sameComparison(row: FieldComparison): boolean {
89
+ if (/\.(?:departure_at|arrival_at)$/.test(row.path)
90
+ && typeof row.expected === 'string'
91
+ && typeof row.actual === 'string') {
92
+ const expectedAt = Date.parse(row.expected)
93
+ const actualAt = Date.parse(row.actual)
94
+ if (Number.isFinite(expectedAt) && Number.isFinite(actualAt)) return expectedAt === actualAt
95
+ }
96
+ return sameValue(row.expected, row.actual)
97
+ }
98
+
99
+ function fixtureComparisons(expected: SessionComparableRecord, actual: ComparableRecordInput): FieldComparison[] {
100
+ const rows: FieldComparison[] = [
101
+ { path: 'query_id', expected: expected.query_id, actual: actual.query_id },
102
+ { path: 'route_segments.length', expected: expected.route_segments.length, actual: actual.route_segments?.length },
103
+ { path: 'journey_type', expected: expected.journey_type, actual: actual.journey_type },
104
+ { path: 'currency', expected: expected.currency, actual: actual.currency },
105
+ { path: 'price', expected: expected.price, actual: actual.price },
106
+ { path: 'source', expected: expected.source, actual: actual.source },
107
+ { path: 'fetched_at', expected: expected.fetched_at, actual: actual.fetched_at },
108
+ { path: 'verdict', expected: expected.verdict, actual: actual.verdict },
109
+ ]
110
+ for (let index = 0; index < expected.route_segments.length; index += 1) {
111
+ const exp = expected.route_segments[index]!
112
+ const act = actual.route_segments?.[index]
113
+ rows.push(
114
+ { path: `route_segments[${index}].from`, expected: exp.from, actual: act?.from },
115
+ { path: `route_segments[${index}].to`, expected: exp.to, actual: act?.to },
116
+ { path: `route_segments[${index}].departure_at`, expected: exp.departure_at, actual: act?.departure_at },
117
+ { path: `route_segments[${index}].arrival_at`, expected: exp.arrival_at, actual: act?.arrival_at },
118
+ { path: `route_segments[${index}].transport_number`, expected: exp.transport_number, actual: act?.transport_number },
119
+ )
120
+ }
121
+ return rows
122
+ }
123
+
124
+ export function scoreSessionFixture(
125
+ expected: SessionComparableRecord,
126
+ actual: ComparableRecordInput,
127
+ threshold = SESSION_FIELD_ACCURACY_THRESHOLD,
128
+ ): SessionFixtureScore {
129
+ const rows = fixtureComparisons(expected, actual)
130
+ const fixtureErrors = [
131
+ ...rows.filter((row) => isMissing(row.expected)).map((row) => row.path),
132
+ ...requiredMissing(expected),
133
+ ]
134
+ const missing: string[] = []
135
+ const incorrect: string[] = []
136
+ let correct = 0
137
+ for (const row of rows) {
138
+ if (isMissing(row.actual)) {
139
+ missing.push(row.path)
140
+ } else if (!sameValue(row.expected, row.actual)) {
141
+ incorrect.push(row.path)
142
+ } else {
143
+ correct += 1
144
+ }
145
+ }
146
+ const total = rows.length
147
+ const accuracy = total > 0 ? correct / total : 0
148
+ return {
149
+ pass: fixtureErrors.length === 0 && accuracy >= threshold,
150
+ threshold,
151
+ correct,
152
+ total,
153
+ accuracy,
154
+ missing,
155
+ incorrect,
156
+ fixture_errors: [...new Set(fixtureErrors)],
157
+ }
158
+ }
159
+
160
+ export type DoubleSourceState =
161
+ | 'comparable'
162
+ | 'divergent'
163
+ | 'waiting_attach'
164
+ | 'waiting_login'
165
+ | 'challenge_stop'
166
+ | 'guard_violation'
167
+ | 'source_unavailable'
168
+ | 'invalid_contract'
169
+
170
+ export interface DoubleSourceEvaluation {
171
+ state: DoubleSourceState
172
+ retry_allowed: boolean
173
+ quota_disposition: 'evidence_ready' | 'no_spend_waiting_user' | 'no_spend_stop'
174
+ mismatches: string[]
175
+ missing: string[]
176
+ price_delta?: number
177
+ }
178
+
179
+ function isIsoTimestamp(value: string): boolean {
180
+ return value.trim() !== '' && Number.isFinite(Date.parse(value))
181
+ }
182
+
183
+ function requiredMissing(record: SessionComparableRecord): string[] {
184
+ const missing: string[] = []
185
+ if (!record.query_id.trim()) missing.push('query_id')
186
+ if (record.route_segments.length === 0) missing.push('route_segments')
187
+ if (!record.currency.trim()) missing.push('currency')
188
+ if (!(record.price > 0)) missing.push('price')
189
+ if (!record.source.trim()) missing.push('source')
190
+ if (!isIsoTimestamp(record.fetched_at)) missing.push('fetched_at')
191
+ if (record.journey_type === 'direct' && record.route_segments.length !== 1) missing.push('journey_type/route_segments')
192
+ if (record.journey_type === 'transfer' && record.route_segments.length < 2) missing.push('journey_type/route_segments')
193
+ for (let index = 0; index < record.route_segments.length; index += 1) {
194
+ const segment = record.route_segments[index]!
195
+ if (!segment.from.trim()) missing.push(`route_segments[${index}].from`)
196
+ if (!segment.to.trim()) missing.push(`route_segments[${index}].to`)
197
+ if (!isIsoTimestamp(segment.departure_at)) missing.push(`route_segments[${index}].departure_at`)
198
+ if (!isIsoTimestamp(segment.arrival_at)) missing.push(`route_segments[${index}].arrival_at`)
199
+ if (!segment.transport_number.trim()) missing.push(`route_segments[${index}].transport_number`)
200
+ }
201
+ return missing
202
+ }
203
+
204
+ function baseEvaluation(
205
+ state: DoubleSourceState,
206
+ quotaDisposition: DoubleSourceEvaluation['quota_disposition'],
207
+ extra: Partial<Pick<DoubleSourceEvaluation, 'mismatches' | 'missing' | 'price_delta'>> = {},
208
+ ): DoubleSourceEvaluation {
209
+ return {
210
+ state,
211
+ retry_allowed: false,
212
+ quota_disposition: quotaDisposition,
213
+ mismatches: extra.mismatches ?? [],
214
+ missing: extra.missing ?? [],
215
+ ...(extra.price_delta === undefined ? {} : { price_delta: extra.price_delta }),
216
+ }
217
+ }
218
+
219
+ function alignmentComparisons(official: SessionComparableRecord, session: SessionComparableRecord): FieldComparison[] {
220
+ const rows: FieldComparison[] = [
221
+ { path: 'query_id', expected: official.query_id, actual: session.query_id },
222
+ { path: 'route_segments.length', expected: official.route_segments.length, actual: session.route_segments.length },
223
+ { path: 'journey_type', expected: official.journey_type, actual: session.journey_type },
224
+ { path: 'currency', expected: official.currency, actual: session.currency },
225
+ ]
226
+ const comparableSegments = Math.min(official.route_segments.length, session.route_segments.length)
227
+ for (let index = 0; index < comparableSegments; index += 1) {
228
+ const exp = official.route_segments[index]!
229
+ const act = session.route_segments[index]!
230
+ rows.push(
231
+ { path: `route_segments[${index}].from`, expected: exp.from, actual: act.from },
232
+ { path: `route_segments[${index}].to`, expected: exp.to, actual: act.to },
233
+ { path: `route_segments[${index}].departure_at`, expected: exp.departure_at, actual: act.departure_at },
234
+ { path: `route_segments[${index}].arrival_at`, expected: exp.arrival_at, actual: act.arrival_at },
235
+ { path: `route_segments[${index}].transport_number`, expected: exp.transport_number, actual: act.transport_number },
236
+ )
237
+ }
238
+ return rows
239
+ }
240
+
241
+ export function evaluateDoubleSource(input: {
242
+ official?: SessionComparableRecord
243
+ session?: SessionComparableRecord
244
+ }): DoubleSourceEvaluation {
245
+ const official = input.official
246
+ const session = input.session
247
+ if (session?.verdict === 'challenged' || official?.verdict === 'challenged') {
248
+ return baseEvaluation('challenge_stop', 'no_spend_stop')
249
+ }
250
+ if ((session?.read_guard_blocked ?? 0) !== 0 || (official?.read_guard_blocked ?? 0) !== 0) {
251
+ return baseEvaluation('guard_violation', 'no_spend_stop')
252
+ }
253
+ if (!session) return baseEvaluation('source_unavailable', 'no_spend_stop')
254
+ if (session.verdict === 'needs-attach') return baseEvaluation('waiting_attach', 'no_spend_waiting_user')
255
+ if (session.verdict === 'needs-login') return baseEvaluation('waiting_login', 'no_spend_waiting_user')
256
+ if (!official || official.verdict !== 'hit' || session.verdict !== 'hit') {
257
+ return baseEvaluation('source_unavailable', 'no_spend_stop')
258
+ }
259
+ const missing = [
260
+ ...requiredMissing(official).map((path) => `official.${path}`),
261
+ ...requiredMissing(session).map((path) => `session.${path}`),
262
+ ]
263
+ if (missing.length > 0) {
264
+ return baseEvaluation('invalid_contract', 'no_spend_stop', { missing })
265
+ }
266
+ const mismatches = alignmentComparisons(official, session)
267
+ .filter((row) => !sameComparison(row))
268
+ .map((row) => row.path)
269
+ return baseEvaluation(mismatches.length === 0 ? 'comparable' : 'divergent', 'evidence_ready', {
270
+ mismatches,
271
+ price_delta: session.price - official.price,
272
+ })
273
+ }
@@ -49,6 +49,22 @@ export interface TransportOptions {
49
49
  headless?: boolean
50
50
  /** ReadGuard 审计落盘路径(缺省仅内存计数) */
51
51
  auditPath?: string
52
+ /**
53
+ * 是否挂 ReadGuard(默认 true;检索面强制:检索会话不存在无守卫形态)。
54
+ * 唯一豁免 = 登录 bootstrap(guard:false):那是用户本人的凭证入口页,agent 只开页+只读轮询
55
+ * cookie 名,从不检索/点提交——守卫若在场,反而会物理 abort 用户自己的登录 POST
56
+ * (护照登录端点多为 POST /login/submit,命中写词模式),既挡登录又让我们站在用户
57
+ * 凭证流的中间(隐私+可靠性双输)。该形态下不发起任何检索导航、不落审计。
58
+ */
59
+ guard?: boolean
60
+ /**
61
+ * 开**自己的新标签页**(默认 false=沿用既有首页——那是用户的页面!)。
62
+ * 人机共治纪律(2026-08-29 founder:「我根本就看不到登录页面」):登录与检索一律
63
+ * newPage 开自己的页,绝不劫持用户已有标签页;closeOwnPage 控制收尾是否关掉自己开的页。
64
+ */
65
+ newPage?: boolean
66
+ /** newPage:true 时,close() 是否连自己开的标签页一起关(默认 true;登录引导保持 false——把登录页留给用户) */
67
+ closeOwnPage?: boolean
52
68
  }
53
69
 
54
70
  function devtoolsWsEndpoint(): { ws: string } | { err: string } {
@@ -92,22 +108,38 @@ export async function openSession(opts: TransportOptions = {}): Promise<SessionT
92
108
  return { ok: false, summary: `chrome launch failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
93
109
  }
94
110
  }
95
- // fail-closed:guard 装不上即断开返回失败——不给无守卫的导航面
96
- let guard: ReadGuardHandle
97
- try {
98
- guard = await attachReadGuardPuppeteer(browser as unknown as Parameters<typeof attachReadGuardPuppeteer>[0], opts.auditPath)
99
- } catch (e) {
100
- await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
101
- return { ok: false, summary: `read-guard attach failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
111
+ // fail-closed:guard 装不上即断开返回失败——不给无守卫的检索会话形态
112
+ // (唯一豁免:登录 bootstrap 显式 guard:false,见 TransportOptions 注释)
113
+ let guard: ReadGuardHandle | undefined
114
+ if (opts.guard !== false) {
115
+ try {
116
+ guard = await attachReadGuardPuppeteer(browser as unknown as Parameters<typeof attachReadGuardPuppeteer>[0], opts.auditPath)
117
+ } catch (e) {
118
+ await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
119
+ return { ok: false, summary: `read-guard attach failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
120
+ }
121
+ }
122
+ // 标签页纪律:ownPage=自己开的新页(newPage:true)——用它,收尾按 closeOwnPage 关;
123
+ // 否则沿用既有首页(仅 persistent 兼容形态;cdp 检索/登录一律 newPage)
124
+ let ownPage: boolean = false
125
+ let page
126
+ if (opts.newPage) {
127
+ page = await browser.newPage()
128
+ ownPage = true
129
+ } else {
130
+ page = (await browser.pages())[0] ?? await browser.newPage()
102
131
  }
103
- const page = (await browser.pages())[0] ?? (await browser.newPage())
104
132
  return {
105
133
  ok: true,
106
134
  browser,
107
135
  page,
108
- guard,
136
+ guard: guard ?? { blockedCount: () => 0, requestCount: () => 0 },
109
137
  close: async () => {
138
+ // 自己开的标签页按 closeOwnPage 收尾(登录引导保持 false——登录页留给用户);
110
139
  // cdp:只断开连接,绝不关用户浏览器;persistent:关自己拉起的实例
140
+ if (ownPage && (opts.closeOwnPage ?? true)) {
141
+ await (page as { close(): Promise<void> }).close().catch(() => { /* ignore */ })
142
+ }
111
143
  await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
112
144
  },
113
145
  }