@whyshu/argent-engine 0.0.0-stage → 0.6.1

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 (340) hide show
  1. package/MANIFEST.json +341 -0
  2. package/engine/VERSION +1 -0
  3. package/engine/agents/catalog.json +454 -0
  4. package/engine/configs/_aliases.proposed.json +651 -0
  5. package/engine/configs/_migration-plan.proposed.json +899 -0
  6. package/engine/configs/account.json +272 -0
  7. package/engine/configs/advanced.json +205 -0
  8. package/engine/configs/host.json +149 -0
  9. package/engine/configs/hr.json +27 -0
  10. package/engine/configs/notify.json +264 -0
  11. package/engine/core/cli/argent.mjs +277 -0
  12. package/engine/core/contracts/argent.contracts.json +1065 -0
  13. package/engine/core/contracts/verbs.schema.json +2539 -0
  14. package/engine/core/lib/argent-datadir.mjs +223 -0
  15. package/engine/core/lib/audit.mjs +187 -0
  16. package/engine/core/lib/claim.mjs +258 -0
  17. package/engine/core/lib/config-catalog.mjs +1172 -0
  18. package/engine/core/lib/doctor-customer.mjs +577 -0
  19. package/engine/core/lib/flow-list.mjs +138 -0
  20. package/engine/core/lib/flow-registry.mjs +48 -0
  21. package/engine/core/lib/init.mjs +247 -0
  22. package/engine/core/lib/ledger.mjs +124 -0
  23. package/engine/core/lib/license-activate.mjs +813 -0
  24. package/engine/core/lib/license-device.mjs +314 -0
  25. package/engine/core/lib/license-file.mjs +349 -0
  26. package/engine/core/lib/license-guard.mjs +123 -0
  27. package/engine/core/lib/license-ops.mjs +1645 -0
  28. package/engine/core/lib/license-verdict.mjs +133 -0
  29. package/engine/core/lib/license.mjs +577 -0
  30. package/engine/core/lib/notify/dedup.mjs +101 -0
  31. package/engine/core/lib/notify/feishu.mjs +38 -0
  32. package/engine/core/lib/notify/index.mjs +95 -0
  33. package/engine/core/lib/notify/verb.mjs +327 -0
  34. package/engine/core/lib/notify/webhook.mjs +135 -0
  35. package/engine/core/lib/notify/wecom.mjs +36 -0
  36. package/engine/core/lib/python.mjs +145 -0
  37. package/engine/core/lib/sop.mjs +116 -0
  38. package/engine/core/lib/versions.mjs +205 -0
  39. package/engine/core/mcp/client.mjs +67 -0
  40. package/engine/core/mcp/http.mjs +138 -0
  41. package/engine/core/mcp/server.mjs +702 -0
  42. package/engine/scripts/_engine_rules_probe.py +15 -0
  43. package/engine/scripts/analyze-full-day.py +568 -0
  44. package/engine/scripts/archive/feishu-bridge.js +246 -0
  45. package/engine/scripts/argent-agents.mjs +527 -0
  46. package/engine/scripts/argent-capabilities.mjs +776 -0
  47. package/engine/scripts/argent-catalog.mjs +186 -0
  48. package/engine/scripts/argent-datadir.py +62 -0
  49. package/engine/scripts/argent-doctor.mjs +624 -0
  50. package/engine/scripts/argent-env-write.mjs +302 -0
  51. package/engine/scripts/argent-schedule.mjs +620 -0
  52. package/engine/scripts/argent-setup-check.mjs +369 -0
  53. package/engine/scripts/argent-upgrade.mjs +1046 -0
  54. package/engine/scripts/base-bootstrap.sh +576 -0
  55. package/engine/scripts/billing-mode-daily.sh +13 -0
  56. package/engine/scripts/build-engine-package.mjs +126 -0
  57. package/engine/scripts/capability-packs.lock.json +484 -0
  58. package/engine/scripts/capability_pack.py +1242 -0
  59. package/engine/scripts/check-1.0-criteria.mjs +355 -0
  60. package/engine/scripts/check-agent-catalog.mjs +141 -0
  61. package/engine/scripts/check-billing-mode.sh +378 -0
  62. package/engine/scripts/check-child-sessions.mjs +83 -0
  63. package/engine/scripts/check-config-catalog.mjs +677 -0
  64. package/engine/scripts/check-docs-drift.mjs +522 -0
  65. package/engine/scripts/check-dsh-web-expose.sh +98 -0
  66. package/engine/scripts/check-judgment-drift.mjs +403 -0
  67. package/engine/scripts/check-license.mjs +199 -0
  68. package/engine/scripts/check-model-auth.py +443 -0
  69. package/engine/scripts/check-pack-closure.mjs +661 -0
  70. package/engine/scripts/check-portable-symbols.mjs +116 -0
  71. package/engine/scripts/check-preset-landing.mjs +124 -0
  72. package/engine/scripts/check-production-home.mjs +602 -0
  73. package/engine/scripts/check-role-mapping.mjs +461 -0
  74. package/engine/scripts/check-sample-redaction.mjs +320 -0
  75. package/engine/scripts/check-skill-classification.mjs +188 -0
  76. package/engine/scripts/check-sop-consistency.mjs +431 -0
  77. package/engine/scripts/check-test-env-safety.mjs +627 -0
  78. package/engine/scripts/check-version-consistency.mjs +452 -0
  79. package/engine/scripts/ci-run.sh +558 -0
  80. package/engine/scripts/collect-evidence.mjs +42 -0
  81. package/engine/scripts/config-explain.mjs +278 -0
  82. package/engine/scripts/configure-provider.rb +29 -0
  83. package/engine/scripts/customer-config-migrate.mjs +179 -0
  84. package/engine/scripts/customer-preflight.py +892 -0
  85. package/engine/scripts/customer-readiness.sh +135 -0
  86. package/engine/scripts/daily-report-inbox.py +242 -0
  87. package/engine/scripts/daily-report-reconcile.mjs +312 -0
  88. package/engine/scripts/daily-report.mjs +1310 -0
  89. package/engine/scripts/daily-report.sh +47 -0
  90. package/engine/scripts/data/lip-product-dict.json +306 -0
  91. package/engine/scripts/deliver-customer.mjs +932 -0
  92. package/engine/scripts/delivery-faces.txt +31 -0
  93. package/engine/scripts/desktop-trial.sh +146 -0
  94. package/engine/scripts/dev-window.sh +261 -0
  95. package/engine/scripts/drill-clean-machine.sh +315 -0
  96. package/engine/scripts/dsh-api-client.mjs +215 -0
  97. package/engine/scripts/dsh-backup-scope.sh +83 -0
  98. package/engine/scripts/dsh-offline-pack.mjs +77 -0
  99. package/engine/scripts/dsh-overlay-pin.sh +87 -0
  100. package/engine/scripts/dsh-plane.mjs +153 -0
  101. package/engine/scripts/dsh-portforward.py +223 -0
  102. package/engine/scripts/dsh-preflight.mjs +179 -0
  103. package/engine/scripts/dsh-release-watch-daily.sh +19 -0
  104. package/engine/scripts/dsh-release-watch.mjs +458 -0
  105. package/engine/scripts/dsh-remote-check.sh +99 -0
  106. package/engine/scripts/dsh-restart.sh +179 -0
  107. package/engine/scripts/dsh-restore.sh +111 -0
  108. package/engine/scripts/dsh-scope-rewire.mjs +803 -0
  109. package/engine/scripts/dsh-snapshot.mjs +116 -0
  110. package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
  111. package/engine/scripts/dsh-upgrade.mjs +612 -0
  112. package/engine/scripts/dsh-url.sh +86 -0
  113. package/engine/scripts/dsh-web-launch.mjs +155 -0
  114. package/engine/scripts/ensure-store-windows.py +509 -0
  115. package/engine/scripts/entitlements.py +458 -0
  116. package/engine/scripts/env_probe.py +411 -0
  117. package/engine/scripts/feishu-card.mjs +252 -0
  118. package/engine/scripts/final-check.mjs +43 -0
  119. package/engine/scripts/fix-skill-frontmatter.py +209 -0
  120. package/engine/scripts/flow-auto-retry.py +454 -0
  121. package/engine/scripts/flow-requirements.json +735 -0
  122. package/engine/scripts/flow-scaffold.py +275 -0
  123. package/engine/scripts/flow-timers-plist.sh +87 -0
  124. package/engine/scripts/flow-timers.sh +184 -0
  125. package/engine/scripts/flow-trigger-wake.mjs +284 -0
  126. package/engine/scripts/flow-trigger.sh +571 -0
  127. package/engine/scripts/flow_billing.py +354 -0
  128. package/engine/scripts/flow_capability_stats.py +314 -0
  129. package/engine/scripts/flow_case.py +194 -0
  130. package/engine/scripts/flow_claim.py +497 -0
  131. package/engine/scripts/flow_config.py +1025 -0
  132. package/engine/scripts/flow_failure.py +588 -0
  133. package/engine/scripts/flow_learn.py +105 -0
  134. package/engine/scripts/flow_ledger.py +235 -0
  135. package/engine/scripts/flow_policy.py +238 -0
  136. package/engine/scripts/flow_preflight.py +339 -0
  137. package/engine/scripts/flow_registry.py +341 -0
  138. package/engine/scripts/flow_retry.py +304 -0
  139. package/engine/scripts/flow_return_to_ops.py +191 -0
  140. package/engine/scripts/flow_source.py +315 -0
  141. package/engine/scripts/flow_sources/__init__.py +53 -0
  142. package/engine/scripts/flow_sources/cases_json.py +106 -0
  143. package/engine/scripts/flow_sources/common.py +428 -0
  144. package/engine/scripts/flow_sources/feishu_base.py +273 -0
  145. package/engine/scripts/flow_stale.py +185 -0
  146. package/engine/scripts/flow_status_reconcile.py +344 -0
  147. package/engine/scripts/flow_triage.py +849 -0
  148. package/engine/scripts/flow_write_verify.py +496 -0
  149. package/engine/scripts/fulfillment.js +586 -0
  150. package/engine/scripts/gen-agent-bundles.mjs +276 -0
  151. package/engine/scripts/gen-preset-rows.mjs +259 -0
  152. package/engine/scripts/init-ml.sh +2278 -0
  153. package/engine/scripts/install-internal-role.sh +190 -0
  154. package/engine/scripts/install-license.mjs +641 -0
  155. package/engine/scripts/install-one-shot.mjs +665 -0
  156. package/engine/scripts/integration_contract.py +220 -0
  157. package/engine/scripts/integrations/wenyao/adapter.py +98 -0
  158. package/engine/scripts/integrations/xzh/adapter.py +75 -0
  159. package/engine/scripts/invoice-mapping.example.json +40 -0
  160. package/engine/scripts/invoice-table.sh +195 -0
  161. package/engine/scripts/invoice_carrier_notice.py +279 -0
  162. package/engine/scripts/invoice_fail_closeout.py +297 -0
  163. package/engine/scripts/invoice_lip_runner.py +1508 -0
  164. package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
  165. package/engine/scripts/invoice_ready_audit.py +191 -0
  166. package/engine/scripts/invoice_runner.py +675 -0
  167. package/engine/scripts/invoice_stageb.py +516 -0
  168. package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
  169. package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
  170. package/engine/scripts/lease.py +377 -0
  171. package/engine/scripts/lib/agent-install.mjs +649 -0
  172. package/engine/scripts/lib/argent-datadir.mjs +16 -0
  173. package/engine/scripts/lib/argent-datadir.sh +18 -0
  174. package/engine/scripts/lib/argent_datadir.py +285 -0
  175. package/engine/scripts/lib/argent_filelock.py +507 -0
  176. package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
  177. package/engine/scripts/lib/cdp_ports.py +197 -0
  178. package/engine/scripts/lib/customer-env.mjs +102 -0
  179. package/engine/scripts/lib/daily-report-core.mjs +517 -0
  180. package/engine/scripts/lib/dep-declaration.mjs +81 -0
  181. package/engine/scripts/lib/deps-migrate.mjs +310 -0
  182. package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
  183. package/engine/scripts/lib/dsh-scope.mjs +500 -0
  184. package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
  185. package/engine/scripts/lib/dsh-versions.mjs +96 -0
  186. package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
  187. package/engine/scripts/lib/is-direct-run.mjs +61 -0
  188. package/engine/scripts/lib/patch-compose.mjs +241 -0
  189. package/engine/scripts/lib/patch-target.mjs +35 -0
  190. package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
  191. package/engine/scripts/lib/plugin-packages.mjs +332 -0
  192. package/engine/scripts/lib/preset-compat.mjs +127 -0
  193. package/engine/scripts/lib/preset-install.mjs +575 -0
  194. package/engine/scripts/lib/preset-mechanism.mjs +56 -0
  195. package/engine/scripts/lib/preset-sync.mjs +59 -0
  196. package/engine/scripts/lib/profile-retire.mjs +97 -0
  197. package/engine/scripts/lib/python-runtime.mjs +190 -0
  198. package/engine/scripts/lib/registry.mjs +72 -0
  199. package/engine/scripts/lib/repo-root.sh +56 -0
  200. package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
  201. package/engine/scripts/lib/schedule-probe.mjs +835 -0
  202. package/engine/scripts/lib/scope-pointer.mjs +164 -0
  203. package/engine/scripts/lib/settings-panels.mjs +816 -0
  204. package/engine/scripts/lib/stdio_utf8.py +86 -0
  205. package/engine/scripts/lib/wake-auth.mjs +392 -0
  206. package/engine/scripts/lib/webdriver_ipc.py +264 -0
  207. package/engine/scripts/lib/zstd-frames.mjs +104 -0
  208. package/engine/scripts/license-e2e-drill.mjs +426 -0
  209. package/engine/scripts/license-gate.mjs +245 -0
  210. package/engine/scripts/lip_backlog.py +171 -0
  211. package/engine/scripts/lip_table.py +185 -0
  212. package/engine/scripts/local-usage.mjs +214 -0
  213. package/engine/scripts/migrate-providers.mjs +127 -0
  214. package/engine/scripts/migrate-skills.sh +65 -0
  215. package/engine/scripts/ml-oauth-setup.mjs +550 -0
  216. package/engine/scripts/ml-store-ids.json +26 -0
  217. package/engine/scripts/ml_artifact_attribution.py +333 -0
  218. package/engine/scripts/ml_capacity_probe.py +1158 -0
  219. package/engine/scripts/ml_hub_status.py +1556 -0
  220. package/engine/scripts/ml_schema_check.py +115 -0
  221. package/engine/scripts/ml_store_ids.py +139 -0
  222. package/engine/scripts/npm-publish-argent.mjs +499 -0
  223. package/engine/scripts/npm-publish-argent.sh +20 -0
  224. package/engine/scripts/orchestrator.py +13811 -0
  225. package/engine/scripts/pack-plugins.mjs +140 -0
  226. package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
  227. package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
  228. package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
  229. package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
  230. package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
  231. package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
  232. package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
  233. package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
  234. package/engine/scripts/patches/verify-hardening.mjs +167 -0
  235. package/engine/scripts/patches/verify-session-migration.mjs +149 -0
  236. package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
  237. package/engine/scripts/plan-role-rename.mjs +125 -0
  238. package/engine/scripts/plugin-bootcheck.mjs +451 -0
  239. package/engine/scripts/plugin-composition.json +15 -0
  240. package/engine/scripts/poc-mcp-host.mjs +455 -0
  241. package/engine/scripts/poll-fulfillment.sh +295 -0
  242. package/engine/scripts/poll-invoice.sh +76 -0
  243. package/engine/scripts/profile-doctor.mjs +686 -0
  244. package/engine/scripts/provider-select.sh +71 -0
  245. package/engine/scripts/provider-switch.sh +146 -0
  246. package/engine/scripts/provider-sync-target.check.mjs +64 -0
  247. package/engine/scripts/python-runtime.sh +78 -0
  248. package/engine/scripts/query_runner.py +310 -0
  249. package/engine/scripts/release-argent.sh +574 -0
  250. package/engine/scripts/repair-unresolved-tools.mjs +178 -0
  251. package/engine/scripts/retire-inventory.mjs +40 -0
  252. package/engine/scripts/run-tests.sh +257 -0
  253. package/engine/scripts/s1-audit.mjs +85 -0
  254. package/engine/scripts/self-sign-license.mjs +179 -0
  255. package/engine/scripts/session-health-scan.mjs +63 -0
  256. package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
  257. package/engine/scripts/setup.sh +22 -0
  258. package/engine/scripts/skill-install.mjs +629 -0
  259. package/engine/scripts/sku_translate.py +148 -0
  260. package/engine/scripts/sync-plugins.sh +90 -0
  261. package/engine/scripts/sync-presets.sh +27 -0
  262. package/engine/scripts/sync-scripts.sh +445 -0
  263. package/engine/scripts/telemetry.py +873 -0
  264. package/engine/scripts/ui-copy-count.mjs +220 -0
  265. package/engine/scripts/usage-anchor.sh +137 -0
  266. package/engine/scripts/usage_report.py +531 -0
  267. package/engine/scripts/validate-preset-schema.mjs +208 -0
  268. package/engine/scripts/vertical-init.mjs +323 -0
  269. package/engine/scripts/vertical-lint.mjs +482 -0
  270. package/engine/scripts/wenyao-browser.sh +74 -0
  271. package/engine/scripts/wenyao-fixture-export.py +116 -0
  272. package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
  273. package/engine/scripts/wenyao-login.sh +35 -0
  274. package/engine/scripts/wenyao-template-map.json +292 -0
  275. package/engine/scripts/wenyao_attach.py +131 -0
  276. package/engine/scripts/wenyao_download_template.py +144 -0
  277. package/engine/scripts/wenyao_fill_template.py +568 -0
  278. package/engine/scripts/wenyao_login.py +145 -0
  279. package/engine/scripts/wenyao_portal.py +633 -0
  280. package/engine/scripts/wenyao_recon.py +230 -0
  281. package/engine/scripts/wenyao_run.py +503 -0
  282. package/engine/scripts/wenyao_shadow_diff.py +160 -0
  283. package/engine/scripts/wenyao_skill_contract.py +156 -0
  284. package/engine/scripts/wenyao_template_fetch.py +175 -0
  285. package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
  286. package/engine/scripts/ziniao-auth-probe.sh +268 -0
  287. package/engine/scripts/ziniao-keepalive.sh +217 -0
  288. package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
  289. package/engine/scripts/ziniao-windows.sh +154 -0
  290. package/engine/skills/README.md +86 -0
  291. package/engine/skills/argent-dev/SKILL.md +41 -0
  292. package/engine/skills/internal.json +37 -0
  293. package/engine/skills/lktop-pm/SKILL.md +35 -0
  294. package/engine/skills/lktopapp-dev/SKILL.md +34 -0
  295. package/engine/skills/ml-config-guide/SKILL.md +50 -0
  296. package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
  297. package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
  298. package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
  299. package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
  300. package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
  301. package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
  302. package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
  303. package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
  304. package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
  305. package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
  306. package/engine/skills/ml-invoice/SKILL.md +506 -0
  307. package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
  308. package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
  309. package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
  310. package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
  311. package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
  312. package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
  313. package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
  314. package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
  315. package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
  316. package/engine/skills/pet-dev/SKILL.md +42 -0
  317. package/engine/skills/whyshu-pm/SKILL.md +35 -0
  318. package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
  319. package/engine/version.mjs +1 -0
  320. package/engine/verticals/README.md +103 -0
  321. package/engine/verticals/_engine-words.baseline.txt +237 -0
  322. package/engine/verticals/_engine-words.json +19 -0
  323. package/engine/verticals/mercadolibre/README.md +43 -0
  324. package/engine/verticals/mercadolibre/config.json +709 -0
  325. package/engine/verticals/mercadolibre/flows.json +738 -0
  326. package/engine/verticals/mercadolibre/manifest.json +538 -0
  327. package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
  328. package/engine/verticals/mercadolibre/preflight.py +143 -0
  329. package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
  330. package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
  331. package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
  332. package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
  333. package/engine/verticals/mercadolibre/prompts/full.md +91 -0
  334. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
  335. package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
  336. package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
  337. package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
  338. package/package.json +22 -4
  339. package/requirements.txt +5 -0
  340. package/README.md +0 -3
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env python3
2
+ """flow_write_verify —— **幂等写步骤的「失败前核验」纯判据**(步骤 3 / 4 / 8)。
3
+
4
+ ## 为什么需要它(2026-10-09 生产实测,本模块的出生理由)
5
+
6
+ 货件 **79191341**(record `recvvgPQgoBScO`):引擎步骤8 报
7
+ `selector_not_found 预约失败:取消预约**未执行**(3/3 次未出现 Cancelar reserva 按钮…)`,
8
+ 现场其实是**平台错误页**(`Algo salió mal`)⇒ 按钮渲染不出来 ⇒ 判"没点到"。
9
+ 而只读复核显示:`status=Reserva cancelada`、`appointment` 为空、标签+箱唛已上传
10
+ ⇒ **取消确实已生效**(幂等成立),台账被人工纠正为 `done`,而表里仍是 `FULL状态=失败`。
11
+
12
+ ⇒ 结论:**幂等写步骤的失败判定必须先核验真相**。本模块就是那层判据:
13
+ 把「只读真相来源」变成**三态结论**,任何一处都**不可能**因为"核验本身出问题"而判成功。
14
+
15
+ ## 三态(**唯一口径**,调用方只认这三个值)
16
+
17
+ | 值 | 含义 | 调用方该做什么 |
18
+ |---|---|---|
19
+ | `effective` | 只读证据**明确**显示该写操作其实已生效(幂等/续传) | **判成功**(⛔ 不产生 failed 事件),台账/备注写"报错但已核验生效(幂等)"+证据来源 |
20
+ | `not_effective` | 只读证据**明确否定**(预约仍在/没取消/没有货件号) | **保持现在的失败路径与文案**(逐字不变) |
21
+ | `unavailable` | 真相**取不到/不确定**(探针不可用、页面残缺、超时、两源皆 null) | **保守**:仍判失败,但文案里必须写明「核验不可用(原因)」 |
22
+
23
+ ⛔ **铁律(有回归钉住)**:`unavailable` **永远不是** `effective`;核验函数**抛异常**也**永远**是
24
+ `unavailable`(见 `run()`)——"因为核验失败就当作成功"是本模块**明确禁止**的形态。
25
+
26
+ ## 判据表(逐条对应实现;来源全部**复用既有读取路径**,⛔ 不新造)
27
+
28
+ | 步骤 | 只读真相来源 | 判 `effective` | 判 `not_effective` | 判 `unavailable` |
29
+ |---|---|---|---|---|
30
+ | 3 建单 | 页面货件号(`resolve_shipment_id`,既有逻辑)+ 表内货件号 | 任一非空(续传语义:已有号 ⇒ 不重建) | 探针可用 ∧ 两处都没有号 | 页面探针不可用 ∧ 表内也没有号 |
31
+ | 4 预约 | 列表页 Shipment 行(与 `query_runner.py --mode shipment` **同一读取路径**:`INBOUNDS_URL?query=<号>` + `JS_EXTRACT_SHIPMENTS`) | `appointment` 非空(幂等:已预约就不重复预约) | 行取到 ∧ `appointment` 为空 | 行取不到 / 读取报错 / 字段残缺 |
32
+ | 8 取消预约 | ① hub-v2 探针(`ml_hub_status.parse_hub_dump` 的 `appointment_cancelled`)② 列表页 `status` / `appointment` | hub `true` **或** 列表 `status=Reserva cancelada` **或** 列表 `appointment` 为空(任一为真) | hub **硬反证**(`Cancelar reserva` 仍在/改期入口在)**或** 列表 `appointment` 非空 | 两源都判不了(探针不可用 / 页面残缺 / 字段空) |
33
+
34
+ ### 步骤8 的**反证优先**(与 `ml_hub_status._appointment_cancelled` 同一条纪律)
35
+
36
+ `ml_hub_status` 的模块 docstring 写明:"反证优先 ⇒ `false` 压过一切正证据"。
37
+ 本模块**沿用**该纪律:hub 给出**硬反证**(判据 ∈ `HUB_ALIVE_JUDGES`:预约卡上仍有取消按钮/
38
+ 改期入口/明确"预约还活着"的文案)时,即使列表页说已取消,也判 `not_effective`
39
+ —— 两源冲突时**以"预约还活着"为准**(⛔ 绝不把真失败洗白)。冲突事实进 `evidence`,运维可复核。
40
+
41
+ ## 分层(与 `ml_hub_status.py` 同构)
42
+
43
+ - **纯判据(本文件 100%)**:只吃**证据 dict**、只吐结论;无浏览器、无网络、无宿主依赖(只有 stdlib)
44
+ ⇒ 可用纯夹具做回归(`scripts/tests/prefail-verify.test.py`)。
45
+ - **取证据**(薄壳,在 `orchestrator.py`):`_verify_write_step()` 只负责"只读导航 + evaluate",
46
+ 判据一律回落到本模块 —— ⛔ 别在编排器里再写一份 if/else 判据。
47
+
48
+ ## CLI(离线,不需要浏览器;供人工复核与门禁)
49
+
50
+ python3 scripts/flow_write_verify.py --step 8 --fixture cancelled-by-list
51
+ python3 scripts/flow_write_verify.py --step 8 --hub-file h.json --row-file r.json --json
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import pathlib
59
+ import re
60
+ import sys
61
+ from typing import Any, Callable, Optional
62
+
63
+ # ────────────────────────────────────────────────
64
+ # 三态常量(**唯一口径**;⛔ 不要在别处再写字符串字面量)
65
+ # ────────────────────────────────────────────────
66
+ VERDICT_EFFECTIVE = "effective"
67
+ VERDICT_NOT_EFFECTIVE = "not_effective"
68
+ VERDICT_UNAVAILABLE = "unavailable"
69
+ VERDICTS = (VERDICT_EFFECTIVE, VERDICT_NOT_EFFECTIVE, VERDICT_UNAVAILABLE)
70
+
71
+ STEPS = (3, 4, 8)
72
+ STEP_TITLES = {3: "步骤3 建单", 4: "步骤4 预约", 8: "步骤8 取消预约"}
73
+ # 判成功时写进备注/台账的**真相短语**(与 `STEP_TITLES` 一起构成唯一措辞的一环)
74
+ TRUTH_LABELS = {
75
+ (3, VERDICT_EFFECTIVE): "已有货件号",
76
+ (4, VERDICT_EFFECTIVE): "已预约",
77
+ (8, VERDICT_EFFECTIVE): "已取消",
78
+ }
79
+
80
+ # 列表页 `status` 的**取消**文案(确定性词表;与 `ml_hub_status` 的 `_APPT_CANCELLED_RE` 同族)。
81
+ # ⚠️ 只用区分度高的词:`cancelad` 单独太宽("cancelada" 可能出现在别的语境),故要求带
82
+ # `reserva`/`cita` 主体,或 `sin reserva`("没有预约")。
83
+ _LIST_CANCELLED_RE = re.compile(
84
+ r"reserva\s+cancelada|cita\s+cancelada|cancelaste\s+la\s+reserva|sin\s+reserva",
85
+ re.IGNORECASE)
86
+
87
+ # hub 的**硬反证**判据名(预约明确还活着)——取自 `ml_hub_status._appointment_cancelled` 的
88
+ # `(值, 判据名, …)` 返回值,逐字对齐;⛔ 别改这里的名字而不改那边。
89
+ HUB_ALIVE_JUDGES = (
90
+ "cancel-button-present",
91
+ "appointment-alive-text",
92
+ "appointment-reschedule-affordance",
93
+ )
94
+
95
+
96
+ # ────────────────────────────────────────────────
97
+ # 取值原语(全部**防御式**:畸形入参一律当"取不到",⛔ 不抛异常)
98
+ # ────────────────────────────────────────────────
99
+ def _text(v: Any) -> str:
100
+ if v is None:
101
+ return ""
102
+ if isinstance(v, bool):
103
+ return "true" if v else "false"
104
+ return str(v).strip()
105
+
106
+
107
+ def _one_line(v: Any, limit: int = 160) -> str:
108
+ """压成单行 + 截断(证据串要能进表备注/台账,⛔ 不能带换行)。"""
109
+ s = " ".join(_text(v).split())
110
+ return s[:limit] + ("…" if len(s) > limit else "")
111
+
112
+
113
+ # ⛔ 2026-10-10 生产事故(**假成功**):ML 列表页在"**没有预约**"时返回的字面量是 `"-"`
114
+ # (只读实测:未预约票 `appointment='-'`;已预约票 `appointment='11/octubre 2026, 13:00 hs…'`)。
115
+ # 旧实现只看"非空" ⇒ `-` 被当成已预约 ⇒ 步骤4 报错后被判 success,**表里标完成 + 发了完成卡**,
116
+ # 而货件其实一张预约都没有。⇒ 一切"空值占位符"必须归一为空串。
117
+ _APPT_EMPTY_TOKENS = {"-", "--", "—", "–", "n/a", "na", "none", "null", "sin cita", "sin reserva"}
118
+
119
+
120
+ def _row_appointment(row: Any) -> str:
121
+ """列表页 `appointment` 的**有无判据**(唯一实现):`-`/`—`/空/none… 一律视为"无预约"。"""
122
+ s = _one_line((row or {}).get("appointment")) if isinstance(row, dict) else ""
123
+ return "" if s.strip().lower() in _APPT_EMPTY_TOKENS else s
124
+
125
+
126
+ # ⛔ **第二道假成功闸门**(2026-10-10,紧接上面那次占位符事故):一次**真实预约**的 `appointment`
127
+ # **必带数字**(日期/时间,实测形态 `11/octubre 2026, 13:00 hs…`)。判"已预约"时**要求含数字**:
128
+ # 平台改版/新文案带来的**未知占位符**(如 `Programada`、`Pendiente de confirmar`)一律**不算已预约**
129
+ # ⇒ 走**保守失败**(⛔ 宁可让人复核,也绝不表标完成)。
130
+ # ⚠️ 方向刻意不对称:本闸门只收紧"**判成功**"(步骤4);步骤8 的"预约仍在"反证仍按
131
+ # `_row_appointment()` 的非空判断(未知值 ⇒ 当作"仍在" ⇒ 判失败)——两个方向都朝"失败"偏。
132
+ _APPT_DIGIT_RE = re.compile(r"\d")
133
+
134
+
135
+ def _row_appointment_booked(row: Any) -> str:
136
+ """**只有"确实像一次真实预约"时才返回非空**(步骤4 判"已预约"的唯一入口)。
137
+
138
+ 判据 = `_row_appointment()` 归一后非空 ∧ 含数字。⛔ 未知占位符 ⇒ 空 ⇒ 不判成功。
139
+ """
140
+ s = _row_appointment(row)
141
+ return s if s and _APPT_DIGIT_RE.search(s) else ""
142
+
143
+
144
+ def _row_status(row: Any) -> str:
145
+ return _one_line((row or {}).get("status")) if isinstance(row, dict) else ""
146
+
147
+
148
+ def _hub_flag(hub: Any) -> Optional[bool]:
149
+ """hub 的 `appointment_cancelled` 三态(`True`/`False`/`None`)。
150
+
151
+ ⛔ 只认**真布尔**:字符串 `"true"`/`"false"` 一律当"判不了"(页面解析产物必须已是布尔,
152
+ 宽松解析会把"拿不到"读成"没取消/已取消",两个方向都危险)。探针无效(`probe_ok=False`)
153
+ 同样当"判不了"。
154
+ """
155
+ if not isinstance(hub, dict):
156
+ return None
157
+ if not hub.get("probe_ok"):
158
+ return None
159
+ v = hub.get("appointment_cancelled")
160
+ return v if isinstance(v, bool) else None
161
+
162
+
163
+ def _hub_judge(hub: Any) -> str:
164
+ if not isinstance(hub, dict):
165
+ return ""
166
+ return _one_line(((hub.get("signals") or {}) if isinstance(hub.get("signals"), dict) else {})
167
+ .get("appointment_cancelled_by"), 60)
168
+
169
+
170
+ def _hub_alive_hard(hub: Any) -> bool:
171
+ """hub 的 `appointment_cancelled=False` 是不是**硬反证**(预约明确还活着)。"""
172
+ return _hub_flag(hub) is False and _hub_judge(hub) in HUB_ALIVE_JUDGES
173
+
174
+
175
+ def _result(verdict: str, why: str, evidence: list, sources: list, data: Any = None) -> dict:
176
+ """统一结论形状(`verdict` 一定是三态之一;`evidence`/`sources` 供收口文案逐字引用)。
177
+
178
+ `data` = **结构化事实**(调用方要据此改状态时用,例如步骤3 核验出的货件号 ⇒ 必须落表,
179
+ 否则下一步又回到"没有货件号")。⛔ 它只为 `effective` 服务,判失败时不带任何事实。
180
+ """
181
+ if verdict not in VERDICTS: # 防御:非法值 ⇒ 保守
182
+ verdict, why = VERDICT_UNAVAILABLE, f"非法结论 {verdict!r} ⇒ 按不可用保守处理"
183
+ return {
184
+ "verdict": verdict,
185
+ "effective": verdict == VERDICT_EFFECTIVE,
186
+ "why": _one_line(why, 240),
187
+ "evidence": [e for e in (_one_line(x, 200) for x in (evidence or [])) if e],
188
+ "sources": [s for s in (_one_line(x, 80) for x in (sources or [])) if s],
189
+ "data": dict(data) if isinstance(data, dict) and verdict == VERDICT_EFFECTIVE else {},
190
+ }
191
+
192
+
193
+ # ────────────────────────────────────────────────
194
+ # 各步判据
195
+ # ────────────────────────────────────────────────
196
+ def verify_step3(*, table_shipment_id: Any = "", page_shipment_id: Any = "",
197
+ page_from_url: bool = False, probe_ok: bool = True,
198
+ probe_error: Any = "") -> dict:
199
+ """步骤3(建单):**已有货件号 ⇒ 不是失败**(续传语义,⛔ 不重建)。
200
+
201
+ - `table_shipment_id` = 表内「货件号」(行里已有 ⇒ 本来就会跳过步3,这里是二次兜底);
202
+ - `page_shipment_id` = **既有取号逻辑**(`orchestrator.resolve_shipment_id`,URL `/inbounds/` 优先、
203
+ `/plans/` 计划号永不采信)在**失败后现取**的结果(⑮ 竞态:页面其实已跳 hub 却报了失败);
204
+ - `page_from_url` = 该号是否**来自 URL 的 `/inbounds/<号>`**(既有逻辑的**最高优先级**来源)。
205
+ ⛔ 只有它为真时页面号才算数:标题/正文兜底可能命中**列表文案里的别的号**(与缺陷⑰
206
+ "把计划号当货件号"同族)⇒ 宁可判"拿不准",也不拿一个未经 URL 确认的号去判成功。
207
+ - `probe_ok=False` = 页面取号探针本身没跑成(evaluate 抛异常/页面已关)⇒ 只有表内号能救。
208
+ """
209
+ t_id, p_id = _text(table_shipment_id), _text(page_shipment_id)
210
+ if t_id:
211
+ return _result(VERDICT_EFFECTIVE, f"表内已有货件号 {t_id} ⇒ 续传语义(不重建)",
212
+ [f"货件号={t_id}"], ["表内货件号"], {"shipment_id": t_id, "from": "table"})
213
+ if p_id and page_from_url:
214
+ return _result(VERDICT_EFFECTIVE, f"页面已产出货件号 {p_id} ⇒ 建单其实已生效",
215
+ [f"货件号={p_id}", "来源:URL /inbounds/"], ["页面货件号(既有取号逻辑)"],
216
+ {"shipment_id": p_id, "from": "page-url"})
217
+ if not probe_ok:
218
+ return _result(VERDICT_UNAVAILABLE,
219
+ f"页面取号探针不可用({_one_line(probe_error) or '未给出原因'})∧ 表内无货件号",
220
+ [], ["页面取号探针", "表内货件号"])
221
+ if p_id:
222
+ return _result(VERDICT_UNAVAILABLE,
223
+ f"页面取到疑似号 {p_id} 但**未落到 URL /inbounds/**(可能只是列表文案里的号)"
224
+ f"⇒ 判不了,按不可用保守处理",
225
+ [], ["页面货件号(既有取号逻辑)", "表内货件号"])
226
+ return _result(VERDICT_NOT_EFFECTIVE, "页面与表内都没有货件号 ⇒ 建单确实没生效",
227
+ ["货件号=(空)"], ["页面货件号(既有取号逻辑)", "表内货件号"])
228
+
229
+
230
+ def verify_step4(*, shipment_row: Any = None, read_ok: bool = True,
231
+ read_error: Any = "") -> dict:
232
+ """步骤4(预约):**`appointment` 像一次真实预约 ⇒ 预约已成功**(幂等,⛔ 不重复预约)。
233
+
234
+ 真相来源 = 列表页 Shipment 行,与 `query_runner.py --mode shipment` **同一读取路径**
235
+ (`INBOUNDS_URL?query=<号>` + `JS_EXTRACT_SHIPMENTS`)⇒ 复用,不新造。
236
+ ⛔ "非空"**不等于**"已预约":空占位符(`-` 等)与未知文案都要排除(见 `_row_appointment_booked`)。
237
+ """
238
+ if not read_ok or not isinstance(shipment_row, dict):
239
+ return _result(VERDICT_UNAVAILABLE,
240
+ f"列表页读取不可用({_one_line(read_error) or '查不到该货件号'})⇒ 预约状态无法核验",
241
+ [], ["列表页 Shipment 行"])
242
+ appt, status = _row_appointment(shipment_row), _row_status(shipment_row)
243
+ booked = _row_appointment_booked(shipment_row)
244
+ if booked:
245
+ return _result(VERDICT_EFFECTIVE, f"列表页 appointment={booked} ⇒ 预约已成功(幂等)",
246
+ [f"appointment={booked}", f"status={status}"], ["列表页 Shipment 行"])
247
+ shown = appt or "(空/占位符)"
248
+ why = (f"列表页 appointment={shown}(**不像真实预约**:无日期数字 ⇒ 不算已预约;"
249
+ f"status={status or '空'})⇒ 预约未生效") if appt else \
250
+ f"列表页 appointment 为空(status={status or '空'})⇒ 预约未生效"
251
+ return _result(VERDICT_NOT_EFFECTIVE, why,
252
+ [f"appointment={shown}", f"status={status}"], ["列表页 Shipment 行"])
253
+
254
+
255
+ def verify_step8(*, hub: Any = None, shipment_row: Any = None, list_ok: bool = True,
256
+ list_error: Any = "", hub_error: Any = "") -> dict:
257
+ """步骤8(取消预约):**hub 或列表页任一为真 ⇒ 取消已生效 ⇒ 判成功**。
258
+
259
+ ⛔ 反证优先:hub **硬反证**(预约卡仍有取消按钮/改期入口)⇒ `not_effective`,
260
+ 即便列表页说已取消(两源冲突以"预约还活着"为准;冲突进 `evidence` 供复核)。
261
+ """
262
+ evidence, sources, positives, negatives, unknowns = [], [], [], [], []
263
+
264
+ # ── ① hub-v2 探针 ──────────────────────────────
265
+ if not isinstance(hub, dict) or not hub.get("probe_ok"):
266
+ why = _one_line(hub_error) or ("hub-v2 探针未运行" if hub is None else "hub-v2 页面残缺(probe_ok=false)")
267
+ unknowns.append(f"hub-v2 不可用({why})")
268
+ sources.append("hub-v2 探针")
269
+ else:
270
+ sources.append("hub-v2 探针")
271
+ flag, judge = _hub_flag(hub), _hub_judge(hub)
272
+ tag = f"hub-v2 appointment_cancelled={_text(flag)}" + (f"(判据 {judge})" if judge else "")
273
+ if flag is True:
274
+ positives.append(tag)
275
+ evidence.append(f"{tag} ⇒ 已取消")
276
+ elif flag is False:
277
+ negatives.append(tag)
278
+ evidence.append(f"{tag} ⇒ 预约仍有效" if judge in HUB_ALIVE_JUDGES else f"{tag} ⇒ 环节未结束")
279
+ else:
280
+ unknowns.append(f"hub-v2 appointment_cancelled=null(判不了,判据 {judge or '无'})")
281
+
282
+ # ── ② 列表页 ───────────────────────────────────
283
+ row_ok = bool(list_ok) and isinstance(shipment_row, dict)
284
+ if not row_ok:
285
+ unknowns.append(f"列表页不可用({_one_line(list_error) or '查不到该货件号'})")
286
+ sources.append("列表页 Shipment 行")
287
+ else:
288
+ sources.append("列表页 Shipment 行")
289
+ appt, status = _row_appointment(shipment_row), _row_status(shipment_row)
290
+ if _LIST_CANCELLED_RE.search(status):
291
+ positives.append(f"列表页 status={status}")
292
+ evidence.append(f"列表页 status={status} ⇒ 已取消")
293
+ elif appt:
294
+ negatives.append(f"列表页 appointment={appt}(预约仍在)")
295
+ evidence.append(f"列表页 appointment={appt} ⇒ 预约仍在")
296
+ elif status:
297
+ # 有 status 但预约列为空 ⇒ 没有生效中的预约 = 取消(或不曾预约)目标已达成(幂等)
298
+ positives.append(f"列表页 appointment 为空(status={status})")
299
+ evidence.append(f"列表页 appointment 为空(status={status})⇒ 无生效中预约")
300
+ else:
301
+ unknowns.append("列表页字段残缺(status 与 appointment 皆空)⇒ 判不了")
302
+
303
+ # ── ③ 汇总(⛔ 反证优先;unknown 永远不升级为 success)──
304
+ if _hub_alive_hard(hub):
305
+ conflict = f";⚠️ 与列表页证据冲突({';'.join(positives)})" if positives else ""
306
+ return _result(VERDICT_NOT_EFFECTIVE,
307
+ f"hub-v2 反证:{_hub_judge(hub)} ⇒ 预约仍有效{conflict}",
308
+ evidence, sources)
309
+ if positives:
310
+ return _result(VERDICT_EFFECTIVE,
311
+ f"已核验取消生效({';'.join(positives)})", evidence, sources)
312
+ if negatives:
313
+ return _result(VERDICT_NOT_EFFECTIVE,
314
+ f"核验明确否定:{';'.join(negatives)} ⇒ 取消未生效", evidence, sources)
315
+ return _result(VERDICT_UNAVAILABLE,
316
+ ";".join(unknowns) or "两处真相都取不到", evidence, sources)
317
+
318
+
319
+ VERIFIERS: dict[int, Callable[..., dict]] = {3: verify_step3, 4: verify_step4, 8: verify_step8}
320
+
321
+
322
+ def run(step: int, *args: Any, **kwargs: Any) -> dict:
323
+ """**永不抛**包装:核验函数抛异常 ⇒ `unavailable`(⛔ 绝不当成功;有回归钉住)。
324
+
325
+ 为什么需要它(而不是让调用方 try/except):核验本身出问题(选择器变了、探针超时、
326
+ 证据 dict 畸形)在**任何**调用点都**只能是"判不了"**。把这条纪律钉在一个函数里,
327
+ 就不会出现"某条路径忘了 try ⇒ 异常冒泡被上层当成功"的形态。
328
+ """
329
+ fn = VERIFIERS.get(int(step))
330
+ if fn is None:
331
+ return _result(VERDICT_UNAVAILABLE, f"步骤{step} 没有核验器(只有 {sorted(VERIFIERS)})", [], [])
332
+ try:
333
+ out = fn(*args, **kwargs)
334
+ except Exception as exc: # noqa: BLE001 —— 见 docstring
335
+ return _result(VERDICT_UNAVAILABLE,
336
+ f"核验过程异常({type(exc).__name__}: {_one_line(exc, 120)})⇒ 按不可用保守处理",
337
+ [], [])
338
+ if not isinstance(out, dict):
339
+ return _result(VERDICT_UNAVAILABLE, f"核验返回非 dict({type(out).__name__})⇒ 按不可用保守处理", [], [])
340
+ # ⚠️ 必须把 `data` 一起收敛:它是"判成功后调用方要据此改状态"的结构化事实
341
+ # (步骤3 核验出的货件号 ⇒ 要落表,否则下一轮又回到"没有货件号 ⇒ 重建")。
342
+ return _result(str(out.get("verdict") or ""), str(out.get("why") or ""),
343
+ list(out.get("evidence") or []), list(out.get("sources") or []),
344
+ out.get("data"))
345
+
346
+
347
+ def is_effective(verdict: Any) -> bool:
348
+ """**判成功的唯一入口**:只认 `effective` **且**证据串非空(空证据不算核验过)。"""
349
+ if not isinstance(verdict, dict):
350
+ return False
351
+ return (str(verdict.get("verdict") or "") == VERDICT_EFFECTIVE
352
+ and bool(verdict.get("evidence")))
353
+
354
+
355
+ def unavailable(why: Any) -> dict:
356
+ """构造一个 `unavailable` 结论(核验模块缺失/取证据异常/返回畸形时的**唯一**兜底)。
357
+
358
+ ⛔ 存在的意义就是"没有第二条路能产出 `effective`":调用方拿不到结论时只能调它。
359
+ """
360
+ return _result(VERDICT_UNAVAILABLE, _text(why) or "核验不可用(未给出原因)", [], [])
361
+
362
+
363
+ def truth_label(step: int, verdict: Any) -> str:
364
+ """判成功时的真相短语(`已有货件号`/`已预约`/`已取消`)——进备注串,**单一实现**。"""
365
+ v = str((verdict or {}).get("verdict") if isinstance(verdict, dict) else verdict or "")
366
+ return TRUTH_LABELS.get((int(step), v), "已生效")
367
+
368
+
369
+ def evidence_line(verdict: Any) -> str:
370
+ """证据来源串(进备注/台账:hub-v2 的哪一项 / 列表页 status)——**单一实现**。"""
371
+ if not isinstance(verdict, dict):
372
+ return ""
373
+ parts = [str(x) for x in (verdict.get("evidence") or []) if str(x).strip()]
374
+ return ";".join(parts)
375
+
376
+
377
+ # ────────────────────────────────────────────────
378
+ # 内置夹具(纯离线;供 CLI 演示与回归共用判据)
379
+ # ────────────────────────────────────────────────
380
+ # ⚠️ 夹具里的 hub dict 用**真机形态**的字段(`probe_ok` / `appointment_cancelled` / `signals`),
381
+ # 与 `ml_hub_status.parse_hub_dump()` 的产物同形 ⇒ 夹具与真机走**同一条判据**。
382
+ FIXTURES: dict[str, dict] = {
383
+ # 本现场复刻(79191341):平台错误页 ⇒ 按钮找不到 ⇒ 脚本报失败;而真相是「已取消」。
384
+ "cancelled-by-list": {
385
+ "step": 8,
386
+ "hub": {"probe_ok": True, "appointment_cancelled": None,
387
+ "signals": {"appointment_cancelled_by": "appointment-region-missing"}},
388
+ "shipment_row": {"id": "79191341", "appointment": "", "status": "Reserva cancelada"},
389
+ "expect": VERDICT_EFFECTIVE,
390
+ },
391
+ "cancelled-by-hub": {
392
+ "step": 8,
393
+ "hub": {"probe_ok": True, "appointment_cancelled": True,
394
+ "signals": {"appointment_cancelled_by": "cancelled-text"}},
395
+ "shipment_row": None,
396
+ "list_error": "列表页读取超时",
397
+ "expect": VERDICT_EFFECTIVE,
398
+ },
399
+ "appointment-still-alive": {
400
+ "step": 8,
401
+ "hub": {"probe_ok": True, "appointment_cancelled": False,
402
+ "signals": {"appointment_cancelled_by": "cancel-button-present"}},
403
+ "shipment_row": {"id": "79191341", "appointment": "2026-10-12 14:00", "status": "Reserva programada"},
404
+ "expect": VERDICT_NOT_EFFECTIVE,
405
+ },
406
+ "probe-unusable": {
407
+ "step": 8, "hub": None, "hub_error": "store_busy", "shipment_row": None,
408
+ "list_error": "页面残缺", "expect": VERDICT_UNAVAILABLE,
409
+ },
410
+ "appointment-booked": {
411
+ "step": 4,
412
+ "shipment_row": {"id": "79191341", "appointment": "2026-10-12 14:00-16:00", "status": "Reserva programada"},
413
+ "expect": VERDICT_EFFECTIVE,
414
+ },
415
+ "appointment-missing": {
416
+ "step": 4,
417
+ "shipment_row": {"id": "79191341", "appointment": "", "status": "Pendiente de agendar"},
418
+ "expect": VERDICT_NOT_EFFECTIVE,
419
+ },
420
+ "resume-shipment": {
421
+ "step": 3, "table_shipment_id": "79191341", "expect": VERDICT_EFFECTIVE,
422
+ },
423
+ "no-shipment": {
424
+ "step": 3, "page_shipment_id": "", "probe_ok": True, "expect": VERDICT_NOT_EFFECTIVE,
425
+ },
426
+ }
427
+
428
+
429
+ def run_fixture(name: str) -> dict:
430
+ """跑内置夹具(离线;返回 `{'name','step','verdict','expect','pass'}`)。"""
431
+ f = dict(FIXTURES.get(name) or {})
432
+ if not f:
433
+ raise KeyError(name)
434
+ step = int(f.pop("step"))
435
+ expect = f.pop("expect", "")
436
+ got = run(step, **f)
437
+ return {"name": name, "step": step, "verdict": got["verdict"], "expect": expect,
438
+ "pass": got["verdict"] == expect, "why": got["why"], "evidence": got["evidence"]}
439
+
440
+
441
+ def _load_json_arg(path: str) -> Any:
442
+ if not path:
443
+ return None
444
+ if path == "-":
445
+ return json.loads(sys.stdin.read() or "{}")
446
+ return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
447
+
448
+
449
+ def main(argv: Optional[list[str]] = None) -> int:
450
+ ap = argparse.ArgumentParser(description="幂等写步骤的失败前核验(纯判据,离线)")
451
+ ap.add_argument("--step", type=int, choices=list(VERIFIERS))
452
+ ap.add_argument("--fixture", help="跑内置夹具(" + "、".join(FIXTURES) + ")")
453
+ ap.add_argument("--table-shipment-id", default="", help="步骤3:表内货件号")
454
+ ap.add_argument("--page-shipment-id", default="", help="步骤3:页面现取的货件号")
455
+ ap.add_argument("--probe-ok", default="1", help="步骤3:页面取号探针是否跑成(0/1)")
456
+ ap.add_argument("--hub-file", help="hub 结论 JSON(ml_hub_status.parse_hub_dump 的产物)")
457
+ ap.add_argument("--row-file", help="列表页 Shipment 行 JSON")
458
+ ap.add_argument("--list-error", default="", help="列表页读取失败原因(取不到行时填)")
459
+ ap.add_argument("--hub-error", default="", help="hub 探针不可用原因")
460
+ ap.add_argument("--json", action="store_true")
461
+ a = ap.parse_args(argv)
462
+
463
+ if a.fixture:
464
+ r = run_fixture(a.fixture)
465
+ print(json.dumps(r, ensure_ascii=False, indent=2) if a.json
466
+ else f" {r['name']}|step{r['step']}|{r['verdict']}(期望 {r['expect']})"
467
+ + ("✅" if r["pass"] else "❌") + f"|{r['why']}")
468
+ return 0 if r["pass"] else 1
469
+
470
+ if not a.step:
471
+ print("需要 --step 或 --fixture", file=sys.stderr)
472
+ return 2
473
+ kw: dict = {}
474
+ if a.step == 3:
475
+ kw = {"table_shipment_id": a.table_shipment_id, "page_shipment_id": a.page_shipment_id,
476
+ "probe_ok": a.probe_ok.strip() not in ("0", "false", "no")}
477
+ elif a.step == 4:
478
+ row = _load_json_arg(a.row_file)
479
+ kw = {"shipment_row": row, "read_ok": row is not None, "read_error": a.list_error}
480
+ else:
481
+ hub = _load_json_arg(a.hub_file)
482
+ row = _load_json_arg(a.row_file)
483
+ kw = {"hub": hub, "shipment_row": row, "list_ok": row is not None,
484
+ "list_error": a.list_error, "hub_error": a.hub_error}
485
+ out = run(a.step, **kw)
486
+ if a.json:
487
+ print(json.dumps(out, ensure_ascii=False, indent=2))
488
+ else:
489
+ print(f" {STEP_TITLES.get(a.step, a.step)}|{out['verdict']}|{out['why']}")
490
+ if out["evidence"]:
491
+ print(f" 证据:{';'.join(out['evidence'])}")
492
+ return 0
493
+
494
+
495
+ if __name__ == "__main__":
496
+ raise SystemExit(main())