@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
@@ -18,10 +18,16 @@ import {
18
18
  hotelClaimVerdict,
19
19
  itineraryInvariants,
20
20
  latestFactsForRouteDate,
21
+ railClaimVerdict,
22
+ renderFlightFact,
23
+ renderHotelFact,
24
+ renderPolicyFact,
21
25
  type BookableFact,
22
26
  type FlightClaim,
23
27
  type FlightClaimVerdict,
24
28
  type ItineraryFacts,
29
+ type PolicyFact,
30
+ type RailClaim,
25
31
  } from './bookable-facts.ts'
26
32
 
27
33
  export const ARTIFACT_GATE_SCHEMA = 'gotry_artifact_gate.v1' as const
@@ -49,6 +55,19 @@ export interface ExtractedFlightClaim extends FlightClaim {
49
55
  carrier_only?: string
50
56
  }
51
57
 
58
+ /** 车次 claim(issue #299):中文高铁/动车/城际/直达代码(G/D/C/Z + 3–4 位)。
59
+ * 闸侧不与航班 claim 合并;`railClaimVerdict` 只在 train 事实里查同号回溯,
60
+ * 缺事实/缺上下文 fail-closed(`rail_claim_unverified`),不得用历史班期或
61
+ * static-schedule 凑合格回溯。`gotry_flyai_search kind:'train'` 经
62
+ * `factsFromFlyai` 与 12306 `sessionTrainSearch` 均沿现有 typed fact log 接线;
63
+ * session 事实须通过 typed parser outcome + seat availability/freshness contract,
64
+ * 列表不含票价;
65
+ * 不新增 RailFact 渲染器,真实供应商可售性仍需独立证据。 */
66
+ export interface ExtractedRailClaim extends RailClaim {
67
+ line: number
68
+ text: string
69
+ }
70
+
52
71
  export interface ExtractedPolicyClaim {
53
72
  line: number
54
73
  text: string
@@ -74,6 +93,7 @@ export interface ExtractedHotelClaim {
74
93
 
75
94
  export interface ExtractedClaims {
76
95
  flights: ExtractedFlightClaim[]
96
+ trains: ExtractedRailClaim[]
77
97
  policies: ExtractedPolicyClaim[]
78
98
  airports: ExtractedAirportClaim[]
79
99
  /** 含「直飞」断言且可解析航线的行(通用直飞规则:该日无任何在架直飞即违例) */
@@ -83,8 +103,14 @@ export interface ExtractedClaims {
83
103
  anchors: Map<number, string>
84
104
  }
85
105
 
86
- /** 航班号:2 位承运码 + 3-4 位数字(UO784/EK328/MF1538/CZ8582/9C8781/FD597) */
106
+ /** 航班号:2 位承运码 + 3-4 位数字(UO784/EK328/MF1538/CZ8582/9C8781/FD597)
107
+ * 中文高铁/动车/城际/直达代码(G/D/C/Z + 3–4 位)由 `TRAIN_NO` 单独收集,
108
+ * 不进入航班 claim,issue #299。 */
87
109
  const FLIGHT_NO = /(?<![A-Za-z0-9])([A-Z0-9]{2}\d{3,4})(?![\d])/g
110
+ /** 车次号:中文 G/D/C/Z + 3-4 位数字(G1234/D3112/C2001/Z1/Z9999)。issue #299:
111
+ * 闸侧 train claim 集合与航班 claim 完全分离,`flightClaimVerdict` 不再吞 train
112
+ * 事实,`railClaimVerdict` 只在 train 事实里查同号回溯。 */
113
+ const TRAIN_NO = /(?<![A-Za-z0-9])([GDCZ]\d{3,4})(?![A-Za-z0-9])/g
88
114
  /** 承运级直飞断言:「8L 直飞」「FZ 直飞香港」——无航班号的航线存在性断言 */
89
115
  const CARRIER_DIRECT = /(?<![A-Za-z0-9])([A-Z0-9]{2})(?!\d)(?:\s|[一-龥]){0,6}直飞/g
90
116
  /** 中文承运名 → 二字码(承运级断言覆盖;只列闸需要的常见出境承运) */
@@ -92,11 +118,34 @@ const CARRIER_ZH: Record<string, string> = {
92
118
  东航: 'MU', 南航: 'CZ', 国航: 'CA', 厦航: 'MF', 春秋: '9C', 祥鹏: '8L',
93
119
  亚航: 'FD', 越捷: 'VZ', 国泰: 'CX', 港航: 'HX', 香港快运: 'UO', 阿联酋: 'EK', 泰航: 'TG',
94
120
  }
95
- const POLICY_WORD = /免签|落地签|签证|入境申报|过境免/
121
+ /** Finite hotel-category vocabulary shared by heading activation and row recognition (issue #301). */
122
+ const LODGING_VOCAB_RE = /酒店|住宿|客栈|民宿|度假村|青旅|青年旅舍|别墅|公寓/
123
+ /** Markdown ATX heading level 1–6 (根实测 #347:5/6 级 lodging heading 必须激活) */
124
+ const HEADING_ATX = /^(#{1,6})\s+(.+)$/
125
+ /** 「政策」关键词(issue #273 父 + 子 #302):覆盖签证/免签/落地签/海关/过境五大类 + 子 #302 显式列举的政策词有限并集;
126
+ * 海关申报 与 入境申报 同性质但被原 regex 漏掉,补一个 demonstrative miss。
127
+ * 子 #302 扩词边界:仅 EVUS/ETA/eVisa/疫苗/疫苗接种/健康申报/隔离/工作签/居留/返程签/护照有效期/黄皮书/保险;拉丁 token
128
+ * 大小写不敏感但不嵌入更长 Latin 词(REVUS ≠ EVUS);不做 NLP/同义词/任意政策词表扩展。
129
+ * 子 #381(D-26 R1)扩词:入境章/落地章/出境章/入境卡/出境卡/出入境卡——与落地签同族的
130
+ * 移民章/卡六词,根反例「普吉岛入境章费用 800元」原全零抽取静默过闸,缺「截至」按既有
131
+ * policy_without_as_of 显式拒绝。仍为有限机械词表,可列举扩充、不做语义模型。 */
132
+ const POLICY_WORD = /免签|落地签|入境章|落地章|出境章|入境卡|出境卡|出入境卡|签证|入境申报|海关申报|过境免|疫苗|疫苗接种|健康申报|隔离|工作签|居留|返程签|护照有效期|黄皮书|保险|(?<![A-Za-z])(?:EVUS|ETA|eVisa)(?![A-Za-z])/i
96
133
  /** as_of 必须是「截至 + 具体日期」——「现行 60 天」不算时间边界(issue #46 政策行) */
97
134
  const AS_OF_WORD = /截至\s*\d{4}[-/年]\d{1,2}|as[_ ]?of\s*\d{4}/i
98
135
  const CHECK_MARK = /[✓✅]/
99
136
 
137
+ /** 交易承诺短语有限并集(D-26 R1,issue #381):费用/收费/押金/支付/缴费类下单措辞。
138
+ * 与「词表外金额写法」同一行同现、且该行经全部既有抽取原语后零 claim 时,按既有
139
+ * unverified_price_claim fail-closed。可列举扩充,不做 NLP/同义词/语义模型(#302 同款边界);
140
+ * 可订性短语(有房/可订)不在其中——由 §10/§12 酒店闸负责,其「裸短语不入闸」边界
141
+ * (#301/#347)保持不变。 */
142
+ const COMMITMENT_TRANSACTION_RE = /费用|收费|手续费|服务费|签证费|通行费|通关费|小费|押金|定金|订金|机场税|港口税|支付|付款|缴款|缴费|现付|到付|付现|代订|代购/
143
+ /** 受支持币种词表外的金额写法(D-26 R1,issue #381):`hardPricesInLine` 只识别
144
+ * ¥/[A-Z]{3} 前缀硬价;CJK 币种后缀({N}元/泰铢/美元…)与 $/$ 前缀是其覆盖不了的
145
+ * 下单金额写法(根反例「800元」)。¥/ISO 前缀金额不在其中——零 claim 行的
146
+ * ¥/ISO 金额(预算/约价口径)属 R2 价格容忍度 founder 决策边界,不由本网拒绝。 */
147
+ const OFF_VOCAB_AMOUNT_RE = /(?:\d[\d,]*(?:\.\d+)?\s*(?:元|美元|美金|泰铢|铢|日元|日圆|港币|港元|欧元|卢比|比索|林吉特|印尼盾|迪拉姆|里亚尔))|(?:[$$]\s*\d[\d,]*(?:\.\d+)?)/
148
+
100
149
  /** 「直飞」断言(排除否定前置:无/不/没有/未见直飞;函数实现避开变宽 lookbehind 引擎差异) */
101
150
  function hasDirectAssertion(line: string): boolean {
102
151
  const idx = line.indexOf('直飞')
@@ -105,7 +154,7 @@ function hasDirectAssertion(line: string): boolean {
105
154
  return !(before.endsWith('无') || before.endsWith('不') || before.endsWith('没有') || before.endsWith('未见'))
106
155
  }
107
156
 
108
- interface SectionCtx { origin?: string; destination?: string; date?: string }
157
+ interface SectionCtx { origin?: string; destination?: string; date?: string; /** 当前行是否在 lodging heading 上下文中(issue #301 无 token 行入闸) */ lodging?: boolean; /** 活动 lodging heading 的 ATX 层级栈(浅→深);同/更高级 non-lodging heading 弹出栈顶 */ lodgingDepths?: number[] }
109
158
 
110
159
  /** 行内第一个命中词表的城市中文名(酒店 claim 的目的地键;无命中=undefined) */
111
160
  function occ1(line: string, map: AirlineAirportMap): string | undefined {
@@ -161,7 +210,7 @@ function routeCtxOf(text: string, map: AirlineAirportMap, defaultYear?: number):
161
210
  * 行内自带上下文优先。默认年份由 trip window 提供(远期行程的年份不含糊)。
162
211
  */
163
212
  export function extractClaims(markdown: string, map: AirlineAirportMap, opts?: { trip_year?: number }): ExtractedClaims {
164
- const claims: ExtractedClaims = { flights: [], policies: [], airports: [], direct_lines: [], hotels: [], anchors: new Map() }
213
+ const claims: ExtractedClaims = { flights: [], trains: [], policies: [], airports: [], direct_lines: [], hotels: [], anchors: new Map() }
165
214
  const lines = markdown.split('\n')
166
215
  let section: SectionCtx = {}
167
216
  // 渲染原语锚点(单向生成):带 fact:<id> 的行只走锚点确定性回溯,启发式抽取让位
@@ -174,9 +223,27 @@ export function extractClaims(markdown: string, map: AirlineAirportMap, opts?: {
174
223
  claims.anchors.set(lineNo, anchorMatch[1]!.toLowerCase())
175
224
  continue
176
225
  }
177
- if (/^#{1,4}\s/.test(line)) {
226
+ if (HEADING_ATX.test(line)) {
178
227
  const ctx = routeCtxOf(line, map, opts?.trip_year)
179
- section = { origin: ctx.origin, destination: ctx.destination, date: ctx.date ?? section.date }
228
+ // lodging heading 上下文栈(issue #301 真实反例:无 token 行入闸):
229
+ // - lodging heading:在该层级压栈(更深栈顶替代外层语义,栈整体保留)
230
+ // - 同级/更高级 non-lodging heading:弹栈直到空
231
+ // - 更深的 non-lodging heading:继承栈
232
+ const headingMatch = line.match(HEADING_ATX)!
233
+ const headingLevel = headingMatch[1]!.length
234
+ const headingText = headingMatch[2]!
235
+ const isLodgingHeading = LODGING_VOCAB_RE.test(headingText)
236
+ const depths = (section.lodgingDepths ?? []).slice()
237
+ if (isLodgingHeading) {
238
+ // 同级 lodging heading 替换该层栈,更深的栈项保留
239
+ while (depths.length > 0 && depths[depths.length - 1]! >= headingLevel) depths.pop()
240
+ depths.push(headingLevel)
241
+ } else {
242
+ // 同级/更高级 non-lodging heading 弹出栈顶直至更浅或空
243
+ while (depths.length > 0 && depths[depths.length - 1]! >= headingLevel) depths.pop()
244
+ }
245
+ const lodging = depths.length > 0
246
+ section = { origin: ctx.origin, destination: ctx.destination, date: ctx.date ?? section.date, lodging, lodgingDepths: depths }
180
247
  continue
181
248
  }
182
249
  const own = routeCtxOf(line, map, opts?.trip_year)
@@ -184,8 +251,18 @@ export function extractClaims(markdown: string, map: AirlineAirportMap, opts?: {
184
251
  const destination = own.destination ?? section.destination
185
252
  const date = own.date ?? section.date
186
253
 
254
+ const trainNos = new Set([...line.matchAll(TRAIN_NO)].map(m => m[1]!.toUpperCase()))
255
+ for (const trainNo of trainNos) {
256
+ claims.trains.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: trainNo, origin, destination, date })
257
+ }
187
258
  for (const m of line.matchAll(FLIGHT_NO)) {
188
- claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: m[1]!.toUpperCase(), origin, destination, date })
259
+ const code = m[1]!.toUpperCase()
260
+ // 闸侧 train/flight 分离:中文高铁/动车/城际/直达前缀不得进入航班 claim。
261
+ // FLIGHT_NO 也会把 G1234 这类单字母车次整体匹配出来;只跳过同一行
262
+ // TRAIN_NO 实际命中的完整 token。不能按首字母跳过,否则合法双字母航司
263
+ // CZ8582 会被误删。
264
+ if (trainNos.has(code)) continue
265
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: code, origin, destination, date })
189
266
  }
190
267
  for (const m of line.matchAll(CARRIER_DIRECT)) {
191
268
  const carrier = m[1]!.toUpperCase()
@@ -207,10 +284,26 @@ export function extractClaims(markdown: string, map: AirlineAirportMap, opts?: {
207
284
  if (POLICY_WORD.test(line)) {
208
285
  claims.policies.push({ line: lineNo, text: line.trim().slice(0, 160), has_as_of: AS_OF_WORD.test(line) })
209
286
  }
210
- // 酒店 claim(D-26):断言可住性(有房/可订/已核验/✓)的行——目的地与档期缺失时 fail-closed
211
- const HOTEL_ASSERT = /酒店|住宿|客栈|民宿/
287
+ // 酒店 claim(D-26 + 子 #301 扩词):断言可住性(有房/可订/已核验/✓)的行——目的地与档期缺失时 fail-closed
288
+ // #273 已有 酒店|住宿|客栈|民宿;子 #301 扩词覆盖 度假村/青旅/青年旅舍/别墅/公寓 五类非关键词住宿
289
+ // category(精品酒店 由 酒店 承接,不重复声明);lodging heading(§住宿/§酒店/§青旅 等,ATX 1–6)下完全无住宿类
290
+ // token 的可住断言也由 section.lodging 升为 hotel claim。行内匹配分两路:
291
+ // (a) 行内含 LODGING_VOCAB_RE 词表 token → 需 HOTEL_BOOKABLE 短语/标记
292
+ // (b) 行内无 token 但 lodging context 激活 → 需 HOTEL_BOOKABILITY 短语(有房/可订/.../可住),
293
+ // 单独的 ✓/✅ 不构成 bookability(例:`靠近地铁 ✓` 在 §住宿 下不是可订断言)。
294
+ // 检测到可订短语且带 ✓,未核验时再加 unconditional_check。
295
+ // 同级/更高级 non-lodging heading 退出上下文,更深 heading 继承;lodging depth 栈保留外层活动层级。
296
+ // 行内/heading 词汇同源 = LODGING_VOCAB_RE,不可漂移。无住宿类关键词仍走兜底 →
297
+ // unverifiable_hotel_claim(§10f 既有证据)。route/date section context 对航班逻辑不动;
298
+ // anchored 行由 `if (claims.anchors.has(c.line)) continue` 提前让位。
299
+ const HOTEL_BOOKABILITY = /有房|可订|在架|已核验|已验证|可住/
212
300
  const HOTEL_BOOKABLE = /有房|可订|在架|已核验|已验证|可住|✓|✅/
213
- if (HOTEL_ASSERT.test(line) && HOTEL_BOOKABLE.test(line)) {
301
+ const hasCategoryToken = LODGING_VOCAB_RE.test(line)
302
+ const inLodgingSection = !!section.lodging
303
+ const isBookabilityClaim = hasCategoryToken
304
+ ? HOTEL_BOOKABLE.test(line)
305
+ : inLodgingSection && HOTEL_BOOKABILITY.test(line)
306
+ if (isBookabilityClaim) {
214
307
  const stays = [...line.matchAll(/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/g)].map(m => m[1]!)
215
308
  claims.hotels.push({
216
309
  line: lineNo,
@@ -249,7 +342,10 @@ export type GateViolationKind =
249
342
  | 'budget_floor_inconsistent' // 预算下限话术低于分项最低合计
250
343
  | 'date_order' // 住宿/段日期越窗或倒挂
251
344
  | 'unverifiable_hotel_claim' // 酒店可住断言无 exact-date 事实回溯(D-26)
345
+ | 'rail_claim_unverified' // 车次 claim 无 train exact-date 事实回溯(issue #299)
252
346
  | 'fact_anchor_unknown' // 渲染锚点 fact:<id> 在注册表不存在(锚点被手改/伪造)
347
+ | 'unverified_price_claim' // 硬价缺少可比较的权威来源或可靠绑定(issue #300)
348
+ | 'price_contradicted' // 行内可靠硬价格与 exact-date 事实价格冲突(issue #300)
253
349
 
254
350
  export interface GateViolation {
255
351
  kind: GateViolationKind
@@ -275,11 +371,228 @@ function lineTimesContradict(text: string, dep?: string, arr?: string): boolean
275
371
  return !times.some(t => t === dep || t === arr)
276
372
  }
277
373
 
374
+ /** Renderer 固定的 reminder 短语正则(只此一句,日期为合法 ISO 阳历日)。
375
+ * 与 `policyCanonicalBody` 同源;闸侧只识别这一固定短语+位置(尾随在
376
+ * `[<source>@<fetched_at> #<query_id>]` 锚点块之前、收尾 `<!-- fact:<id> -->` 之前)。
377
+ * 用于 no-context 兼容性回退:从此正则抽日期,重建 `{...f, review_by: date}` 经
378
+ * renderer 还原整行,byte-for-byte 比对——不再手动拆 body/provenance。 */
379
+ const POLICY_REMINDER_PHRASE_RE = /;远期政策须复核——到 (\d{4}-\d{2}-\d{2}) 再核验一次/
380
+
381
+ interface HardPrice {
382
+ amount: number
383
+ currency: string
384
+ token: string
385
+ start: number
386
+ }
387
+
388
+ /**
389
+ * 只抽取行内可可靠绑定的硬价。起价/约价/模糊值不进入比较,不支持的币种
390
+ * 只进入未核验分支;不做汇率换算,也不把酒店 priceRaw 带入这里(issue #300)。
391
+ */
392
+ function hardPricesInLine(text: string): HardPrice[] {
393
+ const prices: HardPrice[] = []
394
+ const amount = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`
395
+ const pattern = new RegExp(String.raw`(?:¥\s*(${amount})(?![\dA-Za-z,])|\b([A-Z]{3})\s*(${amount})(?![\dA-Za-z,]))`, 'gi')
396
+ for (const match of text.matchAll(pattern)) {
397
+ const amountText = match[1] ?? match[3]
398
+ if (!amountText || match.index === undefined) continue
399
+ const before = text.slice(Math.max(0, match.index - 8), match.index)
400
+ const after = text.slice(match.index + match[0].length, match.index + match[0].length + 8)
401
+ if (/(?:约|大约|约为|起价|起步|from)\s*$/i.test(before)
402
+ || /^\s*(?:起|起价|起步|左右|上下|以上|\+)/i.test(after)) continue
403
+ prices.push({
404
+ amount: Number(amountText.replaceAll(',', '')),
405
+ currency: match[1] ? 'CNY' : match[2]!.toUpperCase(),
406
+ token: amountText,
407
+ start: match.index,
408
+ })
409
+ }
410
+ return prices
411
+ }
412
+
413
+ interface MalformedHardPrice {
414
+ currency: string
415
+ token: string
416
+ start: number
417
+ }
418
+
419
+ /**
420
+ * 先识别完整 token 之外的畸形逗号数字;¥7xx 等既有非数字/模糊值不在此列。
421
+ */
422
+ function malformedHardPricesInLine(text: string): MalformedHardPrice[] {
423
+ const amount = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`
424
+ const token = String.raw`\d[\d,]*(?:\.\d+)?`
425
+ const pattern = new RegExp(String.raw`(?:¥\s*|\b([A-Z]{3})\s*)(${token})(?![\dA-Za-z])`, 'gi')
426
+ const malformed: MalformedHardPrice[] = []
427
+ for (const match of text.matchAll(pattern)) {
428
+ const raw = match[2]
429
+ if (!raw || !raw.includes(',') || new RegExp(`^(?:${amount})$`).test(raw)) continue
430
+ malformed.push({ currency: match[1] ? match[1].toUpperCase() : 'CNY', token: raw, start: match.index ?? 0 })
431
+ }
432
+ return malformed
433
+ }
434
+
435
+ interface MoneyField {
436
+ text: string
437
+ offset: number
438
+ index: number
439
+ }
440
+
441
+ interface AmbiguousHardPrice {
442
+ currency: string
443
+ token: string
444
+ start: number
445
+ }
446
+
447
+ interface FareRoleAnalysis {
448
+ prices: HardPrice[]
449
+ malformed: MalformedHardPrice[]
450
+ ambiguous: AmbiguousHardPrice[]
451
+ }
452
+
453
+ function moneyFields(text: string): MoneyField[] {
454
+ const fields = text.split(/[;;|,]/)
455
+ const scopes: MoneyField[] = []
456
+ let offset = 0
457
+ for (const [index, field] of fields.entries()) {
458
+ scopes.push({ text: field, offset, index })
459
+ offset += field.length + 1
460
+ }
461
+ return scopes
462
+ }
463
+
464
+ const EXPLICIT_FARE_LABEL = /(?:票价|机票价|fare|price)\s*[::]?\s*$/i
465
+ const PREVIOUS_MONEY_TOKEN = /(?:¥\s*|\b[A-Z]{3}\s*)\d[\d,]*(?:\.\d+)?\s*$/i
466
+
467
+ /**
468
+ * 分类 money role,而不是把「同一行」当成 fare:
469
+ * canonical 是第一个 money token 所在的首字段;后续 token 只有显式
470
+ * fare label 才可比。其它有文字标签的字段忽略;裸 token 或前面只是
471
+ * 另一个金额的 token 无可靠归属,必须 fail closed。
472
+ */
473
+ function analyzeFareRoles(text: string): FareRoleAnalysis {
474
+ const analysis: FareRoleAnalysis = { prices: [], malformed: [], ambiguous: [] }
475
+ for (const field of moneyFields(text)) {
476
+ const prices = hardPricesInLine(field.text)
477
+ const malformed = malformedHardPricesInLine(field.text)
478
+ const tokens = [
479
+ ...prices.map(price => ({ kind: 'complete' as const, start: price.start, price, malformed: undefined })),
480
+ ...malformed.map(item => ({ kind: 'malformed' as const, start: item.start, price: undefined, malformed: item })),
481
+ ].sort((a, b) => a.start - b.start)
482
+ for (const [tokenIndex, token] of tokens.entries()) {
483
+ const prefix = field.text.slice(0, token.start).trim()
484
+ const canonical = field.index === 0 && tokenIndex === 0
485
+ const explicitFare = EXPLICIT_FARE_LABEL.test(prefix)
486
+ const reliableFare = canonical || explicitFare
487
+ if (reliableFare) {
488
+ if (token.price) analysis.prices.push({ ...token.price, start: token.price.start + field.offset })
489
+ if (token.malformed) analysis.malformed.push({ ...token.malformed, start: token.malformed.start + field.offset })
490
+ continue
491
+ }
492
+ // A bare token, or a token immediately following another money token,
493
+ // is ambiguous; any other textual prefix accounts for a non-fare field.
494
+ if (prefix.length === 0 || PREVIOUS_MONEY_TOKEN.test(prefix)) {
495
+ const currency = token.price?.currency ?? token.malformed?.currency ?? 'CNY'
496
+ const raw = token.price?.token ?? token.malformed?.token ?? ''
497
+ analysis.ambiguous.push({ currency, token: raw, start: token.start + field.offset })
498
+ }
499
+ }
500
+ }
501
+ return analysis
502
+ }
503
+
504
+ function flightTrainPriceScope(text: string, fact: Extract<BookableFact, { kind: 'flight' | 'train' }>): string {
505
+ const no = fact.flight_no.toUpperCase()
506
+ const noStart = text.toUpperCase().indexOf(no)
507
+ if (noStart < 0) return ''
508
+ const afterNo = text.slice(noStart + no.length)
509
+ const nextFlight = afterNo.match(FLIGHT_NO)
510
+ const anchor = text.indexOf('<!-- fact:', noStart + no.length)
511
+ const evidence = afterNo.search(/\[[^\]\n]*#[^\]\n]*\]/)
512
+ let end = text.length
513
+ if (nextFlight?.index !== undefined) end = Math.min(end, noStart + no.length + nextFlight.index)
514
+ // The rendered fare is before its evidence chain; amounts after it are
515
+ // unrelated fees/budget text even when an anchor follows later on the line.
516
+ if (evidence >= 0) end = Math.min(end, noStart + no.length + evidence)
517
+ if (anchor >= 0) end = Math.min(end, anchor)
518
+ return text.slice(noStart, end)
519
+ }
520
+
521
+ function addPriceVerification(
522
+ violations: GateViolation[],
523
+ line: number,
524
+ fullText: string,
525
+ visibleText: string,
526
+ fact: Extract<BookableFact, { kind: 'flight' | 'train' }>,
527
+ ): boolean {
528
+ const fullScope = flightTrainPriceScope(fullText, fact)
529
+ const visibleScope = flightTrainPriceScope(visibleText, fact)
530
+ const fullAnalysis = analyzeFareRoles(fullScope)
531
+ const visibleAnalysis = analyzeFareRoles(visibleScope)
532
+ const allPrices = fullAnalysis.prices
533
+ const visiblePrices = visibleAnalysis.prices
534
+ const hasAnchor = fullText.includes('<!-- fact:')
535
+
536
+ if (fullAnalysis.malformed.length > 0 || fullAnalysis.ambiguous.length > 0) {
537
+ const malformed = fullAnalysis.malformed[0]
538
+ const ambiguous = fullAnalysis.ambiguous[0]
539
+ violations.push({
540
+ kind: 'unverified_price_claim',
541
+ line,
542
+ detail: malformed
543
+ ? `${fact.flight_no} fare role 含畸形硬价格 ${malformed.currency} ${malformed.token}——数字分组不完整,按未核验处理,不得截断或当作完整票价比较`
544
+ : `${fact.flight_no} 行内硬价格 ${ambiguous?.currency ?? 'CNY'} ${ambiguous?.token ?? ''} 无可靠 fare role 归属——按未核验处理,不得把裸金额当作 exact-date 票价`,
545
+ })
546
+ return true
547
+ }
548
+
549
+ // Heuristic claims retain the existing 120-character extraction window. If a
550
+ // hard price only appears outside it, it cannot be safely bound to this claim.
551
+ if (!hasAnchor && allPrices.some(p => p.start >= visibleScope.length)) {
552
+ violations.push({
553
+ kind: 'unverified_price_claim',
554
+ line,
555
+ detail: `${fact.flight_no} 行内硬价格未锚定且超出 120 字抽取窗口——无法作为 exact-date 事实核验,按未核验处理`,
556
+ })
557
+ return true
558
+ }
559
+
560
+ if (allPrices.length === 0) return false
561
+ const factCurrency = fact.currency?.toUpperCase()
562
+ for (const rendered of visiblePrices) {
563
+ if (fact.price === undefined || !Number.isFinite(fact.price) || factCurrency !== 'CNY') {
564
+ violations.push({
565
+ kind: 'unverified_price_claim',
566
+ line,
567
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} 缺少可比较的权威 exact-date 事实价格——按未核验处理,不作汇率或币种猜测`,
568
+ })
569
+ return true
570
+ }
571
+ // ¥/CNY only compare against a CNY fact. No FX or ambiguous-currency guess.
572
+ if (rendered.currency !== factCurrency) {
573
+ violations.push({
574
+ kind: 'unverified_price_claim',
575
+ line,
576
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} 不支持直接比较事实 ${factCurrency} ${fact.price}——按未核验处理,不作汇率换算`,
577
+ })
578
+ return true
579
+ }
580
+ if (rendered.amount === fact.price) continue
581
+ violations.push({
582
+ kind: 'price_contradicted',
583
+ line,
584
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} ≠ exact-date 事实 ${factCurrency} ${fact.price}——价格事实矛盾,不得改写工具返回价格`,
585
+ })
586
+ return true
587
+ }
588
+ return false
589
+ }
590
+
278
591
  export function gateArtifact(
279
592
  markdown: string,
280
593
  facts: BookableFact[],
281
594
  map: AirlineAirportMap,
282
- opts?: { trip_year?: number; itinerary?: ItineraryFacts },
595
+ opts?: { trip_year?: number; itinerary?: ItineraryFacts; tripStart?: string },
283
596
  ): GateReport {
284
597
  const claims = extractClaims(markdown, map, opts)
285
598
  const violations: GateViolation[] = []
@@ -287,16 +600,196 @@ export function gateArtifact(
287
600
 
288
601
  // 渲染锚点优先(issue #118 单向生成):带 fact:<id> 的行确定性回溯注册表——
289
602
  // 锚点在=按事实 bookability 判;锚点不存在=手改/伪造,直接违例。启发式对锚点行让位。
603
+ const lines = markdown.split('\n')
290
604
  for (const [lineNo, factId] of claims.anchors) {
291
605
  const f = facts.find(x => x.fact_id === factId)
292
606
  if (!f) {
293
607
  violations.push({ kind: 'fact_anchor_unknown', line: lineNo, detail: `渲染锚点 fact:${factId} 不在事实注册表——锚点被手改或伪造,产物不可信` })
294
608
  continue
295
609
  }
610
+ if (f.kind === 'train') {
611
+ const rendered = lines[lineNo - 1] ?? ''
612
+ const canonical = renderFlightFact(f)
613
+ if (rendered.trim() !== canonical.trim()) {
614
+ violations.push({
615
+ kind: 'fact_anchor_unknown',
616
+ line: lineNo,
617
+ detail: `train 锚点行与 canonical renderer 不一致——车次/时刻/日期/route 可能被手改;期望 ${canonical}`,
618
+ })
619
+ continue
620
+ }
621
+ const rail = railClaimVerdict(facts, {
622
+ flight_no: f.flight_no,
623
+ origin: f.route.origin,
624
+ destination: f.route.destination,
625
+ date: f.date,
626
+ })
627
+ if (rail.verdict !== 'traceable') {
628
+ violations.push({ kind: 'rail_claim_unverified', line: lineNo, detail: `${f.flight_no}: 锚点未通过 train exact-date 回溯(${rail.verdict})——${rail.reason}` })
629
+ continue
630
+ }
631
+ }
632
+ // 航班锚点行字段指纹(issue #363,D-26 残余收口):锚点存在 ≠ 可见
633
+ // 内容与事实一致——flight_no 是「锚点 + 渲染原语」之间唯一可手改的
634
+ // 关键字段,保留 fact_id/anchor/registry 而改 flight_no(UO724→UO999)
635
+ // 仍会被「同号回溯」路径误判为 traceable,根反例验收正是此形态。
636
+ // 闸侧改用字段指纹:从渲染行重新抽 flight_no token,必须严格 ==
637
+ // 事实 flight_no。同一字段不引入 whole-line equality;承运/时刻/route/
638
+ // 日期/价格/打码段的兼容(§4b + §9 + §10 + §11)继续由既有判定
639
+ // 原语负责,不被新指纹吞并。同一行 trip-test 不命中即 fail-closed。
640
+ if (f.kind === 'flight') {
641
+ const rendered = lines[lineNo - 1] ?? ''
642
+ const canonicalFlightNo = f.flight_no.toUpperCase()
643
+ const renderedFlightNo = (() => {
644
+ for (const m of rendered.matchAll(FLIGHT_NO)) {
645
+ const code = m[1]?.toUpperCase()
646
+ if (code) return code
647
+ }
648
+ return undefined
649
+ })()
650
+ if (!renderedFlightNo || renderedFlightNo !== canonicalFlightNo) {
651
+ violations.push({
652
+ kind: 'fact_anchor_unknown',
653
+ line: lineNo,
654
+ detail: `flight 锚点行 flight_no 字段指纹不符——锚点存在但 visible flight_no 与事实 ${canonicalFlightNo} 不一致(渲染抽到 ${renderedFlightNo ?? '∅'});保留 fact_id/registry/anchor 改写可见航班号不被允许,期望行内含 ${canonicalFlightNo}`,
655
+ })
656
+ continue
657
+ }
658
+ }
659
+ // 酒店锚点行字段指纹(issue #363,D-26 残余收口):destination + 档期
660
+ // 是「锚点 + 渲染原语」之间可手改的关键字段;保留 fact_id/anchor/
661
+ // registry 改写 目的地(大理→丽江)或 档期(2026-10-01→2026-12-25)
662
+ // 仍会被 hotelClaimVerdict 的同目的地同档期回溯判为 traceable,
663
+ // 根反例验收正是此形态。闸侧改为字段指纹:渲染行必须严格包含
664
+ // 事实的 destination(精确子串)+ check_in(精确子串)+ check_out
665
+ // (精确子串)三项;任一缺失或不一致 → fact_anchor_unknown fail-closed。
666
+ // 不引入 whole-line equality;价格打码/承运/时刻/政策的兼容形态
667
+ // 继续由既有判定原语负责。打码语义(options_masked = 9)与酒店事实
668
+ // 无 price 字段不变。
669
+ if (f.kind === 'hotel') {
670
+ const rendered = lines[lineNo - 1] ?? ''
671
+ const expectedDestination = f.destination
672
+ const expectedCheckIn = f.check_in
673
+ const expectedCheckOut = f.check_out
674
+ const missingDestination = !!expectedDestination && !rendered.includes(expectedDestination)
675
+ const missingCheckIn = !!expectedCheckIn && !rendered.includes(expectedCheckIn)
676
+ const missingCheckOut = !!expectedCheckOut && !rendered.includes(expectedCheckOut)
677
+ if (missingDestination || missingCheckIn || missingCheckOut) {
678
+ violations.push({
679
+ kind: 'fact_anchor_unknown',
680
+ line: lineNo,
681
+ detail: `hotel 锚点行字段指纹不符——锚点存在但 visible destination=${expectedDestination ?? '∅'}/check_in=${expectedCheckIn ?? '∅'}/check_out=${expectedCheckOut ?? '∅'} 与事实字段不严格一致(渲染行=${JSON.stringify(rendered)});保留 fact_id/registry/anchor 改写可见目的地或档期不被允许`,
682
+ })
683
+ continue
684
+ }
685
+ }
296
686
  if (f.kind !== 'policy' && f.bookability === 'unavailable_exact_date') {
297
687
  violations.push({ kind: 'not_in_source', line: lineNo, detail: `锚点事实为 exact-date 负事实(${(f as { fetched_at?: string }).fetched_at ?? ''})——负事实对应的可住/可订断言不得出现` })
298
688
  continue
299
689
  }
690
+ // 政策行内容指纹(issue #273 + #359,D-26 残余收口):
691
+ // 整行 = canonical body + ` <!-- fact:<id> -->`,单一权威面与 renderPolicyFact
692
+ // 共享 `policyCanonicalBody` 构造器;subject/statement/source/fetched_at/
693
+ // query_id/as_of 任一改动 → 行文本不再等于 canonical → fact_anchor_unknown
694
+ // fail-closed。锚点行不得含前导非空文本(允许锚点前的尾随空白)、第二个
695
+ // `<!-- fact:` 锚点、或任何「借合法锚点却写相反政策」的借用形态;行尾只允许
696
+ // `<!-- fact:<id> -->` 收尾。
697
+ //
698
+ // 兼容性回退(legacy public callers):当调用面未传 `opts.tripStart` 且未在
699
+ // itinerary 给出 `trip_start` 时,闸侧额外接受 renderer 产出的两个 canonical
700
+ // 形态之一(no-reminder 与 renderer-fixed 提醒 `;远期政策须复核——
701
+ // 到 YYYY-MM-DD 再核验一次`,日期为合法 ISO 阳历日)。这是 renderer 固定
702
+ // 短语+位置,不是任意 body 改写或后缀。该回退只匹配 renderer 自身输出,
703
+ // 不重新拼装、不猜 tripStart、不动 fact_id;有 `opts.tripStart` 或
704
+ // `itinerary.trip_start` 时优先严格比对对应 reminder,不退到回退集合。
705
+ if (f.kind === 'policy') {
706
+ const rendered = lines[lineNo - 1] ?? ''
707
+ // Precedence(issue #359 direct-API contract):explicit `opts.tripStart` →
708
+ // `opts.itinerary.trip_start` → undefined。policy 分支渲染上下文相关
709
+ // canonical 行(严格比对 + 结构失败诊断)全部走同一个
710
+ // `effectiveTripStart`,不再要求调用方在 `opts.tripStart` 与
711
+ // `itinerary.trip_start` 之间复制同一天。原来由 `index.ts` 桥接
712
+ // itinerary → tripStart 的注册路径仍然兼容(显式 tripStart 优先,语义不变)。
713
+ const explicitTripStart = typeof opts?.tripStart === 'string' && opts.tripStart.length > 0 ? opts.tripStart : undefined
714
+ const itineraryTripStart = typeof opts?.itinerary?.trip_start === 'string' && opts.itinerary.trip_start.length > 0
715
+ ? opts.itinerary!.trip_start
716
+ : undefined
717
+ const effectiveTripStart: string | undefined = explicitTripStart ?? itineraryTripStart
718
+ const anchorRe = /<!-- fact:([0-9a-f]{16}) -->/
719
+ const m = rendered.match(anchorRe)
720
+ const noSecondAnchor = !m || m.index === undefined
721
+ ? true
722
+ : !rendered.slice(0, m.index).includes('<!-- fact:')
723
+ const exactlyOneAnchor = m && m.index !== undefined && noSecondAnchor
724
+ const trailingNonEmpty = m && m.index !== undefined
725
+ ? /\S/.test(rendered.slice(m.index + m[0]!.length))
726
+ : true
727
+ const exactAnchorId = m && m.index !== undefined && m[1] === f.fact_id
728
+ const structureOk = exactlyOneAnchor && exactAnchorId && !trailingNonEmpty
729
+ if (!structureOk) {
730
+ const canonical = renderPolicyFact(f, effectiveTripStart)
731
+ violations.push({
732
+ kind: 'fact_anchor_unknown',
733
+ line: lineNo,
734
+ detail: `政策锚点行结构不合法——必须为 canonical body + 单一「 <!-- fact:<id> -->」且行尾无后置非空文本(检测:行=${JSON.stringify(rendered)},期望=${JSON.stringify(canonical)});手改/伪造锚点或借用合法锚点写相反政策均 fail-closed`,
735
+ })
736
+ continue
737
+ }
738
+ const hasExplicitTripContext = effectiveTripStart !== undefined
739
+ if (!hasExplicitTripContext) {
740
+ // 兼容性回退(legacy public callers):仅当事实无 `review_by` 时,接受
741
+ // renderer 产出的两个固定 canonical 形态——不手动拆 body/provenance。
742
+ // (a) no-reminder 行 —— 严格等于 `renderPolicyFact(f)`。
743
+ // (b) reminder 行 —— 从 renderer 固定短语里**只抽日期**,把日期塞回
744
+ // `{...f, review_by: date}` 经 renderer 重建整行,与原行 byte-for-byte
745
+ // 比对;同时校验日期是真实 ISO 阳历日。这样 subject/statement/source/
746
+ // fetched_at/query_id/as_of、reminder 位置/短语、锚点结构、provenance
747
+ // 块全都由 renderer 重新产出并比对,不再人工拆段。
748
+ // 若事实自带 `review_by`,renderer 唯一合法 reminder 日期就是 `f.review_by`;
749
+ // (a) 已涵盖合法 pass;(b) 走 `{...f, review_by: extractedDate}` 时若
750
+ // extractedDate ≠ `f.review_by` 必因重建 ≠ 原行而 fall through,fail-closed。
751
+ if (rendered === renderPolicyFact(f)) {
752
+ traceable++
753
+ continue
754
+ }
755
+ if (typeof f.review_by !== 'string') {
756
+ const remMatch = rendered.match(POLICY_REMINDER_PHRASE_RE)
757
+ if (remMatch) {
758
+ const isoDate = remMatch[1]!
759
+ const realIso = (() => {
760
+ const dm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate)
761
+ if (!dm) return false
762
+ const y = Number(dm[1]); const mo = Number(dm[2]); const da = Number(dm[3])
763
+ if (mo < 1 || mo > 12 || da < 1 || da > 31) return false
764
+ const test = new Date(Date.UTC(y, mo - 1, da))
765
+ return test.getUTCFullYear() === y && test.getUTCMonth() === mo - 1 && test.getUTCDate() === da
766
+ })()
767
+ if (realIso && rendered === renderPolicyFact({ ...f, review_by: isoDate })) {
768
+ traceable++
769
+ continue
770
+ }
771
+ }
772
+ }
773
+ }
774
+ // 即使 trip 上下文已知,renderer 不接 tripStart 时也会产出 no-reminder canonical;
775
+ // 该形态与 `renderPolicyFact(f)` 严格相等,在此处也必须合法(renderer 的
776
+ // tripStart argument 是 optional)。否则闸会把「renderer 选择不带 reminder」
777
+ // 误判成内容指纹不符,误伤合法事实行。
778
+ if (rendered === renderPolicyFact(f)) {
779
+ traceable++
780
+ continue
781
+ }
782
+ const canonical = renderPolicyFact(f, effectiveTripStart)
783
+ if (rendered !== canonical) {
784
+ violations.push({
785
+ kind: 'fact_anchor_unknown',
786
+ line: lineNo,
787
+ detail: `政策锚点行内容指纹不符——期望 ${canonical},实际 ${rendered};subject/statement/source/fetched_at/query_id/as_of 任一不一致均 fail-closed`,
788
+ })
789
+ continue
790
+ }
791
+ }
792
+ if ((f.kind === 'flight' || f.kind === 'train') && addPriceVerification(violations, lineNo, lines[lineNo - 1] ?? '', lines[lineNo - 1] ?? '', f)) continue
300
793
  traceable++
301
794
  }
302
795
 
@@ -305,6 +798,10 @@ export function gateArtifact(
305
798
  if (claims.anchors.has(c.line)) continue
306
799
  if (!c.destination) {
307
800
  violations.push({ kind: 'unverifiable_hotel_claim', line: c.line, detail: `酒店可住断言缺目的地上下文,无法回溯 exact-date 事实——fail closed 按未核验处理` })
801
+ // 无目的地时也检查无条件 ✓:对未核验住宿仍不应打确定性标记
802
+ if (CHECK_MARK.test(c.text)) {
803
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: '对未核验酒店使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记' })
804
+ }
308
805
  continue
309
806
  }
310
807
  const r = hotelClaimVerdict(facts, { destination: c.destination, check_in: c.check_in, check_out: c.check_out })
@@ -333,7 +830,7 @@ export function gateArtifact(
333
830
  && (!c.date || f.date === c.date))
334
831
  if (!hasCarrier) {
335
832
  const scoped = c.origin && c.destination && c.date
336
- ? latestFactsForRouteDate(facts, c.origin, c.destination, c.date)
833
+ ? latestFactsForRouteDate(facts, c.origin, c.destination, c.date, 'flight')
337
834
  : []
338
835
  violations.push({
339
836
  kind: scoped.length > 0 ? 'not_in_source' : 'carrier_direct_unverified',
@@ -356,6 +853,7 @@ export function gateArtifact(
356
853
  })
357
854
  continue
358
855
  }
856
+ if (r.fact && addPriceVerification(violations, c.line, lines[c.line - 1] ?? '', c.text, r.fact)) continue
359
857
  traceable++
360
858
  continue
361
859
  }
@@ -366,6 +864,31 @@ export function gateArtifact(
366
864
  }
367
865
  }
368
866
 
867
+ // 车次 claim 独立于航班 claim:同 route/date 的航班事实不能证明车次存在,
868
+ // 也不能把缺失车次写成通用 not_in_source。只有 train kind 的结构化
869
+ // exact-date 事实才可回溯;不存在 renderRailFact 或 static schedule 降级。
870
+ for (const c of claims.trains) {
871
+ if (claims.anchors.has(c.line)) continue
872
+ const r = railClaimVerdict(facts, c)
873
+ if (r.verdict === 'traceable') {
874
+ if (lineTimesContradict(c.text, r.fact?.dep_local, r.fact?.arr_local)) {
875
+ violations.push({
876
+ kind: 'rail_claim_unverified',
877
+ line: c.line,
878
+ detail: `${c.flight_no} 时刻与 train exact-date 快照不符(快照 ${r.fact?.dep_local}→${r.fact?.arr_local} ${r.fact?.date})——不得改写结构化车次事实`,
879
+ })
880
+ continue
881
+ }
882
+ if (r.fact && addPriceVerification(violations, c.line, lines[c.line - 1] ?? '', c.text, r.fact)) continue
883
+ traceable++
884
+ continue
885
+ }
886
+ violations.push({ kind: 'rail_claim_unverified', line: c.line, detail: `${c.flight_no}: ${r.verdict}——${r.reason}` })
887
+ if (CHECK_MARK.test(c.text)) {
888
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: `对未核验车次 ${c.flight_no} 使用无条件 ✓/✅——只有 train bookable_exact_date 才允许确定性标记` })
889
+ }
890
+ }
891
+
369
892
  // 通用直飞规则:该行未点名承运时,要求该 route+date 存在任一在架直飞;
370
893
  // 「该日可售仅有中转/0 条」却写直飞 = 无证据分支(issue #46「DMK→KMG 可考虑 8L 直飞」行同款)
371
894
  for (const d of claims.direct_lines) {
@@ -374,7 +897,7 @@ export function gateArtifact(
374
897
  violations.push({ kind: 'route_unqueried', line: d.line, detail: `直飞断言缺 route/date 上下文,无法回溯——fail closed 按未核验处理` })
375
898
  continue
376
899
  }
377
- const scoped = latestFactsForRouteDate(facts, d.origin, d.destination, d.date)
900
+ const scoped = latestFactsForRouteDate(facts, d.origin, d.destination, d.date, 'flight')
378
901
  if (scoped.length === 0) {
379
902
  violations.push({ kind: 'route_unqueried', line: d.line, detail: `${d.origin}→${d.destination} ${d.date} 的直飞断言从未经 exact-date 源核验` })
380
903
  continue
@@ -408,11 +931,34 @@ export function gateArtifact(
408
931
  }
409
932
  }
410
933
 
934
+ // 词表外下单措辞 fail-closed(D-26 R1,issue #381,接管 #273 公开跟踪):
935
+ // 根反例(main 6e85d36)「普吉岛入境章费用 800元,现场支付」全零抽取静默过闸。
936
+ // 兜底词网:仅当(1)该行经全部既有抽取原语(锚点/航班/车次/政策/酒店/机场映射/
937
+ // 直飞)后零 claim,且(2)行内同时命中交易承诺短语与词表外金额写法,才按既有
938
+ // unverified_price_claim 显式拒绝——不发明新违例通道。已有 claim 的行由各自
939
+ // 既有机制负责,词网不重复计违例;heading 行与抽取面一致不入网。
940
+ const claimedLines = new Set<number>(claims.anchors.keys())
941
+ for (const c of [...claims.flights, ...claims.trains, ...claims.policies, ...claims.hotels, ...claims.airports, ...claims.direct_lines]) claimedLines.add(c.line)
942
+ lines.forEach((line, idx) => {
943
+ const lineNo = idx + 1
944
+ if (claimedLines.has(lineNo) || HEADING_ATX.test(line)) return
945
+ if (COMMITMENT_TRANSACTION_RE.test(line) && OFF_VOCAB_AMOUNT_RE.test(line)) {
946
+ violations.push({
947
+ kind: 'unverified_price_claim',
948
+ line: lineNo,
949
+ detail: '词表外下单措辞 fail-closed:该行含交易承诺措辞且金额为受支持币种词表(¥/ISO 前缀)外的写法,无 exact-date 事实可回溯——按未核验价格处理,不得静默过闸;改用渲染原语锚点或附「截至 YYYY-MM-DD」可溯来源(D-26 R1,issue #381)',
950
+ })
951
+ }
952
+ })
953
+
411
954
  // 联程措辞:含「联程」的行,逐航班号查 protected_connection——
412
955
  // 只有全部航腿同票保护才允许称联程;分票/自助转机必须显式标红
413
956
  markdown.split('\n').forEach((line, i) => {
414
957
  if (!line.includes('联程')) return
415
- const nos = [...line.matchAll(FLIGHT_NO)].map(m => m[1]!.toUpperCase())
958
+ const trainNos = new Set([...line.matchAll(TRAIN_NO)].map(m => m[1]!.toUpperCase()))
959
+ const nos = [...line.matchAll(FLIGHT_NO)]
960
+ .map(m => m[1]!.toUpperCase())
961
+ .filter(no => !trainNos.has(no))
416
962
  const matched = facts.filter((f): f is Extract<BookableFact, { kind: 'flight' | 'train' }> =>
417
963
  (f.kind === 'flight' || f.kind === 'train') && nos.includes(f.flight_no.toUpperCase()))
418
964
  const allProtected = nos.length > 0
@@ -437,7 +983,7 @@ export function gateArtifact(
437
983
  return {
438
984
  schema: ARTIFACT_GATE_SCHEMA,
439
985
  verdict: violations.length > 0 ? 'blocked' : 'pass',
440
- claims_checked: claims.flights.length + claims.policies.length + claims.airports.length,
986
+ claims_checked: claims.flights.length + claims.trains.length + claims.policies.length + claims.airports.length,
441
987
  traceable,
442
988
  violations,
443
989
  presentation: violations.length > 0 ? 'verified_label_forbidden' : 'verified_itinerary_allowed',