foxclaw 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/.github/workflows/release.yml +5 -0
- package/python/CHANGELOG.md +100 -0
- package/python/CHANGELOG_CN.md +100 -0
- package/python/README.md +17 -2
- package/python/README_CN.md +38 -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/CONTENT_GUARD.md +189 -0
- package/python/docs/CONTENT_GUARD_CN.md +189 -0
- package/python/docs/RELEASE.md +78 -37
- 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 +74 -152
- package/python/foxclaw/agents/base.py +82 -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 +23 -3
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +59 -12
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/bootstrap.py +114 -0
- 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 +50 -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/behavior/__init__.py +20 -0
- package/python/foxclaw/behavior/behavior.yaml +62 -0
- package/python/foxclaw/behavior/spec.py +287 -0
- 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/content_guard.py +341 -0
- 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 +53 -60
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +74 -150
- 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/sensitive_write.py +283 -0
- 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 +18 -201
- package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
- package/python/frontend/v3/assets/index-tN90dpUH.js +34 -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 +42 -3
- 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 +16 -44
- 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 +17 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +4 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_agent_rules.py +81 -0
- 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_behavior_spec.py +198 -0
- 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_conflict_dismiss.py +143 -0
- package/python/tests/test_content_guard.py +177 -0
- 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_sensitive_write.py +181 -0
- 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/build_content_lexicon.py +218 -0
- package/python/tools/bundle_web.py +98 -0
- 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/recheck_conflicts.py +156 -0
- package/python/tools/release_check.py +24 -15
- 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/vendor/hkc/hkc_api/routes/ability.py +31 -0
- package/python/vendor/hkc/hkc_api/schemas.py +11 -0
- package/python/vendor/hkc/hkc_kee/kee.py +124 -3
- package/python/vendor/hkc/hkc_sdk/base.py +22 -0
- package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
- package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
- package/python/foxclaw/api/app.py +0 -123
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
|
@@ -24,8 +24,6 @@ from dataclasses import dataclass, field
|
|
|
24
24
|
from datetime import datetime, timedelta
|
|
25
25
|
from typing import Optional
|
|
26
26
|
|
|
27
|
-
# ── 币种 ──────────────────────────────────────────────────
|
|
28
|
-
# 别名按长度倒序匹配:"块钱" 必须先于 "块","人民币" 先于 "元",否则会切错。
|
|
29
27
|
CURRENCY_ALIASES = {
|
|
30
28
|
"人民币": "CNY", "块钱": "CNY", "块": "CNY", "元": "CNY", "圆": "CNY",
|
|
31
29
|
"软妹币": "CNY", "rmb": "CNY", "cny": "CNY",
|
|
@@ -43,23 +41,17 @@ _SYMBOLS = {"¥": "CNY", "¥": "CNY", "$": "USD", "€": "EUR", "£": "GBP", "
|
|
|
43
41
|
_UNIT_ALT = "|".join(re.escape(u) for u in
|
|
44
42
|
sorted(CURRENCY_ALIASES, key=len, reverse=True))
|
|
45
43
|
|
|
46
|
-
# 倍数后缀:1.5k / 2w / 3万 —— 不是币种,只是量级
|
|
47
44
|
_SCALE = {"k": 1000, "千": 1000, "w": 10000, "万": 10000}
|
|
48
45
|
|
|
49
|
-
# ── 中文数字 ──────────────────────────────────────────────
|
|
50
46
|
_CN_DIGITS = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
|
|
51
47
|
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
|
|
52
48
|
_CN_UNITS = {"十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000}
|
|
53
49
|
_CN_NUM = "[零〇一二两三四五六七八九十百千万亿]{1,12}"
|
|
54
50
|
|
|
55
|
-
# ── 量词:数字 + 量词是"几件/2 袋",不是金额,必须先剔除 ─────────
|
|
56
|
-
# 注意「块」不在此表 —— 它是货币单位(105 块),不是量词。
|
|
57
|
-
# 「人」「位」故意不收:会吃掉「105 人民币」的「人」。
|
|
58
51
|
_QUANTIFIERS = ("件|个|只|双|条|包|袋|瓶|杯|斤|公斤|kg|克|张|台|部|本|支|盒|箱|"
|
|
59
52
|
"次|份|碗|串|根|桶|套|顿|晚|小时|分钟|公里|千米|米|升|码|岁|ml")
|
|
60
53
|
_QUANT_PAT = re.compile(rf"(?:\d[\d,.]*|{_CN_NUM})\s*(?:{_QUANTIFIERS})", re.I)
|
|
61
54
|
|
|
62
|
-
# ── 花费/收入动词 ────────────────────────────────────────
|
|
63
55
|
_EXPENSE_VERBS = ("花了", "花费", "花", "付了", "付款", "支付", "买了", "买", "购",
|
|
64
56
|
"消费", "充值", "充了", "交", "缴", "扣", "订", "租", "打车", "吃")
|
|
65
57
|
_INCOME_VERBS = ("收入", "工资", "发了", "发工资", "收到", "收款", "进账", "退款",
|
|
@@ -68,7 +60,6 @@ _INCOME_VERBS = ("收入", "工资", "发了", "发工资", "收到", "收款",
|
|
|
68
60
|
_VERB_PAT = re.compile("|".join(sorted(_EXPENSE_VERBS, key=len, reverse=True)))
|
|
69
61
|
_INCOME_PAT = re.compile("|".join(sorted(_INCOME_VERBS, key=len, reverse=True)))
|
|
70
62
|
|
|
71
|
-
# ── 分类关键词(顺序即优先级:订阅要先于娱乐,否则「腾讯视频会员」会归到娱乐)──
|
|
72
63
|
CATEGORY_LABELS = {
|
|
73
64
|
"food": "餐饮食品", "clothing": "服饰", "transport": "交通出行",
|
|
74
65
|
"housing": "居住", "subscription": "订阅服务", "entertainment": "娱乐",
|
|
@@ -81,7 +72,6 @@ _CATEGORY_RULES = [
|
|
|
81
72
|
("food", ["大米", "米面", "面粉", "菜", "肉", "排骨", "水果", "奶茶", "咖啡",
|
|
82
73
|
"饭", "早餐", "午餐", "晚餐", "夜宵", "外卖", "零食", "超市", "牛奶",
|
|
83
74
|
"鸡蛋", "面包", "饮料", "火锅", "烧烤", "酒", "水饺", "面条", "油盐",
|
|
84
|
-
# 常见餐饮商户:用户报备时往往只说店名
|
|
85
75
|
"星巴克", "瑞幸", "麦当劳", "肯德基", "海底捞", "喜茶", "蜜雪", "食堂"]),
|
|
86
76
|
("clothing", ["衣服", "裤子", "鞋", "外套", "裙子", "衬衫", "t恤", "帽子",
|
|
87
77
|
"包", "袜子", "内衣", "羽绒服", "西装", "围巾", "手套", "牛仔裤"]),
|
|
@@ -96,14 +86,13 @@ _CATEGORY_RULES = [
|
|
|
96
86
|
("entertainment", ["电影", "游戏", "ktv", "演出", "演唱会", "剧本杀", "健身", "按摩"]),
|
|
97
87
|
]
|
|
98
88
|
|
|
99
|
-
|
|
100
|
-
_DAY_WORDS = [ # 长的先匹配,避免「大后天」被「后天」吃掉
|
|
89
|
+
_DAY_WORDS = [
|
|
101
90
|
("大后天", 3), ("大前天", -3), ("后天", 2), ("前天", -2),
|
|
102
91
|
("昨天", -1), ("昨日", -1), ("昨晚", -1), ("昨儿", -1),
|
|
103
92
|
("今天", 0), ("今日", 0), ("今儿", 0), ("刚刚", 0), ("刚才", 0),
|
|
104
93
|
("明天", 1), ("明日", 1), ("明儿", 1),
|
|
105
94
|
]
|
|
106
|
-
_MONTH_WORDS = [
|
|
95
|
+
_MONTH_WORDS = [
|
|
107
96
|
("上上个月", -2), ("上上月", -2), ("上个月", -1), ("上一个月", -1),
|
|
108
97
|
("上月", -1), ("下下个月", 2), ("下下月", 2), ("下个月", 1),
|
|
109
98
|
("下一个月", 1), ("下月", 1), ("这个月", 0), ("本月", 0), ("这月", 0),
|
|
@@ -115,24 +104,21 @@ _WEEK_WORDS = [("上周", -7), ("上星期", -7), ("上个星期", -7),
|
|
|
115
104
|
("下周", 7), ("下星期", 7), ("下个星期", 7)]
|
|
116
105
|
|
|
117
106
|
|
|
118
|
-
# ══════════════════════════════════════════════════════════
|
|
119
|
-
# 解析结果
|
|
120
|
-
# ══════════════════════════════════════════════════════════
|
|
121
107
|
@dataclass
|
|
122
108
|
class ParsedExpense:
|
|
123
109
|
"""一句话里解析出的一笔钱(还没落账)。"""
|
|
124
|
-
text: str
|
|
125
|
-
direction: str = "expense"
|
|
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
|
|
|
@@ -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")
|