@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,1158 @@
1
+ #!/usr/bin/env python3
2
+ """仓容(库容)前置探测 —— **纯判据 + 只读探针**(FULL 自动路径成功率修复批,2026-10-10)。
3
+
4
+ ## 它解决什么(用户亲历 + 我方实测)
5
+
6
+ 10-10 客户机 lip 表跑约 30 条 FULL,**约 1/3 失败**;同一天 CLI 手动串行跑 6 票**全部 8/8 成功**。
7
+ 逐条对账后,失败**全部**与「**仓容满** / 预约页形态异常」相关:平台原文
8
+
9
+ para enviar productos, primero deberás liberar espacio de almacenamiento
10
+ retirando stock de baja calidad
11
+
12
+ 且列表页显示 `N Envíos para terminar de preparar`。旧路径**不先探测就每次撞墙**:步骤4 预约
13
+ 一路试到窗口尽头,把「仓容满」这个**一句话就能判定的前提问题**写成了一个看起来像
14
+ 「平台没放号 / 页面形态异常」的业务失败,运营看了也不知道该干什么。
15
+
16
+ ⇒ 本模块给「**进步骤4 之前先只读看一眼仓容**」这条链一份**确定性判据**。
17
+
18
+ ## 分层(⛔ 别把浏览器塞进判据里)
19
+
20
+ - **纯判据(本文件 99%)**:`capacity_verdict()` 吃「只读证据字典」吐三态结论。
21
+ **无浏览器、无网络、无宿主依赖**(只有 stdlib)⇒ 可用纯夹具做回归。
22
+ - **只读探针**(`JS_CAPACITY_DUMP` + 编排器的 `Orchestrator._capacity_precheck()`):
23
+ 导航到店铺列表页(**只读,⛔ 不点任何按钮**)→ `page.evaluate` → 交给本模块判定。
24
+
25
+ ## 三态(**唯一口径**,调用方只认这三个值)
26
+
27
+ | 值 | 含义 | 调用方该做什么 |
28
+ |---|---|---|
29
+ | `capacity_full` | 页面**逐字**出现平台仓容原文 **∧ 该原文处于「严格阻断形态」** ⇒ 仓容确已满 | **⛔ 不进入步骤4**,直接落 `capacity_full` 类失败(文案见 `capacity_failure_text()`) |
30
+ | `capacity_available` | 页面**确认可读**且**没有任何**仓容原文 ⇒ 仓容可用 | 照常进入步骤4;若本 case 有活跃的仓容类 defer 标记 ⇒ **当日解除**(见 `defer_lift_decision()`) |
31
+ | `unavailable` | 其余**一切**情形(页面取不到 / 超时 / 平台错误页 / 关键区域未渲染 /「Enviar productos」明确不存在 / **命中原文但不是阻断形态(含说明卡)** / 结构证据不足) | **保守**:⛔ **不**判仓容满、⛔ **也**不判仓容正常 —— 照常尝试预约,证据如实留痕 |
32
+
33
+ ⛔ **铁律(有回归钉住)**:`unavailable` **永远不是** `capacity_full`,也**永远不是**
34
+ `capacity_available`。探针抛异常也**永远**是 `unavailable`(见 `capacity_verdict()` 的
35
+ 残缺闸门)——"因为探测失败就当作仓容满"会**凭空拦下本来能成的票**(自动路径成功率反而更低),
36
+ "因为探测失败就当作仓容正常"则等于没有这道闸门。
37
+
38
+ ## 判据表(逐条对应实现;**顺序即优先级,先命中先赢**)
39
+
40
+ | 步 | 条件 | 结论 |
41
+ |---|---|---|
42
+ | 0 | **残缺闸门**:无来源 / 所有来源 `ok=False`(导航失败、超时、evaluate 异常) | `unavailable`(逐源原因进 `evidence`) |
43
+ | 1 | 平台原文命中(文本 / 既有 `capacity_warning` / **命中元素**)**∧ 严格阻断形态成立**(见下节) | `capacity_full`(原文**逐字**进 `quote`) |
44
+ | 1b | 平台原文命中但**阻断形态不成立**(说明/帮助卡、折叠在页面底部、元素不可见、**结构证据不足**) | **`unavailable`**(`judge=quote-not-blocking`)⇒ 照常尝试步骤4,证据(命中文本/元素位置与可见性/阻断横幅有无/按钮状态)全进 `evidence` |
45
+ | 2 | 原文未命中 ∧ 有来源 `ok=True` ∧ **是**平台错误页(`error_page` 非空) | `unavailable`(错误页 ⇒ 读不到仓容真相) |
46
+ | 3 | 原文未命中 ∧ 有来源 `ok=True` ∧ 页面文本非空 ∧ `enviar_present is not False` | `capacity_available` |
47
+ | 4 | 其余(`ok=True` 但文本空 / 明确"Enviar productos 不存在") | `unavailable` |
48
+
49
+ ### 严格阻断形态(判据要**纯函数化**:`blocking_form()`;2026-10-12 收紧)
50
+
51
+ **为什么收紧**:旧判据是「整页 `innerText` 前 6000 字**包含**该短语」**或**「任一 `.andes-card`
52
+ 含 `liberar espacio`」⇒ **见词就判满**。生产实测(2026-10-12,货件 79294682):屏幕 OCR 显示
53
+ 该页是**正常的 FULL 管理页**(徽标 `84 Envíos para terminar de preparar`、按钮 **`Enviar productos`
54
+ 在**、列表有正常行、**没有**任何"库容满"横幅),命中来自 DOM 里**别处**的文本(最可能是页面
55
+ 下方的**说明/帮助卡**含同一句话)⇒ 判成 `capacity_full` 把**能跑的票**拦死(代价 = 一天延迟 +
56
+ 人工介入;实测同一票手动重跑 **8/8 成功**)。所以:**平台原文只是必要条件,不是充分条件**。
57
+
58
+ 一个来源命中原文时,**同时**满足下列三条才算「阻断形态」(三选一即算,`shape` 进证据):
59
+
60
+ 1. **可交互/阻断位置**(任一):
61
+ - **S1 模态/对话框内**:命中元素在**可见**的 `.andes-modal` / `.andes-dialog` /
62
+ `[role=dialog|alertdialog]` 内(模态本来就是阻断交互的载体);
63
+ - **S2 alert/banner 紧邻主操作按钮上方**:命中元素在 alert/banner 容器内
64
+ (`.andes-alert`/`.andes-banner`/`.andes-message`/`.andes-notification`/`[role=alert]`),
65
+ 且与「`Enviar productos`」按钮**水平重叠**、纵向间距 ≤ `PRIMARY_ADJACENT_GAP_PX`;
66
+ - **S3 主操作按钮缺失/禁用**:列表**已渲染**时按钮 `enviar_present is False` 或
67
+ `enviar_disabled is True`(`selectors.md` 记「库容满时该按钮不显示/禁用」)。
68
+ 2. **该元素可见**:非零 rect ∧ 自身 `display != none` ∧ `visibility ∉ {hidden, collapse}` ∧
69
+ `opacity > 0`(零 rect 同时覆盖"祖先 `display:none`"——那种元素**不该**参与阻断)。
70
+ 3. **该元素参与阻断**:在**视口内**(不被折叠到页面底部)∧ **不在说明/帮助卡内**
71
+ (modal / alert 容器例外)∧ **有几何证据**(缺 viewport/rect ⇒ **不知道**,一律不硬判)。
72
+
73
+ ⛔ **结构证据不足(旧探针形态 / 无 `quote_hits` / 无 viewport)⇒ 一律 `unavailable`**
74
+ (保守照常尝试),⛔ **绝不**因为"文本里有那串字"就判满。这条是 2026-10-12 误报的根治点:
75
+ **宁可漏判(= 照常试一次,成本几秒),不可误拦(= 一天延迟 + 人工)。**
76
+
77
+ ### 三个刻意的设计(改这条 = 改判据)
78
+
79
+ 1. **仓容与"日历形态"是两条链,⛔ 绝不合并**:
80
+ 本模块**只**回答"仓容满不满";「`div.day` 命中 0 ⇒ 日历没渲染」由
81
+ `orchestrator.appointment_form_verdict()` 独立判定(那是**页面形态**问题,另有取证与处置)。
82
+ 本模块**不看** `div.day` / `div.hour` 的命中数,也**不产出**任何"无时段/没放号"的说法。
83
+ 2. **`N Envíos para terminar de preparar` 是佐证,不是判据**:
84
+ 它单独出现**只说明该店还有待备货的货件**(正常状态也会有),⇒ 只记
85
+ `corroborated=True` + `prepared_count=N` **留痕**,⛔ **不足以**判 `capacity_full`
86
+ (把它当判据 = 把正常页判成仓容满,凭空拦住能成的票,比漏判更贵)。反过来,
87
+ 平台原文命中**且阻断形态成立**时**不论**有没有这句横幅都判 `capacity_full`
88
+ (那时原文是硬证据,漏判 = 继续撞墙)。
89
+ 3. **`enviar_present is False` 只在"列表已渲染"时才作数**(`rendered is True`):
90
+ `skills/ml-fulfillment-sop/references/selectors.md` 已记「库容满时 `Enviar productos`
91
+ 按钮不显示/禁用」⇒ 按钮**明确不存在/禁用**且列表渲染过,是 S3 阻断形态的一部分。
92
+ 但 `None`(没找到 ≠ 不存在,可能是选择器没命中/页面没渲染完)**不作数** ——
93
+ 否则选择器一漂移,这道闸门就恒 `unavailable`,等于白做。
94
+ 4. **命中原文 ≠ 判"仓容正常"**:只在**一个平台原文片段都没有**时才敢判
95
+ `capacity_available`(那是能**当日解除 defer 护栏**的唯一证据)。命中但非阻断 ⇒
96
+ `unavailable`(照常尝试步骤4,但**不**提前解除护栏)——两条纪律方向不同,不是矛盾
97
+ (同 `defer_lift_decision()` 的说明)。
98
+
99
+ ## 与既有 `failureClasses` 的关系(⛔ 不新造第二条真相)
100
+
101
+ 平台原文片段来自**既有**判据,逐条有出处:
102
+
103
+ - `liberar espacio` —— `scripts/orchestrator.py` 的 `JS_EXTRACT_SHIPMENTS`(步骤1 既有探针的
104
+ `capacity_warning`)与 `skills/ml-fulfillment-sop/references/selectors.md`(「库容警告容器」行);
105
+ - `liberar espacio de almacenamiento` / `retirar stock de baja calidad` —— 客户机台账里的
106
+ **平台原文**(本批的背景证据,逐字)。
107
+
108
+ ⚠️ **2026-10-12 起,"原文片段"只用于「命中识别」与「分类侧」**(失败文案的 `quote`、
109
+ `failureClasses.capacityFull` 的机器标记)——**拦截判据另加"严格阻断形态"这一层**(见上节)。
110
+ 生产实测证明:同一句话也会出现在页面的**说明/帮助卡**里,而那时平台**并没有**拒绝发货
111
+ (同一票手动用运维开关重跑 **8/8 成功**)。
112
+
113
+ `scripts/flow-requirements.json#failureClasses.capacityFull` 是**分类侧**的同一份口径;
114
+ `PLATFORM_QUOTES` 与它**必须一致**(门禁 `scripts/tests/capacity-precheck.test.py` 钉住)。
115
+
116
+ ## CLI(离线,不需要浏览器)
117
+
118
+ python3 scripts/ml_capacity_probe.py --fixture full # 跑内置夹具
119
+ python3 scripts/ml_capacity_probe.py --evidence-file e.json # 判一份已抓到的证据
120
+ python3 scripts/ml_capacity_probe.py --fixture full --json
121
+
122
+ 真机抓取(只读)走编排器:`--mode capacity-probe [--store-name <店>]`。
123
+ """
124
+
125
+ from __future__ import annotations
126
+
127
+ import argparse
128
+ import json
129
+ import pathlib
130
+ import re
131
+ import sys
132
+ from typing import Any, Optional
133
+
134
+ # ══════════════════════════════════════════════════════════════════════════
135
+ # 一、三态常量(**唯一口径**;⛔ 不要在别处再写字符串字面量)
136
+ # ══════════════════════════════════════════════════════════════════════════
137
+ VERDICT_FULL = "capacity_full"
138
+ VERDICT_AVAILABLE = "capacity_available"
139
+ VERDICT_UNAVAILABLE = "unavailable"
140
+ VERDICTS = (VERDICT_FULL, VERDICT_AVAILABLE, VERDICT_UNAVAILABLE)
141
+
142
+ # ══════════════════════════════════════════════════════════════════════════
143
+ # 二、判据常量(**唯一一份**;⛔ 不许在 JS / 编排器 / 文档里再写第二份)
144
+ # ══════════════════════════════════════════════════════════════════════════
145
+
146
+ #: 平台仓容原文的**关键片段**(命中任一 ⇒ 仓容满)。出自上面 docstring 的「与既有判据的关系」。
147
+ PLATFORM_QUOTES = (
148
+ "liberar espacio de almacenamiento",
149
+ # 平台那句话里动词有两种形态(客户机台账抓到的是**副动词** `retirando`;既有技能文档里
150
+ # 写的是不定式 `retirar`)⇒ **两种都收**(漏了任一种 = 该形态的页面上闸门失效)。
151
+ "retirar stock de baja calidad",
152
+ "retirando stock de baja calidad",
153
+ "stock de baja calidad",
154
+ "liberar espacio",
155
+ )
156
+
157
+ #: 「`N Envíos para terminar de preparar`」——**佐证**判据(存在性 + N),⛔ 单独不足以判仓容满。
158
+ PREPARED_BANNER_RE = re.compile(
159
+ r"(\d{1,6})\s*env[ií]os?\s+para\s+terminar\s+de\s+preparar", re.IGNORECASE)
160
+
161
+ #: 平台原文那句自带的**引导语**(回引整句时用它把句首切准;客户机台账里的原文逐字)。
162
+ QUOTE_LEADINS = ("para enviar productos", "primero deberás", "primero deberas")
163
+
164
+ #: 业务词(用于文案措辞;⛔ 与 `DEFER_TAG` 分开:后者是**标记串**,会被写入表备注并被读回)。
165
+ CAPACITY_TERM = "仓容"
166
+
167
+ #: 仓容满时**唯一**该做的动作(运营口径)。文案必须逐字含它。
168
+ ACTION_TEXT = "需运营清低质库存腾库容"
169
+
170
+ #: 「Enviar productos」入口(`selectors.md`:库容满时此按钮不显示/禁用)。
171
+ ENVIAR_LABEL = "Enviar productos"
172
+
173
+ #: 平台错误页特征(与 `orchestrator.appointment_form_verdict` / failureClasses 同一批词元;
174
+ #: 只用来判"这一页读不出真相",⛔ **不**用它产出任何业务结论)。
175
+ ERROR_PAGE_MARKERS = (
176
+ "algo salió mal", "algo salio mal", "estamos trabajando para solucionarlo",
177
+ "hubo un error accediendo", "error code:",
178
+ )
179
+
180
+ # ── 严格阻断形态(2026-10-12;判据见模块 docstring,实现见 `blocking_form()`)─────────
181
+
182
+ #: 「紧邻主操作按钮」的**纵向**容忍度(px):命中 alert 底边到主按钮顶边的间距上限。
183
+ #: 再远就不算"紧邻"(页面下方的说明/帮助卡正是靠这条被排除)。
184
+ PRIMARY_ADJACENT_GAP_PX = 240
185
+
186
+ #: 「水平重叠」的容忍度(px):命中元素与主按钮在横向上允许的错位量。
187
+ PRIMARY_OVERLAP_TOL_PX = 40
188
+
189
+ #: 单来源最多参与判定的**命中元素**条数(JS 侧也截断;两道闸门,防结构证据爆掉日志)。
190
+ MAX_HITS = 8
191
+
192
+ #: 阻断形态名(进 `evidence`/`block_shape`;⛔ 判据与文案同源,别在别处再写一份)。
193
+ SHAPE_MODAL = "S1 模态/对话框内"
194
+ SHAPE_ALERT = "S2 alert/banner 紧邻主按钮上方"
195
+ SHAPE_CTA = "S3 主操作按钮缺失/禁用"
196
+
197
+ #: 「命中原文但非阻断形态」的判据码(`judge`)—— 便于事后按它统计误报。
198
+ JUDGE_QUOTE_NOT_BLOCKING = "quote-not-blocking"
199
+
200
+ #: 「Enviar productos」按钮状态的可读措辞(进证据串,⛔ 不参与判定)。
201
+ _PRIMARY_UNRENDERED = "列表未渲染(缺失/禁用不构成负信号)"
202
+ _PRIMARY_ABSENT = "**缺失**(列表已渲染)"
203
+ _PRIMARY_DISABLED = "**存在但禁用**"
204
+ _PRIMARY_OK = "存在且可点"
205
+ _PRIMARY_UNKNOWN = "未确认(选择器未命中 ⇒ 不构成负信号)"
206
+
207
+ #: 失败文案前缀标记:`capacity_full` 类失败的**机器可识别**标记(分类器模式之一,见注册表)。
208
+ CLASS_MARKER = "capacity_full"
209
+
210
+ #: 表备注 / 台账里的仓容 defer 标记(次日/当日拾取层按它识别)。**同源同文**:写与读都用它。
211
+ DEFER_TAG = "仓容满"
212
+
213
+ #: `kind` 标记(failures.jsonl 的 `auto_fail_retry`):与「时段不可用」的 `defer` 分开计数。
214
+ DEFER_KIND = "defer_capacity"
215
+ #: 解除 defer 的审计事件名(写 `failures.jsonl`,⛔ **不写**统一台账 —— 理由见 `defer_lift_decision`)。
216
+ LIFT_EVENT = "defer_lifted"
217
+
218
+ #: 仓容类**当日**重试上限(超限 ⇒ 转人工;⛔ 不是无限重试)。
219
+ DEFER_MAX_LIFT = 2
220
+
221
+
222
+ # ══════════════════════════════════════════════════════════════════════════
223
+ # 三、只读探针 JS(⛔ 只 dump 证据,**不判定**)
224
+ # ══════════════════════════════════════════════════════════════════════════
225
+ # 设计纪律:JS **不**做"仓容满不满"的判断(那会让判据出现第二份、且改判据要改 JS)。
226
+ # 它只把页面**原文**、**命中元素的结构证据**与几个可复算的布尔/计数 dump 出来,
227
+ # 判定一律回落到 `capacity_verdict()`(纯函数)⇒ 判据可离线回归。
228
+ # ⛔ 全程只读:不 click / 不 fill / 不 submit / 不改 URL 状态。
229
+ JS_CAPACITY_DUMP = r"""(function(){
230
+ var norm = function(s){ return (s || '').replace(/\s+/g, ' ').trim(); };
231
+ var body = norm((document.body && (document.body.innerText || document.body.textContent)) || '');
232
+ // 页面文本截断(够判据用即可;⛔ 不整页回传 —— 长文本会让台账/日志爆掉)
233
+ var text = body.length > 6000 ? body.slice(0, 6000) : body;
234
+ // 「N Envíos para terminar de preparar」:佐证(存在性 + N)
235
+ var m = body.match(/(\d{1,6})\s*Env[ií]os?\s+para\s+terminar\s+de\s+preparar/i);
236
+ // 「Enviar productos」入口:只在**页面确实渲染了列表**时才敢说"按钮不存在"
237
+ // (没渲染完 = null,不是 false —— 否则选择器一漂移本闸门就恒 unavailable)
238
+ var rendered = document.querySelectorAll('table.andes-table tbody tr').length > 0;
239
+ var enviar = null, enviar_disabled = null, enviar_rect = null;
240
+ var btns = document.querySelectorAll('a.andes-button, button.andes-button, a, button');
241
+ for (var i = 0; i < btns.length; i++) {
242
+ var b = btns[i];
243
+ if (norm(b.textContent || '') !== 'Enviar productos') continue;
244
+ enviar = true;
245
+ // 禁用态(三种写法都认:原生 disabled / aria-disabled / andes 的修饰类)
246
+ enviar_disabled = !!(b.disabled === true
247
+ || (b.getAttribute && b.getAttribute('aria-disabled') === 'true')
248
+ || /andes-button--disabled|andes-button--inactive|is-disabled/.test(String(b.className || '')));
249
+ var br = b.getBoundingClientRect ? b.getBoundingClientRect() : null;
250
+ if (br) {
251
+ enviar_rect = {x: Math.round(br.left), y: Math.round(br.top),
252
+ w: Math.round(br.width), h: Math.round(br.height)};
253
+ }
254
+ break;
255
+ }
256
+ if (enviar === null && rendered) { enviar = false; }
257
+ // ── 命中平台原文的**元素**结构证据(2026-10-12:判据收紧为"阻断形态"的唯一依据)──────
258
+ // 为什么必须有它:旧判据是「整页 innerText 前 6000 字**包含**该短语 或 任一 .andes-card 含它」
259
+ // ⇒ 页面下方**说明/帮助卡**里的同一句话会把**能跑的票**判成"仓容满"(生产实测误报:
260
+ // 同一票手动重跑 8/8 成功)。所以本 JS 逐元素测量「可见 / 在视口内 / 在模态或 alert
261
+ // 容器内 / 在说明卡内」,外加主按钮几何与禁用态 —— **只看证据,不下结论**。
262
+ var FRAGS = ['liberar espacio de almacenamiento', 'retirar stock de baja calidad',
263
+ 'retirando stock de baja calidad', 'liberar espacio'];
264
+ var hits = [], total = 0, scanned = 0, scan_truncated = false;
265
+ var all = document.querySelectorAll('body *');
266
+ for (var k = 0; k < all.length; k++) {
267
+ if (scanned++ > 8000) { scan_truncated = true; break; } // 扫描上界(防超大页面拖慢只读探针)
268
+ var el = all[k];
269
+ var own = norm(el.textContent || '').toLowerCase();
270
+ if (!own) continue;
271
+ var frag = '';
272
+ for (var f = 0; f < FRAGS.length; f++) {
273
+ if (own.indexOf(FRAGS[f]) >= 0) { frag = FRAGS[f]; break; }
274
+ }
275
+ if (!frag) continue;
276
+ // 只记「最内层」命中:子元素也含同句时父元素不再单列(否则一个卡片就把 8 条名额刷满)
277
+ var inner = false;
278
+ for (var c = 0; c < el.children.length; c++) {
279
+ if (norm(el.children[c].textContent || '').toLowerCase().indexOf(frag) >= 0) { inner = true; break; }
280
+ }
281
+ if (inner) continue;
282
+ total++;
283
+ if (hits.length >= 8) continue; // 结构证据截断(⛔ 不是整页 dump)
284
+ var cs = window.getComputedStyle ? window.getComputedStyle(el) : null;
285
+ var r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
286
+ var modal = el.closest ? el.closest('.andes-modal, .andes-dialog, [role="dialog"], [role="alertdialog"]') : null;
287
+ var alertBox = el.closest ? el.closest('.andes-alert, .andes-banner, .andes-message, .andes-notification, [role="alert"]') : null;
288
+ var card = el.closest ? el.closest('.andes-card') : null;
289
+ hits.push({
290
+ frag: frag,
291
+ tag: String(el.tagName || '').toLowerCase(),
292
+ cls: String((typeof el.className === 'string' ? el.className : '') || '').slice(0, 120),
293
+ role: String((el.getAttribute && el.getAttribute('role')) || '').slice(0, 40),
294
+ display: cs ? String(cs.display || '') : '',
295
+ visibility: cs ? String(cs.visibility || '') : '',
296
+ opacity: cs ? String(cs.opacity || '') : '',
297
+ rect: r ? {x: Math.round(r.left), y: Math.round(r.top),
298
+ w: Math.round(r.width), h: Math.round(r.height)} : null,
299
+ in_modal: !!modal, in_alert: !!alertBox, in_card: !!card,
300
+ chars: (el.textContent || '').length
301
+ });
302
+ }
303
+ // 既有探针的同一判据(`.andes-card` 含 liberar espacio)—— **只作冗余证据**保留:
304
+ // ⛔ 它单独**不再**能判"仓容满"(说明卡正是旧判据误报的来源,见 capacity_verdict 判据表)。
305
+ var warning = null;
306
+ var cards = document.querySelectorAll('.andes-card');
307
+ for (var j = 0; j < cards.length; j++) {
308
+ if (norm(cards[j].textContent || '').toLowerCase().indexOf('liberar espacio') >= 0) {
309
+ warning = 'capacity_warning'; break;
310
+ }
311
+ }
312
+ return JSON.stringify({
313
+ page: 'inbounds-list',
314
+ ok: true,
315
+ url: String(location.href || ''),
316
+ text: text,
317
+ rendered: rendered,
318
+ enviar_present: enviar,
319
+ enviar_disabled: enviar_disabled,
320
+ enviar_rect: enviar_rect,
321
+ prepared_count: m ? parseInt(m[1], 10) : null,
322
+ prepared_banner: !!m,
323
+ capacity_warning: warning,
324
+ quote_hits: hits,
325
+ quote_hit_total: total,
326
+ scan_truncated: scan_truncated,
327
+ viewport: {w: (window.innerWidth || 0), h: (window.innerHeight || 0)}
328
+ });
329
+ })();"""
330
+
331
+
332
+ # ══════════════════════════════════════════════════════════════════════════
333
+ # 四、纯判据
334
+ # ══════════════════════════════════════════════════════════════════════════
335
+
336
+ def _norm_rect(raw: Any) -> Optional[dict]:
337
+ """几何证据归一(`{x,y,w,h}` 全为有限数才认;缺一项 ⇒ `None`)。**从不抛异常**。"""
338
+ if not isinstance(raw, dict):
339
+ return None
340
+ out: dict[str, float] = {}
341
+ for k in ("x", "y", "w", "h"):
342
+ try:
343
+ out[k] = float(raw.get(k))
344
+ except (TypeError, ValueError):
345
+ return None
346
+ return out
347
+
348
+
349
+ def _norm_wh(raw: Any) -> Optional[dict]:
350
+ """**视口**几何归一(`{w,h}`;非正数或缺项 ⇒ `None` = 不知道)。**从不抛异常**。"""
351
+ if not isinstance(raw, dict):
352
+ return None
353
+ try:
354
+ w, h = float(raw.get("w")), float(raw.get("h"))
355
+ except (TypeError, ValueError):
356
+ return None
357
+ return {"w": w, "h": h} if w > 0 and h > 0 else None
358
+
359
+
360
+ def _norm_hit(raw: Any) -> dict:
361
+ """归一一个**命中元素**的结构证据,并只做**测量学**推导(可见 / 在视口内 / 紧邻主按钮)。
362
+
363
+ ⛔ 这里**不**下"是否阻断"的结论 —— 那是 `blocking_form()` 的唯一职责(判据只有一处)。
364
+ `rect` 全零 ⇒ `visible=False`:这条同时覆盖了"祖先 `display:none`"(那种元素
365
+ `getBoundingClientRect()` 全零、自身 computed display 却仍是 `block`,只查自身样式会**误判可见**)。
366
+ """
367
+ d = raw if isinstance(raw, dict) else {}
368
+ rect = _norm_rect(d.get("rect"))
369
+ disp = str(d.get("display") or "").strip().lower()
370
+ vis = str(d.get("visibility") or "").strip().lower()
371
+ try:
372
+ op: Optional[float] = float(d.get("opacity"))
373
+ except (TypeError, ValueError):
374
+ op = None
375
+ visible = bool(rect and rect["w"] > 0 and rect["h"] > 0
376
+ and disp != "none" and vis not in ("hidden", "collapse")
377
+ and (op is None or op > 0))
378
+ return {
379
+ "frag": str(d.get("frag") or "")[:60],
380
+ "tag": str(d.get("tag") or "")[:24],
381
+ "cls": str(d.get("cls") or "")[:120],
382
+ "role": str(d.get("role") or "")[:40],
383
+ "rect": rect,
384
+ "display": disp,
385
+ "visibility": vis,
386
+ "opacity": op,
387
+ "visible": visible,
388
+ "in_modal": bool(d.get("in_modal")),
389
+ "in_alert": bool(d.get("in_alert")),
390
+ "in_card": bool(d.get("in_card")),
391
+ "chars": d.get("chars"),
392
+ }
393
+
394
+
395
+ def _norm_source(src: Any) -> dict:
396
+ """把任意来源(dict / JSON 字符串 / None)归一成判据要的形状。**从不抛异常**。"""
397
+ if isinstance(src, str):
398
+ try:
399
+ src = json.loads(src)
400
+ except (ValueError, TypeError):
401
+ src = {}
402
+ if not isinstance(src, dict):
403
+ src = {}
404
+ page = str(src.get("page") or src.get("url") or "?")[:80]
405
+ text = src.get("text")
406
+ text = "" if text is None else str(text)
407
+ err = src.get("error_page") or ""
408
+ if not err:
409
+ low = text.lower()
410
+ for mk in ERROR_PAGE_MARKERS:
411
+ if mk in low:
412
+ err = mk
413
+ break
414
+ hits_raw = src.get("quote_hits")
415
+ if not isinstance(hits_raw, (list, tuple)):
416
+ hits_raw = []
417
+ hits = [_norm_hit(h) for h in hits_raw[:MAX_HITS]]
418
+ try:
419
+ total = int(src.get("quote_hit_total"))
420
+ except (TypeError, ValueError):
421
+ total = len(hits)
422
+ vp = _norm_wh(src.get("viewport"))
423
+ out = {
424
+ "page": page,
425
+ "ok": bool(src.get("ok")),
426
+ "url": str(src.get("url") or "")[:300],
427
+ "text": text,
428
+ "rendered": src.get("rendered"),
429
+ "error_page": str(err)[:120],
430
+ "enviar_present": src.get("enviar_present"),
431
+ "enviar_disabled": src.get("enviar_disabled"),
432
+ "enviar_rect": _norm_rect(src.get("enviar_rect")),
433
+ "prepared_count": src.get("prepared_count"),
434
+ "prepared_banner": bool(src.get("prepared_banner")),
435
+ "capacity_warning": bool(src.get("capacity_warning")),
436
+ "hits": hits,
437
+ "hits_total": max(total, len(hits)),
438
+ "hits_truncated": max(total, len(hits)) > len(hits),
439
+ "viewport": vp,
440
+ "scan_truncated": bool(src.get("scan_truncated")),
441
+ "note": str(src.get("note") or "")[:160],
442
+ # 下面三个由 `_annotate_source()` 填(判定用的推导值;先占位,保证键恒在)
443
+ "primary_gone": False,
444
+ "blocking": False,
445
+ "block_reason": "",
446
+ }
447
+ _annotate_source(out)
448
+ return out
449
+
450
+
451
+ def _annotate_source(src: dict) -> None:
452
+ """就地补**判定用的测量学推导**(可见/视口/紧邻主按钮/参与阻断)。⛔ 不下结论。"""
453
+ vp = src.get("viewport")
454
+ btn = src.get("enviar_rect")
455
+ # 按钮"缺失/禁用"只在**列表已渲染**时才作数(没渲染完 = 不知道,不是负信号)
456
+ src["primary_gone"] = bool(src.get("rendered")) and (
457
+ src.get("enviar_present") is False or src.get("enviar_disabled") is True)
458
+ for h in src.get("hits") or []:
459
+ r = h.get("rect")
460
+ h["in_viewport"] = bool(r and vp and r["y"] < vp["h"] and (r["y"] + r["h"]) > 0)
461
+ h["below_fold"] = bool(r and vp and r["y"] >= vp["h"])
462
+ ab = False
463
+ if r and btn:
464
+ overlap = (r["x"] < btn["x"] + btn["w"] + PRIMARY_OVERLAP_TOL_PX
465
+ and r["x"] + r["w"] > btn["x"] - PRIMARY_OVERLAP_TOL_PX)
466
+ gap = btn["y"] - (r["y"] + r["h"]) # 命中元素底边 → 主按钮顶边
467
+ ab = bool(overlap and (-PRIMARY_OVERLAP_TOL_PX <= gap <= PRIMARY_ADJACENT_GAP_PX))
468
+ h["above_primary"] = ab
469
+ # 「参与阻断的位置」:可见 ∧ 在视口内(不被折叠到页面底部)∧ **不是说明/帮助卡**
470
+ # (modal / alert 容器例外 —— 它们本来就是阻断形态的载体)
471
+ h["participates"] = bool(h["visible"] and h["in_viewport"]
472
+ and (h["in_modal"] or h["in_alert"] or not h["in_card"]))
473
+ h["blocks"] = False # 由 blocking_form() 落值(留痕用)
474
+ h["shape"] = ""
475
+
476
+
477
+ def primary_state(src: dict) -> str:
478
+ """「`Enviar productos`」按钮状态的可读措辞(进证据串;⛔ 不参与判定)。"""
479
+ if not src.get("rendered"):
480
+ return _PRIMARY_UNRENDERED
481
+ if src.get("enviar_present") is False:
482
+ return _PRIMARY_ABSENT
483
+ if src.get("enviar_disabled") is True:
484
+ return _PRIMARY_DISABLED
485
+ if src.get("enviar_present") is True:
486
+ return _PRIMARY_OK
487
+ return _PRIMARY_UNKNOWN
488
+
489
+
490
+ def hit_desc(h: dict) -> str:
491
+ """一个命中元素的**一行可读描述**(标签/几何/可见性/位置)—— 进证据串,⛔ 不塞整页文本。"""
492
+ r = h.get("rect") or {}
493
+ pos = (f"@({int(r.get('x', 0))},{int(r.get('y', 0))} {int(r.get('w', 0))}x{int(r.get('h', 0))})"
494
+ if r else "@(无几何)")
495
+ first_cls = (str(h.get("cls") or "").split() or [""])[0]
496
+ where = [k for k, on in (("modal", h.get("in_modal")), ("alert", h.get("in_alert")),
497
+ ("card", h.get("in_card"))) if on]
498
+ return (f"<{h.get('tag') or '?'}{('.' + first_cls) if first_cls else ''}>"
499
+ f"{pos} 可见={'是' if h.get('visible') else '否'}"
500
+ f" 视口内={'是' if h.get('in_viewport') else '否'}"
501
+ f" 位置={'/'.join(where) or '页面正文'}")
502
+
503
+
504
+ def blocking_form(src: dict) -> tuple[bool, str, str]:
505
+ """**严格阻断形态**判定(纯函数,输入 = `_norm_source()` 归一后的**单来源**)。
506
+
507
+ 返回 `(是否阻断, 理由串, 形态名)`。三条硬闸门(缺一不可,见模块 docstring):
508
+
509
+ 1. **位置**(S1 模态内 / S2 alert 紧邻主按钮上方 / S3 主按钮缺失或禁用);
510
+ 2. **可见**(非零 rect ∧ 非 `display:none` ∧ 非 `visibility:hidden` ∧ `opacity>0`);
511
+ 3. **参与阻断**(在视口内、不在说明/帮助卡内、有几何证据)。
512
+
513
+ ⛔ **结构证据不足**(旧探针形态没有 `quote_hits` / 没有 viewport)**一律不阻断** ——
514
+ 这正是 2026-10-12 误报的根治点:「文本里出现那串字」**永远**不足以判仓容满。
515
+ """
516
+ if not src.get("ok"):
517
+ return False, "来源不可用(页面未取到)", ""
518
+ if not src.get("hits"):
519
+ why = ("命中平台原文片段,但**没有任何命中元素的结构证据**"
520
+ "(旧探针 / 说明卡片 / 命中在文本截断之外)⇒ 无法区分「平台拒绝」与「说明文案」")
521
+ if src.get("capacity_warning") or quote_hit(src.get("text") or ""):
522
+ return False, why, ""
523
+ return False, "页面未命中任何平台原文片段", ""
524
+ if not src.get("viewport"):
525
+ return False, "命中元素有结构证据但**缺视口几何**(无法判「是否折叠在页面底部」)⇒ 不硬判", ""
526
+ for h in src.get("hits") or []:
527
+ if not h.get("participates"):
528
+ continue
529
+ if h.get("in_modal"):
530
+ h["blocks"], h["shape"] = True, SHAPE_MODAL
531
+ return True, SHAPE_MODAL, SHAPE_MODAL
532
+ if h.get("in_alert") and h.get("above_primary"):
533
+ h["blocks"], h["shape"] = True, SHAPE_ALERT
534
+ return True, SHAPE_ALERT, SHAPE_ALERT
535
+ if src.get("primary_gone"):
536
+ h["blocks"], h["shape"] = True, SHAPE_CTA
537
+ return True, SHAPE_CTA, SHAPE_CTA
538
+ return False, _why_not_blocking(src), ""
539
+
540
+
541
+ def _why_not_blocking(src: dict) -> str:
542
+ """「命中了原文但**不构成阻断**」的可读理由(逐条覆盖三类否定原因 + 按钮状态)。"""
543
+ hits = src.get("hits") or []
544
+ invisible = sum(1 for h in hits if not h.get("visible"))
545
+ below = sum(1 for h in hits if h.get("visible") and not h.get("in_viewport"))
546
+ cards = sum(1 for h in hits if h.get("participates") is False and h.get("in_card")
547
+ and not h.get("in_alert") and not h.get("in_modal"))
548
+ why = (f"命中元素 {len(hits)} 个,但**均不在阻断位置**"
549
+ f"(不可见 {invisible} 个 / 折叠在页面底部或视口外 {below} 个 / "
550
+ f"位于说明·帮助卡内 {cards} 个)")
551
+ if src.get("rendered") and src.get("enviar_present") is True \
552
+ and src.get("enviar_disabled") is not True:
553
+ why += ",且「Enviar productos」按钮**在且可点**(平台并未阻止进入发货流程)"
554
+ return why
555
+
556
+
557
+ def _sources_of(ev: Any) -> list:
558
+ """取来源清单:支持 `{"sources":[…]}` 与**单来源扁平形**(夹具友好)。"""
559
+ if not isinstance(ev, dict):
560
+ return []
561
+ raw = ev.get("sources")
562
+ if raw is None:
563
+ return [_norm_source(ev)] if ev else []
564
+ if isinstance(raw, dict):
565
+ raw = [raw]
566
+ if not isinstance(raw, (list, tuple)):
567
+ return []
568
+ return [_norm_source(s) for s in raw]
569
+
570
+
571
+ def quote_hit(text: str) -> str:
572
+ """返回文本里命中的**平台原文片段**(最长的那个,便于逐字回引);没命中返回空串。"""
573
+ low = str(text or "").lower()
574
+ hits = [q for q in PLATFORM_QUOTES if q in low]
575
+ if not hits:
576
+ return ""
577
+ return max(hits, key=len)
578
+
579
+
580
+ def extract_quote(text: str) -> str:
581
+ """从页面文本里切出**平台原文整句**(给运营看的话,越完整越好)。
582
+
583
+ ⛔ 只做「按句子边界切一段含命中的原文」,**不做翻译/改写**(解读正是历史事故的根因)。
584
+ 切不到就回落到命中的片段本身。
585
+ """
586
+ s = re.sub(r"\s+", " ", str(text or "")).strip()
587
+ if not s:
588
+ return ""
589
+ low = s.lower()
590
+ pos, frag = -1, ""
591
+ for q in PLATFORM_QUOTES:
592
+ i = low.find(q)
593
+ if i >= 0 and (pos < 0 or i < pos):
594
+ pos, frag = i, q
595
+ if pos < 0:
596
+ return ""
597
+ # 往左找到句首(`.!?` / 列表分隔符 `|` / 串首,最多回看 160 字符)
598
+ start = 0
599
+ for i in range(pos - 1, max(-1, pos - 160), -1):
600
+ if s[i] in ".!?||":
601
+ start = i + 1
602
+ break
603
+ # 平台那句话自带引导语(`Para enviar productos, primero deberás …`)⇒ 有就更准地切
604
+ # (取**最早**那条引导语:`Para enviar productos` 在 `primero deberás` 之前,取最早才不会丢头)
605
+ leads = [low.rfind(lead, max(0, pos - 160), pos) for lead in QUOTE_LEADINS]
606
+ leads = [j for j in leads if j >= 0]
607
+ if leads:
608
+ start = max(start, min(leads))
609
+ # 往右找到句末(最多前看 260 字符)
610
+ m = re.search(r"[.!?]", s[pos + len(frag):pos + len(frag) + 260])
611
+ end = (pos + len(frag) + m.end()) if m else min(len(s), pos + len(frag) + 160)
612
+ out = s[start:end].strip(" ||,;")
613
+ return out or frag
614
+
615
+
616
+ def capacity_verdict(ev: Any = None) -> dict:
617
+ """由**只读页面证据**判定仓容三态 —— 失败文案的唯一措辞来源。
618
+
619
+ 证据(`ev`)形状(也接受 `{"sources": [ … ]}` 多来源):
620
+
621
+ `page` / `ok` / `text` / `rendered` / `error_page`
622
+ `enviar_present`(True/False/None) / `enviar_disabled` / `enviar_rect`
623
+ `prepared_count` / `prepared_banner` / `capacity_warning`(既有步骤1探针的同一判据)
624
+ `quote_hits`(**命中元素的结构证据**:可见性/视口/模态·alert·说明卡/几何)
625
+ `viewport`(`{w,h}`,视口坐标;用来判"是否折叠在页面底部")
626
+
627
+ 返回 `{code, conclusion, evidence, text, action, quote, corroborated, prepared_count,
628
+ hit, where, blocking, block_shape, block_reason, sources}`;
629
+ `text` = 可直接写进失败文案的整句(只有 `capacity_full` 才有动作段)。
630
+
631
+ 判据(**顺序即优先级**,逐条见模块 docstring 的判据表):
632
+ 残缺 ⇒ 原文∧**阻断形态** ⇒ 原文但非阻断(⇒unavailable) ⇒ 错误页 ⇒ 可读 ⇒ 其余。
633
+ """
634
+ sources = _sources_of(ev)
635
+ ok_sources = [s for s in sources if s["ok"]]
636
+
637
+ # ── ⓪ 残缺闸门:一条可用来源都没有 ⇒ 证据不足(⛔ 既不是满、也不是正常)──────
638
+ if not ok_sources:
639
+ why = ";".join(
640
+ f"{s['page']}=" + (s["note"] or "未取到") for s in sources) or "没有任何来源"
641
+ return _verdict(VERDICT_UNAVAILABLE,
642
+ "仓容前置探测**不可用**(页面取不到/超时)⇒ 证据不足,"
643
+ "按保守口径**照常尝试预约**(⛔ 不判仓容满、⛔ 也不判仓容正常)",
644
+ f"可用来源=0/{len(sources) or 0}|{why}", sources,
645
+ note="unavailable")
646
+
647
+ # ── ① 平台原文命中 **∧ 严格阻断形态** ⇒ 仓容满(⛔ 见词就判满 = 2026-10-12 误报根因)──
648
+ # 逐来源先"识别命中"(文本 / 既有 capacity_warning / **命中元素**,三通道任一),
649
+ # 再问 `blocking_form()`:**只有严格阻断形态**才判满;命中但非阻断 ⇒ 落 ①b(保守降级)。
650
+ quote, hit_src = "", ""
651
+ hit_frag, hit_where, hit_text = "", "", ""
652
+ block_shape, block_reason = "", ""
653
+ first_hit = "" # 第一个命中来源的片段(①b 的判据:**命中**就够,不必阻断)
654
+ blocked_src: Optional[dict] = None
655
+ for s in ok_sources:
656
+ frag = quote_hit(s["text"])
657
+ if not frag and s["capacity_warning"]:
658
+ frag = "liberar espacio" # 既有探针(`.andes-card` 命中)的同一片段
659
+ if not frag and s["hits"]:
660
+ frag = s["hits"][0]["frag"] or "liberar espacio" # 文本被截断但元素命中(新探针更全)
661
+ if not frag:
662
+ s["blocking"], s["block_reason"] = False, "未命中平台原文片段"
663
+ continue
664
+ ok_block, why, shape = blocking_form(s)
665
+ s["blocking"], s["block_reason"] = ok_block, why
666
+ if not first_hit: # 第一个命中来源:留痕基线(文本/元素/位置/按钮状态)
667
+ first_hit = frag
668
+ hit_src, hit_frag, hit_text = s["page"], frag, s["text"]
669
+ hit_where = ";".join(hit_desc(h) for h in (s["hits"] or [])[:3]) or "(无元素几何证据)"
670
+ block_reason = why
671
+ if ok_block: # 严格阻断形态:改引**阻断来源**的原文(逐字回引)
672
+ blocked_src, block_shape = s, shape
673
+ quote = extract_quote(s["text"]) or frag
674
+ hit_src, hit_frag, hit_text = s["page"], frag, s["text"]
675
+ hit_where = ";".join(hit_desc(h) for h in (s["hits"] or [])[:3]) or "(无元素几何证据)"
676
+ block_reason = why
677
+ break
678
+ if blocked_src is not None:
679
+ prepared = next((s["prepared_count"] for s in ok_sources
680
+ if s["prepared_count"] is not None), None)
681
+ banner = any(s["prepared_banner"] for s in ok_sources)
682
+ ev_line = (f"页面={hit_src}|平台原文命中={hit_frag or quote_hit(quote) or 'liberar espacio'}"
683
+ f"|**阻断形态成立**={block_shape}"
684
+ + (f"({block_reason})" if block_reason and block_reason != block_shape else "")
685
+ + f"|命中元素:{hit_where}"
686
+ f"|「{ENVIAR_LABEL}」={primary_state(blocked_src)}"
687
+ + (f"|佐证:{prepared} Envíos para terminar de preparar"
688
+ if banner else "|佐证:未出现「N Envíos para terminar de preparar」")
689
+ + ("|既有探针 capacity_warning=命中"
690
+ if any(s["capacity_warning"] for s in ok_sources) else ""))
691
+ return _verdict(VERDICT_FULL,
692
+ f"{CAPACITY_TERM}满:平台**已明确拒绝**继续发货({CAPACITY_TERM}已满)"
693
+ f"⇒ **不进入步骤4**(再试也只会撞同一面墙)",
694
+ ev_line, sources, quote=quote,
695
+ corroborated=banner, prepared_count=prepared, note="platform-quote",
696
+ hit=hit_frag, where=hit_where, blocking=True,
697
+ block_shape=block_shape, block_reason=block_reason)
698
+
699
+ # ── ①b 命中平台原文但**不是**阻断形态 ⇒ 保守降级(**照常尝试步骤4**)────────────
700
+ # 2026-10-12 现场:命中来自页面别处的说明/帮助卡,平台**并没有**拒绝发货
701
+ # ⇒ ⛔ 不判满;也⛔ 不判"正常"(命中原文就不敢说库容正常、不提前解除 defer 护栏)。
702
+ if first_hit:
703
+ prepared = next((s["prepared_count"] for s in ok_sources
704
+ if s["prepared_count"] is not None), None)
705
+ banner = any(s["prepared_banner"] for s in ok_sources)
706
+ _hit_src = next((s for s in ok_sources if s["block_reason"] != "未命中平台原文片段"), {})
707
+ ev_line = (f"页面={hit_src}|命中平台原文={hit_frag}"
708
+ f"|**阻断形态=未成立**({block_reason})"
709
+ f"|命中元素:{hit_where}"
710
+ f"|命中上下文=({_excerpt(hit_text, hit_frag)})"
711
+ f"|「{ENVIAR_LABEL}」={primary_state(_hit_src)}"
712
+ f"|命中总数={sum(s['hits_total'] for s in ok_sources)}"
713
+ + (f"|佐证:{prepared} Envíos para terminar de preparar" if banner else ""))
714
+ return _verdict(VERDICT_UNAVAILABLE,
715
+ f"{CAPACITY_TERM}前置探测**不可用**:页面命中平台{CAPACITY_TERM}原文,但"
716
+ f"**不是阻断形态**(说明卡/折叠在页面底部/结构证据不足)"
717
+ f"⇒ 证据不足,按保守口径**照常尝试预约**(⛔ 不判{CAPACITY_TERM}满)",
718
+ ev_line, sources, quote="",
719
+ corroborated=banner, prepared_count=prepared,
720
+ note=JUDGE_QUOTE_NOT_BLOCKING,
721
+ hit=hit_frag, where=hit_where, blocking=False,
722
+ block_shape="", block_reason=block_reason)
723
+
724
+ # ── ② 平台错误页 ⇒ 读不出仓容真相(保守)────────────────────────────────
725
+ healthy = [s for s in ok_sources if not s["error_page"] and s["text"].strip()]
726
+ if not healthy:
727
+ errs = ";".join(f"{s['page']}=" + (s["error_page"] or "页面文本为空")
728
+ for s in ok_sources)
729
+ return _verdict(VERDICT_UNAVAILABLE,
730
+ "仓容前置探测**不可用**(平台错误页/页面未渲染)⇒ 证据不足,"
731
+ "按保守口径**照常尝试预约**(⛔ 不判仓容满、⛔ 也不判仓容正常)",
732
+ f"可用来源={len(ok_sources)} 但均不可判定|{errs}", sources,
733
+ note="error-page-or-empty")
734
+
735
+ # ── ③ 可读且无原文 ⇒ 仓容可用(唯一能让"仓容类 defer"当日解除的证据)──────
736
+ blocked = [s for s in healthy if s["enviar_present"] is False]
737
+ if len(blocked) == len(healthy):
738
+ return _verdict(VERDICT_UNAVAILABLE,
739
+ "仓容前置探测**不可用**:页面可读但"
740
+ f"「{ENVIAR_LABEL}」入口**明确不存在**(`selectors.md` 记库容满时该按钮"
741
+ "不显示 ⇒ 无法据此判「仓容正常」)⇒ 证据不足,照常尝试预约",
742
+ f"可读来源={len(healthy)}|「{ENVIAR_LABEL}」均不存在", sources,
743
+ note="enviar-absent")
744
+ best = next((s for s in healthy if s["enviar_present"] is True), healthy[0])
745
+ prepared = next((s["prepared_count"] for s in healthy
746
+ if s["prepared_count"] is not None), None)
747
+ banner = any(s["prepared_banner"] for s in healthy)
748
+ ev_line = (f"页面={best['page']} 可读且形态正常|平台仓容原文命中=0"
749
+ f"|「{ENVIAR_LABEL}」入口="
750
+ + ("在" if best["enviar_present"] is True else "未确认(不构成负信号)")
751
+ + (f"|佐证:{prepared} Envíos para terminar de preparar"
752
+ if banner else ""))
753
+ return _verdict(VERDICT_AVAILABLE,
754
+ f"{CAPACITY_TERM}前置探测:**{CAPACITY_TERM}可用**(页面可读、无平台{CAPACITY_TERM}原文)"
755
+ f"⇒ 允许进入步骤4;若本票有活跃的{DEFER_TAG}类 defer 标记 ⇒ **当日解除**",
756
+ ev_line, sources,
757
+ corroborated=banner, prepared_count=prepared, note="no-quote")
758
+
759
+
760
+ def _excerpt(text: str, frag: str, width: int = 60) -> str:
761
+ """命中片段周围的**短上下文**(≤ ~140 字符)—— 给事后判误报用,⛔ 绝不回整页文本。"""
762
+ s = re.sub(r"\s+", " ", str(text or "")).strip()
763
+ if not s or not frag:
764
+ return ""
765
+ i = s.lower().find(str(frag).lower())
766
+ if i < 0:
767
+ return s[:width]
768
+ return s[max(0, i - width):i + len(frag) + width][:140]
769
+
770
+
771
+ def _verdict(code: str, conclusion: str, evidence: str, sources: list, *,
772
+ quote: str = "", corroborated: bool = False,
773
+ prepared_count: Optional[int] = None, note: str = "",
774
+ hit: str = "", where: str = "", blocking: bool = False,
775
+ block_shape: str = "", block_reason: str = "") -> dict:
776
+ """装配结论(**唯一**出口,保证每个结论都带计数与证据来源)。
777
+
778
+ `hit`/`where`/`blocking`/`block_shape`/`block_reason` = **误报事后判定**要的留痕
779
+ (命中文本 / 命中元素位置与可见性 / 阻断横幅有无 / 主按钮状态在 `evidence` 里),
780
+ ⛔ 都**不含整页文本**(`quote` 只放平台原文整句,且**只有 `capacity_full` 才有**)。
781
+ """
782
+ assert code in VERDICTS, f"未知裁决码 {code}"
783
+ diag = f"{conclusion}|证据:{evidence}"
784
+ if quote:
785
+ diag += f"|平台原文:{quote}"
786
+ return {
787
+ "code": code,
788
+ "conclusion": conclusion,
789
+ "evidence": evidence,
790
+ "brief": _brief(conclusion),
791
+ # `text`:仓容满 ⇒ 可**直接**写进表备注/台账/卡片的**唯一**失败文案(`capacity_failure_text` 产出);
792
+ # 其余两态 ⇒ 只是诊断串(⛔ 不写表 —— 它们不是失败)。
793
+ "text": (capacity_failure_text({"conclusion": conclusion, "evidence": evidence,
794
+ "quote": quote, "code": code})
795
+ if code == VERDICT_FULL else diag),
796
+ "action": ACTION_TEXT if code == VERDICT_FULL else "",
797
+ "quote": quote,
798
+ "corroborated": bool(corroborated),
799
+ "prepared_count": prepared_count,
800
+ "judge": note,
801
+ # ── 阻断形态留痕(2026-10-12)──────────────────────────────────────────
802
+ # ⛔ 恒存在(哪怕为空串):调用方/报表不用"键在不在"来猜结论是怎么来的。
803
+ "hit": hit, # 命中的平台原文片段(⛔ 不是整页文本)
804
+ "where": where, # 命中元素的位置/可见性摘要
805
+ "blocking": bool(blocking), # 是否**严格阻断形态**(只有它为真才可能 full)
806
+ "block_shape": block_shape, # S1/S2/S3
807
+ "block_reason": block_reason,
808
+ "sources": [{"page": s["page"], "ok": s["ok"], "rendered": s["rendered"],
809
+ "error_page": s["error_page"], "enviar_present": s["enviar_present"],
810
+ "enviar_disabled": s["enviar_disabled"],
811
+ "primary_gone": bool(s.get("primary_gone")),
812
+ "prepared_count": s["prepared_count"],
813
+ "quote_hit": quote_hit(s["text"]) or ("liberar espacio" if s["capacity_warning"] else ""),
814
+ "capacity_warning": s["capacity_warning"],
815
+ "blocking": bool(s.get("blocking")),
816
+ "block_reason": str(s.get("block_reason") or "")[:200],
817
+ "hits_total": s.get("hits_total"),
818
+ "hits": [{"frag": h.get("frag"), "tag": h.get("tag"), "cls": h.get("cls")[:60],
819
+ "role": h.get("role"), "rect": h.get("rect"),
820
+ "visible": h.get("visible"), "in_viewport": h.get("in_viewport"),
821
+ "below_fold": h.get("below_fold"),
822
+ "in_modal": h.get("in_modal"), "in_alert": h.get("in_alert"),
823
+ "in_card": h.get("in_card"),
824
+ "above_primary": h.get("above_primary"),
825
+ "participates": h.get("participates"),
826
+ "blocks": h.get("blocks"), "shape": h.get("shape"),
827
+ "chars": h.get("chars")} for h in (s.get("hits") or [])],
828
+ "viewport": s.get("viewport"),
829
+ "chars": len(s["text"]), "note": s["note"]} for s in sources],
830
+ }
831
+
832
+
833
+ def _brief(conclusion: str) -> str:
834
+ """一句话摘要(卡片/日志用)——取结论里第一段(`|`/`⇒` 之前)。"""
835
+ return re.split(r"[||⇒]", str(conclusion or ""))[0].strip()[:120]
836
+
837
+
838
+ def capacity_failure_text(verdict: Optional[dict] = None, extra: str = "") -> str:
839
+ """把判定结果拼成**唯一**的失败文案(表备注 / 台账 / 卡片共用同一串)。
840
+
841
+ 形状:`步骤4仓容前置探测:<结论>|证据:<证据>|唯一动作:<动作>`(仓容满时才有动作段)。
842
+ **必须**含平台原文(`quote` 逐字)与唯一动作(`ACTION_TEXT`)—— 两条都有回归钉住。
843
+ """
844
+ v = verdict or {}
845
+ body = (f"步骤4仓容前置探测:{v.get('conclusion') or DEFER_TAG}"
846
+ f"|证据:{v.get('evidence') or '(无)'}")
847
+ q = str(v.get("quote") or "").strip()
848
+ if q:
849
+ body += f"|平台原文:{q}"
850
+ if v.get("code") == VERDICT_FULL:
851
+ # 机器可识别标记:三处证据(表备注 / 台账 `failed.reason` / 卡片 reason)都能被
852
+ # 程序分类(`analyze-full-day.py` 的桶与 `failureClasses.capacityFull` 都认它)。
853
+ body += f"|分类:{CLASS_MARKER}|唯一动作:{ACTION_TEXT}"
854
+ tail = str(extra or "").strip("| ")
855
+ return f"{body}|{tail}" if tail else body
856
+
857
+
858
+ # ══════════════════════════════════════════════════════════════════════════
859
+ # 四·五、运维开关(**纯函数**;2026-10-12 现场"疑似误报")
860
+ # ══════════════════════════════════════════════════════════════════════════
861
+
862
+ #: 环境变量名(唯一字面量;orchestrator 从这里 import,⛔ 不写第二份)
863
+ PRECHECK_SWITCH_ENV = "ARGENT_CAPACITY_PRECHECK"
864
+
865
+ #: 显式关闭的取值(大小写/空白不敏感);⛔ 其余值(含空/未设)一律**不关**
866
+ _SWITCH_OFF_VALUES = ("0", "off", "false", "no")
867
+
868
+
869
+ def precheck_switch_off(raw: Any = None) -> bool:
870
+ """运维开关是否**显式关闭**拦截(纯函数,判据唯一、可离线回归)。
871
+
872
+ ⚠️ 只认 `0/off/false/no`;`""`/未设/其它值 ⇒ `False`(**默认仍拦**,⛔ 不因笔误放行)。
873
+ """
874
+ return str(raw if raw is not None else "").strip().lower() in _SWITCH_OFF_VALUES
875
+
876
+
877
+ def apply_precheck_switch(verdict: Optional[dict], raw: Any = None) -> dict:
878
+ """按运维开关**降级**结论 —— 仅当 `code == capacity_full` ∧ 开关显式关 ⇒ 改判 `unavailable`。
879
+
880
+ 语义(重要,⛔ 别误读为"放行"):`unavailable` 是本模块既有的**保守路径** ——
881
+ 「证据不足 ⇒ 照常尝试步骤4」,即**把拦截权交回步骤4 自身**(平台若真满,步骤4 仍会失败,
882
+ 并留下截图/失败文案)。原判证据**一个字都不丢**,落在 `bypassed` 字段(含 code/evidence/
883
+ quote/action/佐证计数),调用方照常打印。
884
+
885
+ · `available` / `unavailable` ⇒ **原样返回**(幂等,开关对它们无意义);
886
+ · 开关未设/非关闭值 ⇒ **原样返回**(默认行为不变,回归钉住);
887
+ · `verdict` 为空/非 dict ⇒ 返回 `{}`(⛔ 不上抛)。
888
+ """
889
+ v = dict(verdict) if isinstance(verdict, dict) else {}
890
+ if not v or v.get("code") != VERDICT_FULL or not precheck_switch_off(raw):
891
+ return v
892
+ v["bypassed"] = {
893
+ "by": PRECHECK_SWITCH_ENV,
894
+ "from_code": v.get("code"),
895
+ "evidence": v.get("evidence") or "",
896
+ "quote": v.get("quote") or "",
897
+ "action": v.get("action") or "",
898
+ "prepared_count": v.get("prepared_count"),
899
+ }
900
+ v["code"] = VERDICT_UNAVAILABLE
901
+ v["conclusion"] = (f"仓容前置探测原判 {VERDICT_FULL},但**运维开关 {PRECHECK_SWITCH_ENV}=off 已关闭拦截**"
902
+ f"⇒ 按「证据不足」保守路径**照常尝试步骤4**(原判证据见 bypassed)")
903
+ v["text"] = (f"{v['conclusion']}|原判证据:{v['bypassed']['evidence'] or '(无)'}"
904
+ + (f"|原判平台原文:{v['bypassed']['quote']}" if v["bypassed"]["quote"] else ""))
905
+ return v
906
+
907
+
908
+ # ══════════════════════════════════════════════════════════════════════════
909
+ # 五、defer 解除(**纯函数**;⛔ 只对仓容这一条链开放)
910
+ # ══════════════════════════════════════════════════════════════════════════
911
+
912
+ def defer_lift_decision(verdict: Optional[dict] = None, *,
913
+ prior_kind: str = "", prior_marks: int = 0,
914
+ max_lift: int = DEFER_MAX_LIFT) -> dict:
915
+ """判定本票的**仓容类 defer** 是否可在**当日**解除 —— 判据唯一、可离线回归。
916
+
917
+ ⇔ `lift=True` 的**全部**条件(缺一不可):
918
+
919
+ 1. 本次预检结论 = `capacity_available`(**有证据**说仓容可用);
920
+ 2. 本票**确实**有活跃的仓容类 defer 标记(`prior_kind == DEFER_KIND`);
921
+ 3. 已解除次数 `prior_marks < max_lift`(**上限**,防打转)。
922
+
923
+ ⛔ 明确**不**解除的两种情形(回归钉住):
924
+ · 预检 `capacity_full` ⇒ 继续 defer(本来就不该试);
925
+ · 预检 `unavailable` ⇒ **保守不解除** —— 注意这与"步骤4 照常尝试"看似矛盾,其实分属两件事:
926
+ 「要不要进步骤4」在无证据时按**业务保守**(照常试,免得探测故障拦住能成的票);
927
+ 「要不要提前解除护栏」在无证据时按**防打转保守**(不解除,免得无证据地当日反复撞墙)。
928
+ 两者都在 `docs/本机接班运行清单.md` §8.72 写明。
929
+ · `prior_kind` 是 `defer`(时段不可用)⇒ **一律不解除**:那条链**只**走"次日重试"。
930
+
931
+ ⛔ 审计写 `failures.jsonl`(`event=defer_lifted`),**不**写统一台账:统一台账的事件类型是
932
+ 分诊的判据(`flow_triage` 的"有效末条事件"规则),新增一个事件类型会让票落到
933
+ "分诊未归类 ⇒ needs_human"兜底桶 —— 那是**改语义**,不是留审计。
934
+ """
935
+ v = verdict or {}
936
+ code = str(v.get("code") or "")
937
+ out = {"lift": False, "reason": "", "audit": {}}
938
+ if code != VERDICT_AVAILABLE:
939
+ out["reason"] = (f"预检结论={code or '(无)'} ⇒ 不解除"
940
+ + ("(仓容仍满)" if code == VERDICT_FULL
941
+ else "(证据不足 ⇒ 防打转保守:不解除护栏)" if code == VERDICT_UNAVAILABLE
942
+ else ""))
943
+ return out
944
+ if prior_kind != DEFER_KIND:
945
+ out["reason"] = f"本票没有活跃的仓容类 defer 标记(prior_kind={prior_kind or '(无)'})⇒ 无需解除"
946
+ return out
947
+ if prior_marks >= max_lift:
948
+ out["reason"] = f"已解除 {prior_marks}/{max_lift} 次(达上限)⇒ 转人工,不再当日重试"
949
+ return out
950
+ out["lift"] = True
951
+ out["reason"] = f"仓容预检=可用且解除次数 {prior_marks}/{max_lift} 未达上限 ⇒ 当日解除 defer"
952
+ out["audit"] = {
953
+ "by": "orchestrator:capacity-precheck",
954
+ "basis": code,
955
+ "evidence": str(v.get("evidence") or "")[:300],
956
+ "prior_marks": prior_marks,
957
+ "max_lift": max_lift,
958
+ }
959
+ return out
960
+
961
+
962
+ # ══════════════════════════════════════════════════════════════════════════
963
+ # 六、夹具(纯离线;⛔ 不含任何真实生产页面原文/账号/店铺名)
964
+ # ══════════════════════════════════════════════════════════════════════════
965
+ _FIXTURE_FULL_TEXT = (
966
+ "Envios Full | 12 Envíos para terminar de preparar "
967
+ "Para enviar productos, primero deberás liberar espacio de almacenamiento "
968
+ "retirando stock de baja calidad. Ver más"
969
+ )
970
+
971
+ #: 视口几何(夹具统一用 1440×800;「视口内/折叠在页面底部」两个判据都靠它)。
972
+ _FIXTURE_VIEWPORT = {"w": 1440, "h": 800}
973
+
974
+ #: 主按钮几何(夹具):在页面右上方,可点。
975
+ _FIXTURE_ENVIAR_RECT = {"x": 1180, "y": 300, "w": 180, "h": 36}
976
+
977
+ #: 2026-10-12 生产实测页面的**结构**(脱敏:无店铺名/账号/货件号;只保留判据要的那几句)。
978
+ #: 屏幕 OCR 实测:这是**正常的 FULL 管理页** —— 徽标 `84 Envíos para terminar de preparar`、
979
+ #: 提示 `Completa las tareas pendientes para enviar tu stock`、按钮 **`Enviar productos` 在**、
980
+ #: 列表有正常行、**没有**任何"库容满"横幅;而 `liberar espacio …` 出现在页面**下方说明卡**里。
981
+ _FIXTURE_HELP_CARD_TEXT = (
982
+ "Envios Full | 84 Envíos para terminar de preparar "
983
+ "Completa las tareas pendientes para enviar tu stock "
984
+ "Enviar productos | Estado | Acciones "
985
+ "En preparación 84 | En tránsito 0 | Recibidos 12 "
986
+ "Si necesitas más espacio, puedes liberar espacio de almacenamiento "
987
+ "retirando stock de baja calidad."
988
+ )
989
+
990
+
991
+ def _hit_help_card(y: int = 980, *, visible: bool = True) -> dict:
992
+ """「说明/帮助卡里的那句话」这个命中元素(2026-10-12 误报的来源形态)。"""
993
+ return {
994
+ "frag": "liberar espacio de almacenamiento", "tag": "p",
995
+ "cls": "andes-card__body andes-text-secondary", "role": "",
996
+ "display": "block" if visible else "none",
997
+ "visibility": "visible", "opacity": "1",
998
+ "rect": ({"x": 120, "y": y, "w": 1200, "h": 60} if visible
999
+ else {"x": 0, "y": 0, "w": 0, "h": 0}),
1000
+ "in_modal": False, "in_alert": False, "in_card": True, "chars": 240,
1001
+ }
1002
+
1003
+
1004
+ FIXTURES: dict[str, dict] = {
1005
+ # ① 仓容满(**严格阻断形态 S2**:alert 紧邻主按钮正上方 + 平台原文 + 佐证横幅)⇒ capacity_full
1006
+ "full": {"sources": [{
1007
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_FULL_TEXT,
1008
+ "rendered": True, "enviar_present": True, "enviar_disabled": False,
1009
+ "enviar_rect": dict(_FIXTURE_ENVIAR_RECT),
1010
+ "prepared_count": 12, "prepared_banner": True, "capacity_warning": "capacity_warning",
1011
+ "viewport": dict(_FIXTURE_VIEWPORT),
1012
+ "quote_hits": [{
1013
+ "frag": "liberar espacio de almacenamiento", "tag": "div",
1014
+ "cls": "andes-alert andes-alert--error andes-alert--banner", "role": "alert",
1015
+ "display": "block", "visibility": "visible", "opacity": "1",
1016
+ # 底边 284,主按钮顶边 300 ⇒ 纵向间距 16px("紧邻");横向与按钮重叠
1017
+ "rect": {"x": 1180, "y": 236, "w": 180, "h": 48},
1018
+ "in_modal": False, "in_alert": True, "in_card": False, "chars": 120,
1019
+ }],
1020
+ }]},
1021
+ # ①b 仓容满(**严格阻断形态 S3**:列表已渲染 ∧ 主按钮缺失 ⇒ 命中元素参与阻断)⇒ capacity_full
1022
+ "cta-gone": {"sources": [{
1023
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_FULL_TEXT,
1024
+ "rendered": True, "enviar_present": False, "enviar_disabled": None,
1025
+ "enviar_rect": None,
1026
+ "prepared_count": 12, "prepared_banner": True, "capacity_warning": "capacity_warning",
1027
+ "viewport": dict(_FIXTURE_VIEWPORT),
1028
+ "quote_hits": [{
1029
+ "frag": "liberar espacio de almacenamiento", "tag": "p", "cls": "andes-text",
1030
+ "role": "", "display": "block", "visibility": "visible", "opacity": "1",
1031
+ "rect": {"x": 120, "y": 180, "w": 900, "h": 40},
1032
+ "in_modal": False, "in_alert": False, "in_card": False, "chars": 90,
1033
+ }],
1034
+ }]},
1035
+ # ★ 今(2026-10-12)FALSE-POSITIVE 夹具:页面含 `liberar espacio`(说明卡)**但**
1036
+ # 按钮在且可点、**没有**阻断横幅 ⇒ ⛔ 绝不 capacity_full(保守 unavailable ⇒ 照常尝试步骤4)
1037
+ "help-card": {"sources": [{
1038
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_HELP_CARD_TEXT,
1039
+ "rendered": True, "enviar_present": True, "enviar_disabled": False,
1040
+ "enviar_rect": dict(_FIXTURE_ENVIAR_RECT),
1041
+ "prepared_count": 84, "prepared_banner": True, "capacity_warning": "capacity_warning",
1042
+ "viewport": dict(_FIXTURE_VIEWPORT),
1043
+ # 说明卡在**视口下方**(y=980 ≥ 800)⇒ 折叠在页面底部 + 说明卡,两重都不参与阻断
1044
+ "quote_hits": [_hit_help_card(980)],
1045
+ }]},
1046
+ # ★ 同一形态的**更严**变体:说明卡就在视口内(y=520)⇒ 仍**不**判满(帮助卡不参与阻断)
1047
+ "help-card-visible": {"sources": [{
1048
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_HELP_CARD_TEXT,
1049
+ "rendered": True, "enviar_present": True, "enviar_disabled": False,
1050
+ "enviar_rect": dict(_FIXTURE_ENVIAR_RECT),
1051
+ "prepared_count": 84, "prepared_banner": True, "capacity_warning": "capacity_warning",
1052
+ "viewport": dict(_FIXTURE_VIEWPORT),
1053
+ "quote_hits": [_hit_help_card(520)],
1054
+ }]},
1055
+ # ★ 命中元素**不可见**(`display:none` 的模板/折叠块)⇒ 不参与阻断 ⇒ 不判满
1056
+ "hidden-note": {"sources": [{
1057
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_HELP_CARD_TEXT,
1058
+ "rendered": True, "enviar_present": True, "enviar_disabled": False,
1059
+ "enviar_rect": dict(_FIXTURE_ENVIAR_RECT),
1060
+ "prepared_count": 84, "prepared_banner": True, "capacity_warning": "capacity_warning",
1061
+ "viewport": dict(_FIXTURE_VIEWPORT),
1062
+ "quote_hits": [_hit_help_card(300, visible=False)],
1063
+ }]},
1064
+ # ★ **旧判据会判满**的那个形态(文本命中 + 既有 capacity_warning + 按钮缺失,但**没有**
1065
+ # 任何结构证据):现在一律 unavailable(⛔ 见词不再判满;"无法区分 ⇒ 保守照常尝试")
1066
+ "legacy-no-structure": {"sources": [{
1067
+ "page": "inbounds-list", "ok": True, "text": _FIXTURE_HELP_CARD_TEXT,
1068
+ "rendered": True, "enviar_present": False,
1069
+ "prepared_count": 84, "prepared_banner": True, "capacity_warning": "capacity_warning",
1070
+ }]},
1071
+ # ② 正常页(负样本)⇒ capacity_available
1072
+ "normal": {"sources": [{
1073
+ "page": "inbounds-list", "ok": True,
1074
+ "text": "Envios Full | 3 Envíos para terminar de preparar "
1075
+ "Enviar productos | Estado | Acciones",
1076
+ "rendered": True, "enviar_present": True,
1077
+ "prepared_count": 3, "prepared_banner": True, "capacity_warning": None,
1078
+ }]},
1079
+ # ②b 佐证横幅在、但**没有**平台原文 ⇒ 仍是可用(⛔ 横幅单独不足以判满)
1080
+ "banner-only": {"sources": [{
1081
+ "page": "inbounds-list", "ok": True,
1082
+ "text": "Envios Full | 9 Envíos para terminar de preparar (anda-card)",
1083
+ "rendered": True, "enviar_present": True, "prepared_count": 9,
1084
+ "prepared_banner": True, "capacity_warning": None,
1085
+ }]},
1086
+ # ③ 探测不可用:取不到页面
1087
+ "unavailable": {"sources": [{
1088
+ "page": "inbounds-list", "ok": False, "note": "Timeout 30000ms exceeded",
1089
+ "text": "", "rendered": None, "enviar_present": None,
1090
+ }]},
1091
+ # ③b 探测不可用:平台错误页
1092
+ "error-page": {"sources": [{
1093
+ "page": "inbounds-list", "ok": True,
1094
+ "text": "Algo salió mal Estamos trabajando para solucionarlo. Ir a Gestión de envíos Full",
1095
+ "rendered": False, "enviar_present": None,
1096
+ }]},
1097
+ # ③c 探测不可用:可读但入口明确不存在(`selectors.md`:库容满时该按钮不显示)
1098
+ "enviar-absent": {"sources": [{
1099
+ "page": "inbounds-list", "ok": True,
1100
+ "text": "Envios Full | Estado | Acciones",
1101
+ "rendered": True, "enviar_present": False,
1102
+ }]},
1103
+ }
1104
+
1105
+
1106
+ def load_fixture(name: str) -> dict:
1107
+ """取内置夹具。⛔ 未知名字**响亮失败**,不静默退化成空证据(那会判成 unavailable 掩盖笔误)。"""
1108
+ if name not in FIXTURES:
1109
+ raise KeyError(f"未知夹具:{name}(可用:{', '.join(sorted(FIXTURES))})")
1110
+ return json.loads(json.dumps(FIXTURES[name])) # 深拷贝,防夹具被调用方改坏
1111
+
1112
+
1113
+ def fixture_names() -> list:
1114
+ return sorted(FIXTURES)
1115
+
1116
+
1117
+ def _main(argv: Optional[list[str]] = None) -> int:
1118
+ p = argparse.ArgumentParser(
1119
+ prog="ml_capacity_probe.py",
1120
+ description="仓容(库容)前置探测:纯判据(离线;真机抓取用 orchestrator --mode capacity-probe)")
1121
+ src = p.add_mutually_exclusive_group(required=True)
1122
+ src.add_argument("--evidence-file", help="只读证据 JSON 文件(`-` = 从 stdin 读)")
1123
+ src.add_argument("--fixture", choices=fixture_names(), help="跑内置夹具")
1124
+ p.add_argument("--json", action="store_true", help="输出完整 JSON(默认:中文摘要 + 结论)")
1125
+ a = p.parse_args(argv)
1126
+
1127
+ if a.fixture:
1128
+ try:
1129
+ ev: Any = load_fixture(a.fixture)
1130
+ except KeyError as exc:
1131
+ print(f"⛔ {exc}", file=sys.stderr)
1132
+ return 2
1133
+ elif a.evidence_file == "-":
1134
+ ev = sys.stdin.read()
1135
+ else:
1136
+ try:
1137
+ ev = pathlib.Path(a.evidence_file).read_text(encoding="utf-8")
1138
+ except OSError as exc:
1139
+ print(f"⛔ 读不到证据文件:{exc}", file=sys.stderr)
1140
+ return 2
1141
+
1142
+ v = capacity_verdict(ev)
1143
+ if a.json:
1144
+ print(json.dumps(v, ensure_ascii=False, indent=2))
1145
+ return 0
1146
+ icon = {VERDICT_FULL: "⛔", VERDICT_AVAILABLE: "✅", VERDICT_UNAVAILABLE: "❔"}[v["code"]]
1147
+ print(f"{icon} {v['code']}:{v['conclusion']}")
1148
+ print(f" 证据:{v['evidence']}")
1149
+ if v["quote"]:
1150
+ print(f" 平台原文:{v['quote']}")
1151
+ if v["action"]:
1152
+ print(f" 唯一动作:{v['action']}")
1153
+ print(f" 失败文案:{v['text']}")
1154
+ return 0
1155
+
1156
+
1157
+ if __name__ == "__main__":
1158
+ sys.exit(_main())