@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
@@ -1,55 +1,93 @@
1
- import { flightClaimVerdict, hotelClaimVerdict, itineraryInvariants, latestFactsForRouteDate } from './bookable-facts.js';
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
+ import { flightClaimVerdict, hotelClaimVerdict, itineraryInvariants, latestFactsForRouteDate, railClaimVerdict, renderFlightFact, renderPolicyFact, } from './bookable-facts.js';
2
16
  export const ARTIFACT_GATE_SCHEMA = 'gotry_artifact_gate.v1';
17
+ /** 航班号:2 位承运码 + 3-4 位数字(UO784/EK328/MF1538/CZ8582/9C8781/FD597)。
18
+ * 中文高铁/动车/城际/直达代码(G/D/C/Z + 3–4 位)由 `TRAIN_NO` 单独收集,
19
+ * 不进入航班 claim,issue #299。 */
3
20
  const FLIGHT_NO = /(?<![A-Za-z0-9])([A-Z0-9]{2}\d{3,4})(?![\d])/g;
21
+ /** 车次号:中文 G/D/C/Z + 3-4 位数字(G1234/D3112/C2001/Z1/Z9999)。issue #299:
22
+ * 闸侧 train claim 集合与航班 claim 完全分离,`flightClaimVerdict` 不再吞 train
23
+ * 事实,`railClaimVerdict` 只在 train 事实里查同号回溯。 */
24
+ const TRAIN_NO = /(?<![A-Za-z0-9])([GDCZ]\d{3,4})(?![A-Za-z0-9])/g;
25
+ /** 承运级直飞断言:「8L 直飞」「FZ 直飞香港」——无航班号的航线存在性断言 */
4
26
  const CARRIER_DIRECT = /(?<![A-Za-z0-9])([A-Z0-9]{2})(?!\d)(?:\s|[一-龥]){0,6}直飞/g;
27
+ /** 中文承运名 → 二字码(承运级断言覆盖;只列闸需要的常见出境承运) */
5
28
  const CARRIER_ZH = {
6
- 东航: 'MU',
7
- 南航: 'CZ',
8
- 国航: 'CA',
9
- 厦航: 'MF',
10
- 春秋: '9C',
11
- 祥鹏: '8L',
12
- 亚航: 'FD',
13
- 越捷: 'VZ',
14
- 国泰: 'CX',
15
- 港航: 'HX',
16
- 香港快运: 'UO',
17
- 阿联酋: 'EK',
18
- 泰航: 'TG'
29
+ 东航: 'MU', 南航: 'CZ', 国航: 'CA', 厦航: 'MF', 春秋: '9C', 祥鹏: '8L',
30
+ 亚航: 'FD', 越捷: 'VZ', 国泰: 'CX', 港航: 'HX', 香港快运: 'UO', 阿联酋: 'EK', 泰航: 'TG',
19
31
  };
20
- const POLICY_WORD = /免签|落地签|签证|入境申报|过境免/;
32
+ /** Finite hotel-category vocabulary shared by heading activation and row recognition (issue #301). */
33
+ const LODGING_VOCAB_RE = /酒店|住宿|客栈|民宿|度假村|青旅|青年旅舍|别墅|公寓/;
34
+ /** Markdown ATX heading level 1–6 (根实测 #347:5/6 级 lodging heading 必须激活) */
35
+ const HEADING_ATX = /^(#{1,6})\s+(.+)$/;
36
+ /** 「政策」关键词(issue #273 父 + 子 #302):覆盖签证/免签/落地签/海关/过境五大类 + 子 #302 显式列举的政策词有限并集;
37
+ * 海关申报 与 入境申报 同性质但被原 regex 漏掉,补一个 demonstrative miss。
38
+ * 子 #302 扩词边界:仅 EVUS/ETA/eVisa/疫苗/疫苗接种/健康申报/隔离/工作签/居留/返程签/护照有效期/黄皮书/保险;拉丁 token
39
+ * 大小写不敏感但不嵌入更长 Latin 词(REVUS ≠ EVUS);不做 NLP/同义词/任意政策词表扩展。
40
+ * 子 #381(D-26 R1)扩词:入境章/落地章/出境章/入境卡/出境卡/出入境卡——与落地签同族的
41
+ * 移民章/卡六词,根反例「普吉岛入境章费用 800元」原全零抽取静默过闸,缺「截至」按既有
42
+ * policy_without_as_of 显式拒绝。仍为有限机械词表,可列举扩充、不做语义模型。 */
43
+ const POLICY_WORD = /免签|落地签|入境章|落地章|出境章|入境卡|出境卡|出入境卡|签证|入境申报|海关申报|过境免|疫苗|疫苗接种|健康申报|隔离|工作签|居留|返程签|护照有效期|黄皮书|保险|(?<![A-Za-z])(?:EVUS|ETA|eVisa)(?![A-Za-z])/i;
44
+ /** as_of 必须是「截至 + 具体日期」——「现行 60 天」不算时间边界(issue #46 政策行) */
21
45
  const AS_OF_WORD = /截至\s*\d{4}[-/年]\d{1,2}|as[_ ]?of\s*\d{4}/i;
22
46
  const CHECK_MARK = /[✓✅]/;
47
+ /** 交易承诺短语有限并集(D-26 R1,issue #381):费用/收费/押金/支付/缴费类下单措辞。
48
+ * 与「词表外金额写法」同一行同现、且该行经全部既有抽取原语后零 claim 时,按既有
49
+ * unverified_price_claim fail-closed。可列举扩充,不做 NLP/同义词/语义模型(#302 同款边界);
50
+ * 可订性短语(有房/可订)不在其中——由 §10/§12 酒店闸负责,其「裸短语不入闸」边界
51
+ * (#301/#347)保持不变。 */
52
+ const COMMITMENT_TRANSACTION_RE = /费用|收费|手续费|服务费|签证费|通行费|通关费|小费|押金|定金|订金|机场税|港口税|支付|付款|缴款|缴费|现付|到付|付现|代订|代购/;
53
+ /** 受支持币种词表外的金额写法(D-26 R1,issue #381):`hardPricesInLine` 只识别
54
+ * ¥/[A-Z]{3} 前缀硬价;CJK 币种后缀({N}元/泰铢/美元…)与 $/$ 前缀是其覆盖不了的
55
+ * 下单金额写法(根反例「800元」)。¥/ISO 前缀金额不在其中——零 claim 行的
56
+ * ¥/ISO 金额(预算/约价口径)属 R2 价格容忍度 founder 决策边界,不由本网拒绝。 */
57
+ const OFF_VOCAB_AMOUNT_RE = /(?:\d[\d,]*(?:\.\d+)?\s*(?:元|美元|美金|泰铢|铢|日元|日圆|港币|港元|欧元|卢比|比索|林吉特|印尼盾|迪拉姆|里亚尔))|(?:[$$]\s*\d[\d,]*(?:\.\d+)?)/;
58
+ /** 「直飞」断言(排除否定前置:无/不/没有/未见直飞;函数实现避开变宽 lookbehind 引擎差异) */
23
59
  function hasDirectAssertion(line) {
24
60
  const idx = line.indexOf('直飞');
25
- if (idx < 0) return false;
61
+ if (idx < 0)
62
+ return false;
26
63
  const before = line.slice(Math.max(0, idx - 3), idx);
27
64
  return !(before.endsWith('无') || before.endsWith('不') || before.endsWith('没有') || before.endsWith('未见'));
28
65
  }
66
+ /** 行内第一个命中词表的城市中文名(酒店 claim 的目的地键;无命中=undefined) */
29
67
  function occ1(line, map) {
30
- for (const city of Object.keys(map.city_alias)){
31
- if (line.includes(city)) return city;
68
+ for (const city of Object.keys(map.city_alias)) {
69
+ if (line.includes(city))
70
+ return city;
32
71
  }
33
72
  return undefined;
34
73
  }
74
+ /** 从文本(小节标题或行)解析航线上下文:箭头紧邻城市对(方向最可靠)优先,否则按出现位置取前两个;日期 M.D 或 MM-DD */
35
75
  function routeCtxOf(text, map, defaultYear) {
36
76
  const occ = [];
37
- for (const [city, iata] of Object.entries(map.city_alias)){
77
+ for (const [city, iata] of Object.entries(map.city_alias)) {
38
78
  let pos = text.indexOf(city);
39
- while(pos >= 0){
40
- occ.push({
41
- city,
42
- iata,
43
- pos
44
- });
79
+ while (pos >= 0) {
80
+ occ.push({ city, iata, pos });
45
81
  pos = text.indexOf(city, pos + city.length);
46
82
  }
47
83
  }
48
- occ.sort((a, b)=>a.pos - b.pos);
84
+ occ.sort((a, b) => a.pos - b.pos);
49
85
  const ctx = {};
50
- outer: for(let i = 0; i < occ.length; i++){
51
- for(let j = i + 1; j < occ.length; j++){
52
- if (occ[j].iata === occ[i].iata) continue;
86
+ // 「迪拜10:05→深圳」「迪拜 → 深圳」——两城市间有箭头即取方向(表格单元格顺序不可靠)
87
+ outer: for (let i = 0; i < occ.length; i++) {
88
+ for (let j = i + 1; j < occ.length; j++) {
89
+ if (occ[j].iata === occ[i].iata)
90
+ continue;
53
91
  const between = text.slice(occ[i].pos + occ[i].city.length, occ[j].pos);
54
92
  if (/→|⇀|->/.test(between)) {
55
93
  ctx.origin = occ[i].iata;
@@ -73,19 +111,18 @@ function routeCtxOf(text, map, defaultYear) {
73
111
  }
74
112
  return ctx;
75
113
  }
114
+ /**
115
+ * 从 markdown 抽取全部可下单 claim。
116
+ * 小节标题(### 段2|香港→普吉(7.17 周六))为下方行提供 route/date 上下文;
117
+ * 行内自带上下文优先。默认年份由 trip window 提供(远期行程的年份不含糊)。
118
+ */
76
119
  export function extractClaims(markdown, map, opts) {
77
- const claims = {
78
- flights: [],
79
- policies: [],
80
- airports: [],
81
- direct_lines: [],
82
- hotels: [],
83
- anchors: new Map()
84
- };
120
+ const claims = { flights: [], trains: [], policies: [], airports: [], direct_lines: [], hotels: [], anchors: new Map() };
85
121
  const lines = markdown.split('\n');
86
122
  let section = {};
123
+ // 渲染原语锚点(单向生成):带 fact:<id> 的行只走锚点确定性回溯,启发式抽取让位
87
124
  const ANCHOR = /fact:([0-9a-f]{16})/;
88
- for(let i = 0; i < lines.length; i++){
125
+ for (let i = 0; i < lines.length; i++) {
89
126
  const line = lines[i];
90
127
  const lineNo = i + 1;
91
128
  const anchorMatch = line.match(ANCHOR);
@@ -93,306 +130,700 @@ export function extractClaims(markdown, map, opts) {
93
130
  claims.anchors.set(lineNo, anchorMatch[1].toLowerCase());
94
131
  continue;
95
132
  }
96
- if (/^#{1,4}\s/.test(line)) {
133
+ if (HEADING_ATX.test(line)) {
97
134
  const ctx = routeCtxOf(line, map, opts?.trip_year);
98
- section = {
99
- origin: ctx.origin,
100
- destination: ctx.destination,
101
- date: ctx.date ?? section.date
102
- };
135
+ // lodging heading 上下文栈(issue #301 真实反例:无 token 行入闸):
136
+ // - lodging heading:在该层级压栈(更深栈顶替代外层语义,栈整体保留)
137
+ // - 同级/更高级 non-lodging heading:弹栈直到空
138
+ // - 更深的 non-lodging heading:继承栈
139
+ const headingMatch = line.match(HEADING_ATX);
140
+ const headingLevel = headingMatch[1].length;
141
+ const headingText = headingMatch[2];
142
+ const isLodgingHeading = LODGING_VOCAB_RE.test(headingText);
143
+ const depths = (section.lodgingDepths ?? []).slice();
144
+ if (isLodgingHeading) {
145
+ // 同级 lodging heading 替换该层栈,更深的栈项保留
146
+ while (depths.length > 0 && depths[depths.length - 1] >= headingLevel)
147
+ depths.pop();
148
+ depths.push(headingLevel);
149
+ }
150
+ else {
151
+ // 同级/更高级 non-lodging heading 弹出栈顶直至更浅或空
152
+ while (depths.length > 0 && depths[depths.length - 1] >= headingLevel)
153
+ depths.pop();
154
+ }
155
+ const lodging = depths.length > 0;
156
+ section = { origin: ctx.origin, destination: ctx.destination, date: ctx.date ?? section.date, lodging, lodgingDepths: depths };
103
157
  continue;
104
158
  }
105
159
  const own = routeCtxOf(line, map, opts?.trip_year);
106
160
  const origin = own.origin ?? section.origin;
107
161
  const destination = own.destination ?? section.destination;
108
162
  const date = own.date ?? section.date;
109
- for (const m of line.matchAll(FLIGHT_NO)){
110
- claims.flights.push({
111
- line: lineNo,
112
- text: line.trim().slice(0, 120),
113
- flight_no: m[1].toUpperCase(),
114
- origin,
115
- destination,
116
- date
117
- });
163
+ const trainNos = new Set([...line.matchAll(TRAIN_NO)].map(m => m[1].toUpperCase()));
164
+ for (const trainNo of trainNos) {
165
+ claims.trains.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: trainNo, origin, destination, date });
166
+ }
167
+ for (const m of line.matchAll(FLIGHT_NO)) {
168
+ const code = m[1].toUpperCase();
169
+ // 闸侧 train/flight 分离:中文高铁/动车/城际/直达前缀不得进入航班 claim。
170
+ // FLIGHT_NO 也会把 G1234 这类单字母车次整体匹配出来;只跳过同一行
171
+ // TRAIN_NO 实际命中的完整 token。不能按首字母跳过,否则合法双字母航司
172
+ // CZ8582 会被误删。
173
+ if (trainNos.has(code))
174
+ continue;
175
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: code, origin, destination, date });
118
176
  }
119
- for (const m of line.matchAll(CARRIER_DIRECT)){
177
+ for (const m of line.matchAll(CARRIER_DIRECT)) {
120
178
  const carrier = m[1].toUpperCase();
121
- if (!/[A-Z]/.test(carrier)) continue;
122
- if (new RegExp(`${carrier}\\d`).test(line)) continue;
123
- claims.flights.push({
124
- line: lineNo,
125
- text: line.trim().slice(0, 120),
126
- flight_no: '',
127
- carrier_only: carrier,
128
- origin,
129
- destination,
130
- date
131
- });
179
+ // 纯数字(时刻尾数,如「21:35 直飞」的 35)不是承运码;已是航班号前缀的不重复记
180
+ if (!/[A-Z]/.test(carrier))
181
+ continue;
182
+ if (new RegExp(`${carrier}\\d`).test(line))
183
+ continue;
184
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: '', carrier_only: carrier, origin, destination, date });
132
185
  }
133
186
  if (hasDirectAssertion(line) && (origin || destination)) {
134
- claims.direct_lines.push({
135
- line: lineNo,
136
- text: line.trim().slice(0, 120),
137
- origin,
138
- destination,
139
- date
140
- });
141
- for (const [zh, code] of Object.entries(CARRIER_ZH)){
142
- if (new RegExp(`(?<![一-龥])${zh}`).test(line) && !new RegExp(`${code}\\d`).test(line) && !claims.flights.some((c)=>c.line === lineNo && c.carrier_only === code)) {
143
- claims.flights.push({
144
- line: lineNo,
145
- text: line.trim().slice(0, 120),
146
- flight_no: '',
147
- carrier_only: code,
148
- origin,
149
- destination,
150
- date
151
- });
187
+ claims.direct_lines.push({ line: lineNo, text: line.trim().slice(0, 120), origin, destination, date });
188
+ // 中文承运名直飞断言(「东航 南京→普吉(白天班)」无二字码,FLIGHT_NO/CARRIER_DIRECT 都漏);
189
+ // 词首不得紧贴汉字——「国泰航空」里的「泰航」不是泰航
190
+ for (const [zh, code] of Object.entries(CARRIER_ZH)) {
191
+ if (new RegExp(`(?<![一-龥])${zh}`).test(line) && !new RegExp(`${code}\\d`).test(line) && !claims.flights.some(c => c.line === lineNo && c.carrier_only === code)) {
192
+ claims.flights.push({ line: lineNo, text: line.trim().slice(0, 120), flight_no: '', carrier_only: code, origin, destination, date });
152
193
  }
153
194
  }
154
195
  }
155
196
  if (POLICY_WORD.test(line)) {
156
- claims.policies.push({
157
- line: lineNo,
158
- text: line.trim().slice(0, 160),
159
- has_as_of: AS_OF_WORD.test(line)
160
- });
197
+ claims.policies.push({ line: lineNo, text: line.trim().slice(0, 160), has_as_of: AS_OF_WORD.test(line) });
161
198
  }
162
- const HOTEL_ASSERT = /酒店|住宿|客栈|民宿/;
199
+ // 酒店 claim(D-26 + 子 #301 扩词):断言可住性(有房/可订/已核验/✓)的行——目的地与档期缺失时 fail-closed
200
+ // 父 #273 已有 酒店|住宿|客栈|民宿;子 #301 扩词覆盖 度假村/青旅/青年旅舍/别墅/公寓 五类非关键词住宿
201
+ // category(精品酒店 由 酒店 承接,不重复声明);lodging heading(§住宿/§酒店/§青旅 等,ATX 1–6)下完全无住宿类
202
+ // token 的可住断言也由 section.lodging 升为 hotel claim。行内匹配分两路:
203
+ // (a) 行内含 LODGING_VOCAB_RE 词表 token → 需 HOTEL_BOOKABLE 短语/标记
204
+ // (b) 行内无 token 但 lodging context 激活 → 需 HOTEL_BOOKABILITY 短语(有房/可订/.../可住),
205
+ // 单独的 ✓/✅ 不构成 bookability(例:`靠近地铁 ✓` 在 §住宿 下不是可订断言)。
206
+ // 检测到可订短语且带 ✓,未核验时再加 unconditional_check。
207
+ // 同级/更高级 non-lodging heading 退出上下文,更深 heading 继承;lodging depth 栈保留外层活动层级。
208
+ // 行内/heading 词汇同源 = LODGING_VOCAB_RE,不可漂移。无住宿类关键词仍走兜底 →
209
+ // unverifiable_hotel_claim(§10f 既有证据)。route/date section context 对航班逻辑不动;
210
+ // anchored 行由 `if (claims.anchors.has(c.line)) continue` 提前让位。
211
+ const HOTEL_BOOKABILITY = /有房|可订|在架|已核验|已验证|可住/;
163
212
  const HOTEL_BOOKABLE = /有房|可订|在架|已核验|已验证|可住|✓|✅/;
164
- if (HOTEL_ASSERT.test(line) && HOTEL_BOOKABLE.test(line)) {
165
- const stays = [
166
- ...line.matchAll(/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/g)
167
- ].map((m)=>m[1]);
213
+ const hasCategoryToken = LODGING_VOCAB_RE.test(line);
214
+ const inLodgingSection = !!section.lodging;
215
+ const isBookabilityClaim = hasCategoryToken
216
+ ? HOTEL_BOOKABLE.test(line)
217
+ : inLodgingSection && HOTEL_BOOKABILITY.test(line);
218
+ if (isBookabilityClaim) {
219
+ const stays = [...line.matchAll(/(?<!\d)(\d{4}-\d{2}-\d{2})(?!\d)/g)].map(m => m[1]);
168
220
  claims.hotels.push({
169
221
  line: lineNo,
170
222
  text: line.trim().slice(0, 160),
171
223
  destination: occ1(line, map),
172
224
  check_in: stays[0],
173
- check_out: stays[1]
225
+ check_out: stays[1],
174
226
  });
175
227
  }
176
- for (const [city, carriers] of Object.entries(map.carrier_airport)){
177
- const airports = [
178
- ...new Set(Object.values(carriers))
179
- ];
180
- if (!airports.some((a)=>line.includes(a))) continue;
181
- const mentioned = Object.keys(carriers).filter((c)=>new RegExp(`(?<![A-Za-z0-9])${c}(?![A-Za-z0-9])`).test(line));
182
- if (mentioned.length === 0) continue;
183
- const airport = airports.find((a)=>line.includes(a));
184
- if (airport) claims.airports.push({
185
- line: lineNo,
186
- text: line.trim().slice(0, 120),
187
- carriers: mentioned,
188
- airport,
189
- city
190
- });
228
+ // 航司→机场映射 claim:同一行出现承运码 + 机场码,且该城是双机场映射面
229
+ for (const [city, carriers] of Object.entries(map.carrier_airport)) {
230
+ const airports = [...new Set(Object.values(carriers))];
231
+ if (!airports.some(a => line.includes(a)))
232
+ continue;
233
+ const mentioned = Object.keys(carriers).filter(c => new RegExp(`(?<![A-Za-z0-9])${c}(?![A-Za-z0-9])`).test(line));
234
+ if (mentioned.length === 0)
235
+ continue;
236
+ const airport = airports.find(a => line.includes(a));
237
+ if (airport)
238
+ claims.airports.push({ line: lineNo, text: line.trim().slice(0, 120), carriers: mentioned, airport, city });
191
239
  }
192
240
  }
193
241
  return claims;
194
242
  }
243
+ /** 行内 HH:MM 与已溯源事实的时刻对照:写了时刻却一个都对不上 = 混入旧航季(contradicted) */
195
244
  function lineTimesContradict(text, dep, arr) {
196
- if (!dep && !arr) return false;
197
- const times = [
198
- ...text.matchAll(/(?<!\d)(\d{1,2}:\d{2})(?!\d)/g)
199
- ].map((m)=>m[1]);
200
- if (times.length === 0) return false;
201
- return !times.some((t)=>t === dep || t === arr);
245
+ if (!dep && !arr)
246
+ return false;
247
+ const times = [...text.matchAll(/(?<!\d)(\d{1,2}:\d{2})(?!\d)/g)].map(m => m[1]);
248
+ if (times.length === 0)
249
+ return false;
250
+ return !times.some(t => t === dep || t === arr);
251
+ }
252
+ /** Renderer 固定的 reminder 短语正则(只此一句,日期为合法 ISO 阳历日)。
253
+ * 与 `policyCanonicalBody` 同源;闸侧只识别这一固定短语+位置(尾随在
254
+ * `[<source>@<fetched_at> #<query_id>]` 锚点块之前、收尾 `<!-- fact:<id> -->` 之前)。
255
+ * 用于 no-context 兼容性回退:从此正则抽日期,重建 `{...f, review_by: date}` 经
256
+ * renderer 还原整行,byte-for-byte 比对——不再手动拆 body/provenance。 */
257
+ const POLICY_REMINDER_PHRASE_RE = /;远期政策须复核——到 (\d{4}-\d{2}-\d{2}) 再核验一次/;
258
+ /**
259
+ * 只抽取行内可可靠绑定的硬价。起价/约价/模糊值不进入比较,不支持的币种
260
+ * 只进入未核验分支;不做汇率换算,也不把酒店 priceRaw 带入这里(issue #300)。
261
+ */
262
+ function hardPricesInLine(text) {
263
+ const prices = [];
264
+ const amount = String.raw `(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`;
265
+ const pattern = new RegExp(String.raw `(?:¥\s*(${amount})(?![\dA-Za-z,])|\b([A-Z]{3})\s*(${amount})(?![\dA-Za-z,]))`, 'gi');
266
+ for (const match of text.matchAll(pattern)) {
267
+ const amountText = match[1] ?? match[3];
268
+ if (!amountText || match.index === undefined)
269
+ continue;
270
+ const before = text.slice(Math.max(0, match.index - 8), match.index);
271
+ const after = text.slice(match.index + match[0].length, match.index + match[0].length + 8);
272
+ if (/(?:约|大约|约为|起价|起步|from)\s*$/i.test(before)
273
+ || /^\s*(?:起|起价|起步|左右|上下|以上|\+)/i.test(after))
274
+ continue;
275
+ prices.push({
276
+ amount: Number(amountText.replaceAll(',', '')),
277
+ currency: match[1] ? 'CNY' : match[2].toUpperCase(),
278
+ token: amountText,
279
+ start: match.index,
280
+ });
281
+ }
282
+ return prices;
283
+ }
284
+ /**
285
+ * 先识别完整 token 之外的畸形逗号数字;¥7xx 等既有非数字/模糊值不在此列。
286
+ */
287
+ function malformedHardPricesInLine(text) {
288
+ const amount = String.raw `(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?`;
289
+ const token = String.raw `\d[\d,]*(?:\.\d+)?`;
290
+ const pattern = new RegExp(String.raw `(?:¥\s*|\b([A-Z]{3})\s*)(${token})(?![\dA-Za-z])`, 'gi');
291
+ const malformed = [];
292
+ for (const match of text.matchAll(pattern)) {
293
+ const raw = match[2];
294
+ if (!raw || !raw.includes(',') || new RegExp(`^(?:${amount})$`).test(raw))
295
+ continue;
296
+ malformed.push({ currency: match[1] ? match[1].toUpperCase() : 'CNY', token: raw, start: match.index ?? 0 });
297
+ }
298
+ return malformed;
299
+ }
300
+ function moneyFields(text) {
301
+ const fields = text.split(/[;;|,]/);
302
+ const scopes = [];
303
+ let offset = 0;
304
+ for (const [index, field] of fields.entries()) {
305
+ scopes.push({ text: field, offset, index });
306
+ offset += field.length + 1;
307
+ }
308
+ return scopes;
309
+ }
310
+ const EXPLICIT_FARE_LABEL = /(?:票价|机票价|fare|price)\s*[::]?\s*$/i;
311
+ const PREVIOUS_MONEY_TOKEN = /(?:¥\s*|\b[A-Z]{3}\s*)\d[\d,]*(?:\.\d+)?\s*$/i;
312
+ /**
313
+ * 分类 money role,而不是把「同一行」当成 fare:
314
+ * canonical 是第一个 money token 所在的首字段;后续 token 只有显式
315
+ * fare label 才可比。其它有文字标签的字段忽略;裸 token 或前面只是
316
+ * 另一个金额的 token 无可靠归属,必须 fail closed。
317
+ */
318
+ function analyzeFareRoles(text) {
319
+ const analysis = { prices: [], malformed: [], ambiguous: [] };
320
+ for (const field of moneyFields(text)) {
321
+ const prices = hardPricesInLine(field.text);
322
+ const malformed = malformedHardPricesInLine(field.text);
323
+ const tokens = [
324
+ ...prices.map(price => ({ kind: 'complete', start: price.start, price, malformed: undefined })),
325
+ ...malformed.map(item => ({ kind: 'malformed', start: item.start, price: undefined, malformed: item })),
326
+ ].sort((a, b) => a.start - b.start);
327
+ for (const [tokenIndex, token] of tokens.entries()) {
328
+ const prefix = field.text.slice(0, token.start).trim();
329
+ const canonical = field.index === 0 && tokenIndex === 0;
330
+ const explicitFare = EXPLICIT_FARE_LABEL.test(prefix);
331
+ const reliableFare = canonical || explicitFare;
332
+ if (reliableFare) {
333
+ if (token.price)
334
+ analysis.prices.push({ ...token.price, start: token.price.start + field.offset });
335
+ if (token.malformed)
336
+ analysis.malformed.push({ ...token.malformed, start: token.malformed.start + field.offset });
337
+ continue;
338
+ }
339
+ // A bare token, or a token immediately following another money token,
340
+ // is ambiguous; any other textual prefix accounts for a non-fare field.
341
+ if (prefix.length === 0 || PREVIOUS_MONEY_TOKEN.test(prefix)) {
342
+ const currency = token.price?.currency ?? token.malformed?.currency ?? 'CNY';
343
+ const raw = token.price?.token ?? token.malformed?.token ?? '';
344
+ analysis.ambiguous.push({ currency, token: raw, start: token.start + field.offset });
345
+ }
346
+ }
347
+ }
348
+ return analysis;
349
+ }
350
+ function flightTrainPriceScope(text, fact) {
351
+ const no = fact.flight_no.toUpperCase();
352
+ const noStart = text.toUpperCase().indexOf(no);
353
+ if (noStart < 0)
354
+ return '';
355
+ const afterNo = text.slice(noStart + no.length);
356
+ const nextFlight = afterNo.match(FLIGHT_NO);
357
+ const anchor = text.indexOf('<!-- fact:', noStart + no.length);
358
+ const evidence = afterNo.search(/\[[^\]\n]*#[^\]\n]*\]/);
359
+ let end = text.length;
360
+ if (nextFlight?.index !== undefined)
361
+ end = Math.min(end, noStart + no.length + nextFlight.index);
362
+ // The rendered fare is before its evidence chain; amounts after it are
363
+ // unrelated fees/budget text even when an anchor follows later on the line.
364
+ if (evidence >= 0)
365
+ end = Math.min(end, noStart + no.length + evidence);
366
+ if (anchor >= 0)
367
+ end = Math.min(end, anchor);
368
+ return text.slice(noStart, end);
369
+ }
370
+ function addPriceVerification(violations, line, fullText, visibleText, fact) {
371
+ const fullScope = flightTrainPriceScope(fullText, fact);
372
+ const visibleScope = flightTrainPriceScope(visibleText, fact);
373
+ const fullAnalysis = analyzeFareRoles(fullScope);
374
+ const visibleAnalysis = analyzeFareRoles(visibleScope);
375
+ const allPrices = fullAnalysis.prices;
376
+ const visiblePrices = visibleAnalysis.prices;
377
+ const hasAnchor = fullText.includes('<!-- fact:');
378
+ if (fullAnalysis.malformed.length > 0 || fullAnalysis.ambiguous.length > 0) {
379
+ const malformed = fullAnalysis.malformed[0];
380
+ const ambiguous = fullAnalysis.ambiguous[0];
381
+ violations.push({
382
+ kind: 'unverified_price_claim',
383
+ line,
384
+ detail: malformed
385
+ ? `${fact.flight_no} fare role 含畸形硬价格 ${malformed.currency} ${malformed.token}——数字分组不完整,按未核验处理,不得截断或当作完整票价比较`
386
+ : `${fact.flight_no} 行内硬价格 ${ambiguous?.currency ?? 'CNY'} ${ambiguous?.token ?? ''} 无可靠 fare role 归属——按未核验处理,不得把裸金额当作 exact-date 票价`,
387
+ });
388
+ return true;
389
+ }
390
+ // Heuristic claims retain the existing 120-character extraction window. If a
391
+ // hard price only appears outside it, it cannot be safely bound to this claim.
392
+ if (!hasAnchor && allPrices.some(p => p.start >= visibleScope.length)) {
393
+ violations.push({
394
+ kind: 'unverified_price_claim',
395
+ line,
396
+ detail: `${fact.flight_no} 行内硬价格未锚定且超出 120 字抽取窗口——无法作为 exact-date 事实核验,按未核验处理`,
397
+ });
398
+ return true;
399
+ }
400
+ if (allPrices.length === 0)
401
+ return false;
402
+ const factCurrency = fact.currency?.toUpperCase();
403
+ for (const rendered of visiblePrices) {
404
+ if (fact.price === undefined || !Number.isFinite(fact.price) || factCurrency !== 'CNY') {
405
+ violations.push({
406
+ kind: 'unverified_price_claim',
407
+ line,
408
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} 缺少可比较的权威 exact-date 事实价格——按未核验处理,不作汇率或币种猜测`,
409
+ });
410
+ return true;
411
+ }
412
+ // ¥/CNY only compare against a CNY fact. No FX or ambiguous-currency guess.
413
+ if (rendered.currency !== factCurrency) {
414
+ violations.push({
415
+ kind: 'unverified_price_claim',
416
+ line,
417
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} 不支持直接比较事实 ${factCurrency} ${fact.price}——按未核验处理,不作汇率换算`,
418
+ });
419
+ return true;
420
+ }
421
+ if (rendered.amount === fact.price)
422
+ continue;
423
+ violations.push({
424
+ kind: 'price_contradicted',
425
+ line,
426
+ detail: `${fact.flight_no} 行内硬价格 ${rendered.currency} ${rendered.amount} ≠ exact-date 事实 ${factCurrency} ${fact.price}——价格事实矛盾,不得改写工具返回价格`,
427
+ });
428
+ return true;
429
+ }
430
+ return false;
202
431
  }
203
432
  export function gateArtifact(markdown, facts, map, opts) {
204
433
  const claims = extractClaims(markdown, map, opts);
205
434
  const violations = [];
206
435
  let traceable = 0;
207
- for (const [lineNo, factId] of claims.anchors){
208
- const f = facts.find((x)=>x.fact_id === factId);
436
+ // 渲染锚点优先(issue #118 单向生成):带 fact:<id> 的行确定性回溯注册表——
437
+ // 锚点在=按事实 bookability 判;锚点不存在=手改/伪造,直接违例。启发式对锚点行让位。
438
+ const lines = markdown.split('\n');
439
+ for (const [lineNo, factId] of claims.anchors) {
440
+ const f = facts.find(x => x.fact_id === factId);
209
441
  if (!f) {
210
- violations.push({
211
- kind: 'fact_anchor_unknown',
212
- line: lineNo,
213
- detail: `渲染锚点 fact:${factId} 不在事实注册表——锚点被手改或伪造,产物不可信`
214
- });
442
+ violations.push({ kind: 'fact_anchor_unknown', line: lineNo, detail: `渲染锚点 fact:${factId} 不在事实注册表——锚点被手改或伪造,产物不可信` });
215
443
  continue;
216
444
  }
217
- if (f.kind !== 'policy' && f.bookability === 'unavailable_exact_date') {
218
- violations.push({
219
- kind: 'not_in_source',
220
- line: lineNo,
221
- detail: `锚点事实为 exact-date 负事实(${f.fetched_at ?? ''})——负事实对应的可住/可订断言不得出现`
445
+ if (f.kind === 'train') {
446
+ const rendered = lines[lineNo - 1] ?? '';
447
+ const canonical = renderFlightFact(f);
448
+ if (rendered.trim() !== canonical.trim()) {
449
+ violations.push({
450
+ kind: 'fact_anchor_unknown',
451
+ line: lineNo,
452
+ detail: `train 锚点行与 canonical renderer 不一致——车次/时刻/日期/route 可能被手改;期望 ${canonical}`,
453
+ });
454
+ continue;
455
+ }
456
+ const rail = railClaimVerdict(facts, {
457
+ flight_no: f.flight_no,
458
+ origin: f.route.origin,
459
+ destination: f.route.destination,
460
+ date: f.date,
222
461
  });
462
+ if (rail.verdict !== 'traceable') {
463
+ violations.push({ kind: 'rail_claim_unverified', line: lineNo, detail: `${f.flight_no}: 锚点未通过 train exact-date 回溯(${rail.verdict})——${rail.reason}` });
464
+ continue;
465
+ }
466
+ }
467
+ // 航班锚点行字段指纹(issue #363,D-26 残余收口):锚点存在 ≠ 可见
468
+ // 内容与事实一致——flight_no 是「锚点 + 渲染原语」之间唯一可手改的
469
+ // 关键字段,保留 fact_id/anchor/registry 而改 flight_no(UO724→UO999)
470
+ // 仍会被「同号回溯」路径误判为 traceable,根反例验收正是此形态。
471
+ // 闸侧改用字段指纹:从渲染行重新抽 flight_no token,必须严格 ==
472
+ // 事实 flight_no。同一字段不引入 whole-line equality;承运/时刻/route/
473
+ // 日期/价格/打码段的兼容(§4b + §9 + §10 + §11)继续由既有判定
474
+ // 原语负责,不被新指纹吞并。同一行 trip-test 不命中即 fail-closed。
475
+ if (f.kind === 'flight') {
476
+ const rendered = lines[lineNo - 1] ?? '';
477
+ const canonicalFlightNo = f.flight_no.toUpperCase();
478
+ const renderedFlightNo = (() => {
479
+ for (const m of rendered.matchAll(FLIGHT_NO)) {
480
+ const code = m[1]?.toUpperCase();
481
+ if (code)
482
+ return code;
483
+ }
484
+ return undefined;
485
+ })();
486
+ if (!renderedFlightNo || renderedFlightNo !== canonicalFlightNo) {
487
+ violations.push({
488
+ kind: 'fact_anchor_unknown',
489
+ line: lineNo,
490
+ detail: `flight 锚点行 flight_no 字段指纹不符——锚点存在但 visible flight_no 与事实 ${canonicalFlightNo} 不一致(渲染抽到 ${renderedFlightNo ?? '∅'});保留 fact_id/registry/anchor 改写可见航班号不被允许,期望行内含 ${canonicalFlightNo}`,
491
+ });
492
+ continue;
493
+ }
494
+ }
495
+ // 酒店锚点行字段指纹(issue #363,D-26 残余收口):destination + 档期
496
+ // 是「锚点 + 渲染原语」之间可手改的关键字段;保留 fact_id/anchor/
497
+ // registry 改写 目的地(大理→丽江)或 档期(2026-10-01→2026-12-25)
498
+ // 仍会被 hotelClaimVerdict 的同目的地同档期回溯判为 traceable,
499
+ // 根反例验收正是此形态。闸侧改为字段指纹:渲染行必须严格包含
500
+ // 事实的 destination(精确子串)+ check_in(精确子串)+ check_out
501
+ // (精确子串)三项;任一缺失或不一致 → fact_anchor_unknown fail-closed。
502
+ // 不引入 whole-line equality;价格打码/承运/时刻/政策的兼容形态
503
+ // 继续由既有判定原语负责。打码语义(options_masked = 9)与酒店事实
504
+ // 无 price 字段不变。
505
+ if (f.kind === 'hotel') {
506
+ const rendered = lines[lineNo - 1] ?? '';
507
+ const expectedDestination = f.destination;
508
+ const expectedCheckIn = f.check_in;
509
+ const expectedCheckOut = f.check_out;
510
+ const missingDestination = !!expectedDestination && !rendered.includes(expectedDestination);
511
+ const missingCheckIn = !!expectedCheckIn && !rendered.includes(expectedCheckIn);
512
+ const missingCheckOut = !!expectedCheckOut && !rendered.includes(expectedCheckOut);
513
+ if (missingDestination || missingCheckIn || missingCheckOut) {
514
+ violations.push({
515
+ kind: 'fact_anchor_unknown',
516
+ line: lineNo,
517
+ detail: `hotel 锚点行字段指纹不符——锚点存在但 visible destination=${expectedDestination ?? '∅'}/check_in=${expectedCheckIn ?? '∅'}/check_out=${expectedCheckOut ?? '∅'} 与事实字段不严格一致(渲染行=${JSON.stringify(rendered)});保留 fact_id/registry/anchor 改写可见目的地或档期不被允许`,
518
+ });
519
+ continue;
520
+ }
521
+ }
522
+ if (f.kind !== 'policy' && f.bookability === 'unavailable_exact_date') {
523
+ violations.push({ kind: 'not_in_source', line: lineNo, detail: `锚点事实为 exact-date 负事实(${f.fetched_at ?? ''})——负事实对应的可住/可订断言不得出现` });
223
524
  continue;
224
525
  }
526
+ // 政策行内容指纹(issue #273 + #359,D-26 残余收口):
527
+ // 整行 = canonical body + ` <!-- fact:<id> -->`,单一权威面与 renderPolicyFact
528
+ // 共享 `policyCanonicalBody` 构造器;subject/statement/source/fetched_at/
529
+ // query_id/as_of 任一改动 → 行文本不再等于 canonical → fact_anchor_unknown
530
+ // fail-closed。锚点行不得含前导非空文本(允许锚点前的尾随空白)、第二个
531
+ // `<!-- fact:` 锚点、或任何「借合法锚点却写相反政策」的借用形态;行尾只允许
532
+ // `<!-- fact:<id> -->` 收尾。
533
+ //
534
+ // 兼容性回退(legacy public callers):当调用面未传 `opts.tripStart` 且未在
535
+ // itinerary 给出 `trip_start` 时,闸侧额外接受 renderer 产出的两个 canonical
536
+ // 形态之一(no-reminder 与 renderer-fixed 提醒 `;远期政策须复核——
537
+ // 到 YYYY-MM-DD 再核验一次`,日期为合法 ISO 阳历日)。这是 renderer 固定
538
+ // 短语+位置,不是任意 body 改写或后缀。该回退只匹配 renderer 自身输出,
539
+ // 不重新拼装、不猜 tripStart、不动 fact_id;有 `opts.tripStart` 或
540
+ // `itinerary.trip_start` 时优先严格比对对应 reminder,不退到回退集合。
541
+ if (f.kind === 'policy') {
542
+ const rendered = lines[lineNo - 1] ?? '';
543
+ // Precedence(issue #359 direct-API contract):explicit `opts.tripStart` →
544
+ // `opts.itinerary.trip_start` → undefined。policy 分支渲染上下文相关
545
+ // canonical 行(严格比对 + 结构失败诊断)全部走同一个
546
+ // `effectiveTripStart`,不再要求调用方在 `opts.tripStart` 与
547
+ // `itinerary.trip_start` 之间复制同一天。原来由 `index.ts` 桥接
548
+ // itinerary → tripStart 的注册路径仍然兼容(显式 tripStart 优先,语义不变)。
549
+ const explicitTripStart = typeof opts?.tripStart === 'string' && opts.tripStart.length > 0 ? opts.tripStart : undefined;
550
+ const itineraryTripStart = typeof opts?.itinerary?.trip_start === 'string' && opts.itinerary.trip_start.length > 0
551
+ ? opts.itinerary.trip_start
552
+ : undefined;
553
+ const effectiveTripStart = explicitTripStart ?? itineraryTripStart;
554
+ const anchorRe = /<!-- fact:([0-9a-f]{16}) -->/;
555
+ const m = rendered.match(anchorRe);
556
+ const noSecondAnchor = !m || m.index === undefined
557
+ ? true
558
+ : !rendered.slice(0, m.index).includes('<!-- fact:');
559
+ const exactlyOneAnchor = m && m.index !== undefined && noSecondAnchor;
560
+ const trailingNonEmpty = m && m.index !== undefined
561
+ ? /\S/.test(rendered.slice(m.index + m[0].length))
562
+ : true;
563
+ const exactAnchorId = m && m.index !== undefined && m[1] === f.fact_id;
564
+ const structureOk = exactlyOneAnchor && exactAnchorId && !trailingNonEmpty;
565
+ if (!structureOk) {
566
+ const canonical = renderPolicyFact(f, effectiveTripStart);
567
+ violations.push({
568
+ kind: 'fact_anchor_unknown',
569
+ line: lineNo,
570
+ detail: `政策锚点行结构不合法——必须为 canonical body + 单一「 <!-- fact:<id> -->」且行尾无后置非空文本(检测:行=${JSON.stringify(rendered)},期望=${JSON.stringify(canonical)});手改/伪造锚点或借用合法锚点写相反政策均 fail-closed`,
571
+ });
572
+ continue;
573
+ }
574
+ const hasExplicitTripContext = effectiveTripStart !== undefined;
575
+ if (!hasExplicitTripContext) {
576
+ // 兼容性回退(legacy public callers):仅当事实无 `review_by` 时,接受
577
+ // renderer 产出的两个固定 canonical 形态——不手动拆 body/provenance。
578
+ // (a) no-reminder 行 —— 严格等于 `renderPolicyFact(f)`。
579
+ // (b) reminder 行 —— 从 renderer 固定短语里**只抽日期**,把日期塞回
580
+ // `{...f, review_by: date}` 经 renderer 重建整行,与原行 byte-for-byte
581
+ // 比对;同时校验日期是真实 ISO 阳历日。这样 subject/statement/source/
582
+ // fetched_at/query_id/as_of、reminder 位置/短语、锚点结构、provenance
583
+ // 块全都由 renderer 重新产出并比对,不再人工拆段。
584
+ // 若事实自带 `review_by`,renderer 唯一合法 reminder 日期就是 `f.review_by`;
585
+ // (a) 已涵盖合法 pass;(b) 走 `{...f, review_by: extractedDate}` 时若
586
+ // extractedDate ≠ `f.review_by` 必因重建 ≠ 原行而 fall through,fail-closed。
587
+ if (rendered === renderPolicyFact(f)) {
588
+ traceable++;
589
+ continue;
590
+ }
591
+ if (typeof f.review_by !== 'string') {
592
+ const remMatch = rendered.match(POLICY_REMINDER_PHRASE_RE);
593
+ if (remMatch) {
594
+ const isoDate = remMatch[1];
595
+ const realIso = (() => {
596
+ const dm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
597
+ if (!dm)
598
+ return false;
599
+ const y = Number(dm[1]);
600
+ const mo = Number(dm[2]);
601
+ const da = Number(dm[3]);
602
+ if (mo < 1 || mo > 12 || da < 1 || da > 31)
603
+ return false;
604
+ const test = new Date(Date.UTC(y, mo - 1, da));
605
+ return test.getUTCFullYear() === y && test.getUTCMonth() === mo - 1 && test.getUTCDate() === da;
606
+ })();
607
+ if (realIso && rendered === renderPolicyFact({ ...f, review_by: isoDate })) {
608
+ traceable++;
609
+ continue;
610
+ }
611
+ }
612
+ }
613
+ }
614
+ // 即使 trip 上下文已知,renderer 不接 tripStart 时也会产出 no-reminder canonical;
615
+ // 该形态与 `renderPolicyFact(f)` 严格相等,在此处也必须合法(renderer 的
616
+ // tripStart argument 是 optional)。否则闸会把「renderer 选择不带 reminder」
617
+ // 误判成内容指纹不符,误伤合法事实行。
618
+ if (rendered === renderPolicyFact(f)) {
619
+ traceable++;
620
+ continue;
621
+ }
622
+ const canonical = renderPolicyFact(f, effectiveTripStart);
623
+ if (rendered !== canonical) {
624
+ violations.push({
625
+ kind: 'fact_anchor_unknown',
626
+ line: lineNo,
627
+ detail: `政策锚点行内容指纹不符——期望 ${canonical},实际 ${rendered};subject/statement/source/fetched_at/query_id/as_of 任一不一致均 fail-closed`,
628
+ });
629
+ continue;
630
+ }
631
+ }
632
+ if ((f.kind === 'flight' || f.kind === 'train') && addPriceVerification(violations, lineNo, lines[lineNo - 1] ?? '', lines[lineNo - 1] ?? '', f))
633
+ continue;
225
634
  traceable++;
226
635
  }
227
- for (const c of claims.hotels){
228
- if (claims.anchors.has(c.line)) continue;
636
+ // 酒店 claim(D-26):目的地+档期回溯酒店事实;无事实 fail-closed
637
+ for (const c of claims.hotels) {
638
+ if (claims.anchors.has(c.line))
639
+ continue;
229
640
  if (!c.destination) {
230
- violations.push({
231
- kind: 'unverifiable_hotel_claim',
232
- line: c.line,
233
- detail: `酒店可住断言缺目的地上下文,无法回溯 exact-date 事实——fail closed 按未核验处理`
234
- });
641
+ violations.push({ kind: 'unverifiable_hotel_claim', line: c.line, detail: `酒店可住断言缺目的地上下文,无法回溯 exact-date 事实——fail closed 按未核验处理` });
642
+ // 无目的地时也检查无条件 ✓:对未核验住宿仍不应打确定性标记
643
+ if (CHECK_MARK.test(c.text)) {
644
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: '对未核验酒店使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记' });
645
+ }
235
646
  continue;
236
647
  }
237
- const r = hotelClaimVerdict(facts, {
238
- destination: c.destination,
239
- check_in: c.check_in,
240
- check_out: c.check_out
241
- });
648
+ const r = hotelClaimVerdict(facts, { destination: c.destination, check_in: c.check_in, check_out: c.check_out });
242
649
  if (r.verdict === 'traceable') {
243
650
  traceable++;
244
651
  continue;
245
652
  }
246
653
  if (r.verdict === 'unverified') {
247
- violations.push({
248
- kind: 'unverifiable_hotel_claim',
249
- line: c.line,
250
- detail: r.reason
251
- });
252
- } else {
253
- violations.push({
254
- kind: 'not_in_source',
255
- line: c.line,
256
- detail: r.reason
257
- });
654
+ violations.push({ kind: 'unverifiable_hotel_claim', line: c.line, detail: r.reason });
655
+ }
656
+ else {
657
+ violations.push({ kind: 'not_in_source', line: c.line, detail: r.reason });
258
658
  }
259
659
  if (CHECK_MARK.test(c.text)) {
260
- violations.push({
261
- kind: 'unconditional_check',
262
- line: c.line,
263
- detail: '对未核验酒店使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记'
264
- });
660
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: '对未核验酒店使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记' });
265
661
  }
266
662
  }
267
- for (const c of claims.flights){
663
+ for (const c of claims.flights) {
268
664
  if (c.carrier_only) {
269
- const hasCarrier = facts.some((f)=>f.kind === 'flight' && f.bookability === 'bookable_exact_date' && f.nonstop !== false && f.flight_no.startsWith(c.carrier_only) && (!c.origin || f.route.origin === c.origin || f.route.origin_airport === c.origin) && (!c.destination || f.route.destination === c.destination || f.route.dest_airport === c.destination) && (!c.date || f.date === c.date));
665
+ // 承运级直飞断言:注册表该 route+date 须存在该承运的 bookable 直飞
666
+ const hasCarrier = facts.some(f => f.kind === 'flight' && f.bookability === 'bookable_exact_date' && f.nonstop !== false
667
+ && f.flight_no.startsWith(c.carrier_only)
668
+ && (!c.origin || f.route.origin === c.origin || f.route.origin_airport === c.origin)
669
+ && (!c.destination || f.route.destination === c.destination || f.route.dest_airport === c.destination)
670
+ && (!c.date || f.date === c.date));
270
671
  if (!hasCarrier) {
271
- const scoped = c.origin && c.destination && c.date ? latestFactsForRouteDate(facts, c.origin, c.destination, c.date) : [];
672
+ const scoped = c.origin && c.destination && c.date
673
+ ? latestFactsForRouteDate(facts, c.origin, c.destination, c.date, 'flight')
674
+ : [];
272
675
  violations.push({
273
676
  kind: scoped.length > 0 ? 'not_in_source' : 'carrier_direct_unverified',
274
677
  line: c.line,
275
- detail: `承运级直飞断言「${c.carrier_only} 直飞」无 exact-date 证据(${c.origin ?? '?'}→${c.destination ?? '?'} ${c.date ?? '?'})` + (scoped.length > 0 && scoped.every((f)=>f.bookability === 'bookable_exact_date' && f.nonstop === false) ? '——该日可售仅有中转' : '——「可考虑/也许有」不得写入决策树')
678
+ detail: `承运级直飞断言「${c.carrier_only} 直飞」无 exact-date 证据(${c.origin ?? '?'}→${c.destination ?? '?'} ${c.date ?? '?'})`
679
+ + (scoped.length > 0 && scoped.every(f => f.bookability === 'bookable_exact_date' && f.nonstop === false)
680
+ ? '——该日可售仅有中转' : '——「可考虑/也许有」不得写入决策树'),
276
681
  });
277
682
  }
278
683
  continue;
279
684
  }
280
685
  const r = flightClaimVerdict(facts, c);
281
686
  if (r.verdict === 'traceable') {
687
+ // 时刻一致性:溯源命中但行内时刻与事实全不符 = 旧航季混入(issue #46 EK328 行)
282
688
  if (lineTimesContradict(c.text, r.fact?.dep_local, r.fact?.arr_local)) {
283
689
  violations.push({
284
690
  kind: 'contradicted',
285
691
  line: c.line,
286
- detail: `${c.flight_no} 时刻与 exact-date 快照不符(快照 ${r.fact?.dep_local}→${r.fact?.arr_local} ${r.fact?.date})——渲染层不得改写工具返回的时刻`
692
+ detail: `${c.flight_no} 时刻与 exact-date 快照不符(快照 ${r.fact?.dep_local}→${r.fact?.arr_local} ${r.fact?.date})——渲染层不得改写工具返回的时刻`,
287
693
  });
288
694
  continue;
289
695
  }
696
+ if (r.fact && addPriceVerification(violations, c.line, lines[c.line - 1] ?? '', c.text, r.fact))
697
+ continue;
290
698
  traceable++;
291
699
  continue;
292
700
  }
293
- violations.push({
294
- kind: r.verdict,
295
- line: c.line,
296
- detail: `${c.flight_no}: ${r.reason}`
297
- });
701
+ violations.push({ kind: r.verdict, line: c.line, detail: `${c.flight_no}: ${r.reason}` });
702
+ // 对不可述 claim 的无条件 ✓/✅ 追加语气违例(验收⑦:远期班期/价格不用无条件 ✓)
298
703
  if (CHECK_MARK.test(c.text)) {
299
- violations.push({
300
- kind: 'unconditional_check',
301
- line: c.line,
302
- detail: `对未核验航班 ${c.flight_no} 使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记`
303
- });
704
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: `对未核验航班 ${c.flight_no} 使用无条件 ✓/✅——只有 bookable_exact_date 才允许确定性标记` });
705
+ }
706
+ }
707
+ // 车次 claim 独立于航班 claim:同 route/date 的航班事实不能证明车次存在,
708
+ // 也不能把缺失车次写成通用 not_in_source。只有 train kind 的结构化
709
+ // exact-date 事实才可回溯;不存在 renderRailFact 或 static schedule 降级。
710
+ for (const c of claims.trains) {
711
+ if (claims.anchors.has(c.line))
712
+ continue;
713
+ const r = railClaimVerdict(facts, c);
714
+ if (r.verdict === 'traceable') {
715
+ if (lineTimesContradict(c.text, r.fact?.dep_local, r.fact?.arr_local)) {
716
+ violations.push({
717
+ kind: 'rail_claim_unverified',
718
+ line: c.line,
719
+ detail: `${c.flight_no} 时刻与 train exact-date 快照不符(快照 ${r.fact?.dep_local}→${r.fact?.arr_local} ${r.fact?.date})——不得改写结构化车次事实`,
720
+ });
721
+ continue;
722
+ }
723
+ if (r.fact && addPriceVerification(violations, c.line, lines[c.line - 1] ?? '', c.text, r.fact))
724
+ continue;
725
+ traceable++;
726
+ continue;
727
+ }
728
+ violations.push({ kind: 'rail_claim_unverified', line: c.line, detail: `${c.flight_no}: ${r.verdict}——${r.reason}` });
729
+ if (CHECK_MARK.test(c.text)) {
730
+ violations.push({ kind: 'unconditional_check', line: c.line, detail: `对未核验车次 ${c.flight_no} 使用无条件 ✓/✅——只有 train bookable_exact_date 才允许确定性标记` });
304
731
  }
305
732
  }
306
- for (const d of claims.direct_lines){
307
- if (claims.flights.some((c)=>c.line === d.line)) continue;
733
+ // 通用直飞规则:该行未点名承运时,要求该 route+date 存在任一在架直飞;
734
+ // 「该日可售仅有中转/0 条」却写直飞 = 无证据分支(issue #46「DMK→KMG 可考虑 8L 直飞」行同款)
735
+ for (const d of claims.direct_lines) {
736
+ if (claims.flights.some(c => c.line === d.line))
737
+ continue; // 航班号/承运级断言已逐条判过
308
738
  if (!d.origin || !d.destination || !d.date) {
309
- violations.push({
310
- kind: 'route_unqueried',
311
- line: d.line,
312
- detail: `直飞断言缺 route/date 上下文,无法回溯——fail closed 按未核验处理`
313
- });
739
+ violations.push({ kind: 'route_unqueried', line: d.line, detail: `直飞断言缺 route/date 上下文,无法回溯——fail closed 按未核验处理` });
314
740
  continue;
315
741
  }
316
- const scoped = latestFactsForRouteDate(facts, d.origin, d.destination, d.date);
742
+ const scoped = latestFactsForRouteDate(facts, d.origin, d.destination, d.date, 'flight');
317
743
  if (scoped.length === 0) {
318
- violations.push({
319
- kind: 'route_unqueried',
320
- line: d.line,
321
- detail: `${d.origin}→${d.destination} ${d.date} 的直飞断言从未经 exact-date 源核验`
322
- });
744
+ violations.push({ kind: 'route_unqueried', line: d.line, detail: `${d.origin}→${d.destination} ${d.date} 的直飞断言从未经 exact-date 源核验` });
323
745
  continue;
324
746
  }
325
- const nonstopCount = scoped.filter((f)=>f.bookability === 'bookable_exact_date' && f.nonstop !== false).length;
747
+ const nonstopCount = scoped.filter(f => f.bookability === 'bookable_exact_date' && f.nonstop !== false).length;
326
748
  if (nonstopCount === 0) {
327
- violations.push({
328
- kind: 'not_in_source',
329
- line: d.line,
330
- detail: `${d.origin}→${d.destination} ${d.date} exact-date 无任何在架直飞(仅中转或 0 条)——不得写直飞`
331
- });
749
+ violations.push({ kind: 'not_in_source', line: d.line, detail: `${d.origin}→${d.destination} ${d.date} exact-date 无任何在架直飞(仅中转或 0 条)——不得写直飞` });
332
750
  }
333
751
  }
334
- for (const a of claims.airports){
752
+ for (const a of claims.airports) {
335
753
  const table = map.carrier_airport[a.city] ?? {};
336
- for (const carrier of a.carriers){
754
+ for (const carrier of a.carriers) {
337
755
  const expected = table[carrier];
338
756
  if (expected && expected !== a.airport) {
339
757
  violations.push({
340
758
  kind: 'airport_mapping_conflict',
341
759
  line: a.line,
342
- detail: `${carrier} 在${a.city}落 ${expected}(映射快照 as_of ${map.meta.as_of}),产物写 ${a.airport}——双机场不互换,航司不合并映射`
760
+ detail: `${carrier} 在${a.city}落 ${expected}(映射快照 as_of ${map.meta.as_of}),产物写 ${a.airport}——双机场不互换,航司不合并映射`,
343
761
  });
344
762
  }
345
763
  }
346
764
  }
347
- for (const p of claims.policies){
765
+ for (const p of claims.policies) {
348
766
  if (!p.has_as_of) {
349
- violations.push({
350
- kind: 'policy_without_as_of',
351
- line: p.line,
352
- detail: `政策断言缺时间边界:动态政策只能表述为「截至 YYYY-MM-DD 的现行政策」并附复核日期`
353
- });
767
+ violations.push({ kind: 'policy_without_as_of', line: p.line, detail: `政策断言缺时间边界:动态政策只能表述为「截至 YYYY-MM-DD 的现行政策」并附复核日期` });
354
768
  }
355
769
  if (CHECK_MARK.test(p.text) && !p.has_as_of) {
770
+ violations.push({ kind: 'unconditional_check', line: p.line, detail: `政策行使用无条件 ✓/✅` });
771
+ }
772
+ }
773
+ // 词表外下单措辞 fail-closed(D-26 R1,issue #381,接管 #273 公开跟踪):
774
+ // 根反例(main 6e85d36)「普吉岛入境章费用 800元,现场支付」全零抽取静默过闸。
775
+ // 兜底词网:仅当(1)该行经全部既有抽取原语(锚点/航班/车次/政策/酒店/机场映射/
776
+ // 直飞)后零 claim,且(2)行内同时命中交易承诺短语与词表外金额写法,才按既有
777
+ // unverified_price_claim 显式拒绝——不发明新违例通道。已有 claim 的行由各自
778
+ // 既有机制负责,词网不重复计违例;heading 行与抽取面一致不入网。
779
+ const claimedLines = new Set(claims.anchors.keys());
780
+ for (const c of [...claims.flights, ...claims.trains, ...claims.policies, ...claims.hotels, ...claims.airports, ...claims.direct_lines])
781
+ claimedLines.add(c.line);
782
+ lines.forEach((line, idx) => {
783
+ const lineNo = idx + 1;
784
+ if (claimedLines.has(lineNo) || HEADING_ATX.test(line))
785
+ return;
786
+ if (COMMITMENT_TRANSACTION_RE.test(line) && OFF_VOCAB_AMOUNT_RE.test(line)) {
356
787
  violations.push({
357
- kind: 'unconditional_check',
358
- line: p.line,
359
- detail: `政策行使用无条件 ✓/✅`
788
+ kind: 'unverified_price_claim',
789
+ line: lineNo,
790
+ detail: '词表外下单措辞 fail-closed:该行含交易承诺措辞且金额为受支持币种词表(¥/ISO 前缀)外的写法,无 exact-date 事实可回溯——按未核验价格处理,不得静默过闸;改用渲染原语锚点或附「截至 YYYY-MM-DD」可溯来源(D-26 R1,issue #381)',
360
791
  });
361
792
  }
362
- }
363
- markdown.split('\n').forEach((line, i)=>{
364
- if (!line.includes('联程')) return;
365
- const nos = [
366
- ...line.matchAll(FLIGHT_NO)
367
- ].map((m)=>m[1].toUpperCase());
368
- const matched = facts.filter((f)=>(f.kind === 'flight' || f.kind === 'train') && nos.includes(f.flight_no.toUpperCase()));
369
- const allProtected = nos.length > 0 && matched.length === nos.length && matched.every((f)=>f.protected_connection === true);
793
+ });
794
+ // 联程措辞:含「联程」的行,逐航班号查 protected_connection——
795
+ // 只有全部航腿同票保护才允许称联程;分票/自助转机必须显式标红
796
+ markdown.split('\n').forEach((line, i) => {
797
+ if (!line.includes('联程'))
798
+ return;
799
+ const trainNos = new Set([...line.matchAll(TRAIN_NO)].map(m => m[1].toUpperCase()));
800
+ const nos = [...line.matchAll(FLIGHT_NO)]
801
+ .map(m => m[1].toUpperCase())
802
+ .filter(no => !trainNos.has(no));
803
+ const matched = facts.filter((f) => (f.kind === 'flight' || f.kind === 'train') && nos.includes(f.flight_no.toUpperCase()));
804
+ const allProtected = nos.length > 0
805
+ && matched.length === nos.length
806
+ && matched.every(f => f.protected_connection === true);
370
807
  if (!allProtected) {
371
808
  violations.push({
372
809
  kind: 'self_transfer_called_through',
373
810
  line: i + 1,
374
- detail: '「联程」措辞无 protected_connection=true 证据——两段接得上不等于同票联程;分票必须标红自助转机(无行李直挂/误机无保护)'
811
+ detail: '「联程」措辞无 protected_connection=true 证据——两段接得上不等于同票联程;分票必须标红自助转机(无行李直挂/误机无保护)',
375
812
  });
376
813
  }
377
814
  });
815
+ // 行程级口径不变量(夜数/legs/预算下限):结构化 itinerary 机器断言并入同一报告
378
816
  if (opts?.itinerary) {
379
- for (const v of itineraryInvariants(opts.itinerary)){
380
- violations.push({
381
- kind: v.kind,
382
- line: 0,
383
- detail: v.detail
384
- });
817
+ for (const v of itineraryInvariants(opts.itinerary)) {
818
+ violations.push({ kind: v.kind, line: 0, detail: v.detail });
385
819
  }
386
820
  }
387
821
  return {
388
822
  schema: ARTIFACT_GATE_SCHEMA,
389
823
  verdict: violations.length > 0 ? 'blocked' : 'pass',
390
- claims_checked: claims.flights.length + claims.policies.length + claims.airports.length,
824
+ claims_checked: claims.flights.length + claims.trains.length + claims.policies.length + claims.airports.length,
391
825
  traceable,
392
826
  violations,
393
- presentation: violations.length > 0 ? 'verified_label_forbidden' : 'verified_itinerary_allowed'
827
+ presentation: violations.length > 0 ? 'verified_label_forbidden' : 'verified_itinerary_allowed',
394
828
  };
395
829
  }
396
-
397
-
398
- //# sourceURL=ts/src/artifact-gate.ts