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
|
@@ -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
|
|
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
|
|
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)
|
|
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")
|
|
@@ -8,16 +8,16 @@ from collections import defaultdict
|
|
|
8
8
|
from datetime import datetime, timedelta
|
|
9
9
|
|
|
10
10
|
from .models import Budget
|
|
11
|
+
from typing import Any
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class FinanceReasoning:
|
|
14
15
|
"""财务推理引擎:把交易流变成可决策的洞察。"""
|
|
15
16
|
|
|
16
|
-
# ── 预算推理 ──────────────────────────────────────────
|
|
17
17
|
@staticmethod
|
|
18
|
-
def spend_by_category(transactions: list, period: str = None) -> dict:
|
|
18
|
+
def spend_by_category(transactions: list, period: str | None = None) -> dict:
|
|
19
19
|
"""按分类汇总支出(绝对值)。period 形如 '2026-06',None=全部。"""
|
|
20
|
-
out = defaultdict(float)
|
|
20
|
+
out: defaultdict[str, float] = defaultdict(float)
|
|
21
21
|
for t in transactions:
|
|
22
22
|
if not t.is_expense:
|
|
23
23
|
continue
|
|
@@ -27,7 +27,7 @@ class FinanceReasoning:
|
|
|
27
27
|
return dict(out)
|
|
28
28
|
|
|
29
29
|
@staticmethod
|
|
30
|
-
def budget_status(transactions: list, budgets: list, period: str = None) -> list:
|
|
30
|
+
def budget_status(transactions: list, budgets: list, period: str | None = None) -> list:
|
|
31
31
|
"""计算每个预算分类的使用状态。"""
|
|
32
32
|
period = period or datetime.now().strftime("%Y-%m")
|
|
33
33
|
spent = FinanceReasoning.spend_by_category(transactions, period)
|
|
@@ -40,22 +40,18 @@ class FinanceReasoning:
|
|
|
40
40
|
|
|
41
41
|
@staticmethod
|
|
42
42
|
def overspend_forecast(transactions: list, budget: Budget,
|
|
43
|
-
period: str = None, now=None) -> dict:
|
|
43
|
+
period: str | None = None, now=None) -> dict:
|
|
44
44
|
"""分类超支预测:按当前消费速度,推算月底是否超预算。"""
|
|
45
45
|
now = now or datetime.now()
|
|
46
46
|
period = period or now.strftime("%Y-%m")
|
|
47
47
|
spent = FinanceReasoning.spend_by_category(transactions, period).get(
|
|
48
48
|
budget.category, 0.0)
|
|
49
|
-
# 当前是本月第几天
|
|
50
49
|
day = now.day
|
|
51
|
-
days_in_month = 30
|
|
50
|
+
days_in_month = 30
|
|
52
51
|
if day <= 0:
|
|
53
52
|
day = 1
|
|
54
53
|
daily_rate = spent / day
|
|
55
54
|
projected = daily_rate * days_in_month
|
|
56
|
-
# 月初外推不可信:day=1 时一笔正常消费就被 ×30 放大成"月底将超支"。
|
|
57
|
-
# 前 7 天样本太少,趋势是噪音 —— 标记 reliable=False,让裁决层(overspend_trend_alert)
|
|
58
|
-
# 这一周内闭嘴。宁可晚一周提醒真超支,也不要天天误报把用户逼到关掉守门人。
|
|
59
55
|
reliable = day >= 7
|
|
60
56
|
return {
|
|
61
57
|
"category": budget.category,
|
|
@@ -69,7 +65,6 @@ class FinanceReasoning:
|
|
|
69
65
|
"day_of_month": day,
|
|
70
66
|
}
|
|
71
67
|
|
|
72
|
-
# ── 风险判断 ──────────────────────────────────────────
|
|
73
68
|
@staticmethod
|
|
74
69
|
def detect_anomalies(transactions: list, threshold: float = 3.5) -> list:
|
|
75
70
|
"""异常消费检测:用 MAD(中位数绝对偏差)而非均值/标准差,
|
|
@@ -86,13 +81,10 @@ class FinanceReasoning:
|
|
|
86
81
|
if len(amounts) < 3:
|
|
87
82
|
continue
|
|
88
83
|
med = median(amounts)
|
|
89
|
-
# MAD = 偏离中位数的中位数
|
|
90
84
|
mad = median([abs(a - med) for a in amounts])
|
|
91
85
|
if mad == 0:
|
|
92
|
-
# 退化:用均值兜底
|
|
93
86
|
mad = (sum(abs(a - med) for a in amounts) / len(amounts)) or 1e-9
|
|
94
87
|
for t in txns:
|
|
95
|
-
# 修正 Z 分数(0.6745 是正态分布常数)
|
|
96
88
|
mz = 0.6745 * (abs(t.amount) - med) / mad
|
|
97
89
|
if mz >= threshold:
|
|
98
90
|
anomalies.append({
|
|
@@ -102,8 +94,6 @@ class FinanceReasoning:
|
|
|
102
94
|
"category": cat,
|
|
103
95
|
"z_score": round(mz, 2),
|
|
104
96
|
"category_median": round(med, 2),
|
|
105
|
-
# med>0 才显示"倍数"(当前 is_expense 排除非负额已保证 med>0,
|
|
106
|
-
# 此处显式防御,避免未来改动 is_expense 定义后除零)
|
|
107
97
|
"reason": (f"{cat} 类异常消费,金额是中位数的 {abs(t.amount)/med:.1f} 倍"
|
|
108
98
|
if med > 0 else f"{cat} 类异常消费(金额 {abs(t.amount):.0f})"),
|
|
109
99
|
})
|
|
@@ -144,7 +134,7 @@ class FinanceReasoning:
|
|
|
144
134
|
同一商户按"最近一次扣款"代表其当前月度订阅价(订阅可能涨价,
|
|
145
135
|
取最新而非遍历到的任意一条;也避免同商户多月记录被累加成虚高总额)。
|
|
146
136
|
"""
|
|
147
|
-
latest = {}
|
|
137
|
+
latest: dict[str, tuple[Any, Any]] = {}
|
|
148
138
|
for t in transactions:
|
|
149
139
|
if t.is_recurring and t.is_expense:
|
|
150
140
|
prev = latest.get(t.entity)
|
|
@@ -184,7 +174,7 @@ class FinanceReasoning:
|
|
|
184
174
|
out = []
|
|
185
175
|
for entity, txns in by_entity.items():
|
|
186
176
|
if len(txns) < 2:
|
|
187
|
-
continue
|
|
177
|
+
continue
|
|
188
178
|
ts = sorted(txns, key=lambda x: x.dt)
|
|
189
179
|
gaps = [(ts[i].dt - ts[i - 1].dt).days for i in range(1, len(ts))]
|
|
190
180
|
gaps = [g for g in gaps if g > 0]
|
|
@@ -195,7 +185,7 @@ class FinanceReasoning:
|
|
|
195
185
|
elif 85 <= g <= 95: cadence, period = "季订", 90
|
|
196
186
|
elif 355 <= g <= 375: cadence, period = "年订", 365
|
|
197
187
|
else:
|
|
198
|
-
continue
|
|
188
|
+
continue
|
|
199
189
|
next_dt = ts[-1].dt + timedelta(days=period)
|
|
200
190
|
days_until = (next_dt - now).days
|
|
201
191
|
if 0 <= days_until <= horizon_days:
|
|
@@ -215,17 +205,13 @@ class FinanceReasoning:
|
|
|
215
205
|
"""
|
|
216
206
|
now = now or datetime.now()
|
|
217
207
|
credits = [t for t in transactions if not t.is_expense]
|
|
218
|
-
claimed = set()
|
|
208
|
+
claimed = set()
|
|
219
209
|
out = []
|
|
220
|
-
# 先处理较早的支出,让它优先认领入账(FIFO,符合"先欠先还")
|
|
221
210
|
expected = sorted((t for t in transactions
|
|
222
211
|
if t.is_expense and "refund_expected" in (t.tags or [])),
|
|
223
212
|
key=lambda x: x.dt)
|
|
224
213
|
for t in expected:
|
|
225
214
|
amt = abs(t.amount)
|
|
226
|
-
# 同商户、等额、在该支出之后、且**尚未被别的退款认领**的入账 → 退款到账
|
|
227
|
-
# 消耗式匹配:一笔入账只抵一笔支出。否则用户标了两笔同额 refund_expected、
|
|
228
|
-
# 只收到一笔退款时,两笔都被误判为已退款 → 第二笔漏报(其实还没到账)。
|
|
229
215
|
match = next((c for c in credits
|
|
230
216
|
if id(c) not in claimed and c.entity == t.entity
|
|
231
217
|
and abs(abs(c.amount) - amt) < 0.01 and c.dt >= t.dt), None)
|
|
@@ -238,9 +224,6 @@ class FinanceReasoning:
|
|
|
238
224
|
"days_waiting": waiting, "transaction_id": t.id})
|
|
239
225
|
return out
|
|
240
226
|
|
|
241
|
-
# ── 阈值自校准 ────────────────────────────────────────
|
|
242
|
-
# 样本门槛:少于这些就不校准,继续用出厂默认。宁可用"公认的经验值",
|
|
243
|
-
# 也不要用 3 笔数据算出来的、听起来很个性化的垃圾阈值。
|
|
244
227
|
CALIB_MIN_TXNS = 20
|
|
245
228
|
CALIB_MIN_DAYS = 30
|
|
246
229
|
|
|
@@ -272,22 +255,19 @@ class FinanceReasoning:
|
|
|
272
255
|
dts = [t.dt for t in expenses]
|
|
273
256
|
span_days = (max(dts) - min(dts)).days
|
|
274
257
|
if span_days < FinanceReasoning.CALIB_MIN_DAYS:
|
|
275
|
-
return {}
|
|
258
|
+
return {}
|
|
276
259
|
|
|
277
260
|
def _round100(v):
|
|
278
|
-
# 取整到百:阈值是给人看的,"800" 比 "783.42" 可信也好记
|
|
279
261
|
return max(100.0, round(v / 100.0) * 100.0)
|
|
280
262
|
|
|
281
263
|
amts = [abs(t.amount) for t in expenses]
|
|
282
264
|
med = median(amts)
|
|
283
265
|
mad = median([abs(a - med) for a in amts])
|
|
284
|
-
if mad == 0:
|
|
266
|
+
if mad == 0:
|
|
285
267
|
mad = (sum(abs(a - med) for a in amts) / len(amts)) or 1e-9
|
|
286
|
-
# 3.5/0.6745:把 detect_anomalies 的修正 Z 阈值(3.5)换算回原尺度偏移量,
|
|
287
|
-
# 使 large 阈值 = 恰好触发异常检测的单笔金额。两条规则从此共用一把尺。
|
|
288
268
|
large = min(_round100(med + (3.5 / 0.6745) * mad), 20000.0)
|
|
289
269
|
|
|
290
|
-
by_day = defaultdict(float)
|
|
270
|
+
by_day: defaultdict[Any, float] = defaultdict(float)
|
|
291
271
|
for t in expenses:
|
|
292
272
|
by_day[t.dt.date()] += abs(t.amount)
|
|
293
273
|
daily_med = median(by_day.values())
|
|
@@ -301,7 +281,6 @@ class FinanceReasoning:
|
|
|
301
281
|
f"{len(expenses)} 笔 / {span_days} 天)"),
|
|
302
282
|
}
|
|
303
283
|
|
|
304
|
-
# ── 预测 ──────────────────────────────────────────────
|
|
305
284
|
@staticmethod
|
|
306
285
|
def burn_rate(transactions: list, days: int = 30) -> dict:
|
|
307
286
|
"""烧钱速度:近 N 天的日均净流出。"""
|
|
@@ -24,9 +24,9 @@ class ExternalRecord:
|
|
|
24
24
|
"""外部流水记录(来自银行/支付宝账单)。"""
|
|
25
25
|
amount: float
|
|
26
26
|
entity: str
|
|
27
|
-
date: str
|
|
27
|
+
date: str
|
|
28
28
|
account: str = ""
|
|
29
|
-
ref: str = ""
|
|
29
|
+
ref: str = ""
|
|
30
30
|
|
|
31
31
|
def to_dict(self) -> dict:
|
|
32
32
|
return {"amount": self.amount, "entity": self.entity,
|
|
@@ -95,7 +95,7 @@ class Reconciler:
|
|
|
95
95
|
|
|
96
96
|
def __init__(self, tolerance_days: int = 3, amount_tolerance: float = 0.01):
|
|
97
97
|
self.tolerance_days = tolerance_days
|
|
98
|
-
self.amount_tolerance = amount_tolerance
|
|
98
|
+
self.amount_tolerance = amount_tolerance
|
|
99
99
|
|
|
100
100
|
def _amount_eq(self, a: float, b: float) -> bool:
|
|
101
101
|
"""金额模糊相等:绝对值在容差范围内 且 收支方向一致(都支出或都收入)。"""
|
|
@@ -105,12 +105,11 @@ class Reconciler:
|
|
|
105
105
|
def reconcile(self, transactions: list, external: list) -> ReconciliationResult:
|
|
106
106
|
"""对账主流程。transactions=系统交易,external=[ExternalRecord]。"""
|
|
107
107
|
res = ReconciliationResult()
|
|
108
|
-
used_ext = set()
|
|
108
|
+
used_ext = set()
|
|
109
109
|
|
|
110
110
|
for t in transactions:
|
|
111
111
|
t_date = _date_only(t.timestamp)
|
|
112
|
-
best = None
|
|
113
|
-
# 1. 精确匹配:金额 + 商户 + 同日
|
|
112
|
+
best = None
|
|
114
113
|
for j, ext in enumerate(external):
|
|
115
114
|
if j in used_ext:
|
|
116
115
|
continue
|
|
@@ -118,7 +117,6 @@ class Reconciler:
|
|
|
118
117
|
and _entity_similar(t.entity, ext.entity)
|
|
119
118
|
and _days_apart(t_date, ext.date) == 0):
|
|
120
119
|
best = (j, "exact"); break
|
|
121
|
-
# 2. 模糊匹配:金额同 + 日期容忍内 + 商户相似
|
|
122
120
|
if best is None:
|
|
123
121
|
for j, ext in enumerate(external):
|
|
124
122
|
if j in used_ext:
|
|
@@ -127,7 +125,6 @@ class Reconciler:
|
|
|
127
125
|
and _entity_similar(t.entity, ext.entity)
|
|
128
126
|
and _days_apart(t_date, ext.date) <= self.tolerance_days):
|
|
129
127
|
best = (j, "fuzzy_date"); break
|
|
130
|
-
# 3. 金额不符匹配:商户同 + 日期接近 + 同方向 + 金额不等(可能手续费)
|
|
131
128
|
if best is None:
|
|
132
129
|
for j, ext in enumerate(external):
|
|
133
130
|
if j in used_ext:
|
|
@@ -140,7 +137,6 @@ class Reconciler:
|
|
|
140
137
|
best = (j, "amount_mismatch"); break
|
|
141
138
|
|
|
142
139
|
if best is None:
|
|
143
|
-
# 系统有、外部无
|
|
144
140
|
res.unmatched_internal.append(self._txn_brief(t))
|
|
145
141
|
res.audit_log.append(
|
|
146
142
|
f"[未匹配·内部] 「{t.entity} ¥{abs(t.amount):.0f}」"
|
|
@@ -165,7 +161,6 @@ class Reconciler:
|
|
|
165
161
|
res.audit_log.append(
|
|
166
162
|
f"[已匹配·{mtype}] 「{t.entity} ¥{abs(t.amount):.0f}」({t_date})")
|
|
167
163
|
|
|
168
|
-
# 剩余未匹配的外部记录:外部有、系统无
|
|
169
164
|
for j, ext in enumerate(external):
|
|
170
165
|
if j not in used_ext:
|
|
171
166
|
res.unmatched_external.append(ext.to_dict())
|