@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,5 +1,29 @@
1
+ /**
2
+ * 可下单事实模型 gotry_bookable_fact.v1(issue #46,ADR-19)。
3
+ *
4
+ * 问题(2026-08-29 实测会话):exact-date 工具全部 miss 后,LLM 用航线页当日班期
5
+ * (2026 快照)填充 2027 行程,航班号/时刻/机场/价格以「✓/推荐」确定性语气手写进
6
+ * 产物——「错误但看起来已验证」比 unknown 更危险。
7
+ *
8
+ * 本层职责(纯函数,零 I/O——持久化见 capabilities/fact-log.ts):
9
+ * 1. 结构化事实 schema:route/exact local date/时刻/航班号/营销与实际承运(分开)/
10
+ * 机场/直飞或中转/价格/币种/source/query_id/fetched_at/as_of/confidence/bookability;
11
+ * 2. 证据分层永不合并:live_inventory(exact-date 命中)/ route_exists(只验证航线)/
12
+ * historical_schedule(旧航季)/ benchmark_price(历史促销价)——miss 落「负事实」,
13
+ * exact-date 无库存 ≠ 可用历史填充;
14
+ * 3. 判定原语:航班 claim 可述性(fail closed)/ 同日衔接硬约束 / 行程统计不变量
15
+ * (酒店夜+机上夜/O&D 段数/flight legs/预算自洽)/ 回头路检测;
16
+ * 4. 确定性渲染:航班行/政策行只从结构化对象生成,政策恒带 as_of + 复核日期,
17
+ * 分票永不称「联程」(protected_connection=true 才允许)。
18
+ *
19
+ * 渲染闸(从 markdown 反向核验)在 artifact-gate.ts。
20
+ */
1
21
  import { createHash } from 'node:crypto';
22
+ import { hasRecognizedAvailableSeat, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
2
23
  export const BOOKABLE_FACT_SCHEMA = 'gotry_bookable_fact.v1';
24
+ // ---------------------------------------------------------------------------
25
+ // id 与查询词(可重放:query_id 即重放凭证)
26
+ // ---------------------------------------------------------------------------
3
27
  function sha16(text) {
4
28
  return createHash('sha256').update(text).digest('hex').slice(0, 16);
5
29
  }
@@ -7,11 +31,14 @@ export function makeQueryId(source, kind, origin, destination, date) {
7
31
  return `${source}:${kind}:${origin}-${destination}:${date}`;
8
32
  }
9
33
  export function makeFactId(parts) {
10
- return sha16(parts.map((p)=>p ?? '').join('|'));
34
+ return sha16(parts.map(p => p ?? '').join('|'));
11
35
  }
12
36
  function iataOf(alias, city) {
13
37
  return alias?.[city] ?? city;
14
38
  }
39
+ /** 12306 exact-date fact freshness rule: capture must be non-future and <=15 minutes old. */
40
+ export const TRAIN_FACT_FRESHNESS_RULE = 'captured_at_non_future_and_max_age_15m';
41
+ export const TRAIN_FACT_MAX_AGE_MS = 15 * 60 * 1000;
15
42
  const YMD = /^\d{4}-\d{2}-\d{2}$/;
16
43
  function hmOf(dateTime) {
17
44
  const m = dateTime?.match(/T?(\d{2}:\d{2})/);
@@ -21,22 +48,13 @@ function dateOf(dateTime, fallback) {
21
48
  const m = dateTime?.match(/^(\d{4}-\d{2}-\d{2})/);
22
49
  return m?.[1] ?? fallback;
23
50
  }
51
+ /** exact-date miss → 负事实(route+date 级「当前不可售/无结果」记录,fail-closed 的另一半) */
24
52
  export function negativeFact(queryId, kind, origin, destination, date, source, fetchedAt, alias) {
25
53
  return {
26
54
  schema: BOOKABLE_FACT_SCHEMA,
27
- fact_id: makeFactId([
28
- 'neg',
29
- kind,
30
- iataOf(alias, origin),
31
- iataOf(alias, destination),
32
- date,
33
- queryId
34
- ]),
55
+ fact_id: makeFactId(['neg', kind, iataOf(alias, origin), iataOf(alias, destination), date, queryId]),
35
56
  kind,
36
- route: {
37
- origin: iataOf(alias, origin),
38
- destination: iataOf(alias, destination)
39
- },
57
+ route: { origin: iataOf(alias, origin), destination: iataOf(alias, destination) },
40
58
  date,
41
59
  flight_no: '',
42
60
  tier: 'live_inventory',
@@ -44,43 +62,33 @@ export function negativeFact(queryId, kind, origin, destination, date, source, f
44
62
  source,
45
63
  query_id: queryId,
46
64
  fetched_at: fetchedAt,
47
- as_of: fetchedAt.slice(0, 10)
65
+ as_of: fetchedAt.slice(0, 10),
48
66
  };
49
67
  }
68
+ /** flyai hit/miss → 事实列(error 不落事实——无结论不是证据) */
50
69
  export function factsFromFlyai(q, r, fetchedAt, alias) {
51
70
  const source = 'flyai';
52
71
  const queryId = makeQueryId(source, q.kind, q.origin, q.destination, q.date);
53
72
  const origin = iataOf(alias, q.origin);
54
73
  const destination = iataOf(alias, q.destination);
55
- if (r.verdict === 'error' || r.verdict === 'needs-setup') return [];
56
- if (r.verdict === 'miss') return [
57
- negativeFact(queryId, q.kind, q.origin, q.destination, q.date, source, fetchedAt, alias)
58
- ];
74
+ if (r.verdict === 'error' || r.verdict === 'needs-setup')
75
+ return [];
76
+ if (r.verdict === 'miss')
77
+ return [negativeFact(queryId, q.kind, q.origin, q.destination, q.date, source, fetchedAt, alias)];
59
78
  const facts = [];
60
- for (const o of r.options ?? []){
61
- if (!o.no || !o.depDateTime) continue;
79
+ for (const o of r.options ?? []) {
80
+ if (!o.no || !o.depDateTime)
81
+ continue;
62
82
  const numeric = typeof o.price === 'number' && Number.isFinite(o.price) && o.price > 0 ? o.price : undefined;
63
83
  facts.push({
64
84
  schema: BOOKABLE_FACT_SCHEMA,
65
- fact_id: makeFactId([
66
- q.kind,
67
- origin,
68
- destination,
69
- q.date,
70
- o.no,
71
- queryId
72
- ]),
85
+ fact_id: makeFactId([q.kind, origin, destination, q.date, o.no, queryId]),
73
86
  kind: q.kind,
74
- route: {
75
- origin,
76
- destination,
77
- origin_airport: o.depStation,
78
- dest_airport: o.arrStation
79
- },
87
+ route: { origin, destination, origin_airport: o.depStation, dest_airport: o.arrStation },
80
88
  date: dateOf(o.depDateTime, q.date),
81
89
  flight_no: o.no,
82
90
  marketing_carrier: o.name || undefined,
83
- operating_carrier: undefined,
91
+ operating_carrier: undefined, // flyai 上游只给营销承运;实际承运未知 ≠ 相同,留空不猜
84
92
  dep_local: hmOf(o.depDateTime),
85
93
  arr_local: hmOf(o.arrDateTime),
86
94
  nonstop: o.nonstop !== false,
@@ -91,44 +99,32 @@ export function factsFromFlyai(q, r, fetchedAt, alias) {
91
99
  source,
92
100
  query_id: queryId,
93
101
  fetched_at: fetchedAt,
94
- as_of: fetchedAt.slice(0, 10)
102
+ as_of: fetchedAt.slice(0, 10),
95
103
  });
96
104
  }
97
- return facts.length > 0 ? facts : [
98
- negativeFact(queryId, q.kind, q.origin, q.destination, q.date, source, fetchedAt, alias)
99
- ];
105
+ // hit 但解析后零有效条目 = miss 同语义(无可用事实,落负事实)
106
+ return facts.length > 0 ? facts : [negativeFact(queryId, q.kind, q.origin, q.destination, q.date, source, fetchedAt, alias)];
100
107
  }
108
+ /** 会话检索 hit → 事实列(用户本人登录态来源,与 flyai 并列独立 source) */
101
109
  export function factsFromSession(q, r, fetchedAt, alias) {
102
110
  const source = 'session:ctrip-flight';
103
111
  const queryId = makeQueryId(source, 'flight', q.origin, q.destination, q.date);
104
112
  const origin = iataOf(alias, q.origin);
105
113
  const destination = iataOf(alias, q.destination);
106
114
  if (r.verdict !== 'hit') {
107
- return r.verdict === 'miss' ? [
108
- negativeFact(queryId, 'flight', q.origin, q.destination, q.date, source, fetchedAt, alias)
109
- ] : [];
115
+ // miss/cooldown/challenged/needs-login:仅「正常返回 0 条」是负事实;通道不可用不落事实
116
+ return r.verdict === 'miss' ? [negativeFact(queryId, 'flight', q.origin, q.destination, q.date, source, fetchedAt, alias)] : [];
110
117
  }
111
118
  const facts = [];
112
- for (const o of r.options ?? []){
113
- if (!o.flightNo || !o.depDateTime) continue;
119
+ for (const o of r.options ?? []) {
120
+ if (!o.flightNo || !o.depDateTime)
121
+ continue;
114
122
  const numeric = typeof o.price === 'number' && Number.isFinite(o.price) && o.price > 0 ? o.price : undefined;
115
123
  facts.push({
116
124
  schema: BOOKABLE_FACT_SCHEMA,
117
- fact_id: makeFactId([
118
- 'flight',
119
- origin,
120
- destination,
121
- q.date,
122
- o.flightNo,
123
- queryId
124
- ]),
125
+ fact_id: makeFactId(['flight', origin, destination, q.date, o.flightNo, queryId]),
125
126
  kind: 'flight',
126
- route: {
127
- origin,
128
- destination,
129
- origin_airport: o.depAirport,
130
- dest_airport: o.arrAirport
131
- },
127
+ route: { origin, destination, origin_airport: o.depAirport, dest_airport: o.arrAirport },
132
128
  date: dateOf(o.depDateTime, q.date),
133
129
  flight_no: o.flightNo,
134
130
  marketing_carrier: o.airline || undefined,
@@ -143,60 +139,157 @@ export function factsFromSession(q, r, fetchedAt, alias) {
143
139
  source,
144
140
  query_id: queryId,
145
141
  fetched_at: fetchedAt,
146
- as_of: fetchedAt.slice(0, 10)
142
+ as_of: fetchedAt.slice(0, 10),
147
143
  });
148
144
  }
149
145
  return facts;
150
146
  }
147
+ function trainCollectionIsFresh(q, r, now) {
148
+ const c = r.collection;
149
+ if (!c || !c.batchId.trim() || !c.queryId.trim() || c.queryId !== `session:12306-train:${c.batchId}`)
150
+ return false;
151
+ if (c.requested.from !== q.from || c.requested.to !== q.to || c.requested.date !== q.date)
152
+ return false;
153
+ if (!c.request || !c.response
154
+ || c.request.date !== c.requested.date
155
+ || c.response.fromStationTelecode !== c.request.fromStationTelecode
156
+ || c.response.toStationTelecode !== c.request.toStationTelecode
157
+ || c.response.date !== c.request.date
158
+ || !validateTrainQueryResponseUrl(c.response.url, c.request).ok)
159
+ return false;
160
+ const fetchedMs = Date.parse(c.fetchedAt);
161
+ const nowMs = now.getTime();
162
+ if (!Number.isFinite(fetchedMs) || !Number.isFinite(nowMs))
163
+ return false;
164
+ return fetchedMs <= nowMs && nowMs - fetchedMs <= TRAIN_FACT_MAX_AGE_MS;
165
+ }
166
+ /** 12306 typed outcome → exact-date train facts.
167
+ * The converter never invents collection identity or timestamps: both must come
168
+ * from the session invocation binding. Only recognized-empty can create a
169
+ * negative fact. A recognized row needs Y plus a closed-set available seat token.
170
+ */
171
+ export function factsFromSessionTrain(q, r, now = new Date()) {
172
+ if (!trainCollectionIsFresh(q, r, now))
173
+ return [];
174
+ const c = r.collection;
175
+ const source = 'session:12306-train';
176
+ const fetchedAt = c.fetchedAt;
177
+ if (r.outcome.kind === 'recognized-empty') {
178
+ return [negativeFact(c.queryId, 'train', q.from, q.to, q.date, source, fetchedAt)];
179
+ }
180
+ if (r.outcome.kind !== 'recognized-nonempty')
181
+ return [];
182
+ const facts = [];
183
+ for (const o of r.outcome.trains ?? []) {
184
+ if (o.canWebBuy !== 'Y' || !hasRecognizedAvailableSeat(o))
185
+ continue;
186
+ facts.push({
187
+ schema: BOOKABLE_FACT_SCHEMA,
188
+ fact_id: makeFactId(['train', q.from, q.to, q.date, o.trainCode, c.queryId]),
189
+ kind: 'train',
190
+ route: { origin: q.from, destination: q.to },
191
+ date: q.date,
192
+ flight_no: o.trainCode,
193
+ dep_local: o.depTime,
194
+ arr_local: o.arrTime,
195
+ tier: 'live_inventory',
196
+ bookability: 'bookable_exact_date',
197
+ source,
198
+ query_id: c.queryId,
199
+ fetched_at: fetchedAt,
200
+ as_of: fetchedAt.slice(0, 10),
201
+ });
202
+ }
203
+ return facts;
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // 注册表(纯函数视图;持久化在 capabilities/fact-log.ts)
207
+ // ---------------------------------------------------------------------------
208
+ /** 同 fact_id 去重,保留 fetched_at 最新(重查覆盖旧快照,历史不丢——append-only 由持久层保证) */
151
209
  export function dedupeFacts(facts) {
152
210
  const byId = new Map();
153
- for (const f of facts){
211
+ for (const f of facts) {
154
212
  const prev = byId.get(f.fact_id);
155
- if (!prev || prev.fetched_at <= f.fetched_at) byId.set(f.fact_id, f);
213
+ if (!prev || prev.fetched_at <= f.fetched_at)
214
+ byId.set(f.fact_id, f);
156
215
  }
157
- return [
158
- ...byId.values()
159
- ].sort((a, b)=>a.fetched_at.localeCompare(b.fetched_at) || a.fact_id.localeCompare(b.fact_id));
216
+ return [...byId.values()].sort((a, b) => a.fetched_at.localeCompare(b.fetched_at) || a.fact_id.localeCompare(b.fact_id));
160
217
  }
161
- export function latestFactsForRouteDate(facts, origin, destination, date) {
162
- const hits = facts.filter((f)=>(f.kind === 'flight' || f.kind === 'train') && f.route.origin === origin && f.route.destination === destination && f.date === date);
218
+ /** route+date 的最新查询结论(同日多次查询:最新一次为准,避免「昨天 miss 今天 hit」陈旧否定) */
219
+ export function latestFactsForRouteDate(facts, origin, destination, date, kind) {
220
+ const hits = facts.filter((f) => (f.kind === 'flight' || f.kind === 'train')
221
+ && (!kind || f.kind === kind)
222
+ && f.route.origin === origin && f.route.destination === destination && f.date === date);
163
223
  const latestQueryTs = new Map();
164
- for (const f of hits){
224
+ for (const f of hits) {
165
225
  const prev = latestQueryTs.get(f.query_id);
166
- if (!prev || prev < f.fetched_at) latestQueryTs.set(f.query_id, f.fetched_at);
226
+ if (!prev || prev < f.fetched_at)
227
+ latestQueryTs.set(f.query_id, f.fetched_at);
167
228
  }
168
- const latestTs = [
169
- ...latestQueryTs.values()
170
- ].sort().pop();
171
- if (!latestTs) return [];
172
- return hits.filter((f)=>f.fetched_at === latestTs);
229
+ const latestTs = [...latestQueryTs.values()].sort().pop();
230
+ if (!latestTs)
231
+ return [];
232
+ return hits.filter(f => f.fetched_at === latestTs);
173
233
  }
234
+ /**
235
+ * 航班 claim 裁决(flight-only):注册表只接受 `kind:'flight'` 的同号事实
236
+ * ——train 事实走 `railClaimVerdict` 独立裁决,不得混入航班 not_in_source
237
+ * 路径(issue #299)。中文高铁/动车/城际/直达代码(G/D/C/Z + 3–4 位)在
238
+ * 闸侧 `extractClaims` 已分流到 `trains` claim,本函数无需再做扩展。
239
+ */
174
240
  export function flightClaimVerdict(facts, claim) {
175
241
  const no = claim.flight_no.toUpperCase().replace(/\s+/g, '');
176
- const sameNo = facts.filter((f)=>(f.kind === 'flight' || f.kind === 'train') && f.flight_no.toUpperCase() === no && f.bookability === 'bookable_exact_date');
177
- const routeMatched = sameNo.filter((f)=>(!claim.origin || f.route.origin === claim.origin || f.route.origin_airport === claim.origin) && (!claim.destination || f.route.destination === claim.destination || f.route.dest_airport === claim.destination) && (!claim.date || f.date === claim.date));
178
- if (routeMatched.length > 0) return {
179
- verdict: 'traceable',
180
- fact: routeMatched[0],
181
- reason: `回溯 ${routeMatched[0].query_id}`
182
- };
242
+ const sameNo = facts.filter((f) => f.kind === 'flight' && f.flight_no.toUpperCase() === no && f.bookability === 'bookable_exact_date');
243
+ const routeMatched = sameNo.filter(f => (!claim.origin || f.route.origin === claim.origin || f.route.origin_airport === claim.origin)
244
+ && (!claim.destination || f.route.destination === claim.destination || f.route.dest_airport === claim.destination)
245
+ && (!claim.date || f.date === claim.date));
246
+ if (routeMatched.length > 0)
247
+ return { verdict: 'traceable', fact: routeMatched[0], reason: `回溯 ${routeMatched[0].query_id}` };
248
+ // 无同号可下单事实:该 route+date 是否查过?
183
249
  if (claim.origin && claim.destination && claim.date) {
184
- const scoped = latestFactsForRouteDate(facts, claim.origin, claim.destination, claim.date);
250
+ const scoped = latestFactsForRouteDate(facts, claim.origin, claim.destination, claim.date, 'flight');
185
251
  if (scoped.length > 0) {
186
252
  return {
187
253
  verdict: 'not_in_source',
188
- reason: scoped.some((f)=>f.bookability === 'unavailable_exact_date') ? `${claim.origin}→${claim.destination} ${claim.date} exact-date 源返回 0 条(${scoped[0].query_id})——不得用历史班期/相邻日期填充` : `${claim.origin}→${claim.destination} ${claim.date} exact-date 源在架 ${scoped.filter((f)=>f.bookability === 'bookable_exact_date').map((f)=>f.flight_no).join('/')}——无 ${no}`
254
+ reason: scoped.some(f => f.bookability === 'unavailable_exact_date')
255
+ ? `${claim.origin}→${claim.destination} ${claim.date} exact-date 源返回 0 条(${scoped[0].query_id})——不得用历史班期/相邻日期填充`
256
+ : `${claim.origin}→${claim.destination} ${claim.date} exact-date 源在架 ${scoped.filter(f => f.bookability === 'bookable_exact_date').map(f => f.flight_no).join('/')}——无 ${no}`,
189
257
  };
190
258
  }
259
+ return { verdict: 'route_unqueried', reason: `${claim.origin}→${claim.destination} ${claim.date} 从未经 exact-date 源核验` };
260
+ }
261
+ return { verdict: 'route_unqueried', reason: `航班 ${no} 无可回溯的 exact-date 事实(缺 route/date 上下文)` };
262
+ }
263
+ const TRAIN_FACT_SOURCES = new Set(['flyai', 'session:12306-train']);
264
+ function isStructuredTrainFact(f) {
265
+ return f.kind === 'train'
266
+ && f.schema === BOOKABLE_FACT_SCHEMA
267
+ && TRAIN_FACT_SOURCES.has(f.source)
268
+ && f.query_id.trim().length > 0
269
+ && f.tier === 'live_inventory';
270
+ }
271
+ export function railClaimVerdict(facts, claim) {
272
+ const no = claim.flight_no.toUpperCase().replace(/\s+/g, '');
273
+ if (!claim.origin || !claim.destination || !claim.date) {
274
+ return { verdict: 'rail_claim_unverified', reason: `车次 ${no} 无可回溯的 exact-date 事实(缺完整 route/date 上下文)` };
275
+ }
276
+ // 先按 train kind 选择该 route+date 的最新查询批次,再在该批次匹配车次。
277
+ // 这样旧 hit 不会越过更新的 miss/unavailable,flight 批次也不能遮蔽 train 批次。
278
+ const scoped = latestFactsForRouteDate(facts, claim.origin, claim.destination, claim.date, 'train')
279
+ .filter(isStructuredTrainFact);
280
+ const sameNo = scoped.filter(f => f.flight_no.toUpperCase() === no
281
+ && f.bookability === 'bookable_exact_date');
282
+ if (sameNo.length > 0)
283
+ return { verdict: 'traceable', fact: sameNo[0], reason: `回溯 ${sameNo[0].query_id}` };
284
+ if (scoped.length > 0) {
191
285
  return {
192
- verdict: 'route_unqueried',
193
- reason: `${claim.origin}→${claim.destination} ${claim.date} 从未经 exact-date 源核验`
286
+ verdict: 'not_in_source',
287
+ reason: scoped.some(f => f.bookability === 'unavailable_exact_date')
288
+ ? `${claim.origin}→${claim.destination} ${claim.date} train exact-date 源返回 0 条(${scoped[0].query_id})——不得用历史班期/相邻日期填充`
289
+ : `${claim.origin}→${claim.destination} ${claim.date} train exact-date 源在架 ${scoped.filter(f => f.bookability === 'bookable_exact_date').map(f => f.flight_no).join('/')}——无 ${no}`,
194
290
  };
195
291
  }
196
- return {
197
- verdict: 'route_unqueried',
198
- reason: `航班 ${no} 无可回溯的 exact-date 事实(缺 route/date 上下文)`
199
- };
292
+ return { verdict: 'route_unqueried', reason: `${claim.origin}→${claim.destination} ${claim.date} train 路线从未经 exact-date 源核验` };
200
293
  }
201
294
  function hmToMin(hm) {
202
295
  const [h, m] = hm.split(':').map(Number);
@@ -205,42 +298,37 @@ function hmToMin(hm) {
205
298
  function minToHm(min) {
206
299
  return `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`;
207
300
  }
301
+ /**
302
+ * 「上午办事 + 当天下午直飞」类核心衔接判定:
303
+ * 最早起飞 = errand_end + 门到机场 + 值机截止;exact-date 在架直飞逐班过闸。
304
+ * 在架为空的失败语义与「有班但接不上」分开陈述(前者是库存问题,后者是约束问题)。
305
+ */
208
306
  export function checkSameDayDeparture(facts, q, c) {
209
307
  const earliest = hmToMin(c.errand_end_local) + c.airport_transit_min + c.checkin_deadline_min;
210
- const scoped = latestFactsForRouteDate(facts, q.origin, q.destination, q.date);
211
- const nonstopBookable = scoped.filter((f)=>f.bookability === 'bookable_exact_date' && f.nonstop !== false);
308
+ const scoped = latestFactsForRouteDate(facts, q.origin, q.destination, q.date, 'flight');
309
+ const nonstopBookable = scoped.filter(f => f.bookability === 'bookable_exact_date' && f.nonstop !== false);
212
310
  const feasible = [];
213
311
  const excluded = [];
214
- for (const f of nonstopBookable){
312
+ for (const f of nonstopBookable) {
215
313
  const dep = f.dep_local ? hmToMin(f.dep_local) : null;
216
314
  if (dep === null) {
217
- excluded.push({
218
- flight_no: f.flight_no,
219
- reason: '缺起飞时刻,不参与衔接判定'
220
- });
221
- } else if (dep >= earliest) {
315
+ excluded.push({ flight_no: f.flight_no, reason: '缺起飞时刻,不参与衔接判定' });
316
+ }
317
+ else if (dep >= earliest) {
222
318
  feasible.push(f);
223
- } else {
224
- excluded.push({
225
- flight_no: f.flight_no,
226
- reason: `${f.dep_local} 起飞早于最早可接时刻 ${minToHm(earliest)}(${c.errand_end_local} 结束事务 + ${c.airport_transit_min}m 到机场 + ${c.checkin_deadline_min}m 值机)`
227
- });
319
+ }
320
+ else {
321
+ excluded.push({ flight_no: f.flight_no, reason: `${f.dep_local} 起飞早于最早可接时刻 ${minToHm(earliest)}(${c.errand_end_local} 结束事务 + ${c.airport_transit_min}m 到机场 + ${c.checkin_deadline_min}m 值机)` });
228
322
  }
229
323
  }
230
324
  const ok = feasible.length > 0;
231
325
  const suggestions = [];
232
326
  if (!ok) {
233
- const inStock = nonstopBookable.map((f)=>`${f.flight_no} ${f.dep_local ?? '?'}`).join('、');
327
+ const inStock = nonstopBookable.map(f => `${f.flight_no} ${f.dep_local ?? '?'}`).join('、');
234
328
  suggestions.push(`增加 1 个${q.origin}住宿夜:改乘次日早班${inStock ? `(当日在架:${inStock})` : ''}`);
235
329
  suggestions.push('或提前一天完成地面事务/提前一天出发,把同日衔接改为隔夜衔接');
236
330
  }
237
- return {
238
- feasible: ok,
239
- earliest_departure_local: minToHm(earliest),
240
- feasible_flights: feasible,
241
- excluded,
242
- suggestions
243
- };
331
+ return { feasible: ok, earliest_departure_local: minToHm(earliest), feasible_flights: feasible, excluded, suggestions };
244
332
  }
245
333
  function daysBetween(a, b) {
246
334
  return Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);
@@ -249,73 +337,79 @@ export function itineraryInvariants(it) {
249
337
  const violations = [];
250
338
  const totalNights = daysBetween(it.trip_start, it.trip_end);
251
339
  let hotelNights = 0;
252
- for (const s of it.stays){
340
+ for (const s of it.stays) {
253
341
  const n = daysBetween(s.check_in, s.check_out);
254
- if (n <= 0) violations.push({
255
- kind: 'date_order',
256
- detail: `${s.place} 住宿 ${s.check_in}→${s.check_out} 晚数 ≤0`
257
- });
258
- else hotelNights += n;
342
+ if (n <= 0)
343
+ violations.push({ kind: 'date_order', detail: `${s.place} 住宿 ${s.check_in}→${s.check_out} 晚数 ≤0` });
344
+ else
345
+ hotelNights += n;
259
346
  }
260
347
  if (hotelNights + it.onboard_nights !== totalNights) {
261
348
  violations.push({
262
349
  kind: 'nights_inconsistent',
263
- detail: `酒店夜 ${hotelNights} + 机上夜 ${it.onboard_nights} = ${hotelNights + it.onboard_nights} ≠ 行程总夜数 ${totalNights}(${it.trip_start}→${it.trip_end})——夜数口径必须由结构化住宿反算,不许自然语言估算`
350
+ detail: `酒店夜 ${hotelNights} + 机上夜 ${it.onboard_nights} = ${hotelNights + it.onboard_nights} ≠ 行程总夜数 ${totalNights}(${it.trip_start}→${it.trip_end})——夜数口径必须由结构化住宿反算,不许自然语言估算`,
264
351
  });
265
352
  }
266
- for (const seg of it.od_segments){
353
+ for (const seg of it.od_segments) {
267
354
  if (seg.mode === 'flight' && seg.legs < 1) {
268
- violations.push({
269
- kind: 'legs_inconsistent',
270
- detail: `${seg.from}→${seg.to}(${seg.date})flight O&D 段 legs=${seg.legs}——每段至少 1 leg`
271
- });
355
+ violations.push({ kind: 'legs_inconsistent', detail: `${seg.from}→${seg.to}(${seg.date})flight O&D 段 legs=${seg.legs}——每段至少 1 leg` });
272
356
  }
273
357
  if (seg.date < it.trip_start || seg.date > it.trip_end) {
274
- violations.push({
275
- kind: 'date_order',
276
- detail: `${seg.from}→${seg.to} 日期 ${seg.date} 落在行程窗 ${it.trip_start}→${it.trip_end} 之外`
277
- });
358
+ violations.push({ kind: 'date_order', detail: `${seg.from}→${seg.to} 日期 ${seg.date} 落在行程窗 ${it.trip_start}→${it.trip_end} 之外` });
278
359
  }
279
360
  }
280
- const minSum = it.budget_items.reduce((acc, b)=>acc + b.min_cny, 0);
361
+ const minSum = it.budget_items.reduce((acc, b) => acc + b.min_cny, 0);
281
362
  if (it.claimed_floor_cny !== undefined && it.claimed_floor_cny < minSum) {
282
363
  violations.push({
283
364
  kind: 'budget_floor_inconsistent',
284
- detail: `预算分项最低合计 ¥${minSum.toLocaleString()} > 产物声称可压到 ¥${it.claimed_floor_cny.toLocaleString()}——下限话术不得低于分项最低合计`
365
+ detail: `预算分项最低合计 ¥${minSum.toLocaleString()} > 产物声称可压到 ¥${it.claimed_floor_cny.toLocaleString()}——下限话术不得低于分项最低合计`,
285
366
  });
286
367
  }
287
368
  return violations;
288
369
  }
289
370
  export function detectBacktrack(stops, nonstopEdges) {
290
371
  const findings = [];
291
- for(let i = 0; i + 2 < stops.length; i++){
372
+ for (let i = 0; i + 2 < stops.length; i++) {
292
373
  const a = stops[i], b = stops[i + 1], c = stops[i + 2];
293
- if (a !== c) continue;
374
+ if (a !== c)
375
+ continue;
376
+ // A→B→A 回头;若 B→D(下一站)有直飞边,建议「去程后直接 B→D」
294
377
  const d = stops[i + 3];
295
- const suggestion = d && nonstopEdges.has(`${b}-${d}`) ? `${b}→${d} 有直飞:改为 ${a}→${b} 后直接 ${b}→${d},消除 ${a}→${b}→${a} 回头路` : undefined;
296
- findings.push({
297
- kind: 'backtrack',
298
- path: [
299
- a,
300
- b,
301
- c
302
- ],
303
- suggestion
304
- });
378
+ const suggestion = d && nonstopEdges.has(`${b}-${d}`)
379
+ ? `${b}→${d} 有直飞:改为 ${a}→${b} 后直接 ${b}→${d},消除 ${a}→${b}→${a} 回头路`
380
+ : undefined;
381
+ findings.push({ kind: 'backtrack', path: [a, b, c], suggestion });
305
382
  }
306
383
  return findings;
307
384
  }
385
+ // ---------------------------------------------------------------------------
386
+ // 确定性渲染:航班/政策行只从结构化对象生成(issue #46 期望①:禁止凭记忆抄写)
387
+ // ---------------------------------------------------------------------------
388
+ /** 远期复核默认 gate:出发前 30 天 */
308
389
  export function defaultReviewBy(tripDate) {
309
390
  const d = new Date(Date.parse(`${tripDate}T00:00:00Z`) - 30 * 86_400_000);
310
391
  return d.toISOString().slice(0, 10);
311
392
  }
393
+ /**
394
+ * 航班/车次事实行。bookable_exact_date 才允许完整班次行;其余一律 fail-closed 措辞。
395
+ * train 复用此 canonical renderer 但只输出车次语义,不输出直飞或价格;永不输出无条件 ✓;
396
+ * 证据链(source/query_id/fetched_at)随行必达。
397
+ */
312
398
  export function renderFlightFact(f) {
313
399
  return renderFlightFactInner(f) + ` <!-- fact:${f.fact_id} -->`;
314
400
  }
315
401
  function renderFlightFactInner(f) {
402
+ // 单向生成锚点(D-26):每条事实行内嵌 fact_id,闸侧锚点匹配 = 确定性回溯
316
403
  const anchor = ` <!-- fact:${f.fact_id} -->`;
317
404
  const route = `${f.route.origin}→${f.route.destination}`;
318
405
  const evidence = `[${f.source}@${f.fetched_at} #${f.query_id}]`;
406
+ if (f.kind === 'train' && f.bookability === 'unavailable_exact_date') {
407
+ const review = f.review_by ?? defaultReviewBy(f.date);
408
+ return `- ${route} ${f.date} 车次${f.flight_no ? ` ${f.flight_no}` : ''}:**未确认/当前不可售**——exact-date 源 ${f.source} 于 ${f.fetched_at.slice(0, 10)} 返回 0 条;到 ${review} 复核,不得用历史班期/相邻日期填充 ${evidence}`;
409
+ }
410
+ if (f.kind === 'train' && f.bookability !== 'bookable_exact_date') {
411
+ return `- ${route} ${f.date} 车次${f.flight_no ? ` ${f.flight_no}` : ''}:**未核验**(${f.bookability})——仅作参考,不得作为可下单方案呈现 ${evidence}`;
412
+ }
319
413
  if (f.bookability === 'unavailable_exact_date') {
320
414
  const review = f.review_by ?? defaultReviewBy(f.date);
321
415
  return `- ${route} ${f.date}:**未确认/当前不可售**——exact-date 源 ${f.source} 于 ${f.fetched_at.slice(0, 10)} 返回 0 条;到 ${review} 复核,不得用历史班期/相邻日期填充 ${evidence}`;
@@ -323,45 +417,80 @@ function renderFlightFactInner(f) {
323
417
  if (f.bookability !== 'bookable_exact_date') {
324
418
  return `- ${route} ${f.date} ${f.flight_no || ''}:**未核验**(${f.bookability})——仅作参考,不得作为可下单方案呈现 ${evidence}`;
325
419
  }
326
- const carrier = f.operating_carrier && f.operating_carrier !== f.marketing_carrier ? `营销 ${f.marketing_carrier ?? '?'} / 实际承运 ${f.operating_carrier}` : f.marketing_carrier ?? '';
420
+ if (f.kind === 'train') {
421
+ const times = f.dep_local && f.arr_local ? ` ${f.dep_local}→${f.arr_local}` : '';
422
+ return `- ${route} ${f.date} 车次 ${f.flight_no}${times} ${evidence}`;
423
+ }
424
+ const carrier = f.operating_carrier && f.operating_carrier !== f.marketing_carrier
425
+ ? `营销 ${f.marketing_carrier ?? '?'} / 实际承运 ${f.operating_carrier}`
426
+ : (f.marketing_carrier ?? '');
327
427
  const times = f.dep_local && f.arr_local ? ` ${f.dep_local}→${f.arr_local}` : '';
328
428
  const nonstop = f.nonstop === false ? '(中转)' : ' 直飞';
329
- const airportNote = f.route.dest_airport && f.route.dest_airport !== f.route.destination ? `(落 ${f.route.dest_airport})` : '';
429
+ // 双机场城市防混淆:落点机场码与城市码不同时显式标注(FD DMK / VZ BKK 不互换)
430
+ const airportNote = f.route.dest_airport && f.route.dest_airport !== f.route.destination
431
+ ? `(落 ${f.route.dest_airport})`
432
+ : '';
330
433
  const price = f.price !== undefined ? ` ¥${f.price}` : ' 价待询';
331
434
  const baggage = f.baggage_included === false ? ',不含托运行李' : '';
332
435
  return `- ${route} ${f.date} ${f.flight_no}${carrier ? `(${carrier})` : ''}${times}${nonstop}${airportNote}${price}${baggage} ${evidence}`;
333
436
  }
437
+ /**
438
+ * 联程/分票连接行:只有同票保护(protected_connection=true)才允许称「联程」;
439
+ * 分票必须显式标红自助转机风险(无行李直挂/误机无保护)。
440
+ */
334
441
  export function renderConnection(legA, legB) {
335
442
  const through = legA.protected_connection === true && legB.protected_connection === true;
336
443
  const head = `${legA.route.origin}→${legA.route.destination} ${legA.flight_no} + ${legB.route.origin}→${legB.route.destination} ${legB.flight_no}`;
337
- return through ? `- ${head}:联程(同一票号/行李直挂/误机保护)` : `- ${head}:⚠️ **分票/自助转机**——两段接得上 ≠ 联程;无同一票号、行李需自取重挂、误机无保护,缓冲与风险自担`;
444
+ return through
445
+ ? `- ${head}:联程(同一票号/行李直挂/误机保护)`
446
+ : `- ${head}:⚠️ **分票/自助转机**——两段接得上 ≠ 联程;无同一票号、行李需自取重挂、误机无保护,缓冲与风险自担`;
338
447
  }
448
+ /**
449
+ * 政策事实行:恒为「截至 as_of 的现行政策」表述 + 复核 gate,永不用无条件 ✓。
450
+ * 远期(出发日晚于 as_of)必须附 review_by(默认 D-30)。
451
+ *
452
+ * 行尾内嵌 `<!-- fact:<fact_id> -->` 锚点(issue #273,ADR-19 typed-anchor 单向生成):
453
+ * 产物经渲染原语生成即自带溯源锚,闸侧锚点确定性回溯;手改/伪造锚点 = fact_anchor_unknown。
454
+ * 与 renderFlightFact / renderHotelFact 同源:启发式 POLICY_WORD 仅覆盖手写/历史产物。
455
+ */
339
456
  export function renderPolicyFact(p, tripStart) {
457
+ return `${policyCanonicalBody(p, tripStart)} <!-- fact:${p.fact_id} -->`;
458
+ }
459
+ /**
460
+ * 政策行 canonical body(不含锚点;issue #359,D-26 残余收口):
461
+ * 闸侧锚点回溯用同一原文,subject/statement/source/fetched_at/query_id 任意一项
462
+ * 改动 → 行文本不再等于 canonical body → fact_anchor_unknown fail-closed。
463
+ * 不走宽松 substring/subset 匹配,避免锚点行被改写 subject/statement 后仍 pass。
464
+ * 复核提醒(review_by 或 tripStart 派生的 defaultReviewBy)是合法的可选段——
465
+ * `tripStart` 与 renderPolicyFact 同一传入,产物与闸两侧语义一致。
466
+ */
467
+ export function policyCanonicalBody(p, tripStart) {
340
468
  const review = p.review_by ?? (tripStart ? defaultReviewBy(tripStart) : undefined);
341
469
  const reviewNote = review ? `;远期政策须复核——到 ${review} 再核验一次` : '';
342
470
  return `- ${p.subject}:截至 ${p.as_of} 的现行政策——${p.statement}${reviewNote} [${p.source}@${p.fetched_at} #${p.query_id}]`;
343
471
  }
472
+ /** 从结构化行程渲染夜数口径行(机器反算,唯一口径) */
344
473
  export function renderNightsLine(it) {
345
474
  const totalNights = daysBetween(it.trip_start, it.trip_end);
346
- const hotelNights = it.stays.reduce((acc, s)=>acc + Math.max(0, daysBetween(s.check_in, s.check_out)), 0);
347
- const flightOd = it.od_segments.filter((s)=>s.mode === 'flight').length;
348
- const flightLegs = it.od_segments.filter((s)=>s.mode === 'flight').reduce((acc, s)=>acc + s.legs, 0);
475
+ const hotelNights = it.stays.reduce((acc, s) => acc + Math.max(0, daysBetween(s.check_in, s.check_out)), 0);
476
+ const flightOd = it.od_segments.filter(s => s.mode === 'flight').length;
477
+ const flightLegs = it.od_segments.filter(s => s.mode === 'flight').reduce((acc, s) => acc + s.legs, 0);
349
478
  return `共 ${daysBetween(it.trip_start, it.trip_end) + 1} 天 ${totalNights} 晚 = ${hotelNights} 个酒店夜 + ${it.onboard_nights} 个机上夜;航班按 O&D ${flightOd} 段(共 ${flightLegs} 个 flight legs)`;
350
479
  }
480
+ // ---------------------------------------------------------------------------
481
+ // 酒店事实(D-26,issue #118):exact-date 酒店检索 hit/miss 落账
482
+ // 纪律:摸底(未定档期)不落账(无 exact-date 语义);needs-setup/error 传输失败永不落负事实
483
+ // ---------------------------------------------------------------------------
351
484
  export function factsFromHotel(input) {
352
- if (!input.checkIn || !input.checkOut) return [];
353
- if (input.verdict !== 'hit' && input.verdict !== 'miss') return [];
485
+ if (!input.checkIn || !input.checkOut)
486
+ return [];
487
+ if (input.verdict !== 'hit' && input.verdict !== 'miss')
488
+ return [];
354
489
  const queryId = `hotel:${input.source}:${input.destination}:${input.checkIn}_${input.checkOut}`;
355
- return [
356
- {
490
+ return [{
357
491
  schema: BOOKABLE_FACT_SCHEMA,
358
492
  kind: 'hotel',
359
- fact_id: makeFactId([
360
- input.destination,
361
- input.checkIn,
362
- input.checkOut,
363
- queryId
364
- ]),
493
+ fact_id: makeFactId([input.destination, input.checkIn, input.checkOut, queryId]),
365
494
  destination: input.destination,
366
495
  check_in: input.checkIn,
367
496
  check_out: input.checkOut,
@@ -372,37 +501,33 @@ export function factsFromHotel(input) {
372
501
  query_id: queryId,
373
502
  fetched_at: input.fetchedAt,
374
503
  as_of: input.fetchedAt.slice(0, 10),
375
- tier: 'live_inventory'
376
- }
377
- ];
504
+ tier: 'live_inventory',
505
+ }];
378
506
  }
507
+ /** 酒店 claim 回溯:同目的地+同档期的 exact-date 事实裁决(无事实=unverified,fail-closed) */
379
508
  export function hotelClaimVerdict(facts, claim) {
380
- const scoped = facts.filter((f)=>f.kind === 'hotel' && f.destination === claim.destination && (!claim.check_in || f.check_in === claim.check_in) && (!claim.check_out || f.check_out === claim.check_out));
509
+ const scoped = facts.filter((f) => f.kind === 'hotel' && f.destination === claim.destination
510
+ && (!claim.check_in || f.check_in === claim.check_in)
511
+ && (!claim.check_out || f.check_out === claim.check_out));
381
512
  if (scoped.length === 0) {
382
513
  return {
383
514
  verdict: 'unverified',
384
- reason: `酒店 claim(${claim.destination}${claim.check_in ? ` ${claim.check_in}→${claim.check_out ?? '?'}` : ''})未经 exact-date 源核验——先检索再写结论`
515
+ reason: `酒店 claim(${claim.destination}${claim.check_in ? ` ${claim.check_in}→${claim.check_out ?? '?'}` : ''})未经 exact-date 源核验——先检索再写结论`,
385
516
  };
386
517
  }
387
- const hit = scoped.find((f)=>f.bookability === 'bookable_exact_date');
388
- if (hit) return {
389
- verdict: 'traceable',
390
- fact: hit,
391
- reason: 'exact-date 命中'
392
- };
518
+ const hit = scoped.find(f => f.bookability === 'bookable_exact_date');
519
+ if (hit)
520
+ return { verdict: 'traceable', fact: hit, reason: 'exact-date 命中' };
393
521
  const latest = scoped[scoped.length - 1];
394
- return {
395
- verdict: 'not_in_source',
396
- fact: latest,
397
- reason: `该档期 exact-date 源正常返回 0 条(${latest.fetched_at})——不得写有房/可订`
398
- };
522
+ return { verdict: 'not_in_source', fact: latest, reason: `该档期 exact-date 源正常返回 0 条(${latest.fetched_at})——不得写有房/可订` };
399
523
  }
524
+ // ---------------------------------------------------------------------------
525
+ // 渲染原语(单向生成,issue #118):事实 → 行,锚点内嵌 fact_id;
526
+ // 产物经原语渲染即自带溯源锚,闸侧锚点匹配 = 确定性回溯,不靠行文猜测
527
+ // ---------------------------------------------------------------------------
400
528
  export function renderHotelFact(f) {
401
529
  if (f.verdict !== 'hit') {
402
530
  return `- ${f.destination} ${f.check_in}→${f.check_out}:exact-date 源 0 条在架(不可订) <!-- fact:${f.fact_id} -->`;
403
531
  }
404
532
  return `- ${f.destination} ${f.check_in}→${f.check_out}:住宿已核验(在架 ${f.options_masked} 家;价格上游打码,真实价以落地页为准) <!-- fact:${f.fact_id} -->`;
405
533
  }
406
-
407
-
408
- //# sourceURL=ts/src/bookable-facts.ts