foxclaw 0.1.2 → 0.1.3

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 (344) 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/CHANGELOG.md +57 -0
  6. package/python/CHANGELOG_CN.md +57 -0
  7. package/python/README.md +11 -2
  8. package/python/README_CN.md +32 -20
  9. package/python/bench/bm25_audit.py +1 -14
  10. package/python/bench/bm25_regression.py +0 -1
  11. package/python/bench/bm25_stress.py +0 -4
  12. package/python/bench/cd_audit.py +0 -23
  13. package/python/bench/chaos.py +6 -8
  14. package/python/bench/e2e_real_models.py +0 -1
  15. package/python/bench/llm_retry_test.py +2 -9
  16. package/python/bench/load_test.py +5 -8
  17. package/python/bench/vector_ivf_upgrade.py +1 -7
  18. package/python/docs/RELEASE.md +4 -3
  19. package/python/docs/RUN_CN.md +2 -2
  20. package/python/evals/governance/cases.py +0 -37
  21. package/python/evals/governance/run_evals.py +5 -37
  22. package/python/examples/governance_middleware_demo.py +1 -6
  23. package/python/examples/langgraph_governance_demo.py +7 -11
  24. package/python/foxclaw/__init__.py +34 -152
  25. package/python/foxclaw/agents/base.py +65 -136
  26. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  27. package/python/foxclaw/agents/impl/finance.py +4 -21
  28. package/python/foxclaw/agents/impl/office.py +7 -24
  29. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  30. package/python/foxclaw/agents/mixins/_context.py +0 -23
  31. package/python/foxclaw/agents/mixins/augment.py +10 -16
  32. package/python/foxclaw/agents/mixins/llm.py +7 -6
  33. package/python/foxclaw/agents/mixins/memory.py +7 -1
  34. package/python/foxclaw/agents/mixins/preference.py +58 -8
  35. package/python/foxclaw/agents/mixins/prompt.py +0 -6
  36. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  37. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  38. package/python/foxclaw/api/__init__.py +18 -77
  39. package/python/foxclaw/api/_state.py +43 -0
  40. package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
  41. package/python/foxclaw/api/middleware.py +5 -28
  42. package/python/foxclaw/api/routes/ahs.py +60 -73
  43. package/python/foxclaw/api/routes/attachments.py +5 -6
  44. package/python/foxclaw/api/routes/browser.py +7 -11
  45. package/python/foxclaw/api/routes/domain.py +18 -26
  46. package/python/foxclaw/api/routes/knowledge.py +3 -21
  47. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  48. package/python/foxclaw/api/routes/memory.py +28 -12
  49. package/python/foxclaw/api/routes/orchestration.py +3 -5
  50. package/python/foxclaw/api/routes/recovery.py +4 -3
  51. package/python/foxclaw/api/routes/review.py +2 -11
  52. package/python/foxclaw/api/routes/search.py +3 -7
  53. package/python/foxclaw/api/routes/system.py +33 -49
  54. package/python/foxclaw/api/routes/task.py +31 -49
  55. package/python/foxclaw/api/routes/ws.py +2 -6
  56. package/python/foxclaw/api/spine.py +1 -4
  57. package/python/foxclaw/browser/action/action_executor.py +1 -5
  58. package/python/foxclaw/browser/action/action_types.py +0 -1
  59. package/python/foxclaw/browser/action/url_policy.py +1 -5
  60. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  61. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  62. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  63. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  64. package/python/foxclaw/browser/service.py +7 -11
  65. package/python/foxclaw/capability/embedding_service.py +4 -6
  66. package/python/foxclaw/channels/__init__.py +9 -11
  67. package/python/foxclaw/channels/discord.py +4 -13
  68. package/python/foxclaw/channels/dispatcher.py +2 -27
  69. package/python/foxclaw/channels/feishu.py +10 -20
  70. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  71. package/python/foxclaw/channels/signing.py +9 -9
  72. package/python/foxclaw/channels/telegram.py +3 -4
  73. package/python/foxclaw/channels/weixin.py +7 -17
  74. package/python/foxclaw/cli.py +42 -13
  75. package/python/foxclaw/cognitive/reasoner.py +9 -10
  76. package/python/foxclaw/cognitive/state.py +18 -24
  77. package/python/foxclaw/core/audit_chain.py +1 -3
  78. package/python/foxclaw/core/audit_sink.py +3 -3
  79. package/python/foxclaw/core/compaction.py +8 -26
  80. package/python/foxclaw/core/config.py +2 -2
  81. package/python/foxclaw/core/confirm_nonce.py +6 -12
  82. package/python/foxclaw/core/envfile.py +2 -2
  83. package/python/foxclaw/core/failure_policy.py +17 -26
  84. package/python/foxclaw/core/gateway.py +6 -10
  85. package/python/foxclaw/core/governance_audit.py +3 -7
  86. package/python/foxclaw/core/idempotency.py +15 -22
  87. package/python/foxclaw/core/injection_guard.py +10 -29
  88. package/python/foxclaw/core/memory_adapter.py +30 -57
  89. package/python/foxclaw/core/metrics.py +4 -5
  90. package/python/foxclaw/core/model_router.py +36 -148
  91. package/python/foxclaw/core/paths.py +1 -1
  92. package/python/foxclaw/core/recovery.py +21 -34
  93. package/python/foxclaw/core/scheduler.py +23 -26
  94. package/python/foxclaw/core/secrets.py +0 -3
  95. package/python/foxclaw/core/sessions.py +4 -6
  96. package/python/foxclaw/core/settings.py +16 -32
  97. package/python/foxclaw/core/shared_write.py +11 -23
  98. package/python/foxclaw/core/steering_queue.py +12 -11
  99. package/python/foxclaw/core/system_bus.py +1 -1
  100. package/python/foxclaw/core/thought_chain.py +30 -32
  101. package/python/foxclaw/core/token_meter.py +9 -13
  102. package/python/foxclaw/core/tools.py +4 -12
  103. package/python/foxclaw/core/tracing.py +25 -40
  104. package/python/foxclaw/core/truth_pipeline.py +5 -9
  105. package/python/foxclaw/core/update_check.py +149 -0
  106. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  107. package/python/foxclaw/core/verify.py +6 -14
  108. package/python/foxclaw/core/web_search.py +4 -28
  109. package/python/foxclaw/finance/evidence.py +14 -18
  110. package/python/foxclaw/finance/fx.py +1 -3
  111. package/python/foxclaw/finance/graph.py +9 -18
  112. package/python/foxclaw/finance/models.py +20 -24
  113. package/python/foxclaw/finance/nlparse.py +12 -72
  114. package/python/foxclaw/finance/policy.py +8 -36
  115. package/python/foxclaw/finance/reasoner.py +3 -11
  116. package/python/foxclaw/finance/reasoning.py +13 -34
  117. package/python/foxclaw/finance/reconcile.py +5 -10
  118. package/python/foxclaw/finance/runtime.py +19 -46
  119. package/python/foxclaw/governance/middleware.py +13 -21
  120. package/python/foxclaw/harness/_base.py +2 -8
  121. package/python/foxclaw/harness/_policy.py +19 -38
  122. package/python/foxclaw/harness/drift.py +1 -13
  123. package/python/foxclaw/harness/event_bus.py +2 -5
  124. package/python/foxclaw/harness/harness.py +9 -97
  125. package/python/foxclaw/harness/loop_detection.py +11 -18
  126. package/python/foxclaw/itinerary/amap_client.py +2 -5
  127. package/python/foxclaw/itinerary/models.py +7 -7
  128. package/python/foxclaw/itinerary/reasoner.py +1 -6
  129. package/python/foxclaw/itinerary/reasoning.py +15 -21
  130. package/python/foxclaw/itinerary/runtime.py +8 -12
  131. package/python/foxclaw/kernel/capabilities.py +5 -6
  132. package/python/foxclaw/kernel/policy_gate.py +4 -10
  133. package/python/foxclaw/kernel/process_manager.py +17 -36
  134. package/python/foxclaw/kernel/scheduler.py +3 -9
  135. package/python/foxclaw/kernel/sentinel.py +13 -23
  136. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  137. package/python/foxclaw/kernel/spine.py +9 -9
  138. package/python/foxclaw/knowledge/__init__.py +4 -20
  139. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  140. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  141. package/python/foxclaw/learning/models.py +13 -13
  142. package/python/foxclaw/learning/reasoner.py +1 -4
  143. package/python/foxclaw/learning/runtime.py +19 -31
  144. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  145. package/python/foxclaw/mcp_server.py +2 -3
  146. package/python/foxclaw/orchestration/__init__.py +16 -33
  147. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  148. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  149. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  150. package/python/foxclaw/planning/llm_planner.py +3 -3
  151. package/python/foxclaw/planning/planner.py +6 -11
  152. package/python/foxclaw/search/agent_port.py +7 -11
  153. package/python/foxclaw/search/evidence.py +0 -4
  154. package/python/foxclaw/search/internal_search.py +3 -5
  155. package/python/foxclaw/search/models.py +11 -13
  156. package/python/foxclaw/search/planner.py +3 -3
  157. package/python/foxclaw/search/retriever.py +1 -1
  158. package/python/foxclaw/search/runtime.py +1 -8
  159. package/python/foxclaw/skills/__init__.py +46 -100
  160. package/python/foxclaw/skills/archive.py +111 -0
  161. package/python/foxclaw/skills/fetch.py +226 -0
  162. package/python/foxclaw/skills/hooks_spec.py +21 -25
  163. package/python/foxclaw/skills/install_cmd.py +216 -0
  164. package/python/foxclaw/skills/lifecycle.py +16 -42
  165. package/python/foxclaw/skills/markdown_skill.py +10 -9
  166. package/python/foxclaw/skills/mcp_client.py +5 -11
  167. package/python/foxclaw/skills/mcp_store.py +0 -3
  168. package/python/foxclaw/skills/permission.py +13 -48
  169. package/python/foxclaw/skills/pipeline.py +21 -53
  170. package/python/foxclaw/skills/policy.py +4 -10
  171. package/python/foxclaw/skills/review.py +3 -3
  172. package/python/frontend/deck/auth.js +0 -7
  173. package/python/frontend/deck/chat_model_picker.js +0 -54
  174. package/python/frontend/deck/domain.js +0 -20
  175. package/python/frontend/deck/main.js +0 -144
  176. package/python/frontend/deck/markdown.js +0 -4
  177. package/python/frontend/deck/settings.js +1 -18
  178. package/python/frontend/deck/state.js +0 -1
  179. package/python/frontend/deck/util.js +0 -2
  180. package/python/frontend/deck/visuals.js +0 -22
  181. package/python/frontend/planet/planet.js +0 -201
  182. package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
  183. package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
  184. package/python/frontend/v3/index.html +3 -3
  185. package/python/frontend/widget_runtime.js +0 -43
  186. package/python/frontend/widgets.js +0 -57
  187. package/python/frontend_src/index.html +1 -1
  188. package/python/frontend_src/package-lock.json +2 -2
  189. package/python/frontend_src/package.json +1 -1
  190. package/python/frontend_src/src/App.vue +3 -7
  191. package/python/frontend_src/src/api.js +0 -7
  192. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  193. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  194. package/python/frontend_src/src/components/Inspector.vue +0 -6
  195. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  196. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  197. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  198. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  199. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  200. package/python/frontend_src/src/components/Stage.vue +71 -25
  201. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  202. package/python/frontend_src/src/components/TopBar.vue +38 -10
  203. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  204. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  205. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  206. package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
  207. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  208. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  209. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  210. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  211. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  212. package/python/frontend_src/src/lib/fmt.js +0 -4
  213. package/python/frontend_src/src/lib/i18n.js +53 -0
  214. package/python/frontend_src/src/lib/locales/en.js +173 -0
  215. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  216. package/python/frontend_src/src/lib/md.js +0 -14
  217. package/python/frontend_src/src/main.js +2 -0
  218. package/python/frontend_src/src/stores/app.js +17 -32
  219. package/python/frontend_src/src/stores/chat.js +0 -34
  220. package/python/frontend_src/src/stores/model.js +0 -8
  221. package/python/frontend_src/src/stores/panels.js +0 -41
  222. package/python/frontend_src/src/stores/profile.js +31 -0
  223. package/python/frontend_src/src/stores/system.js +25 -0
  224. package/python/frontend_src/vite.config.js +0 -7
  225. package/python/pyproject.toml +6 -3
  226. package/python/requirements.txt +311 -0
  227. package/python/tests/conftest.py +0 -22
  228. package/python/tests/run_all.py +3 -66
  229. package/python/tests/test_agent_lifecycle.py +4 -38
  230. package/python/tests/test_ahs_p2.py +4 -25
  231. package/python/tests/test_anthropic_tools.py +1 -15
  232. package/python/tests/test_api.py +19 -9
  233. package/python/tests/test_api_auth.py +1 -5
  234. package/python/tests/test_attachments.py +9 -25
  235. package/python/tests/test_audit_chain.py +2 -7
  236. package/python/tests/test_audit_sink.py +0 -6
  237. package/python/tests/test_browser.py +2 -6
  238. package/python/tests/test_browser_request_guard.py +0 -4
  239. package/python/tests/test_capabilities.py +0 -6
  240. package/python/tests/test_channel_signing.py +2 -9
  241. package/python/tests/test_channels.py +1 -11
  242. package/python/tests/test_config_layering.py +0 -4
  243. package/python/tests/test_confirm_nonce.py +1 -12
  244. package/python/tests/test_context_compaction_wiring.py +1 -7
  245. package/python/tests/test_dispatch.py +0 -6
  246. package/python/tests/test_domain_evals.py +11 -80
  247. package/python/tests/test_domain_import.py +0 -6
  248. package/python/tests/test_domain_reasoners.py +0 -14
  249. package/python/tests/test_e2e.py +0 -17
  250. package/python/tests/test_emb_cache.py +7 -17
  251. package/python/tests/test_embedding_singleton.py +1 -3
  252. package/python/tests/test_evals_smoke.py +0 -24
  253. package/python/tests/test_event_spine.py +10 -18
  254. package/python/tests/test_evidence_semantic.py +1 -14
  255. package/python/tests/test_evidence_sse.py +3 -6
  256. package/python/tests/test_file_ingest.py +0 -11
  257. package/python/tests/test_finance.py +0 -9
  258. package/python/tests/test_finance_ledger.py +5 -12
  259. package/python/tests/test_finance_persist.py +8 -12
  260. package/python/tests/test_gateway.py +3 -22
  261. package/python/tests/test_governance.py +5 -52
  262. package/python/tests/test_governance_audit.py +4 -39
  263. package/python/tests/test_governance_middleware.py +1 -7
  264. package/python/tests/test_hkc.py +6 -24
  265. package/python/tests/test_hkc_index_build_manager.py +0 -33
  266. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  267. package/python/tests/test_hkc_search_event_sync.py +3 -54
  268. package/python/tests/test_import_path_guard.py +0 -6
  269. package/python/tests/test_injection_guard.py +18 -36
  270. package/python/tests/test_integration.py +5 -29
  271. package/python/tests/test_itinerary.py +0 -3
  272. package/python/tests/test_kee_semantic.py +0 -5
  273. package/python/tests/test_kernel.py +1 -11
  274. package/python/tests/test_learning.py +3 -12
  275. package/python/tests/test_llm_admin.py +4 -19
  276. package/python/tests/test_llm_planner.py +1 -23
  277. package/python/tests/test_markdown_skills.py +3 -14
  278. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  279. package/python/tests/test_mcp_connectors.py +3 -15
  280. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  281. package/python/tests/test_mcp_no_memory.py +3 -3
  282. package/python/tests/test_mcp_policy_gate.py +0 -1
  283. package/python/tests/test_mcp_server.py +6 -9
  284. package/python/tests/test_memory_recall.py +1 -4
  285. package/python/tests/test_metrics.py +0 -8
  286. package/python/tests/test_multi_chain.py +1 -13
  287. package/python/tests/test_multimodal.py +5 -18
  288. package/python/tests/test_negotiation.py +3 -13
  289. package/python/tests/test_orchestration.py +1 -11
  290. package/python/tests/test_orchestration_decoupled.py +0 -6
  291. package/python/tests/test_p3_p5_hardening.py +3 -17
  292. package/python/tests/test_parallel_dispatch.py +4 -12
  293. package/python/tests/test_pipeline.py +17 -58
  294. package/python/tests/test_plan_visible.py +4 -4
  295. package/python/tests/test_planner.py +0 -7
  296. package/python/tests/test_policy_gate.py +2 -3
  297. package/python/tests/test_profile.py +67 -0
  298. package/python/tests/test_readme_claims.py +163 -0
  299. package/python/tests/test_reasoner.py +2 -17
  300. package/python/tests/test_release_check.py +2 -13
  301. package/python/tests/test_resume_checkpoint.py +7 -15
  302. package/python/tests/test_routing.py +2 -12
  303. package/python/tests/test_scheduler.py +7 -10
  304. package/python/tests/test_search.py +1 -11
  305. package/python/tests/test_secrets.py +4 -10
  306. package/python/tests/test_semantic.py +1 -10
  307. package/python/tests/test_sessions.py +3 -4
  308. package/python/tests/test_settings.py +2 -18
  309. package/python/tests/test_shadow_learning.py +3 -43
  310. package/python/tests/test_shared_write.py +0 -7
  311. package/python/tests/test_skill_taint_gate.py +1 -10
  312. package/python/tests/test_skills.py +2 -18
  313. package/python/tests/test_skills_fetch.py +282 -0
  314. package/python/tests/test_stats_and_events.py +2 -5
  315. package/python/tests/test_streaming.py +2 -7
  316. package/python/tests/test_supervisor_pipeline.py +4 -5
  317. package/python/tests/test_supervisor_triage.py +4 -10
  318. package/python/tests/test_taint.py +2 -7
  319. package/python/tests/test_task_stream.py +1 -12
  320. package/python/tests/test_thinking_sse.py +0 -1
  321. package/python/tests/test_tool_center.py +1 -17
  322. package/python/tests/test_tool_parallel.py +2 -2
  323. package/python/tests/test_tool_params.py +1 -5
  324. package/python/tests/test_tool_use.py +0 -13
  325. package/python/tests/test_toplevel_concurrency.py +3 -4
  326. package/python/tests/test_tracing.py +7 -41
  327. package/python/tests/test_truth_crystallizer.py +4 -6
  328. package/python/tests/test_truth_flow.py +2 -7
  329. package/python/tests/test_update_check.py +95 -0
  330. package/python/tests/test_verify_evidence.py +8 -21
  331. package/python/tests/test_version_sync.py +35 -6
  332. package/python/tests/test_web_trigger.py +0 -17
  333. package/python/tools/check_faiss_type.py +0 -1
  334. package/python/tools/check_log.ps1 +0 -8
  335. package/python/tools/mypy_baseline.txt +12 -4
  336. package/python/tools/mypy_ratchet.py +52 -6
  337. package/python/tools/pack.py +0 -5
  338. package/python/tools/release_check.py +7 -7
  339. package/python/tools/restart_foxclaw.ps1 +5 -8
  340. package/python/tools/shared_write_audit.py +1 -15
  341. package/python/tools/size_check.py +1 -1
  342. package/python/tools/strip_comments.py +228 -0
  343. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  344. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -18,7 +18,7 @@ from dataclasses import dataclass, field
18
18
  @dataclass
19
19
  class GraphNode:
20
20
  node_id: str
21
- node_type: str # transaction/category/entity/account/budget
21
+ node_type: str
22
22
  label: str
23
23
  attrs: dict = field(default_factory=dict)
24
24
 
@@ -31,7 +31,7 @@ class GraphNode:
31
31
  class GraphEdge:
32
32
  src: str
33
33
  dst: str
34
- rel: str # belongs_to/paid_to/charged_on/monitors
34
+ rel: str
35
35
  attrs: dict = field(default_factory=dict)
36
36
 
37
37
  def to_dict(self) -> dict:
@@ -45,15 +45,13 @@ class FinancialDataGraph:
45
45
  def __init__(self):
46
46
  self.nodes: dict[str, GraphNode] = {}
47
47
  self.edges: list[GraphEdge] = []
48
- self._adj: dict[str, list] = defaultdict(list) # 出边邻接
49
- self._radj: dict[str, list] = defaultdict(list) # 入边邻接
48
+ self._adj: dict[str, list] = defaultdict(list)
49
+ self._radj: dict[str, list] = defaultdict(list)
50
50
 
51
- # ── 构建 ──
52
51
  def _ensure_node(self, node_id: str, node_type: str, label: str, **attrs):
53
52
  if node_id not in self.nodes:
54
53
  self.nodes[node_id] = GraphNode(node_id, node_type, label, dict(attrs))
55
54
  else:
56
- # 已存在 → 累加统计类属性
57
55
  self.nodes[node_id].attrs.update(attrs)
58
56
  return self.nodes[node_id]
59
57
 
@@ -63,7 +61,7 @@ class FinancialDataGraph:
63
61
  self._adj[src].append(e)
64
62
  self._radj[dst].append(e)
65
63
 
66
- def build(self, transactions: list, budgets: list = None):
64
+ def build(self, transactions: list, budgets: list | None = None):
67
65
  """从交易 + 预算构建图。"""
68
66
  self.nodes.clear(); self.edges.clear()
69
67
  self._adj.clear(); self._radj.clear()
@@ -73,7 +71,6 @@ class FinancialDataGraph:
73
71
  self._ensure_node(tid, "transaction", f"{t.entity} ¥{abs(t.amount):.0f}",
74
72
  amount=t.amount, timestamp=t.timestamp,
75
73
  is_expense=t.is_expense)
76
- # 分类节点 + 边(消费统计只累加支出,收入/退款不计入'花费')
77
74
  cat_id = f"cat:{t.category}"
78
75
  cnode = self._ensure_node(cat_id, "category", t.category)
79
76
  if t.is_expense:
@@ -82,19 +79,16 @@ class FinancialDataGraph:
82
79
  else:
83
80
  cnode.attrs["income"] = cnode.attrs.get("income", 0) + abs(t.amount)
84
81
  self._add_edge(tid, cat_id, "belongs_to")
85
- # 商户节点 + 边(同样只把支出计入消费)
86
82
  ent_id = f"ent:{t.entity}"
87
83
  enode = self._ensure_node(ent_id, "entity", t.entity)
88
84
  if t.is_expense:
89
85
  enode.attrs["total"] = enode.attrs.get("total", 0) + abs(t.amount)
90
86
  enode.attrs["count"] = enode.attrs.get("count", 0) + 1
91
87
  self._add_edge(tid, ent_id, "paid_to")
92
- # 账户节点 + 边
93
88
  acc_id = f"acc:{t.account}"
94
89
  self._ensure_node(acc_id, "account", t.account)
95
90
  self._add_edge(tid, acc_id, "charged_on")
96
91
 
97
- # 预算节点 + 监管边(预算 monitors 分类)
98
92
  for b in (budgets or []):
99
93
  bid = f"bud:{b.category}"
100
94
  self._ensure_node(bid, "budget", f"{b.category}预算",
@@ -104,8 +98,7 @@ class FinancialDataGraph:
104
98
  self._add_edge(bid, cat_id, "monitors")
105
99
  return self
106
100
 
107
- # ── 查询 ──
108
- def neighbors(self, node_id: str, rel: str = None) -> list:
101
+ def neighbors(self, node_id: str, rel: str | None = None) -> list:
109
102
  """出边邻居(可按关系过滤)。"""
110
103
  return [self.nodes[e.dst] for e in self._adj.get(node_id, [])
111
104
  if rel is None or e.rel == rel]
@@ -119,11 +112,10 @@ class FinancialDataGraph:
119
112
  """某分类下,按消费额排名的商户 —— 回答'这个分类的钱花在谁身上'。"""
120
113
  cat_id = f"cat:{category}"
121
114
  txns = self.transactions_of(cat_id)
122
- by_entity = defaultdict(float)
115
+ by_entity: defaultdict[str, float] = defaultdict(float)
123
116
  for t in txns:
124
117
  if not t.attrs.get("is_expense"):
125
- continue # 只统计支出,排除收入/退款
126
- # 找这笔交易付给了谁
118
+ continue
127
119
  for e in self._adj.get(t.node_id, []):
128
120
  if e.rel == "paid_to":
129
121
  by_entity[self.nodes[e.dst].label] += abs(t.attrs.get("amount", 0))
@@ -139,7 +131,6 @@ class FinancialDataGraph:
139
131
  cat = self.nodes[cat_id]
140
132
  spent = cat.attrs.get("total", 0)
141
133
  limit = self.nodes[bud_id].attrs.get("monthly_limit") if bud_id in self.nodes else None
142
- # 主要贡献者(商户)
143
134
  contributors = self.top_entities_in_category(category, top=3)
144
135
  result = {
145
136
  "category": category, "found": True,
@@ -170,7 +161,7 @@ class FinancialDataGraph:
170
161
  return sorted(out, key=lambda x: -x["outflow"])
171
162
 
172
163
  def stats(self) -> dict:
173
- by_type = defaultdict(int)
164
+ by_type: defaultdict[str, int] = defaultdict(int)
174
165
  for n in self.nodes.values():
175
166
  by_type[n.node_type] += 1
176
167
  return {"nodes": len(self.nodes), "edges": len(self.edges),
@@ -22,21 +22,20 @@ class Transaction:
22
22
  原币金额与币种另存 `original_amount` / `currency` / `fx_rate` —— 这样「100 美元」
23
23
  不会和「100 元」在求和时混在一起,同时账本里又看得到用户当时说的原话数额。
24
24
  """
25
- amount: float # 负数=支出,正数=收入(本位币 CNY)
26
- category: str # 分类:subscription/food/transport/salary...
27
- entity: str # 商户/对手方:Netflix、星巴克、公司
28
- account: str # 账户:credit_card_1、debit、alipay
25
+ amount: float
26
+ category: str
27
+ entity: str
28
+ account: str
29
29
  timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
30
- tags: list = field(default_factory=list) # ["recurring","media"]
30
+ tags: list = field(default_factory=list)
31
31
  note: str = ""
32
32
  id: str = field(default_factory=lambda: f"txn_{uuid.uuid4().hex[:10]}")
33
- # —— 多币种 & 自然语言记账 ——
34
- currency: str = "CNY" # 原币种
35
- original_amount: Optional[float] = None # 原币金额(正负号与 amount 一致)
36
- fx_rate: float = 1.0 # 记账时原币 → 本位币的折算率
37
- description: str = "" # 报备原话里的"买的什么"
38
- status: str = "settled" # settled=已发生 planned=未来计划
39
- source: str = "manual" # manual / text / csv / bank
33
+ currency: str = "CNY"
34
+ original_amount: Optional[float] = None
35
+ fx_rate: float = 1.0
36
+ description: str = ""
37
+ status: str = "settled"
38
+ source: str = "manual"
40
39
 
41
40
  @property
42
41
  def is_expense(self) -> bool:
@@ -72,8 +71,8 @@ class Transaction:
72
71
  class Budget:
73
72
  """某分类的预算状态"""
74
73
  category: str
75
- monthly_limit: float # 月度上限
76
- spent: float = 0.0 # 本期已花(绝对值)
74
+ monthly_limit: float
75
+ spent: float = 0.0
77
76
  period: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m"))
78
77
 
79
78
  @property
@@ -102,12 +101,12 @@ class FinancialEntity:
102
101
  """账户 / 商户 / 订阅"""
103
102
  name: str
104
103
  entity_type: EntityType
105
- balance: Optional[float] = None # 账户余额(仅 account)
106
- recurring_amount: Optional[float] = None # 周期金额(仅 subscription)
107
- cycle: Optional[str] = None # monthly/yearly(仅 subscription)
104
+ balance: Optional[float] = None
105
+ recurring_amount: Optional[float] = None
106
+ cycle: Optional[str] = None
108
107
  last_seen: str = ""
109
108
  txn_count: int = 0
110
- total_amount: float = 0.0 # 累计金额
109
+ total_amount: float = 0.0
111
110
 
112
111
  def to_dict(self) -> dict:
113
112
  return asdict(self)
@@ -119,16 +118,13 @@ class FinancePolicyRule:
119
118
  与 Harness 的 Policy YAML 同构,但作用于资金行为。
120
119
  """
121
120
  id: str
122
- condition: str # 人类可读条件描述
121
+ condition: str
123
122
  action: Literal["alert", "require_confirmation", "freeze", "veto", "suggest"]
124
123
  threshold: Optional[float] = None
125
- scope: str = "global" # global 或某 category
124
+ scope: str = "global"
126
125
  severity: Literal["info", "warn", "critical"] = "warn"
127
126
  source: Literal["builtin", "user", "learned"] = "builtin"
128
127
  enabled: bool = True
129
- # 自校准留痕:calibrated_value = 上次校准写下的阈值(用于判断"之后有没有人手动改过";
130
- # 若 threshold 已偏离它,说明是人的决定,校准就此收手)。calibrated_from = 依据说明,
131
- # 让阈值可解释 —— 用户看得到"这个 800 是怎么来的",才敢信它。
132
128
  calibrated_value: Optional[float] = None
133
129
  calibrated_from: Optional[str] = None
134
130
 
@@ -144,7 +140,7 @@ class PolicyVerdict:
144
140
  severity: str
145
141
  message: str
146
142
  transaction_id: Optional[str] = None
147
- requires_user_action: bool = False # 是否需要用户确认/介入
143
+ requires_user_action: bool = False
148
144
 
149
145
  def to_dict(self) -> dict:
150
146
  return asdict(self)
@@ -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