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.
- package/index.js +1 -8
- package/package.json +1 -1
- package/python/.env.example +8 -0
- package/python/.github/workflows/ci.yml +47 -2
- package/python/CHANGELOG.md +57 -0
- package/python/CHANGELOG_CN.md +57 -0
- package/python/README.md +11 -2
- package/python/README_CN.md +32 -20
- package/python/bench/bm25_audit.py +1 -14
- package/python/bench/bm25_regression.py +0 -1
- package/python/bench/bm25_stress.py +0 -4
- package/python/bench/cd_audit.py +0 -23
- package/python/bench/chaos.py +6 -8
- package/python/bench/e2e_real_models.py +0 -1
- package/python/bench/llm_retry_test.py +2 -9
- package/python/bench/load_test.py +5 -8
- package/python/bench/vector_ivf_upgrade.py +1 -7
- package/python/docs/RELEASE.md +4 -3
- package/python/docs/RUN_CN.md +2 -2
- package/python/evals/governance/cases.py +0 -37
- package/python/evals/governance/run_evals.py +5 -37
- package/python/examples/governance_middleware_demo.py +1 -6
- package/python/examples/langgraph_governance_demo.py +7 -11
- package/python/foxclaw/__init__.py +34 -152
- package/python/foxclaw/agents/base.py +65 -136
- package/python/foxclaw/agents/impl/_helpers.py +2 -10
- package/python/foxclaw/agents/impl/finance.py +4 -21
- package/python/foxclaw/agents/impl/office.py +7 -24
- package/python/foxclaw/agents/impl/supervisor.py +12 -79
- package/python/foxclaw/agents/mixins/_context.py +0 -23
- package/python/foxclaw/agents/mixins/augment.py +10 -16
- package/python/foxclaw/agents/mixins/llm.py +7 -6
- package/python/foxclaw/agents/mixins/memory.py +7 -1
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +0 -6
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
- package/python/foxclaw/api/middleware.py +5 -28
- package/python/foxclaw/api/routes/ahs.py +60 -73
- package/python/foxclaw/api/routes/attachments.py +5 -6
- package/python/foxclaw/api/routes/browser.py +7 -11
- package/python/foxclaw/api/routes/domain.py +18 -26
- package/python/foxclaw/api/routes/knowledge.py +3 -21
- package/python/foxclaw/api/routes/llm_admin.py +21 -33
- package/python/foxclaw/api/routes/memory.py +28 -12
- package/python/foxclaw/api/routes/orchestration.py +3 -5
- package/python/foxclaw/api/routes/recovery.py +4 -3
- package/python/foxclaw/api/routes/review.py +2 -11
- package/python/foxclaw/api/routes/search.py +3 -7
- package/python/foxclaw/api/routes/system.py +33 -49
- package/python/foxclaw/api/routes/task.py +31 -49
- package/python/foxclaw/api/routes/ws.py +2 -6
- package/python/foxclaw/api/spine.py +1 -4
- package/python/foxclaw/browser/action/action_executor.py +1 -5
- package/python/foxclaw/browser/action/action_types.py +0 -1
- package/python/foxclaw/browser/action/url_policy.py +1 -5
- package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
- package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
- package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
- package/python/foxclaw/browser/perception/element_indexer.py +1 -2
- package/python/foxclaw/browser/service.py +7 -11
- package/python/foxclaw/capability/embedding_service.py +4 -6
- package/python/foxclaw/channels/__init__.py +9 -11
- package/python/foxclaw/channels/discord.py +4 -13
- package/python/foxclaw/channels/dispatcher.py +2 -27
- package/python/foxclaw/channels/feishu.py +10 -20
- package/python/foxclaw/channels/openclaw_bridge.py +4 -9
- package/python/foxclaw/channels/signing.py +9 -9
- package/python/foxclaw/channels/telegram.py +3 -4
- package/python/foxclaw/channels/weixin.py +7 -17
- package/python/foxclaw/cli.py +42 -13
- package/python/foxclaw/cognitive/reasoner.py +9 -10
- package/python/foxclaw/cognitive/state.py +18 -24
- package/python/foxclaw/core/audit_chain.py +1 -3
- package/python/foxclaw/core/audit_sink.py +3 -3
- package/python/foxclaw/core/compaction.py +8 -26
- package/python/foxclaw/core/config.py +2 -2
- package/python/foxclaw/core/confirm_nonce.py +6 -12
- package/python/foxclaw/core/envfile.py +2 -2
- package/python/foxclaw/core/failure_policy.py +17 -26
- package/python/foxclaw/core/gateway.py +6 -10
- package/python/foxclaw/core/governance_audit.py +3 -7
- package/python/foxclaw/core/idempotency.py +15 -22
- package/python/foxclaw/core/injection_guard.py +10 -29
- package/python/foxclaw/core/memory_adapter.py +30 -57
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +36 -148
- package/python/foxclaw/core/paths.py +1 -1
- package/python/foxclaw/core/recovery.py +21 -34
- package/python/foxclaw/core/scheduler.py +23 -26
- package/python/foxclaw/core/secrets.py +0 -3
- package/python/foxclaw/core/sessions.py +4 -6
- package/python/foxclaw/core/settings.py +16 -32
- package/python/foxclaw/core/shared_write.py +11 -23
- package/python/foxclaw/core/steering_queue.py +12 -11
- package/python/foxclaw/core/system_bus.py +1 -1
- package/python/foxclaw/core/thought_chain.py +30 -32
- package/python/foxclaw/core/token_meter.py +9 -13
- package/python/foxclaw/core/tools.py +4 -12
- package/python/foxclaw/core/tracing.py +25 -40
- package/python/foxclaw/core/truth_pipeline.py +5 -9
- package/python/foxclaw/core/update_check.py +149 -0
- package/python/foxclaw/core/vec_recall_adapter.py +11 -37
- package/python/foxclaw/core/verify.py +6 -14
- package/python/foxclaw/core/web_search.py +4 -28
- package/python/foxclaw/finance/evidence.py +14 -18
- package/python/foxclaw/finance/fx.py +1 -3
- package/python/foxclaw/finance/graph.py +9 -18
- package/python/foxclaw/finance/models.py +20 -24
- package/python/foxclaw/finance/nlparse.py +12 -72
- package/python/foxclaw/finance/policy.py +8 -36
- package/python/foxclaw/finance/reasoner.py +3 -11
- package/python/foxclaw/finance/reasoning.py +13 -34
- package/python/foxclaw/finance/reconcile.py +5 -10
- package/python/foxclaw/finance/runtime.py +19 -46
- package/python/foxclaw/governance/middleware.py +13 -21
- package/python/foxclaw/harness/_base.py +2 -8
- package/python/foxclaw/harness/_policy.py +19 -38
- package/python/foxclaw/harness/drift.py +1 -13
- package/python/foxclaw/harness/event_bus.py +2 -5
- package/python/foxclaw/harness/harness.py +9 -97
- package/python/foxclaw/harness/loop_detection.py +11 -18
- package/python/foxclaw/itinerary/amap_client.py +2 -5
- package/python/foxclaw/itinerary/models.py +7 -7
- package/python/foxclaw/itinerary/reasoner.py +1 -6
- package/python/foxclaw/itinerary/reasoning.py +15 -21
- package/python/foxclaw/itinerary/runtime.py +8 -12
- package/python/foxclaw/kernel/capabilities.py +5 -6
- package/python/foxclaw/kernel/policy_gate.py +4 -10
- package/python/foxclaw/kernel/process_manager.py +17 -36
- package/python/foxclaw/kernel/scheduler.py +3 -9
- package/python/foxclaw/kernel/sentinel.py +13 -23
- package/python/foxclaw/kernel/sentinel_client.py +12 -15
- package/python/foxclaw/kernel/spine.py +9 -9
- package/python/foxclaw/knowledge/__init__.py +4 -20
- package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
- package/python/foxclaw/learning/knowledge_graph.py +4 -9
- package/python/foxclaw/learning/models.py +13 -13
- package/python/foxclaw/learning/reasoner.py +1 -4
- package/python/foxclaw/learning/runtime.py +19 -31
- package/python/foxclaw/learning/spaced_repetition.py +2 -7
- package/python/foxclaw/mcp_server.py +2 -3
- package/python/foxclaw/orchestration/__init__.py +16 -33
- package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
- package/python/foxclaw/pipeline/stage_meta.py +21 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
- package/python/foxclaw/planning/llm_planner.py +3 -3
- package/python/foxclaw/planning/planner.py +6 -11
- package/python/foxclaw/search/agent_port.py +7 -11
- package/python/foxclaw/search/evidence.py +0 -4
- package/python/foxclaw/search/internal_search.py +3 -5
- package/python/foxclaw/search/models.py +11 -13
- package/python/foxclaw/search/planner.py +3 -3
- package/python/foxclaw/search/retriever.py +1 -1
- package/python/foxclaw/search/runtime.py +1 -8
- package/python/foxclaw/skills/__init__.py +46 -100
- package/python/foxclaw/skills/archive.py +111 -0
- package/python/foxclaw/skills/fetch.py +226 -0
- package/python/foxclaw/skills/hooks_spec.py +21 -25
- package/python/foxclaw/skills/install_cmd.py +216 -0
- package/python/foxclaw/skills/lifecycle.py +16 -42
- package/python/foxclaw/skills/markdown_skill.py +10 -9
- package/python/foxclaw/skills/mcp_client.py +5 -11
- package/python/foxclaw/skills/mcp_store.py +0 -3
- package/python/foxclaw/skills/permission.py +13 -48
- package/python/foxclaw/skills/pipeline.py +21 -53
- package/python/foxclaw/skills/policy.py +4 -10
- package/python/foxclaw/skills/review.py +3 -3
- package/python/frontend/deck/auth.js +0 -7
- package/python/frontend/deck/chat_model_picker.js +0 -54
- package/python/frontend/deck/domain.js +0 -20
- package/python/frontend/deck/main.js +0 -144
- package/python/frontend/deck/markdown.js +0 -4
- package/python/frontend/deck/settings.js +1 -18
- package/python/frontend/deck/state.js +0 -1
- package/python/frontend/deck/util.js +0 -2
- package/python/frontend/deck/visuals.js +0 -22
- package/python/frontend/planet/planet.js +0 -201
- package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
- package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
- package/python/frontend/v3/index.html +3 -3
- package/python/frontend/widget_runtime.js +0 -43
- package/python/frontend/widgets.js +0 -57
- package/python/frontend_src/index.html +1 -1
- package/python/frontend_src/package-lock.json +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/App.vue +3 -7
- package/python/frontend_src/src/api.js +0 -7
- package/python/frontend_src/src/components/ChatDock.vue +27 -39
- package/python/frontend_src/src/components/DocViewer.vue +0 -9
- package/python/frontend_src/src/components/Inspector.vue +0 -6
- package/python/frontend_src/src/components/LeftRail.vue +60 -39
- package/python/frontend_src/src/components/MenuPopover.vue +9 -11
- package/python/frontend_src/src/components/MessageStream.vue +18 -30
- package/python/frontend_src/src/components/ModelPicker.vue +0 -5
- package/python/frontend_src/src/components/QuickIngest.vue +0 -6
- package/python/frontend_src/src/components/Stage.vue +71 -25
- package/python/frontend_src/src/components/ToolMenu.vue +0 -3
- package/python/frontend_src/src/components/TopBar.vue +38 -10
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
- package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
- package/python/frontend_src/src/lib/fmt.js +0 -4
- package/python/frontend_src/src/lib/i18n.js +53 -0
- package/python/frontend_src/src/lib/locales/en.js +173 -0
- package/python/frontend_src/src/lib/locales/zh.js +174 -0
- package/python/frontend_src/src/lib/md.js +0 -14
- package/python/frontend_src/src/main.js +2 -0
- package/python/frontend_src/src/stores/app.js +17 -32
- package/python/frontend_src/src/stores/chat.js +0 -34
- package/python/frontend_src/src/stores/model.js +0 -8
- package/python/frontend_src/src/stores/panels.js +0 -41
- package/python/frontend_src/src/stores/profile.js +31 -0
- package/python/frontend_src/src/stores/system.js +25 -0
- package/python/frontend_src/vite.config.js +0 -7
- package/python/pyproject.toml +6 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +3 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_ahs_p2.py +4 -25
- package/python/tests/test_anthropic_tools.py +1 -15
- package/python/tests/test_api.py +19 -9
- package/python/tests/test_api_auth.py +1 -5
- package/python/tests/test_attachments.py +9 -25
- package/python/tests/test_audit_chain.py +2 -7
- package/python/tests/test_audit_sink.py +0 -6
- package/python/tests/test_browser.py +2 -6
- package/python/tests/test_browser_request_guard.py +0 -4
- package/python/tests/test_capabilities.py +0 -6
- package/python/tests/test_channel_signing.py +2 -9
- package/python/tests/test_channels.py +1 -11
- package/python/tests/test_config_layering.py +0 -4
- package/python/tests/test_confirm_nonce.py +1 -12
- package/python/tests/test_context_compaction_wiring.py +1 -7
- package/python/tests/test_dispatch.py +0 -6
- package/python/tests/test_domain_evals.py +11 -80
- package/python/tests/test_domain_import.py +0 -6
- package/python/tests/test_domain_reasoners.py +0 -14
- package/python/tests/test_e2e.py +0 -17
- package/python/tests/test_emb_cache.py +7 -17
- package/python/tests/test_embedding_singleton.py +1 -3
- package/python/tests/test_evals_smoke.py +0 -24
- package/python/tests/test_event_spine.py +10 -18
- package/python/tests/test_evidence_semantic.py +1 -14
- package/python/tests/test_evidence_sse.py +3 -6
- package/python/tests/test_file_ingest.py +0 -11
- package/python/tests/test_finance.py +0 -9
- package/python/tests/test_finance_ledger.py +5 -12
- package/python/tests/test_finance_persist.py +8 -12
- package/python/tests/test_gateway.py +3 -22
- package/python/tests/test_governance.py +5 -52
- package/python/tests/test_governance_audit.py +4 -39
- package/python/tests/test_governance_middleware.py +1 -7
- package/python/tests/test_hkc.py +6 -24
- package/python/tests/test_hkc_index_build_manager.py +0 -33
- package/python/tests/test_hkc_manifest_reuse.py +4 -27
- package/python/tests/test_hkc_search_event_sync.py +3 -54
- package/python/tests/test_import_path_guard.py +0 -6
- package/python/tests/test_injection_guard.py +18 -36
- package/python/tests/test_integration.py +5 -29
- package/python/tests/test_itinerary.py +0 -3
- package/python/tests/test_kee_semantic.py +0 -5
- package/python/tests/test_kernel.py +1 -11
- package/python/tests/test_learning.py +3 -12
- package/python/tests/test_llm_admin.py +4 -19
- package/python/tests/test_llm_planner.py +1 -23
- package/python/tests/test_markdown_skills.py +3 -14
- package/python/tests/test_markdown_skills_openclaw.py +4 -9
- package/python/tests/test_mcp_connectors.py +3 -15
- package/python/tests/test_mcp_guardian_discovery.py +2 -6
- package/python/tests/test_mcp_no_memory.py +3 -3
- package/python/tests/test_mcp_policy_gate.py +0 -1
- package/python/tests/test_mcp_server.py +6 -9
- package/python/tests/test_memory_recall.py +1 -4
- package/python/tests/test_metrics.py +0 -8
- package/python/tests/test_multi_chain.py +1 -13
- package/python/tests/test_multimodal.py +5 -18
- package/python/tests/test_negotiation.py +3 -13
- package/python/tests/test_orchestration.py +1 -11
- package/python/tests/test_orchestration_decoupled.py +0 -6
- package/python/tests/test_p3_p5_hardening.py +3 -17
- package/python/tests/test_parallel_dispatch.py +4 -12
- package/python/tests/test_pipeline.py +17 -58
- package/python/tests/test_plan_visible.py +4 -4
- package/python/tests/test_planner.py +0 -7
- package/python/tests/test_policy_gate.py +2 -3
- package/python/tests/test_profile.py +67 -0
- package/python/tests/test_readme_claims.py +163 -0
- package/python/tests/test_reasoner.py +2 -17
- package/python/tests/test_release_check.py +2 -13
- package/python/tests/test_resume_checkpoint.py +7 -15
- package/python/tests/test_routing.py +2 -12
- package/python/tests/test_scheduler.py +7 -10
- package/python/tests/test_search.py +1 -11
- package/python/tests/test_secrets.py +4 -10
- package/python/tests/test_semantic.py +1 -10
- package/python/tests/test_sessions.py +3 -4
- package/python/tests/test_settings.py +2 -18
- package/python/tests/test_shadow_learning.py +3 -43
- package/python/tests/test_shared_write.py +0 -7
- package/python/tests/test_skill_taint_gate.py +1 -10
- package/python/tests/test_skills.py +2 -18
- package/python/tests/test_skills_fetch.py +282 -0
- package/python/tests/test_stats_and_events.py +2 -5
- package/python/tests/test_streaming.py +2 -7
- package/python/tests/test_supervisor_pipeline.py +4 -5
- package/python/tests/test_supervisor_triage.py +4 -10
- package/python/tests/test_taint.py +2 -7
- package/python/tests/test_task_stream.py +1 -12
- package/python/tests/test_thinking_sse.py +0 -1
- package/python/tests/test_tool_center.py +1 -17
- package/python/tests/test_tool_parallel.py +2 -2
- package/python/tests/test_tool_params.py +1 -5
- package/python/tests/test_tool_use.py +0 -13
- package/python/tests/test_toplevel_concurrency.py +3 -4
- package/python/tests/test_tracing.py +7 -41
- package/python/tests/test_truth_crystallizer.py +4 -6
- package/python/tests/test_truth_flow.py +2 -7
- package/python/tests/test_update_check.py +95 -0
- package/python/tests/test_verify_evidence.py +8 -21
- package/python/tests/test_version_sync.py +35 -6
- package/python/tests/test_web_trigger.py +0 -17
- package/python/tools/check_faiss_type.py +0 -1
- package/python/tools/check_log.ps1 +0 -8
- package/python/tools/mypy_baseline.txt +12 -4
- package/python/tools/mypy_ratchet.py +52 -6
- package/python/tools/pack.py +0 -5
- package/python/tools/release_check.py +7 -7
- package/python/tools/restart_foxclaw.ps1 +5 -8
- package/python/tools/shared_write_audit.py +1 -15
- package/python/tools/size_check.py +1 -1
- package/python/tools/strip_comments.py +228 -0
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- 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
|
|
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
|
|
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
|
|
26
|
-
category: str
|
|
27
|
-
entity: str
|
|
28
|
-
account: str
|
|
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)
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
106
|
-
recurring_amount: Optional[float] = None
|
|
107
|
-
cycle: Optional[str] = None
|
|
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"
|
|
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"
|
|
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:
|
|
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)
|
|
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
|
|