@danceiny/gotry 0.0.1-rc.14 → 0.0.1-rc.16

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 (142) hide show
  1. package/README.md +26 -20
  2. package/README.zh-CN.md +27 -21
  3. package/bin/gotry-bootstrap.js +331 -3
  4. package/cordis.gotry-patch.yml +10 -0
  5. package/data/airline-airports.json +39 -0
  6. package/dist/capabilities/agent-reach-deep.js +1 -1
  7. package/dist/capabilities/agent-reach.js +1 -1
  8. package/dist/capabilities/anything.js +1 -1
  9. package/dist/capabilities/artifacts.js +217 -0
  10. package/dist/capabilities/effect.js +293 -0
  11. package/dist/capabilities/fact-log.js +41 -0
  12. package/dist/capabilities/flyai.js +1 -1
  13. package/dist/capabilities/hbcli.js +43 -1
  14. package/dist/capabilities/incident-log.js +1 -1
  15. package/dist/capabilities/opensky.js +1 -1
  16. package/dist/capabilities/resilience.js +90 -0
  17. package/dist/capabilities/session/action-cache.js +1 -1
  18. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  19. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  20. package/dist/capabilities/session/benchmark.js +2 -1
  21. package/dist/capabilities/session/extension-bridge.js +345 -0
  22. package/dist/capabilities/session/extension-channel.js +93 -0
  23. package/dist/capabilities/session/extract.js +1 -1
  24. package/dist/capabilities/session/health-watch.js +216 -0
  25. package/dist/capabilities/session/read-guard.js +1 -1
  26. package/dist/capabilities/session/transport.js +1 -1
  27. package/dist/capabilities/session/wizard.js +377 -0
  28. package/dist/capabilities/session-consent.js +1 -1
  29. package/dist/capabilities/session-login.js +77 -1
  30. package/dist/capabilities/session-search.js +69 -2
  31. package/dist/capabilities/weather.js +1 -1
  32. package/dist/scripts/action-cache-tests.js +1 -1
  33. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  34. package/dist/scripts/agent-reach-tests.js +1 -1
  35. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  36. package/dist/scripts/anything-tests.js +1 -1
  37. package/dist/scripts/async-collect.js +1 -1
  38. package/dist/scripts/booking-saga-tests.js +187 -0
  39. package/dist/scripts/bootstrap-tests.js +49 -3
  40. package/dist/scripts/build-changelog.js +279 -0
  41. package/dist/scripts/changelog-tests.js +165 -0
  42. package/dist/scripts/companion-tests.js +1 -1
  43. package/dist/scripts/diff-test.js +1 -1
  44. package/dist/scripts/effect-tests.js +474 -0
  45. package/dist/scripts/engine-run.js +1 -1
  46. package/dist/scripts/engine-tests.js +1 -1
  47. package/dist/scripts/extension-tests.js +468 -0
  48. package/dist/scripts/fact-gate-tests.js +318 -0
  49. package/dist/scripts/flyai-tests.js +1 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +180 -0
  51. package/dist/scripts/hbcli-tests.js +1 -1
  52. package/dist/scripts/health-watch-cli.js +51 -0
  53. package/dist/scripts/i18n-tests.js +1 -1
  54. package/dist/scripts/incident-tests.js +1 -1
  55. package/dist/scripts/journey-tests.js +1 -1
  56. package/dist/scripts/ledger-tests.js +1 -1
  57. package/dist/scripts/ledger-workflow-crash.js +1 -1
  58. package/dist/scripts/memory-capture-tests.js +1 -1
  59. package/dist/scripts/memory-decay-tests.js +1 -1
  60. package/dist/scripts/memory-metrics.js +1 -1
  61. package/dist/scripts/memory-value-report.js +1 -1
  62. package/dist/scripts/nightly-evidence-tests.js +186 -0
  63. package/dist/scripts/nightly-evidence.js +289 -0
  64. package/dist/scripts/nudge-digest.js +1 -1
  65. package/dist/scripts/onboarding-tests.js +218 -0
  66. package/dist/scripts/opensky-check.js +1 -1
  67. package/dist/scripts/opensky-tests.js +1 -1
  68. package/dist/scripts/price-drift-tests.js +441 -0
  69. package/dist/scripts/price-drift-watch.js +491 -0
  70. package/dist/scripts/probe-poi-tests.js +1 -1
  71. package/dist/scripts/product-metrics.js +1 -1
  72. package/dist/scripts/publish-preverify.js +1 -1
  73. package/dist/scripts/realtime-pricing-tests.js +1 -1
  74. package/dist/scripts/replay-async.js +1 -1
  75. package/dist/scripts/replay-real.js +1 -1
  76. package/dist/scripts/replay.js +1 -1
  77. package/dist/scripts/session-attach-diagnose.js +1 -1
  78. package/dist/scripts/session-attach-poc.js +1 -1
  79. package/dist/scripts/session-benchmark.js +1 -1
  80. package/dist/scripts/session-extract-tests.js +1 -1
  81. package/dist/scripts/session-login.js +1 -1
  82. package/dist/scripts/session-tests.js +1 -1
  83. package/dist/scripts/skeleton-check.js +1 -1
  84. package/dist/scripts/skeleton-integration-test.js +1 -1
  85. package/dist/scripts/skills-contract-tests.js +1 -1
  86. package/dist/scripts/smoke.js +159 -1
  87. package/dist/scripts/state-cli-tests.js +1 -1
  88. package/dist/scripts/state-cli.js +1 -1
  89. package/dist/scripts/time-eval-tests.js +1 -1
  90. package/dist/scripts/travel-timeline-tests.js +1 -1
  91. package/dist/scripts/unified-tests.js +1 -1
  92. package/dist/scripts/weather-tests.js +1 -1
  93. package/dist/scripts/wizard-bootstrap.js +32 -0
  94. package/dist/scripts/z3-race-tests.js +1 -1
  95. package/dist/src/artifact-gate.js +310 -0
  96. package/dist/src/bookable-facts.js +349 -0
  97. package/dist/src/booking-saga.js +153 -0
  98. package/dist/src/bridge.js +1 -1
  99. package/dist/src/companions.js +1 -1
  100. package/dist/src/contracts.js +1 -1
  101. package/dist/src/dsh-llm.js +31 -7
  102. package/dist/src/engine.js +1 -1
  103. package/dist/src/i18n.js +1 -1
  104. package/dist/src/index.js +322 -46
  105. package/dist/src/journey.js +1 -1
  106. package/dist/src/loop.js +1 -1
  107. package/dist/src/memory-capture.js +1 -1
  108. package/dist/src/memory-decay.js +1 -1
  109. package/dist/src/memory-utility.js +1 -1
  110. package/dist/src/mock-llm.js +1 -1
  111. package/dist/src/model.js +1 -1
  112. package/dist/src/realtime-pricing.js +14 -7
  113. package/dist/src/slot-spec.js +1 -1
  114. package/dist/src/state-ledger.js +1 -1
  115. package/dist/src/time-anchor.js +1 -1
  116. package/dist/src/tool-packet.js +1 -1
  117. package/dist/src/travel-slots.js +1 -1
  118. package/dist/src/travel-timeline.js +1 -1
  119. package/dist/src/unified.js +1 -1
  120. package/dist/src/wish-pool.js +1 -1
  121. package/dist/src/z3-shared.js +1 -1
  122. package/extension/README.md +26 -0
  123. package/extension/background.js +160 -0
  124. package/extension/content-bridge.js +41 -0
  125. package/extension/content-main.js +62 -0
  126. package/extension/manifest.json +32 -0
  127. package/package.json +8 -2
  128. package/ts/capabilities/artifacts.ts +235 -0
  129. package/ts/capabilities/fact-log.ts +54 -0
  130. package/ts/capabilities/hbcli.ts +46 -0
  131. package/ts/capabilities/session/benchmark.ts +4 -1
  132. package/ts/capabilities/session/extension-bridge.ts +354 -0
  133. package/ts/capabilities/session/extension-channel.ts +92 -0
  134. package/ts/capabilities/session/health-watch.ts +257 -0
  135. package/ts/capabilities/session/wizard.ts +282 -0
  136. package/ts/capabilities/session-login.ts +53 -2
  137. package/ts/capabilities/session-search.ts +68 -3
  138. package/ts/src/artifact-gate.ts +365 -0
  139. package/ts/src/bookable-facts.ts +557 -0
  140. package/ts/src/booking-saga.ts +133 -0
  141. package/ts/src/dsh-llm.ts +40 -3
  142. package/ts/src/index.ts +230 -28
@@ -1,16 +1,24 @@
1
1
  /**
2
2
  * 会话检索编排层(RFC §3.2):transport → adapter entry → networkHint 嗅探 → 解析 → ReadGuard → 证据链。
3
3
  *
4
+ * 传输车道(2026-08-29 定案,RFC §2.2):**扩展桥为默认**——一次性安装的 MV3 扩展
5
+ * (GoTry Session Bridge)在自己标签页被动嗅探 batchSearch,零 Chrome 系统弹窗;
6
+ * cdp(attach 日常 Chrome,Chrome 144+ 每连接弹权限框)降为显式后备
7
+ * (`GOTRY_SESSION_TRANSPORT=cdp` opt-in,诊断/测试用);persistent 仅测试。
8
+ *
4
9
  * 证据链(L4 增补):[会话:ctrip-flight@ts] = 用户本人会话内实时检索,非官方 API;
5
10
  * 风控命中(verdict='challenged')= degraded,绝不重试、绝不绕过(合规支柱②)。
6
11
  * 节律(§3.4):同站点 ≥30s 间隔 + 单调冷却;超间隔返回 verdict='cooldown'。
7
- * 永不抛错;transport 必带 ReadGuard(fail-closed);测试/巡检用隔离 profile 与 stateRoot。
12
+ * 永不抛错;扩展车道 fail-closed(桥/扩展不可用即 verdict,零花费);测试/巡检用隔离 profile 与 stateRoot。
8
13
  */
9
14
 
10
15
  import { openSession } from './session/transport.ts'
16
+ import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure, NEEDS_EXTENSION_HINT } from './session/extension-channel.ts'
17
+ import { appendFileSync, mkdirSync } from 'node:fs'
18
+ import { dirname } from 'node:path'
11
19
  import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
12
20
 
13
- export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach'
21
+ export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach' | 'needs-extension'
14
22
 
15
23
  export interface SessionSearchResult {
16
24
  ok: boolean
@@ -54,6 +62,22 @@ export function classifyTransportFailure(summary: string, cdpMode: boolean): Ext
54
62
  : 'error'
55
63
  }
56
64
 
65
+ /** 传输车道解析(纯函数,测试锚点):persistent=隔离 profile(测试自检);cdp=显式 opt-in;扩展=默认 */
66
+ export function resolveTransportMode(profileDir?: string): 'cdp' | 'persistent' | 'extension' {
67
+ if (profileDir !== undefined) return 'persistent'
68
+ return (process.env.GOTRY_SESSION_TRANSPORT ?? '').trim().toLowerCase() === 'cdp' ? 'cdp' : 'extension'
69
+ }
70
+
71
+ /** 扩展车道 job 审计(ReadGuard 审计同款 JSONL,kind 区分;auditPath 缺省不落盘) */
72
+ export function appendExtensionAudit(auditPath: string | undefined, entry: { kind: 'extension-session-job'; site: string; url: string; jobId: string; result: string }): void {
73
+ if (!auditPath) return
74
+ try {
75
+ const record = { ts: new Date().toISOString(), ...entry, url: entry.url.slice(0, 400) }
76
+ mkdirSync(dirname(auditPath), { recursive: true })
77
+ appendFileSync(auditPath, JSON.stringify(record) + '\n')
78
+ } catch { /* 审计失败不阻塞检索 */ }
79
+ }
80
+
57
81
  export async function sessionFlightSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
58
82
  const started = Date.now()
59
83
  const ts = new Date().toISOString()
@@ -74,8 +98,49 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
74
98
  return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`)
75
99
  }
76
100
 
101
+ const mode = resolveTransportMode(q.profileDir)
102
+
103
+ if (mode === 'extension') {
104
+ // ① 登录态快查:票据 cookie 名存在性(免标签页,秒回;needs-login 不再先付一次导航成本)
105
+ const login = await extensionCookieNames({ site, domain: SITE_DOMAIN.replace(/^\./, ''), ticketNames: LOGIN_COOKIE_NAMES })
106
+ if (!login.ok) {
107
+ const verdict = classifyBridgeFailure(login.kind)
108
+ return err(verdict, verdict === 'needs-extension' ? `${login.summary};${NEEDS_EXTENSION_HINT}` : login.summary)
109
+ }
110
+ // 登录态闸:用户自己的账号;匿名默认拒(allowAnonymous 仅链路自检且证据标自检态)
111
+ if (login.tickets.length === 0 && !q.allowAnonymous) {
112
+ return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
113
+ }
114
+ // ② 检索 job:后台标签 + MAIN-world 被动嗅探(检索请求由站点自己发出,扩展零写行为)
115
+ const r = await extensionSearchJob({ site, url: entry.url, timeoutMs: q.timeoutMs })
116
+ appendExtensionAudit(q.auditPath, {
117
+ kind: 'extension-session-job', site, url: entry.url, jobId: 'search',
118
+ result: r.ok ? (r.timedOut ? 'timeout' : `body ${r.body.length}B title="${r.title.slice(0, 60)}"`) : `${r.kind}:${r.summary.slice(0, 120)}`,
119
+ })
120
+ if (!r.ok) {
121
+ const verdict = classifyBridgeFailure(r.kind)
122
+ return err(verdict, verdict === 'needs-extension' ? `${r.summary};${NEEDS_EXTENSION_HINT}` : r.summary)
123
+ }
124
+ const title = r.title
125
+ const head = r.body.slice(0, 5000)
126
+ if (CHALLENGE_RE.test(title + head)) {
127
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
128
+ }
129
+ const options = parseBatchSearch(r.body)
130
+ const verdict: SessionVerdict = options.length > 0 ? 'hit' : 'miss'
131
+ return {
132
+ ok: true,
133
+ via: 'session-ctrip-flight',
134
+ evidence: `[会话:${site}@${ts}] ${options.length} options;transport=extension(被动嗅探,零系统弹窗;扩展零写行为=物理只读)${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
135
+ latencyMs: Date.now() - started,
136
+ verdict,
137
+ options,
138
+ }
139
+ }
140
+
77
141
  // 人机共治纪律:检索一律开自己的新标签页(绝不劫持用户已有页面),用完关自己的页
78
- const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: q.profileDir ? 'persistent' : 'cdp', newPage: true })
142
+ // cdp 车道(显式 GOTRY_SESSION_TRANSPORT=cdp opt-in)与 persistent(测试隔离 profile)
143
+ const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: mode === 'persistent' ? 'persistent' : 'cdp', newPage: true })
79
144
  if (!t.ok) {
80
145
  // cdp 未开端口或握手失败 → needs-attach(一次性用户动作);persistent 启动失败仍走 error。
81
146
  // transport 的“端口未开”在连接前返回,文案不含 `cdp attach 失败`,两种形态都要归入同一用户门禁。
@@ -0,0 +1,365 @@
1
+ /**
2
+ * 产物事实闸 gotry_artifact_gate.v1(issue #46,ADR-19)。
3
+ *
4
+ * 最终 artifact 里的每个可下单事实必须能回溯到注册表内的 exact-date 工具结果
5
+ * (query_id 可重放);无法回溯 → 阻止以「已验证方案」输出。
6
+ *
7
+ * 工作方式:从 markdown 反向抽取 claim(航班号/承运直飞/通用直飞断言/机场映射/
8
+ * 政策/✓ 语气/联程措辞),逐条对照事实注册表与航司机场映射表;行程级口径
9
+ * (夜数/legs/预算下限)由结构化 itinerary 的不变量校验并入同一报告。
10
+ * 抽取是保守的:抽不出上下文的 claim 按 route_unqueried 处理(更重,不更轻)——
11
+ * fail closed,不放行「写得含糊所以查不到」的事实。
12
+ *
13
+ * 纯函数,零 I/O。映射表数据见 data/airline-airports.json(as_of 快照)。
14
+ */
15
+
16
+ import {
17
+ flightClaimVerdict,
18
+ itineraryInvariants,
19
+ latestFactsForRouteDate,
20
+ type BookableFact,
21
+ type FlightClaim,
22
+ type FlightClaimVerdict,
23
+ type ItineraryFacts,
24
+ } from './bookable-facts.ts'
25
+
26
+ export const ARTIFACT_GATE_SCHEMA = 'gotry_artifact_gate.v1' as const
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // 映射表(data/airline-airports.json 的内存形状)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface AirlineAirportMap {
33
+ meta: { as_of: string; review_by?: string; source?: string; note?: string }
34
+ /** 中文城市名 → 主机场 IATA(航线上下文解析用,稳定词汇) */
35
+ city_alias: Record<string, string>
36
+ /** 双机场城市的航司→机场映射(冲突检测用,dated 快照):如 曼谷 { FD: DMK, VZ: BKK } */
37
+ carrier_airport: Record<string, Record<string, string>>
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // claim 抽取
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface ExtractedFlightClaim extends FlightClaim {
45
+ line: number
46
+ text: string
47
+ /** 承运级直飞断言(无航班号,如「8L 直飞」/「东航直飞」) */
48
+ carrier_only?: string
49
+ }
50
+
51
+ export interface ExtractedPolicyClaim {
52
+ line: number
53
+ text: string
54
+ has_as_of: boolean
55
+ }
56
+
57
+ export interface ExtractedAirportClaim {
58
+ line: number
59
+ text: string
60
+ carriers: string[]
61
+ airport: string
62
+ city: string
63
+ }
64
+
65
+ export interface ExtractedClaims {
66
+ flights: ExtractedFlightClaim[]
67
+ policies: ExtractedPolicyClaim[]
68
+ airports: ExtractedAirportClaim[]
69
+ /** 含「直飞」断言且可解析航线的行(通用直飞规则:该日无任何在架直飞即违例) */
70
+ direct_lines: Array<{ line: number; text: string; origin?: string; destination?: string; date?: string }>
71
+ }
72
+
73
+ /** 航班号:2 位承运码 + 3-4 位数字(UO784/EK328/MF1538/CZ8582/9C8781/FD597) */
74
+ const FLIGHT_NO = /(?<![A-Za-z0-9])([A-Z0-9]{2}\d{3,4})(?![\d])/g
75
+ /** 承运级直飞断言:「8L 直飞」「FZ 直飞香港」——无航班号的航线存在性断言 */
76
+ const CARRIER_DIRECT = /(?<![A-Za-z0-9])([A-Z0-9]{2})(?!\d)(?:\s|[一-龥]){0,6}直飞/g
77
+ /** 中文承运名 → 二字码(承运级断言覆盖;只列闸需要的常见出境承运) */
78
+ const CARRIER_ZH: Record<string, string> = {
79
+ 东航: 'MU', 南航: 'CZ', 国航: 'CA', 厦航: 'MF', 春秋: '9C', 祥鹏: '8L',
80
+ 亚航: 'FD', 越捷: 'VZ', 国泰: 'CX', 港航: 'HX', 香港快运: 'UO', 阿联酋: 'EK', 泰航: 'TG',
81
+ }
82
+ const POLICY_WORD = /免签|落地签|签证|入境申报|过境免/
83
+ /** as_of 必须是「截至 + 具体日期」——「现行 60 天」不算时间边界(issue #46 政策行) */
84
+ const AS_OF_WORD = /截至\s*\d{4}[-/年]\d{1,2}|as[_ ]?of\s*\d{4}/i
85
+ const CHECK_MARK = /[✓✅]/
86
+
87
+ /** 「直飞」断言(排除否定前置:无/不/没有/未见直飞;函数实现避开变宽 lookbehind 引擎差异) */
88
+ function hasDirectAssertion(line: string): boolean {
89
+ const idx = line.indexOf('直飞')
90
+ if (idx < 0) return false
91
+ const before = line.slice(Math.max(0, idx - 3), idx)
92
+ return !(before.endsWith('无') || before.endsWith('不') || before.endsWith('没有') || before.endsWith('未见'))
93
+ }
94
+
95
+ interface SectionCtx { origin?: string; destination?: string; date?: string }
96
+
97
+ /** 从文本(小节标题或行)解析航线上下文:箭头紧邻城市对(方向最可靠)优先,否则按出现位置取前两个;日期 M.D 或 MM-DD */
98
+ function routeCtxOf(text: string, map: AirlineAirportMap, defaultYear?: number): SectionCtx {
99
+ const occ: Array<{ city: string; iata: string; pos: number }> = []
100
+ for (const [city, iata] of Object.entries(map.city_alias)) {
101
+ let pos = text.indexOf(city)
102
+ while (pos >= 0) {
103
+ occ.push({ city, iata, pos })
104
+ pos = text.indexOf(city, pos + city.length)
105
+ }
106
+ }
107
+ occ.sort((a, b) => a.pos - b.pos)
108
+ const ctx: SectionCtx = {}
109
+ // 「迪拜10:05→深圳」「迪拜 → 深圳」——两城市间有箭头即取方向(表格单元格顺序不可靠)
110
+ outer: for (let i = 0; i < occ.length; i++) {
111
+ for (let j = i + 1; j < occ.length; j++) {
112
+ if (occ[j]!.iata === occ[i]!.iata) continue
113
+ const between = text.slice(occ[i]!.pos + occ[i]!.city.length, occ[j]!.pos)
114
+ if (/→|⇀|->/.test(between)) {
115
+ ctx.origin = occ[i]!.iata
116
+ ctx.destination = occ[j]!.iata
117
+ break outer
118
+ }
119
+ }
120
+ }
121
+ if (!ctx.origin && occ.length >= 2) {
122
+ ctx.origin = occ[0]!.iata
123
+ ctx.destination = occ[1]!.iata
124
+ }
125
+ if (defaultYear) {
126
+ const dm = text.match(/(?<!\d)(\d{1,2})\.(\d{1,2})(?!\d)/) ?? text.match(/(?<!\d)(\d{2})-(\d{2})(?!\d)/)
127
+ if (dm) {
128
+ const mm = Number(dm[1]), dd = Number(dm[2])
129
+ if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) {
130
+ ctx.date = `${defaultYear}-${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`
131
+ }
132
+ }
133
+ }
134
+ return ctx
135
+ }
136
+
137
+ /**
138
+ * 从 markdown 抽取全部可下单 claim。
139
+ * 小节标题(### 段2|香港→普吉(7.17 周六))为下方行提供 route/date 上下文;
140
+ * 行内自带上下文优先。默认年份由 trip window 提供(远期行程的年份不含糊)。
141
+ */
142
+ export function extractClaims(markdown: string, map: AirlineAirportMap, opts?: { trip_year?: number }): ExtractedClaims {
143
+ const claims: ExtractedClaims = { flights: [], policies: [], airports: [], direct_lines: [] }
144
+ const lines = markdown.split('\n')
145
+ let section: SectionCtx = {}
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i]!
148
+ const lineNo = i + 1
149
+ if (/^#{1,4}\s/.test(line)) {
150
+ const ctx = routeCtxOf(line, map, opts?.trip_year)
151
+ section = { origin: ctx.origin, destination: ctx.destination, date: ctx.date ?? section.date }
152
+ continue
153
+ }
154
+ const own = routeCtxOf(line, map, opts?.trip_year)
155
+ const origin = own.origin ?? section.origin
156
+ const destination = own.destination ?? section.destination
157
+ const date = own.date ?? section.date
158
+
159
+ for (const m of line.matchAll(FLIGHT_NO)) {
160
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: m[1]!.toUpperCase(), origin, destination, date })
161
+ }
162
+ for (const m of line.matchAll(CARRIER_DIRECT)) {
163
+ const carrier = m[1]!.toUpperCase()
164
+ // 纯数字(时刻尾数,如「21:35 直飞」的 35)不是承运码;已是航班号前缀的不重复记
165
+ if (!/[A-Z]/.test(carrier)) continue
166
+ if (new RegExp(`${carrier}\\d`).test(line)) continue
167
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: '', carrier_only: carrier, origin, destination, date })
168
+ }
169
+ if (hasDirectAssertion(line) && (origin || destination)) {
170
+ claims.direct_lines.push({ line: lineNo, text: line.trim().slice(0, 120), origin, destination, date })
171
+ // 中文承运名直飞断言(「东航 南京→普吉(白天班)」无二字码,FLIGHT_NO/CARRIER_DIRECT 都漏);
172
+ // 词首不得紧贴汉字——「国泰航空」里的「泰航」不是泰航
173
+ for (const [zh, code] of Object.entries(CARRIER_ZH)) {
174
+ if (new RegExp(`(?<![一-龥])${zh}`).test(line) && !new RegExp(`${code}\\d`).test(line) && !claims.flights.some(c => c.line === lineNo && c.carrier_only === code)) {
175
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: '', carrier_only: code, origin, destination, date })
176
+ }
177
+ }
178
+ }
179
+ if (POLICY_WORD.test(line)) {
180
+ claims.policies.push({ line: lineNo, text: line.trim().slice(0, 160), has_as_of: AS_OF_WORD.test(line) })
181
+ }
182
+ // 航司→机场映射 claim:同一行出现承运码 + 机场码,且该城是双机场映射面
183
+ for (const [city, carriers] of Object.entries(map.carrier_airport)) {
184
+ const airports = [...new Set(Object.values(carriers))]
185
+ if (!airports.some(a => line.includes(a))) continue
186
+ const mentioned = Object.keys(carriers).filter(c => new RegExp(`(?<![A-Za-z0-9])${c}(?![A-Za-z0-9])`).test(line))
187
+ if (mentioned.length === 0) continue
188
+ const airport = airports.find(a => line.includes(a))
189
+ if (airport) claims.airports.push({ line: lineNo, text: line.trim().slice(0, 120), carriers: mentioned, airport, city })
190
+ }
191
+ }
192
+ return claims
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // 闸:逐条对照注册表与映射表;行程级口径由结构化 itinerary 不变量并入
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export type GateViolationKind =
200
+ | FlightClaimVerdict // not_in_source / route_unqueried / contradicted(traceable 不产生违例)
201
+ | 'carrier_direct_unverified' // 承运级直飞断言无 exact-date 证据(「可考虑 8L 直飞」)
202
+ | 'airport_mapping_conflict' // 航司→机场与 dated 映射冲突(FD/VZ 均落 DMK)
203
+ | 'policy_without_as_of' // 政策断言无「截至 YYYY-MM-DD」时间边界
204
+ | 'unconditional_check' // 对未核验/不可售 claim 使用无条件 ✓/✅
205
+ | 'self_transfer_called_through' // 分票/自助转机被称为「联程」
206
+ | 'nights_inconsistent' // 酒店夜+机上夜 ≠ 行程总夜数
207
+ | 'legs_inconsistent' // flight O&D 段与 legs 口径不一致
208
+ | 'budget_floor_inconsistent' // 预算下限话术低于分项最低合计
209
+ | 'date_order' // 住宿/段日期越窗或倒挂
210
+
211
+ export interface GateViolation {
212
+ kind: GateViolationKind
213
+ line: number
214
+ detail: string
215
+ }
216
+
217
+ export interface GateReport {
218
+ schema: typeof ARTIFACT_GATE_SCHEMA
219
+ verdict: 'pass' | 'blocked'
220
+ claims_checked: number
221
+ traceable: number
222
+ violations: GateViolation[]
223
+ /** blocked 时的措辞纪律:不得宣称「已验证方案」 */
224
+ presentation: 'verified_itinerary_allowed' | 'verified_label_forbidden'
225
+ }
226
+
227
+ /** 行内 HH:MM 与已溯源事实的时刻对照:写了时刻却一个都对不上 = 混入旧航季(contradicted) */
228
+ function lineTimesContradict(text: string, dep?: string, arr?: string): boolean {
229
+ if (!dep && !arr) return false
230
+ const times = [...text.matchAll(/(?<!\d)(\d{1,2}:\d{2})(?!\d)/g)].map(m => m[1]!)
231
+ if (times.length === 0) return false
232
+ return !times.some(t => t === dep || t === arr)
233
+ }
234
+
235
+ export function gateArtifact(
236
+ markdown: string,
237
+ facts: BookableFact[],
238
+ map: AirlineAirportMap,
239
+ opts?: { trip_year?: number; itinerary?: ItineraryFacts },
240
+ ): GateReport {
241
+ const claims = extractClaims(markdown, map, opts)
242
+ const violations: GateViolation[] = []
243
+ let traceable = 0
244
+
245
+ for (const c of claims.flights) {
246
+ if (c.carrier_only) {
247
+ // 承运级直飞断言:注册表该 route+date 须存在该承运的 bookable 直飞
248
+ const hasCarrier = facts.some(f =>
249
+ f.kind === 'flight' && f.bookability === 'bookable_exact_date' && f.nonstop !== false
250
+ && f.flight_no.startsWith(c.carrier_only!)
251
+ && (!c.origin || f.route.origin === c.origin || f.route.origin_airport === c.origin)
252
+ && (!c.destination || f.route.destination === c.destination || f.route.dest_airport === c.destination)
253
+ && (!c.date || f.date === c.date))
254
+ if (!hasCarrier) {
255
+ const scoped = c.origin && c.destination && c.date
256
+ ? latestFactsForRouteDate(facts, c.origin, c.destination, c.date)
257
+ : []
258
+ violations.push({
259
+ kind: scoped.length > 0 ? 'not_in_source' : 'carrier_direct_unverified',
260
+ line: c.line,
261
+ detail: `承运级直飞断言「${c.carrier_only} 直飞」无 exact-date 证据(${c.origin ?? '?'}→${c.destination ?? '?'} ${c.date ?? '?'})`
262
+ + (scoped.length > 0 && scoped.every(f => f.bookability === 'bookable_exact_date' && f.nonstop === false)
263
+ ? '——该日可售仅有中转' : '——「可考虑/也许有」不得写入决策树'),
264
+ })
265
+ }
266
+ continue
267
+ }
268
+ const r = flightClaimVerdict(facts, c)
269
+ if (r.verdict === 'traceable') {
270
+ // 时刻一致性:溯源命中但行内时刻与事实全不符 = 旧航季混入(issue #46 EK328 行)
271
+ if (lineTimesContradict(c.text, r.fact?.dep_local, r.fact?.arr_local)) {
272
+ violations.push({
273
+ kind: 'contradicted',
274
+ line: c.line,
275
+ detail: `${c.flight_no} 时刻与 exact-date 快照不符(快照 ${r.fact?.dep_local}→${r.fact?.arr_local} ${r.fact?.date})——渲染层不得改写工具返回的时刻`,
276
+ })
277
+ continue
278
+ }
279
+ traceable++
280
+ continue
281
+ }
282
+ violations.push({ kind: r.verdict, line: c.line, detail: `${c.flight_no}: ${r.reason}` })
283
+ // 对不可述 claim 的无条件 ✓/✅ 追加语气违例(验收⑦:远期班期/价格不用无条件 ✓)
284
+ if (CHECK_MARK.test(c.text)) {
285
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: `对未核验航班 ${c.flight_no} 使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记` })
286
+ }
287
+ }
288
+
289
+ // 通用直飞规则:该行未点名承运时,要求该 route+date 存在任一在架直飞;
290
+ // 「该日可售仅有中转/0 条」却写直飞 = 无证据分支(issue #46「DMK→KMG 可考虑 8L 直飞」行同款)
291
+ for (const d of claims.direct_lines) {
292
+ if (claims.flights.some(c => c.line === d.line)) continue // 航班号/承运级断言已逐条判过
293
+ if (!d.origin || !d.destination || !d.date) {
294
+ violations.push({ kind: 'route_unqueried', line: d.line, detail: `直飞断言缺 route/date 上下文,无法回溯——fail closed 按未核验处理` })
295
+ continue
296
+ }
297
+ const scoped = latestFactsForRouteDate(facts, d.origin, d.destination, d.date)
298
+ if (scoped.length === 0) {
299
+ violations.push({ kind: 'route_unqueried', line: d.line, detail: `${d.origin}→${d.destination} ${d.date} 的直飞断言从未经 exact-date 源核验` })
300
+ continue
301
+ }
302
+ const nonstopCount = scoped.filter(f => f.bookability === 'bookable_exact_date' && f.nonstop !== false).length
303
+ if (nonstopCount === 0) {
304
+ violations.push({ kind: 'not_in_source', line: d.line, detail: `${d.origin}→${d.destination} ${d.date} exact-date 无任何在架直飞(仅中转或 0 条)——不得写直飞` })
305
+ }
306
+ }
307
+
308
+ for (const a of claims.airports) {
309
+ const table = map.carrier_airport[a.city] ?? {}
310
+ for (const carrier of a.carriers) {
311
+ const expected = table[carrier]
312
+ if (expected && expected !== a.airport) {
313
+ violations.push({
314
+ kind: 'airport_mapping_conflict',
315
+ line: a.line,
316
+ detail: `${carrier} 在${a.city}落 ${expected}(映射快照 as_of ${map.meta.as_of}),产物写 ${a.airport}——双机场不互换,航司不合并映射`,
317
+ })
318
+ }
319
+ }
320
+ }
321
+
322
+ for (const p of claims.policies) {
323
+ if (!p.has_as_of) {
324
+ violations.push({ kind: 'policy_without_as_of', line: p.line, detail: `政策断言缺时间边界:动态政策只能表述为「截至 YYYY-MM-DD 的现行政策」并附复核日期` })
325
+ }
326
+ if (CHECK_MARK.test(p.text) && !p.has_as_of) {
327
+ violations.push({ kind: 'unconditional_check', line: p.line, detail: `政策行使用无条件 ✓/✅` })
328
+ }
329
+ }
330
+
331
+ // 联程措辞:含「联程」的行,逐航班号查 protected_connection——
332
+ // 只有全部航腿同票保护才允许称联程;分票/自助转机必须显式标红
333
+ markdown.split('\n').forEach((line, i) => {
334
+ if (!line.includes('联程')) return
335
+ const nos = [...line.matchAll(FLIGHT_NO)].map(m => m[1]!.toUpperCase())
336
+ const matched = facts.filter((f): f is Extract<BookableFact, { kind: 'flight' | 'train' }> =>
337
+ f.kind !== 'policy' && nos.includes(f.flight_no.toUpperCase()))
338
+ const allProtected = nos.length > 0
339
+ && matched.length === nos.length
340
+ && matched.every(f => f.protected_connection === true)
341
+ if (!allProtected) {
342
+ violations.push({
343
+ kind: 'self_transfer_called_through',
344
+ line: i + 1,
345
+ detail: '「联程」措辞无 protected_connection=true 证据——两段接得上不等于同票联程;分票必须标红自助转机(无行李直挂/误机无保护)',
346
+ })
347
+ }
348
+ })
349
+
350
+ // 行程级口径不变量(夜数/legs/预算下限):结构化 itinerary 机器断言并入同一报告
351
+ if (opts?.itinerary) {
352
+ for (const v of itineraryInvariants(opts.itinerary)) {
353
+ violations.push({ kind: v.kind, line: 0, detail: v.detail })
354
+ }
355
+ }
356
+
357
+ return {
358
+ schema: ARTIFACT_GATE_SCHEMA,
359
+ verdict: violations.length > 0 ? 'blocked' : 'pass',
360
+ claims_checked: claims.flights.length + claims.policies.length + claims.airports.length,
361
+ traceable,
362
+ violations,
363
+ presentation: violations.length > 0 ? 'verified_label_forbidden' : 'verified_itinerary_allowed',
364
+ }
365
+ }