@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.
- package/README.md +111 -146
- package/cordis.gotry-patch.yml +14 -7
- package/dist/capabilities/flyai.js +135 -30
- package/dist/capabilities/hbcli.js +14 -2
- package/dist/capabilities/session/benchmark.js +252 -0
- package/dist/capabilities/session/transport.js +25 -10
- package/dist/capabilities/session-consent.js +82 -0
- package/dist/capabilities/session-login.js +119 -0
- package/dist/capabilities/session-search.js +7 -4
- package/dist/capabilities/weather.js +72 -17
- package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
- package/dist/scripts/async-collect.js +33 -5
- package/dist/scripts/hbcli-tests.js +25 -4
- package/dist/scripts/ledger-tests.js +134 -4
- package/dist/scripts/memory-value-report.js +379 -0
- package/dist/scripts/product-metrics.js +569 -0
- package/dist/scripts/session-benchmark.js +261 -0
- package/dist/scripts/session-login.js +28 -0
- package/dist/scripts/session-tests.js +262 -23
- package/dist/scripts/smoke.js +112 -14
- package/dist/scripts/weather-tests.js +8 -1
- package/dist/src/index.js +124 -5
- package/dist/src/loop.js +65 -14
- package/dist/src/state-ledger.js +30 -4
- package/package.json +4 -2
- package/ts/capabilities/flyai.ts +177 -22
- package/ts/capabilities/hbcli.ts +16 -4
- package/ts/capabilities/session/benchmark.ts +273 -0
- package/ts/capabilities/session/transport.ts +41 -9
- package/ts/capabilities/session-consent.ts +127 -0
- package/ts/capabilities/session-login.ts +146 -0
- package/ts/capabilities/session-search.ts +13 -5
- package/ts/capabilities/weather.ts +84 -19
- package/ts/scripts/async-collect.ts +48 -7
- package/ts/src/index.ts +107 -11
- package/ts/src/loop.ts +85 -12
- package/ts/src/state-ledger.ts +48 -4
package/ts/capabilities/flyai.ts
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
const r = await sh(q.cliBin ?? 'npx', ['-y', '@fly-ai/flyai-cli',
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
items =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|