foxclaw 0.1.2 → 0.1.4

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 (368) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/.github/workflows/release.yml +5 -0
  6. package/python/CHANGELOG.md +100 -0
  7. package/python/CHANGELOG_CN.md +100 -0
  8. package/python/README.md +17 -2
  9. package/python/README_CN.md +38 -20
  10. package/python/bench/bm25_audit.py +1 -14
  11. package/python/bench/bm25_regression.py +0 -1
  12. package/python/bench/bm25_stress.py +0 -4
  13. package/python/bench/cd_audit.py +0 -23
  14. package/python/bench/chaos.py +6 -8
  15. package/python/bench/e2e_real_models.py +0 -1
  16. package/python/bench/llm_retry_test.py +2 -9
  17. package/python/bench/load_test.py +5 -8
  18. package/python/bench/vector_ivf_upgrade.py +1 -7
  19. package/python/docs/CONTENT_GUARD.md +189 -0
  20. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  21. package/python/docs/RELEASE.md +78 -37
  22. package/python/docs/RUN_CN.md +2 -2
  23. package/python/evals/governance/cases.py +0 -37
  24. package/python/evals/governance/run_evals.py +5 -37
  25. package/python/examples/governance_middleware_demo.py +1 -6
  26. package/python/examples/langgraph_governance_demo.py +7 -11
  27. package/python/foxclaw/__init__.py +74 -152
  28. package/python/foxclaw/agents/base.py +82 -136
  29. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  30. package/python/foxclaw/agents/impl/finance.py +4 -21
  31. package/python/foxclaw/agents/impl/office.py +7 -24
  32. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  33. package/python/foxclaw/agents/mixins/_context.py +0 -23
  34. package/python/foxclaw/agents/mixins/augment.py +10 -16
  35. package/python/foxclaw/agents/mixins/llm.py +7 -6
  36. package/python/foxclaw/agents/mixins/memory.py +23 -3
  37. package/python/foxclaw/agents/mixins/preference.py +58 -8
  38. package/python/foxclaw/agents/mixins/prompt.py +59 -12
  39. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  40. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  41. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  42. package/python/foxclaw/api/__init__.py +18 -77
  43. package/python/foxclaw/api/_state.py +43 -0
  44. package/python/foxclaw/api/bootstrap.py +114 -0
  45. package/python/foxclaw/api/middleware.py +5 -28
  46. package/python/foxclaw/api/routes/ahs.py +60 -73
  47. package/python/foxclaw/api/routes/attachments.py +5 -6
  48. package/python/foxclaw/api/routes/browser.py +7 -11
  49. package/python/foxclaw/api/routes/domain.py +18 -26
  50. package/python/foxclaw/api/routes/knowledge.py +3 -21
  51. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  52. package/python/foxclaw/api/routes/memory.py +50 -12
  53. package/python/foxclaw/api/routes/orchestration.py +3 -5
  54. package/python/foxclaw/api/routes/recovery.py +4 -3
  55. package/python/foxclaw/api/routes/review.py +2 -11
  56. package/python/foxclaw/api/routes/search.py +3 -7
  57. package/python/foxclaw/api/routes/system.py +33 -49
  58. package/python/foxclaw/api/routes/task.py +31 -49
  59. package/python/foxclaw/api/routes/ws.py +2 -6
  60. package/python/foxclaw/api/spine.py +1 -4
  61. package/python/foxclaw/behavior/__init__.py +20 -0
  62. package/python/foxclaw/behavior/behavior.yaml +62 -0
  63. package/python/foxclaw/behavior/spec.py +287 -0
  64. package/python/foxclaw/browser/action/action_executor.py +1 -5
  65. package/python/foxclaw/browser/action/action_types.py +0 -1
  66. package/python/foxclaw/browser/action/url_policy.py +1 -5
  67. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  68. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  69. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  70. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  71. package/python/foxclaw/browser/service.py +7 -11
  72. package/python/foxclaw/capability/embedding_service.py +4 -6
  73. package/python/foxclaw/channels/__init__.py +9 -11
  74. package/python/foxclaw/channels/discord.py +4 -13
  75. package/python/foxclaw/channels/dispatcher.py +2 -27
  76. package/python/foxclaw/channels/feishu.py +10 -20
  77. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  78. package/python/foxclaw/channels/signing.py +9 -9
  79. package/python/foxclaw/channels/telegram.py +3 -4
  80. package/python/foxclaw/channels/weixin.py +7 -17
  81. package/python/foxclaw/cli.py +42 -13
  82. package/python/foxclaw/cognitive/reasoner.py +9 -10
  83. package/python/foxclaw/cognitive/state.py +18 -24
  84. package/python/foxclaw/core/audit_chain.py +1 -3
  85. package/python/foxclaw/core/audit_sink.py +3 -3
  86. package/python/foxclaw/core/compaction.py +8 -26
  87. package/python/foxclaw/core/config.py +2 -2
  88. package/python/foxclaw/core/confirm_nonce.py +6 -12
  89. package/python/foxclaw/core/content_guard.py +341 -0
  90. package/python/foxclaw/core/envfile.py +2 -2
  91. package/python/foxclaw/core/failure_policy.py +17 -26
  92. package/python/foxclaw/core/gateway.py +6 -10
  93. package/python/foxclaw/core/governance_audit.py +3 -7
  94. package/python/foxclaw/core/idempotency.py +15 -22
  95. package/python/foxclaw/core/injection_guard.py +10 -29
  96. package/python/foxclaw/core/memory_adapter.py +53 -60
  97. package/python/foxclaw/core/metrics.py +4 -5
  98. package/python/foxclaw/core/model_router.py +74 -150
  99. package/python/foxclaw/core/paths.py +1 -1
  100. package/python/foxclaw/core/recovery.py +21 -34
  101. package/python/foxclaw/core/scheduler.py +23 -26
  102. package/python/foxclaw/core/secrets.py +0 -3
  103. package/python/foxclaw/core/sensitive_write.py +283 -0
  104. package/python/foxclaw/core/sessions.py +4 -6
  105. package/python/foxclaw/core/settings.py +16 -32
  106. package/python/foxclaw/core/shared_write.py +11 -23
  107. package/python/foxclaw/core/steering_queue.py +12 -11
  108. package/python/foxclaw/core/system_bus.py +1 -1
  109. package/python/foxclaw/core/thought_chain.py +30 -32
  110. package/python/foxclaw/core/token_meter.py +9 -13
  111. package/python/foxclaw/core/tools.py +4 -12
  112. package/python/foxclaw/core/tracing.py +25 -40
  113. package/python/foxclaw/core/truth_pipeline.py +5 -9
  114. package/python/foxclaw/core/update_check.py +149 -0
  115. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  116. package/python/foxclaw/core/verify.py +6 -14
  117. package/python/foxclaw/core/web_search.py +4 -28
  118. package/python/foxclaw/finance/evidence.py +14 -18
  119. package/python/foxclaw/finance/fx.py +1 -3
  120. package/python/foxclaw/finance/graph.py +9 -18
  121. package/python/foxclaw/finance/models.py +20 -24
  122. package/python/foxclaw/finance/nlparse.py +12 -72
  123. package/python/foxclaw/finance/policy.py +8 -36
  124. package/python/foxclaw/finance/reasoner.py +3 -11
  125. package/python/foxclaw/finance/reasoning.py +13 -34
  126. package/python/foxclaw/finance/reconcile.py +5 -10
  127. package/python/foxclaw/finance/runtime.py +19 -46
  128. package/python/foxclaw/governance/middleware.py +13 -21
  129. package/python/foxclaw/harness/_base.py +2 -8
  130. package/python/foxclaw/harness/_policy.py +19 -38
  131. package/python/foxclaw/harness/drift.py +1 -13
  132. package/python/foxclaw/harness/event_bus.py +2 -5
  133. package/python/foxclaw/harness/harness.py +9 -97
  134. package/python/foxclaw/harness/loop_detection.py +11 -18
  135. package/python/foxclaw/itinerary/amap_client.py +2 -5
  136. package/python/foxclaw/itinerary/models.py +7 -7
  137. package/python/foxclaw/itinerary/reasoner.py +1 -6
  138. package/python/foxclaw/itinerary/reasoning.py +15 -21
  139. package/python/foxclaw/itinerary/runtime.py +8 -12
  140. package/python/foxclaw/kernel/capabilities.py +5 -6
  141. package/python/foxclaw/kernel/policy_gate.py +4 -10
  142. package/python/foxclaw/kernel/process_manager.py +17 -36
  143. package/python/foxclaw/kernel/scheduler.py +3 -9
  144. package/python/foxclaw/kernel/sentinel.py +13 -23
  145. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  146. package/python/foxclaw/kernel/spine.py +9 -9
  147. package/python/foxclaw/knowledge/__init__.py +4 -20
  148. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  149. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  150. package/python/foxclaw/learning/models.py +13 -13
  151. package/python/foxclaw/learning/reasoner.py +1 -4
  152. package/python/foxclaw/learning/runtime.py +19 -31
  153. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  154. package/python/foxclaw/mcp_server.py +2 -3
  155. package/python/foxclaw/orchestration/__init__.py +16 -33
  156. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  157. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  158. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  159. package/python/foxclaw/planning/llm_planner.py +3 -3
  160. package/python/foxclaw/planning/planner.py +6 -11
  161. package/python/foxclaw/search/agent_port.py +7 -11
  162. package/python/foxclaw/search/evidence.py +0 -4
  163. package/python/foxclaw/search/internal_search.py +3 -5
  164. package/python/foxclaw/search/models.py +11 -13
  165. package/python/foxclaw/search/planner.py +3 -3
  166. package/python/foxclaw/search/retriever.py +1 -1
  167. package/python/foxclaw/search/runtime.py +1 -8
  168. package/python/foxclaw/skills/__init__.py +46 -100
  169. package/python/foxclaw/skills/archive.py +111 -0
  170. package/python/foxclaw/skills/fetch.py +226 -0
  171. package/python/foxclaw/skills/hooks_spec.py +21 -25
  172. package/python/foxclaw/skills/install_cmd.py +216 -0
  173. package/python/foxclaw/skills/lifecycle.py +16 -42
  174. package/python/foxclaw/skills/markdown_skill.py +10 -9
  175. package/python/foxclaw/skills/mcp_client.py +5 -11
  176. package/python/foxclaw/skills/mcp_store.py +0 -3
  177. package/python/foxclaw/skills/permission.py +13 -48
  178. package/python/foxclaw/skills/pipeline.py +21 -53
  179. package/python/foxclaw/skills/policy.py +4 -10
  180. package/python/foxclaw/skills/review.py +3 -3
  181. package/python/frontend/deck/auth.js +0 -7
  182. package/python/frontend/deck/chat_model_picker.js +0 -54
  183. package/python/frontend/deck/domain.js +0 -20
  184. package/python/frontend/deck/main.js +0 -144
  185. package/python/frontend/deck/markdown.js +0 -4
  186. package/python/frontend/deck/settings.js +1 -18
  187. package/python/frontend/deck/state.js +0 -1
  188. package/python/frontend/deck/util.js +0 -2
  189. package/python/frontend/deck/visuals.js +0 -22
  190. package/python/frontend/planet/planet.js +18 -201
  191. package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
  192. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  193. package/python/frontend/v3/index.html +3 -3
  194. package/python/frontend/widget_runtime.js +0 -43
  195. package/python/frontend/widgets.js +0 -57
  196. package/python/frontend_src/index.html +1 -1
  197. package/python/frontend_src/package-lock.json +2 -2
  198. package/python/frontend_src/package.json +1 -1
  199. package/python/frontend_src/src/App.vue +3 -7
  200. package/python/frontend_src/src/api.js +0 -7
  201. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  202. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  203. package/python/frontend_src/src/components/Inspector.vue +0 -6
  204. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  205. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  206. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  207. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  208. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  209. package/python/frontend_src/src/components/Stage.vue +71 -25
  210. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  211. package/python/frontend_src/src/components/TopBar.vue +38 -10
  212. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  213. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  214. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  215. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
  216. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  217. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  218. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  219. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  220. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  221. package/python/frontend_src/src/lib/fmt.js +0 -4
  222. package/python/frontend_src/src/lib/i18n.js +53 -0
  223. package/python/frontend_src/src/lib/locales/en.js +173 -0
  224. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  225. package/python/frontend_src/src/lib/md.js +0 -14
  226. package/python/frontend_src/src/main.js +2 -0
  227. package/python/frontend_src/src/stores/app.js +17 -32
  228. package/python/frontend_src/src/stores/chat.js +0 -34
  229. package/python/frontend_src/src/stores/model.js +0 -8
  230. package/python/frontend_src/src/stores/panels.js +16 -44
  231. package/python/frontend_src/src/stores/profile.js +31 -0
  232. package/python/frontend_src/src/stores/system.js +25 -0
  233. package/python/frontend_src/vite.config.js +0 -7
  234. package/python/pyproject.toml +17 -3
  235. package/python/requirements.txt +311 -0
  236. package/python/tests/conftest.py +0 -22
  237. package/python/tests/run_all.py +4 -66
  238. package/python/tests/test_agent_lifecycle.py +4 -38
  239. package/python/tests/test_agent_rules.py +81 -0
  240. package/python/tests/test_ahs_p2.py +4 -25
  241. package/python/tests/test_anthropic_tools.py +1 -15
  242. package/python/tests/test_api.py +19 -9
  243. package/python/tests/test_api_auth.py +1 -5
  244. package/python/tests/test_attachments.py +9 -25
  245. package/python/tests/test_audit_chain.py +2 -7
  246. package/python/tests/test_audit_sink.py +0 -6
  247. package/python/tests/test_behavior_spec.py +198 -0
  248. package/python/tests/test_browser.py +2 -6
  249. package/python/tests/test_browser_request_guard.py +0 -4
  250. package/python/tests/test_capabilities.py +0 -6
  251. package/python/tests/test_channel_signing.py +2 -9
  252. package/python/tests/test_channels.py +1 -11
  253. package/python/tests/test_config_layering.py +0 -4
  254. package/python/tests/test_confirm_nonce.py +1 -12
  255. package/python/tests/test_conflict_dismiss.py +143 -0
  256. package/python/tests/test_content_guard.py +177 -0
  257. package/python/tests/test_context_compaction_wiring.py +1 -7
  258. package/python/tests/test_dispatch.py +0 -6
  259. package/python/tests/test_domain_evals.py +11 -80
  260. package/python/tests/test_domain_import.py +0 -6
  261. package/python/tests/test_domain_reasoners.py +0 -14
  262. package/python/tests/test_e2e.py +0 -17
  263. package/python/tests/test_emb_cache.py +7 -17
  264. package/python/tests/test_embedding_singleton.py +1 -3
  265. package/python/tests/test_evals_smoke.py +0 -24
  266. package/python/tests/test_event_spine.py +10 -18
  267. package/python/tests/test_evidence_semantic.py +1 -14
  268. package/python/tests/test_evidence_sse.py +3 -6
  269. package/python/tests/test_file_ingest.py +0 -11
  270. package/python/tests/test_finance.py +0 -9
  271. package/python/tests/test_finance_ledger.py +5 -12
  272. package/python/tests/test_finance_persist.py +8 -12
  273. package/python/tests/test_gateway.py +3 -22
  274. package/python/tests/test_governance.py +5 -52
  275. package/python/tests/test_governance_audit.py +4 -39
  276. package/python/tests/test_governance_middleware.py +1 -7
  277. package/python/tests/test_hkc.py +6 -24
  278. package/python/tests/test_hkc_index_build_manager.py +0 -33
  279. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  280. package/python/tests/test_hkc_search_event_sync.py +3 -54
  281. package/python/tests/test_import_path_guard.py +0 -6
  282. package/python/tests/test_injection_guard.py +18 -36
  283. package/python/tests/test_integration.py +5 -29
  284. package/python/tests/test_itinerary.py +0 -3
  285. package/python/tests/test_kee_semantic.py +0 -5
  286. package/python/tests/test_kernel.py +1 -11
  287. package/python/tests/test_learning.py +3 -12
  288. package/python/tests/test_llm_admin.py +4 -19
  289. package/python/tests/test_llm_planner.py +1 -23
  290. package/python/tests/test_markdown_skills.py +3 -14
  291. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  292. package/python/tests/test_mcp_connectors.py +3 -15
  293. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  294. package/python/tests/test_mcp_no_memory.py +3 -3
  295. package/python/tests/test_mcp_policy_gate.py +0 -1
  296. package/python/tests/test_mcp_server.py +6 -9
  297. package/python/tests/test_memory_recall.py +1 -4
  298. package/python/tests/test_metrics.py +0 -8
  299. package/python/tests/test_multi_chain.py +1 -13
  300. package/python/tests/test_multimodal.py +5 -18
  301. package/python/tests/test_negotiation.py +3 -13
  302. package/python/tests/test_orchestration.py +1 -11
  303. package/python/tests/test_orchestration_decoupled.py +0 -6
  304. package/python/tests/test_p3_p5_hardening.py +3 -17
  305. package/python/tests/test_parallel_dispatch.py +4 -12
  306. package/python/tests/test_pipeline.py +17 -58
  307. package/python/tests/test_plan_visible.py +4 -4
  308. package/python/tests/test_planner.py +0 -7
  309. package/python/tests/test_policy_gate.py +2 -3
  310. package/python/tests/test_profile.py +67 -0
  311. package/python/tests/test_readme_claims.py +163 -0
  312. package/python/tests/test_reasoner.py +2 -17
  313. package/python/tests/test_release_check.py +2 -13
  314. package/python/tests/test_resume_checkpoint.py +7 -15
  315. package/python/tests/test_routing.py +2 -12
  316. package/python/tests/test_scheduler.py +7 -10
  317. package/python/tests/test_search.py +1 -11
  318. package/python/tests/test_secrets.py +4 -10
  319. package/python/tests/test_semantic.py +1 -10
  320. package/python/tests/test_sensitive_write.py +181 -0
  321. package/python/tests/test_sessions.py +3 -4
  322. package/python/tests/test_settings.py +2 -18
  323. package/python/tests/test_shadow_learning.py +3 -43
  324. package/python/tests/test_shared_write.py +0 -7
  325. package/python/tests/test_skill_taint_gate.py +1 -10
  326. package/python/tests/test_skills.py +2 -18
  327. package/python/tests/test_skills_fetch.py +282 -0
  328. package/python/tests/test_stats_and_events.py +2 -5
  329. package/python/tests/test_streaming.py +2 -7
  330. package/python/tests/test_supervisor_pipeline.py +4 -5
  331. package/python/tests/test_supervisor_triage.py +4 -10
  332. package/python/tests/test_taint.py +2 -7
  333. package/python/tests/test_task_stream.py +1 -12
  334. package/python/tests/test_thinking_sse.py +0 -1
  335. package/python/tests/test_tool_center.py +1 -17
  336. package/python/tests/test_tool_parallel.py +2 -2
  337. package/python/tests/test_tool_params.py +1 -5
  338. package/python/tests/test_tool_use.py +0 -13
  339. package/python/tests/test_toplevel_concurrency.py +3 -4
  340. package/python/tests/test_tracing.py +7 -41
  341. package/python/tests/test_truth_crystallizer.py +4 -6
  342. package/python/tests/test_truth_flow.py +2 -7
  343. package/python/tests/test_update_check.py +95 -0
  344. package/python/tests/test_verify_evidence.py +8 -21
  345. package/python/tests/test_version_sync.py +35 -6
  346. package/python/tests/test_web_trigger.py +0 -17
  347. package/python/tools/build_content_lexicon.py +218 -0
  348. package/python/tools/bundle_web.py +98 -0
  349. package/python/tools/check_faiss_type.py +0 -1
  350. package/python/tools/check_log.ps1 +0 -8
  351. package/python/tools/mypy_baseline.txt +12 -4
  352. package/python/tools/mypy_ratchet.py +52 -6
  353. package/python/tools/pack.py +0 -5
  354. package/python/tools/recheck_conflicts.py +156 -0
  355. package/python/tools/release_check.py +24 -15
  356. package/python/tools/restart_foxclaw.ps1 +5 -8
  357. package/python/tools/shared_write_audit.py +1 -15
  358. package/python/tools/size_check.py +1 -1
  359. package/python/tools/strip_comments.py +228 -0
  360. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  361. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  362. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  363. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  364. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  365. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  366. package/python/foxclaw/api/app.py +0 -123
  367. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  368. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -24,8 +24,6 @@ from dataclasses import dataclass, field
24
24
  from datetime import datetime, timedelta
25
25
  from typing import Optional
26
26
 
27
- # ── 币种 ──────────────────────────────────────────────────
28
- # 别名按长度倒序匹配:"块钱" 必须先于 "块","人民币" 先于 "元",否则会切错。
29
27
  CURRENCY_ALIASES = {
30
28
  "人民币": "CNY", "块钱": "CNY", "块": "CNY", "元": "CNY", "圆": "CNY",
31
29
  "软妹币": "CNY", "rmb": "CNY", "cny": "CNY",
@@ -43,23 +41,17 @@ _SYMBOLS = {"¥": "CNY", "¥": "CNY", "$": "USD", "€": "EUR", "£": "GBP", "
43
41
  _UNIT_ALT = "|".join(re.escape(u) for u in
44
42
  sorted(CURRENCY_ALIASES, key=len, reverse=True))
45
43
 
46
- # 倍数后缀:1.5k / 2w / 3万 —— 不是币种,只是量级
47
44
  _SCALE = {"k": 1000, "千": 1000, "w": 10000, "万": 10000}
48
45
 
49
- # ── 中文数字 ──────────────────────────────────────────────
50
46
  _CN_DIGITS = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
51
47
  "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
52
48
  _CN_UNITS = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
53
49
  _CN_NUM = "[零〇一二两三四五六七八九十百千万亿]{1,12}"
54
50
 
55
- # ── 量词:数字 + 量词是"几件/2 袋",不是金额,必须先剔除 ─────────
56
- # 注意「块」不在此表 —— 它是货币单位(105 块),不是量词。
57
- # 「人」「位」故意不收:会吃掉「105 人民币」的「人」。
58
51
  _QUANTIFIERS = ("件|个|只|双|条|包|袋|瓶|杯|斤|公斤|kg|克|张|台|部|本|支|盒|箱|"
59
52
  "次|份|碗|串|根|桶|套|顿|晚|小时|分钟|公里|千米|米|升|码|岁|ml")
60
53
  _QUANT_PAT = re.compile(rf"(?:\d[\d,.]*|{_CN_NUM})\s*(?:{_QUANTIFIERS})", re.I)
61
54
 
62
- # ── 花费/收入动词 ────────────────────────────────────────
63
55
  _EXPENSE_VERBS = ("花了", "花费", "花", "付了", "付款", "支付", "买了", "买", "购",
64
56
  "消费", "充值", "充了", "交", "缴", "扣", "订", "租", "打车", "吃")
65
57
  _INCOME_VERBS = ("收入", "工资", "发了", "发工资", "收到", "收款", "进账", "退款",
@@ -68,7 +60,6 @@ _INCOME_VERBS = ("收入", "工资", "发了", "发工资", "收到", "收款",
68
60
  _VERB_PAT = re.compile("|".join(sorted(_EXPENSE_VERBS, key=len, reverse=True)))
69
61
  _INCOME_PAT = re.compile("|".join(sorted(_INCOME_VERBS, key=len, reverse=True)))
70
62
 
71
- # ── 分类关键词(顺序即优先级:订阅要先于娱乐,否则「腾讯视频会员」会归到娱乐)──
72
63
  CATEGORY_LABELS = {
73
64
  "food": "餐饮食品", "clothing": "服饰", "transport": "交通出行",
74
65
  "housing": "居住", "subscription": "订阅服务", "entertainment": "娱乐",
@@ -81,7 +72,6 @@ _CATEGORY_RULES = [
81
72
  ("food", ["大米", "米面", "面粉", "菜", "肉", "排骨", "水果", "奶茶", "咖啡",
82
73
  "饭", "早餐", "午餐", "晚餐", "夜宵", "外卖", "零食", "超市", "牛奶",
83
74
  "鸡蛋", "面包", "饮料", "火锅", "烧烤", "酒", "水饺", "面条", "油盐",
84
- # 常见餐饮商户:用户报备时往往只说店名
85
75
  "星巴克", "瑞幸", "麦当劳", "肯德基", "海底捞", "喜茶", "蜜雪", "食堂"]),
86
76
  ("clothing", ["衣服", "裤子", "鞋", "外套", "裙子", "衬衫", "t恤", "帽子",
87
77
  "包", "袜子", "内衣", "羽绒服", "西装", "围巾", "手套", "牛仔裤"]),
@@ -96,14 +86,13 @@ _CATEGORY_RULES = [
96
86
  ("entertainment", ["电影", "游戏", "ktv", "演出", "演唱会", "剧本杀", "健身", "按摩"]),
97
87
  ]
98
88
 
99
- # ── 时间表达 ─────────────────────────────────────────────
100
- _DAY_WORDS = [ # 长的先匹配,避免「大后天」被「后天」吃掉
89
+ _DAY_WORDS = [
101
90
  ("大后天", 3), ("大前天", -3), ("后天", 2), ("前天", -2),
102
91
  ("昨天", -1), ("昨日", -1), ("昨晚", -1), ("昨儿", -1),
103
92
  ("今天", 0), ("今日", 0), ("今儿", 0), ("刚刚", 0), ("刚才", 0),
104
93
  ("明天", 1), ("明日", 1), ("明儿", 1),
105
94
  ]
106
- _MONTH_WORDS = [ # (词, 月偏移)
95
+ _MONTH_WORDS = [
107
96
  ("上上个月", -2), ("上上月", -2), ("上个月", -1), ("上一个月", -1),
108
97
  ("上月", -1), ("下下个月", 2), ("下下月", 2), ("下个月", 1),
109
98
  ("下一个月", 1), ("下月", 1), ("这个月", 0), ("本月", 0), ("这月", 0),
@@ -115,24 +104,21 @@ _WEEK_WORDS = [("上周", -7), ("上星期", -7), ("上个星期", -7),
115
104
  ("下周", 7), ("下星期", 7), ("下个星期", 7)]
116
105
 
117
106
 
118
- # ══════════════════════════════════════════════════════════
119
- # 解析结果
120
- # ══════════════════════════════════════════════════════════
121
107
  @dataclass
122
108
  class ParsedExpense:
123
109
  """一句话里解析出的一笔钱(还没落账)。"""
124
- text: str # 原始片段
125
- direction: str = "expense" # expense / income
126
- amount: float = 0.0 # 原币金额(正数)
110
+ text: str
111
+ direction: str = "expense"
112
+ amount: float = 0.0
127
113
  currency: str = "CNY"
128
114
  when: datetime = field(default_factory=datetime.now)
129
115
  category: str = "misc"
130
- description: str = "" # 买了什么
131
- entity: str = "" # 商户/物品(账本里按它归类)
116
+ description: str = ""
117
+ entity: str = ""
132
118
  account: str = "default"
133
119
  confidence: float = 0.5
134
120
  warnings: list = field(default_factory=list)
135
- planned: bool = False # 未来时间 → 计划支出,不进"已发生"汇总
121
+ planned: bool = False
136
122
 
137
123
  @property
138
124
  def is_planned(self) -> bool:
@@ -151,9 +137,6 @@ class ParsedExpense:
151
137
  }
152
138
 
153
139
 
154
- # ══════════════════════════════════════════════════════════
155
- # 中文数字
156
- # ══════════════════════════════════════════════════════════
157
140
  def cn_to_number(s: str) -> Optional[float]:
158
141
  """「一百零五」→105、「两千三」→2300、「十万」→100000。解析不了返回 None。
159
142
 
@@ -184,18 +167,15 @@ def cn_to_number(s: str) -> Optional[float]:
184
167
  section += num * unit
185
168
  num, last_unit = 0, unit
186
169
  if num:
187
- if zero: # 一百零五
170
+ if zero:
188
171
  section += num
189
- elif last_unit >= 10: # 两千三 → 2300
172
+ elif last_unit >= 10:
190
173
  section += num * max(1, last_unit // 10)
191
174
  else:
192
175
  section += num
193
176
  return float(total + section)
194
177
 
195
178
 
196
- # ══════════════════════════════════════════════════════════
197
- # 时间
198
- # ══════════════════════════════════════════════════════════
199
179
  def _shift_month(now: datetime, months: int, day: Optional[int] = None) -> datetime:
200
180
  """按月份偏移,并把日 clamp 到目标月的合法范围(1 月 31 日 −1 月 → 2 月 28/29)。"""
201
181
  y, m = now.year, now.month + months
@@ -229,7 +209,6 @@ def _parse_when(text: str, now: datetime):
229
209
  when, explicit = dt, True
230
210
  cleaned = cleaned[:m.start()] + " " + cleaned[m.end():]
231
211
 
232
- # 1) 绝对日期 2026-11-05 / 2026年11月5日
233
212
  m = re.search(r"(\d{4})\s*[-/年]\s*(\d{1,2})\s*[-/月]\s*(\d{1,2})\s*[日号]?", cleaned)
234
213
  if m:
235
214
  try:
@@ -238,7 +217,6 @@ def _parse_when(text: str, now: datetime):
238
217
  except ValueError:
239
218
  pass
240
219
 
241
- # 2) 月份偏移(可带"5号"):上个月 / 下个月 5 号
242
220
  if when is None:
243
221
  m = re.search(rf"({_MONTH_ALT})\s*(?:份)?\s*(?:(\d{{1,2}}|[二三四五六七八九十]{{1,3}})\s*[号日])?",
244
222
  cleaned)
@@ -250,7 +228,6 @@ def _parse_when(text: str, now: datetime):
250
228
  day = day if 1 <= day <= 31 else None
251
229
  _take(m, _shift_month(now, offset, day))
252
230
 
253
- # 3) N 月 / N 月 M 号(不带"上/下"修饰)
254
231
  if when is None:
255
232
  m = re.search(rf"(\d{{1,2}}|{_CN_NUM})\s*月份?\s*(?:(\d{{1,2}}|{_CN_NUM})\s*[号日])?",
256
233
  cleaned)
@@ -261,13 +238,10 @@ def _parse_when(text: str, now: datetime):
261
238
  if m.group(2):
262
239
  day = int(m.group(2)) if m.group(2).isdigit() else int(cn_to_number(m.group(2)) or 0)
263
240
  day = day if 1 <= day <= 31 else None
264
- # 没写年份的「9 月」在 11 月说出来,指的就是今年 9 月(已过去);
265
- # 「12 月」在 11 月说,只可能是去年 12 月 —— 未来请说「下个月」。
266
241
  year = now.year - 1 if mon > now.month else now.year
267
242
  dd = min(day or now.day, _month_days(year, mon))
268
243
  _take(m, datetime(year, mon, dd, now.hour, now.minute))
269
244
 
270
- # 4) 今天/昨天/前天/大前天/明天/后天/大后天
271
245
  if when is None:
272
246
  for word, delta in _DAY_WORDS:
273
247
  idx = cleaned.find(word)
@@ -277,7 +251,6 @@ def _parse_when(text: str, now: datetime):
277
251
  cleaned = cleaned[:idx] + " " + cleaned[idx + len(word):]
278
252
  break
279
253
 
280
- # 5) N 天前 / N 天后 / N 日之内
281
254
  if when is None:
282
255
  m = re.search(rf"(\d{{1,3}}|{_CN_NUM})\s*天\s*(前|后)", cleaned)
283
256
  if m:
@@ -285,7 +258,6 @@ def _parse_when(text: str, now: datetime):
285
258
  delta = -n if m.group(2) == "前" else n
286
259
  _take(m, now + timedelta(days=delta))
287
260
 
288
- # 6) 上周/这周/下周
289
261
  if when is None:
290
262
  for word, delta in _WEEK_WORDS:
291
263
  idx = cleaned.find(word)
@@ -294,7 +266,6 @@ def _parse_when(text: str, now: datetime):
294
266
  cleaned = cleaned[:idx] + " " + cleaned[idx + len(word):]
295
267
  break
296
268
 
297
- # 7) 光秃秃的「5 号」:没过 → 本月;已过 → 上个月(口语里 5 号说的都是已经发生的事)
298
269
  if when is None:
299
270
  m = re.search(rf"(\d{{1,2}}|{_CN_NUM})\s*[号日]", cleaned)
300
271
  if m:
@@ -309,9 +280,6 @@ def _parse_when(text: str, now: datetime):
309
280
  return (when or now), cleaned, explicit
310
281
 
311
282
 
312
- # ══════════════════════════════════════════════════════════
313
- # 金额
314
- # ══════════════════════════════════════════════════════════
315
283
  def _to_float(num: str) -> Optional[float]:
316
284
  try:
317
285
  return float(num.replace(",", ""))
@@ -325,14 +293,12 @@ def _extract_amount(text: str):
325
293
  优先级:符号 > 数字+币种 > 数字+量级 > 中文数字+币种/量级 > 动词后的数字 > 唯一的裸数字。
326
294
  越往后越不确定,备注里会写明,让用户知道系统在猜。
327
295
  """
328
- # 1) 符号前置:$105、¥1,050、€30
329
296
  m = re.search(r"([¥¥$€£₩])\s*(\d[\d,]*(?:\.\d+)?)", text)
330
297
  if m:
331
298
  v = _to_float(m.group(2))
332
299
  if v is not None:
333
300
  return v, _SYMBOLS[m.group(1)], [m.group(0)], True, ""
334
301
 
335
- # 2) 数字 + 币种(105 块 / 105 美元 / 105 rmb)
336
302
  m = re.search(rf"(\d[\d,]*(?:\.\d+)?)\s*({_UNIT_ALT})", text, re.I)
337
303
  if m:
338
304
  v = _to_float(m.group(1))
@@ -340,7 +306,6 @@ def _extract_amount(text: str):
340
306
  return v, CURRENCY_ALIASES[m.group(2).lower() if m.group(2).isascii()
341
307
  else m.group(2)], [m.group(0)], True, ""
342
308
 
343
- # 2b) 币种前置:美元 105 / rmb 88
344
309
  m = re.search(rf"({_UNIT_ALT})\s*(\d[\d,]*(?:\.\d+)?)", text, re.I)
345
310
  if m:
346
311
  v = _to_float(m.group(2))
@@ -348,7 +313,6 @@ def _extract_amount(text: str):
348
313
  key = m.group(1).lower() if m.group(1).isascii() else m.group(1)
349
314
  return v, CURRENCY_ALIASES[key], [m.group(0)], True, ""
350
315
 
351
- # 3) 数字 + 量级(1.5k / 2w / 3 万)
352
316
  m = re.search(r"(\d[\d,]*(?:\.\d+)?)\s*(千|[kK]|[wW]|万)", text)
353
317
  if m:
354
318
  v = _to_float(m.group(1))
@@ -356,7 +320,6 @@ def _extract_amount(text: str):
356
320
  scale = _SCALE.get(m.group(2).lower(), 1000)
357
321
  return v * scale, "CNY", [m.group(0)], False, "按人民币处理(只给了量级)"
358
322
 
359
- # 4) 中文数字 + 币种(一百零五块 / 两千美元)
360
323
  m = re.search(rf"({_CN_NUM})\s*({_UNIT_ALT})", text, re.I)
361
324
  if m:
362
325
  v = cn_to_number(m.group(1))
@@ -364,16 +327,12 @@ def _extract_amount(text: str):
364
327
  return v, CURRENCY_ALIASES[m.group(2).lower() if m.group(2).isascii()
365
328
  else m.group(2)], [m.group(0)], True, ""
366
329
 
367
- # 5) 中文数字整串(两千三 / 三千五 / 两万)。
368
- # 注意必须**整串**交给 cn_to_number:先按 (千|万) 切会得到 "两"+"千" = 2000,
369
- # 而用户说的是 2300。
370
330
  m = re.search(rf"({_CN_NUM})", text)
371
331
  if m and len(m.group(1)) >= 2:
372
332
  v = cn_to_number(m.group(1))
373
333
  if v is not None:
374
334
  return v, "CNY", [m.group(0)], False, "按人民币处理(只给了量级)"
375
335
 
376
- # 6) 动词旁边的裸数字(花了 105 / 付 30)
377
336
  m = re.search(rf"(?:{'|'.join(sorted(_EXPENSE_VERBS, key=len, reverse=True))})"
378
337
  rf"\s*(\d[\d,]*(?:\.\d+)?)", text)
379
338
  if m:
@@ -387,9 +346,6 @@ def _extract_amount(text: str):
387
346
  if v is not None:
388
347
  return v, "CNY", [m.group(0)], False, ""
389
348
 
390
- # 7) 裸数字:只有一个就用它;有多个取最大(「买 2 件衣服 105」→ 105)。
391
- # 口语报备省略「元」是常态,单数字不再提示 —— 每笔都挂一句
392
- # 「按人民币处理」纯属噪音,反而让回执显得不可靠。
393
349
  nums = [(v, mm.group(0)) for mm in re.finditer(r"\d[\d,]*(?:\.\d+)?", text)
394
350
  if (v := _to_float(mm.group(0))) is not None]
395
351
  if len(nums) == 1:
@@ -400,9 +356,6 @@ def _extract_amount(text: str):
400
356
  return None
401
357
 
402
358
 
403
- # ══════════════════════════════════════════════════════════
404
- # 分类 / 收支方向
405
- # ══════════════════════════════════════════════════════════
406
359
  def _classify(text: str) -> str:
407
360
  low = text.lower()
408
361
  for cat, words in _CATEGORY_RULES:
@@ -420,9 +373,6 @@ def _direction(text: str) -> str:
420
373
  return "expense"
421
374
 
422
375
 
423
- # 描述清洗:剥掉金额、时间、功能动词与量词,只留下"买的是什么"。
424
- # 保留词先占位 —— 「打车/洗车/加油/租房」里的动词本身就是消费名,
425
- # 删掉会变成「车 25」这种读不懂的残句。
426
376
  _KEEP_PHRASES = ("打车", "洗车", "加油", "吃饭", "租房", "房租", "停车", "话费", "液化气")
427
377
  _STOP_WORDS = (
428
378
  "花了", "花费", "付了", "付款", "支付", "买了", "充值", "充了", "消费",
@@ -440,7 +390,6 @@ def _clean_description(text: str, spans: list) -> str:
440
390
  out = text
441
391
  for sp in spans:
442
392
  out = out.replace(sp, " ")
443
- # 保护动词性名词
444
393
  for i, w in enumerate(_KEEP_PHRASES):
445
394
  out = out.replace(w, f"\x00{i}\x00")
446
395
  for w in sorted(_STOP_WORDS, key=len, reverse=True):
@@ -455,10 +404,7 @@ def _clean_description(text: str, spans: list) -> str:
455
404
  _SPLIT_PAT = re.compile(r"[,,;;\n]+|(?:然后|另外|还有|以及|再就是|接着)")
456
405
 
457
406
 
458
- # ══════════════════════════════════════════════════════════
459
- # 主入口
460
- # ══════════════════════════════════════════════════════════
461
- def parse(text: str, now: datetime = None) -> list:
407
+ def parse(text: str, now: datetime | None = None) -> list:
462
408
  """把一段话解析成 0..N 笔钱。
463
409
 
464
410
  「今天买了一件衣服花了 105 块,还买了 45 块的大米」
@@ -482,9 +428,6 @@ def parse(text: str, now: datetime = None) -> list:
482
428
  inherited_when = p.when
483
429
  out.append(p)
484
430
 
485
- # 拆段只解析出 ≤1 笔 → 换用整句再解析一次。中文口语常带逗号
486
- # (「今天,我买了件衣服,花了 105 块」),拆开会让「衣服」这个分类线索掉在
487
- # 没有金额的段里,整句解析才拿得到完整语境。
488
431
  if len(out) <= 1:
489
432
  whole = _parse_one(text, now, None)
490
433
  if whole is not None:
@@ -498,7 +441,7 @@ def _parse_one(seg: str, now: datetime, inherited_when: Optional[datetime]):
498
441
  if not explicit_time and inherited_when is not None:
499
442
  when = inherited_when
500
443
 
501
- cleaned = _QUANT_PAT.sub(" ", cleaned) # 「一件」「2 袋」不是金额
444
+ cleaned = _QUANT_PAT.sub(" ", cleaned)
502
445
  got = _extract_amount(cleaned)
503
446
  if got is None:
504
447
  return None
@@ -510,7 +453,6 @@ def _parse_one(seg: str, now: datetime, inherited_when: Optional[datetime]):
510
453
  desc = _clean_description(cleaned, spans)
511
454
  category = "income" if direction == "income" else _classify(original)
512
455
 
513
- # 商户:「在星巴克买了…」
514
456
  ent = ""
515
457
  m = re.search(r"在\s*([\u4e00-\u9fa5A-Za-z0-9]{1,10}?)\s*(?:买|花|吃|订|充|消费|缴)", original)
516
458
  if m:
@@ -537,8 +479,6 @@ def _parse_one(seg: str, now: datetime, inherited_when: Optional[datetime]):
537
479
  )
538
480
 
539
481
 
540
- # 完成态付钱动词:「买了/花了/交了」说明钱**已经出去了**;
541
- # 「房租 3000 占收入多少」里的「租」不算 —— 那句在讨论额度,不是在报账。
542
482
  _SETTLED_SPEND = ("花了", "买了", "付了", "支付了", "消费了", "充值了",
543
483
  "交了", "缴了", "扣了", "刷了", "转了", "付账")
544
484
 
@@ -15,7 +15,6 @@ from typing import Optional
15
15
  from .models import FinancePolicyRule, PolicyVerdict, Transaction
16
16
 
17
17
 
18
- # 内置默认规则(对应用户设计里的示例规则)
19
18
  DEFAULT_RULES = [
20
19
  FinancePolicyRule(
21
20
  id="subscription_limit",
@@ -27,9 +26,6 @@ DEFAULT_RULES = [
27
26
  source="builtin",
28
27
  ),
29
28
  FinancePolicyRule(
30
- # 阈值 500(原 200):200 会让几乎每次日常消费都要确认 → 误报爆炸 → 用户索性
31
- # 把守门人关掉,那它就形同虚设。守门人的价值不在"规则多",而在**误报低到你愿意一直开着**。
32
- # 这是可调项:按自己的消费水平改 finance_policy.yaml 即可,不用动代码。
33
29
  id="large_purchase_check",
34
30
  condition="单笔支出 > 500",
35
31
  action="require_confirmation",
@@ -47,14 +43,11 @@ DEFAULT_RULES = [
47
43
  severity="critical",
48
44
  source="builtin",
49
45
  ),
50
- # ── 用户点名要的 4 条(2026-07-16)────────────────────────────────
51
- # 前两条的**检测早就实现了**(detect_duplicate_charges / overspend_forecast),
52
- # 但此前只进分析报告、从不产生裁决 —— 检测到了却不吭声。现接成规则。
53
46
  FinancePolicyRule(
54
47
  id="duplicate_charge_alert",
55
48
  condition="同商户短时间内重复扣款 ≥2 次",
56
49
  action="require_confirmation",
57
- threshold=2.0, # 次数阈值
50
+ threshold=2.0,
58
51
  scope="merchant",
59
52
  severity="warn",
60
53
  source="builtin",
@@ -72,18 +65,16 @@ DEFAULT_RULES = [
72
65
  id="renewal_reminder",
73
66
  condition="订阅/年费将在 7 天内自动续订",
74
67
  action="alert",
75
- threshold=7.0, # 提前几天提醒
68
+ threshold=7.0,
76
69
  scope="subscription",
77
70
  severity="info",
78
71
  source="builtin",
79
72
  ),
80
73
  FinancePolicyRule(
81
- # 注:必须由用户给交易打 tag `refund_expected` —— 系统无法从交易流推断
82
- # "我期待一笔退款"(退款只是一笔入账)。不标记则此规则恒不触发。
83
74
  id="refund_overdue_alert",
84
75
  condition="标记了 refund_expected 的支出,14 天内未收到等额退款",
85
76
  action="alert",
86
- threshold=14.0, # 等待天数
77
+ threshold=14.0,
87
78
  scope="merchant",
88
79
  severity="warn",
89
80
  source="builtin",
@@ -100,8 +91,6 @@ DEFAULT_RULES = [
100
91
  ]
101
92
 
102
93
 
103
- # 出厂阈值快照(模块加载时取,此后 DEFAULT_RULES 不再被改 —— 见 _load_or_init 的 deepcopy)。
104
- # 自校准靠它判断某条规则"是否还停在出厂状态"。
105
94
  FACTORY_THRESHOLDS = {r.id: r.threshold for r in DEFAULT_RULES}
106
95
 
107
96
 
@@ -111,10 +100,6 @@ class FinancePolicyEngine:
111
100
  def __init__(self, policy_path: str = "./finance_policy.yaml"):
112
101
  self.policy_path = Path(policy_path).resolve()
113
102
  self.rules: list = []
114
- # 线程安全铁律:finance_runtime 是单例(Foxclaw.__init__),analyze()→apply_calibration
115
- # 会被请求线程并发调用,改 rule.threshold 并 save() 写 yaml(非原子)。写侧必须串行,
116
- # 否则并发 save 可能把 yaml 写坏。RLock:apply_calibration 持锁时还要调 save()。
117
- # 读侧(evaluate_*)只读单个 float,GIL 下原子(读到旧值或新值都合法),不加锁省热路径开销。
118
103
  self._write_lock = threading.RLock()
119
104
  self._load_or_init()
120
105
 
@@ -122,9 +107,6 @@ class FinancePolicyEngine:
122
107
  if self.policy_path.exists():
123
108
  self._load()
124
109
  else:
125
- # deepcopy 而非 list():list() 只复制**列表**、不复制里面的 FinancePolicyRule 对象,
126
- # 于是所有实例共享同一批可变规则 —— 任何一处改阈值(rule.threshold=...)会污染
127
- # 其它 runtime **以及模块级 DEFAULT_RULES**(要重启才恢复)。规则是可变对象,必须深拷。
128
110
  self.rules = copy.deepcopy(DEFAULT_RULES)
129
111
  self.save()
130
112
 
@@ -135,8 +117,6 @@ class FinancePolicyEngine:
135
117
  if not self.rules:
136
118
  self.rules = copy.deepcopy(DEFAULT_RULES)
137
119
  except Exception:
138
- # 同 _load_or_init:必须 deepcopy。这两个兜底分支是上次漏改的 ——
139
- # 自校准会写 rule.threshold,一旦走到这里就会污染模块级 DEFAULT_RULES。
140
120
  self.rules = copy.deepcopy(DEFAULT_RULES)
141
121
 
142
122
  def save(self):
@@ -147,7 +127,6 @@ class FinancePolicyEngine:
147
127
  "domain": "finance",
148
128
  "rules": [r.to_dict() for r in self.rules],
149
129
  }
150
- # 写临时文件再原子 rename:避免并发/崩溃时读到写了一半的 yaml
151
130
  tmp = self.policy_path.with_suffix(self.policy_path.suffix + ".tmp")
152
131
  tmp.write_text(
153
132
  yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
@@ -161,7 +140,6 @@ class FinancePolicyEngine:
161
140
  self.rules.append(rule)
162
141
  self.save()
163
142
 
164
- # ── 阈值自校准 ────────────────────────────────────────
165
143
  def _may_calibrate(self, rule: FinancePolicyRule) -> bool:
166
144
  """这条规则的阈值,自动校准能不能动?
167
145
 
@@ -196,18 +174,17 @@ class FinancePolicyEngine:
196
174
  continue
197
175
  new_val, basis = s
198
176
  if new_val == rule.threshold:
199
- continue # 值没变 → 不写盘(analyze 每次都会调到这里)
177
+ continue
200
178
  changed.append({"rule_id": rule.id, "from": rule.threshold,
201
179
  "to": new_val, "basis": basis})
202
180
  rule.threshold = new_val
203
- rule.calibrated_value = new_val # 留痕:此后有人改动就能认出来
181
+ rule.calibrated_value = new_val
204
182
  rule.calibrated_from = basis
205
183
  rule.source = "learned"
206
184
  if changed:
207
185
  self.save()
208
186
  return changed
209
187
 
210
- # ── 守门人裁决 ────────────────────────────────────────
211
188
  def evaluate_transaction(self, txn: Transaction,
212
189
  account_balance: Optional[float] = None) -> list:
213
190
  """对单笔交易做规则裁决,返回所有触发的 verdict。"""
@@ -243,9 +220,9 @@ class FinancePolicyEngine:
243
220
  return None
244
221
 
245
222
  def evaluate_state(self, subscription_total: float = 0.0,
246
- anomalies: list = None, duplicates: list = None,
247
- forecasts: list = None, renewals: list = None,
248
- pending_refunds: list = None) -> list:
223
+ anomalies: list | None = None, duplicates: list | None = None,
224
+ forecasts: list | None = None, renewals: list | None = None,
225
+ pending_refunds: list | None = None) -> list:
249
226
  """对整体财务状态做裁决。
250
227
 
251
228
  分工:**runtime 算派生信号,本引擎只裁决**(沿用既有纹理)。
@@ -262,7 +239,6 @@ class FinancePolicyEngine:
262
239
  if not rule.enabled:
263
240
  continue
264
241
 
265
- # 同一商家高频/重复扣款(悄悄连扣)
266
242
  if rule.id == "duplicate_charge_alert":
267
243
  for d in duplicates:
268
244
  if d.get("count", 0) >= (rule.threshold or 2):
@@ -271,10 +247,8 @@ class FinancePolicyEngine:
271
247
  message=f"疑似重复扣款:{d.get('reason', '')}",
272
248
  requires_user_action=True))
273
249
 
274
- # 跨月预算超支**趋势**(还没超,但按当前速度月底会超)
275
250
  if rule.id == "overspend_trend_alert":
276
251
  for f in forecasts:
277
- # reliable 闸:月初 7 天内样本太少,外推是噪音,不报(见 overspend_forecast)
278
252
  if (f.get("reliable", True) and f.get("will_overspend")
279
253
  and f.get("spent_so_far", 0) <= f.get("monthly_limit", 0)):
280
254
  verdicts.append(PolicyVerdict(
@@ -285,7 +259,6 @@ class FinancePolicyEngine:
285
259
  f"限额 {f.get('monthly_limit', 0):.0f})"),
286
260
  requires_user_action=False))
287
261
 
288
- # 订阅/年费自动续订**前**提醒
289
262
  if rule.id == "renewal_reminder":
290
263
  for r in renewals:
291
264
  if r.get("days_until", 999) <= (rule.threshold or 7):
@@ -295,7 +268,6 @@ class FinancePolicyEngine:
295
268
  f"{r.get('amount', 0):.0f}({r.get('cadence', '')})"),
296
269
  requires_user_action=True))
297
270
 
298
- # 退款未到账(需用户显式标记 refund_expected —— 见 detect_pending_refunds)
299
271
  if rule.id == "refund_overdue_alert":
300
272
  for p in pending_refunds:
301
273
  if p.get("days_waiting", 0) >= (rule.threshold or 14):
@@ -13,14 +13,11 @@ from ..cognitive.reasoner import DomainReasoner, ReasonedVerdict, Evidence
13
13
  class FinanceGatekeeperReasoner(DomainReasoner):
14
14
  """subject = (txn, account_balance)。"""
15
15
 
16
- # Step3:LLM 只裁决"逼近阈值"的模糊带 [band×阈值, 阈值)——低于此=明显安全(省成本),
17
- # 高于阈值=规则已触发确认(不在本路径)。
18
16
  AMBIGUITY_BAND = 0.7
19
17
 
20
18
  def __init__(self, policy, runtime=None, llm=None, llm_enabled: bool = False):
21
19
  self.policy = policy
22
- self.runtime = runtime # 可选:FinanceRuntime,用于 Step2 的情境证据(历史/预算/频率)
23
- # Step3:模糊情形的 LLM 裁决器(可选、默认关)。llm 需有 complete(system,messages)->str + is_stub。
20
+ self.runtime = runtime
24
21
  self.llm = llm
25
22
  self.llm_enabled = bool(llm_enabled) and llm is not None and not getattr(llm, "is_stub", True)
26
23
 
@@ -39,12 +36,11 @@ class FinanceGatekeeperReasoner(DomainReasoner):
39
36
 
40
37
  def gather_evidence(self, subject, obs: dict) -> list:
41
38
  txn, bal = subject
42
- verdicts = self.policy.evaluate_transaction(txn, bal) # 规则=裁决证据(raw 非空)
39
+ verdicts = self.policy.evaluate_transaction(txn, bal)
43
40
  ev = [Evidence(factor=v.rule_id, detail=v.message, weight=1.0, raw=v)
44
41
  for v in verdicts]
45
42
  if not verdicts:
46
43
  ev.append(Evidence(factor="rules_clear", detail="未触发任何财务规则", weight=0.5))
47
- # Step2:结构化情境证据(informational,raw=None → **不参与裁决**,只让证据链更丰满)
48
44
  ev += self._context_evidence(txn)
49
45
  return ev
50
46
 
@@ -79,15 +75,12 @@ class FinanceGatekeeperReasoner(DomainReasoner):
79
75
  def arbitrate(self, subject, obs: dict, hyps: list, ev: list) -> ReasonedVerdict:
80
76
  triggered = [e.raw for e in ev if e.raw is not None]
81
77
  if triggered:
82
- # 【硬约束权威】规则触发 → 规则说了算(reject/confirm),**绝不问 LLM**。
83
78
  actions = [v.action for v in triggered]
84
79
  decision = "reject" if ("veto" in actions or "freeze" in actions) else "confirm"
85
80
  return ReasonedVerdict(decision, " / ".join(v.message for v in triggered),
86
81
  ev, hyps, "rules")
87
- # 规则批准:若"逼近阈值"的模糊情形且启用了 LLM,让 LLM 就证据复核(**只能 approve/confirm**)。
88
82
  if self.llm_enabled and self._is_ambiguous(obs):
89
83
  return self._llm_arbitrate(obs, hyps, ev)
90
- # 明显安全 / 未启用 LLM → 规则批准(rationale 与原守门人逐字一致)
91
84
  return ReasonedVerdict(
92
85
  "approve", f"财务批准:{obs['category']} 预算 {obs['amount']} 在规则内",
93
86
  ev, hyps, "rules")
@@ -102,7 +95,7 @@ class FinanceGatekeeperReasoner(DomainReasoner):
102
95
  thr = self._large_threshold()
103
96
  if thr <= 0 or not obs.get("is_expense"):
104
97
  return False
105
- return self.AMBIGUITY_BAND * thr <= obs["amount"] < thr # 逼近阈值但未触发规则
98
+ return self.AMBIGUITY_BAND * thr <= obs["amount"] < thr
106
99
 
107
100
  def _llm_arbitrate(self, obs: dict, hyps: list, ev: list) -> ReasonedVerdict:
108
101
  """【安全不变量】只在"规则已批准"的模糊情形被调;**只能返回 approve 或 confirm**,
@@ -124,6 +117,5 @@ class FinanceGatekeeperReasoner(DomainReasoner):
124
117
  return ReasonedVerdict(
125
118
  "approve", f"财务批准:{obs['category']} 预算 {obs['amount']}(LLM 复核无异常)",
126
119
  ev, hyps, "llm")
127
- # 响应不明 → fail-safe(绝不因 LLM 含糊而自动放行)
128
120
  return ReasonedVerdict("confirm", "LLM 响应不明,保守转确认(fail-safe)",
129
121
  ev, hyps, "llm_failsafe")