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
|
@@ -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())
|
|
@@ -11,12 +11,13 @@ from dataclasses import fields
|
|
|
11
11
|
from datetime import datetime
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
from .models import
|
|
14
|
+
from .models import Budget, EntityType, FinancialEntity, Transaction
|
|
15
15
|
from .reasoning import FinanceReasoning
|
|
16
16
|
from .policy import FinancePolicyEngine
|
|
17
17
|
from .graph import FinancialDataGraph
|
|
18
18
|
from .fx import FxTable
|
|
19
19
|
from .nlparse import CATEGORY_LABELS, parse as parse_expense_text
|
|
20
|
+
from typing import Any
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def _period_of(txn, default: str = "") -> str:
|
|
@@ -46,27 +47,22 @@ class FinanceRuntime:
|
|
|
46
47
|
"""财务 Agent 的资金行为操作系统内核。"""
|
|
47
48
|
|
|
48
49
|
def __init__(self, policy_path: str = "./finance_policy.yaml", hmr=None,
|
|
49
|
-
data_path: str = None):
|
|
50
|
+
data_path: str | None = None):
|
|
50
51
|
self.transactions: list = []
|
|
51
52
|
self.budgets: list = []
|
|
52
|
-
self.entities: dict = {}
|
|
53
|
-
self.account_balances: dict = {}
|
|
53
|
+
self.entities: dict = {}
|
|
54
|
+
self.account_balances: dict = {}
|
|
54
55
|
self.policy = FinancePolicyEngine(policy_path)
|
|
55
56
|
self.reasoning = FinanceReasoning()
|
|
56
|
-
self.hmr = hmr
|
|
57
|
-
# 交易/预算/余额/实体的落盘点:默认与 policy 同目录(调用方传的就是 data_dir)。
|
|
58
|
-
# 此前这些只在内存,进程一重启就清零 → 财务分析永远是"0 笔交易"。
|
|
57
|
+
self.hmr = hmr
|
|
59
58
|
self._data_path = Path(data_path) if data_path else (
|
|
60
59
|
Path(policy_path).resolve().parent / "finance_data.json")
|
|
61
|
-
# 写侧串行:runtime 是单例,请求线程会并发 add/import,analyze 还会触发阈值自校准写盘。
|
|
62
60
|
self._lock = threading.RLock()
|
|
63
61
|
self.load_error: str = ""
|
|
64
62
|
self.save_error: str = ""
|
|
65
|
-
# 多币种:账本按本位币(CNY)算账,汇率表与账本同目录
|
|
66
63
|
self.fx = FxTable(str(self._data_path.parent / "finance_fx.json"))
|
|
67
64
|
self.load()
|
|
68
65
|
|
|
69
|
-
# ── 持久化(原子写 tmp→replace;损坏文件只降级、不阻断启动)──────
|
|
70
66
|
def load(self) -> int:
|
|
71
67
|
"""从落盘点恢复交易/预算/余额/实体,返回恢复的交易条数。
|
|
72
68
|
|
|
@@ -106,7 +102,7 @@ class FinanceRuntime:
|
|
|
106
102
|
except (TypeError, ValueError):
|
|
107
103
|
continue
|
|
108
104
|
if not self.entities and self.transactions:
|
|
109
|
-
self._rebuild_entities()
|
|
105
|
+
self._rebuild_entities()
|
|
110
106
|
return len(self.transactions)
|
|
111
107
|
|
|
112
108
|
def save(self) -> bool:
|
|
@@ -127,11 +123,9 @@ class FinanceRuntime:
|
|
|
127
123
|
encoding="utf-8")
|
|
128
124
|
tmp.replace(self._data_path)
|
|
129
125
|
self.save_error = ""
|
|
130
|
-
# 文件已被本进程的完整快照覆盖 → 上一轮"读不进来"的原因作废(自愈)
|
|
131
126
|
self.load_error = ""
|
|
132
127
|
return True
|
|
133
128
|
except Exception as e:
|
|
134
|
-
# 落盘失败不能假装成功:留在内存里 + 暴露错误,让调用方/界面能看到
|
|
135
129
|
self.save_error = f"{type(e).__name__}: {e}"
|
|
136
130
|
return False
|
|
137
131
|
|
|
@@ -140,7 +134,7 @@ class FinanceRuntime:
|
|
|
140
134
|
for txn in self.transactions:
|
|
141
135
|
ent = self.entities.get(txn.entity)
|
|
142
136
|
if not ent:
|
|
143
|
-
etype = "subscription" if txn.is_recurring else "merchant"
|
|
137
|
+
etype: EntityType = "subscription" if txn.is_recurring else "merchant"
|
|
144
138
|
ent = FinancialEntity(name=txn.entity, entity_type=etype)
|
|
145
139
|
self.entities[txn.entity] = ent
|
|
146
140
|
ent.txn_count += 1
|
|
@@ -149,17 +143,15 @@ class FinanceRuntime:
|
|
|
149
143
|
if txn.is_recurring:
|
|
150
144
|
ent.recurring_amount = abs(txn.amount)
|
|
151
145
|
|
|
152
|
-
# ── 数据录入 ──────────────────────────────────────────
|
|
153
146
|
def add_transaction(self, txn: Transaction,
|
|
154
147
|
run_gatekeeper: bool = True,
|
|
155
148
|
save: bool = True) -> dict:
|
|
156
149
|
"""录入一笔交易,经过守门人裁决,并落盘(save=False 供批量导入只写一次)。"""
|
|
157
150
|
with self._lock:
|
|
158
151
|
self.transactions.append(txn)
|
|
159
|
-
# 更新实体统计
|
|
160
152
|
ent = self.entities.get(txn.entity)
|
|
161
153
|
if not ent:
|
|
162
|
-
etype = "subscription" if txn.is_recurring else "merchant"
|
|
154
|
+
etype: EntityType = "subscription" if txn.is_recurring else "merchant"
|
|
163
155
|
ent = FinancialEntity(name=txn.entity, entity_type=etype)
|
|
164
156
|
self.entities[txn.entity] = ent
|
|
165
157
|
ent.txn_count += 1
|
|
@@ -168,7 +160,6 @@ class FinanceRuntime:
|
|
|
168
160
|
if txn.is_recurring:
|
|
169
161
|
ent.recurring_amount = abs(txn.amount)
|
|
170
162
|
|
|
171
|
-
# 写入 HMR(可选)
|
|
172
163
|
if self.hmr:
|
|
173
164
|
try:
|
|
174
165
|
self.hmr.ingest(
|
|
@@ -179,7 +170,6 @@ class FinanceRuntime:
|
|
|
179
170
|
except Exception:
|
|
180
171
|
pass
|
|
181
172
|
|
|
182
|
-
# 守门人裁决
|
|
183
173
|
verdicts = []
|
|
184
174
|
if run_gatekeeper:
|
|
185
175
|
bal = self.account_balances.get(txn.account)
|
|
@@ -190,7 +180,6 @@ class FinanceRuntime:
|
|
|
190
180
|
"gated": any(v.action in ("veto", "freeze", "require_confirmation")
|
|
191
181
|
for v in verdicts),
|
|
192
182
|
}
|
|
193
|
-
# 写盘放在锁外:避免一次磁盘 IO 把并发进来的读请求全堵住
|
|
194
183
|
if save:
|
|
195
184
|
self.save()
|
|
196
185
|
return result
|
|
@@ -206,9 +195,8 @@ class FinanceRuntime:
|
|
|
206
195
|
self.account_balances[account] = balance
|
|
207
196
|
self.save()
|
|
208
197
|
|
|
209
|
-
# ── 自然语言记账(第一线功能)────────────────────────────
|
|
210
198
|
def record_text(self, text: str, account: str = "default",
|
|
211
|
-
now: datetime = None, dry_run: bool = False) -> dict:
|
|
199
|
+
now: datetime | None = None, dry_run: bool = False) -> dict:
|
|
212
200
|
"""「今天买了一件衣服花了 105 块」→ 落一笔账,返回可读回执。
|
|
213
201
|
|
|
214
202
|
识别(金额/币种/时间/分类)全部走 `nlparse` 的确定性规则,LLM 不参与算钱。
|
|
@@ -279,7 +267,6 @@ class FinanceRuntime:
|
|
|
279
267
|
lines.append(f"· {day} {it.get('description') or it.get('entity') or ''}"
|
|
280
268
|
f" {money} · {label}{flag}")
|
|
281
269
|
|
|
282
|
-
# 汇总期跟着这笔账走:记的是上月 5 号的账,就别报"本月已花"
|
|
283
270
|
period = (items[0].get("timestamp") or "")[:7] or None
|
|
284
271
|
led = self.ledger(period)
|
|
285
272
|
head = "本月" if period == datetime.now().strftime("%Y-%m") else f"{period} "
|
|
@@ -298,7 +285,7 @@ class FinanceRuntime:
|
|
|
298
285
|
lines.append("提示:" + ";".join(warns))
|
|
299
286
|
return "\n".join(lines)
|
|
300
287
|
|
|
301
|
-
def ledger(self, period: str = None) -> dict:
|
|
288
|
+
def ledger(self, period: str | None = None) -> dict:
|
|
302
289
|
"""记账本:按月汇总(已发生 vs 计划、按天、按分类、按币种)。"""
|
|
303
290
|
period = period or datetime.now().strftime("%Y-%m")
|
|
304
291
|
with self._lock:
|
|
@@ -311,7 +298,9 @@ class FinanceRuntime:
|
|
|
311
298
|
expense = round(sum(t.amount for t in settled if t.amount < 0), 2)
|
|
312
299
|
income = round(sum(t.amount for t in settled if t.amount > 0), 2)
|
|
313
300
|
|
|
314
|
-
by_day,
|
|
301
|
+
by_day: dict[str, dict[str, Any]] = {}
|
|
302
|
+
by_cat: dict[str, dict[str, Any]] = {}
|
|
303
|
+
by_cur: dict[str, dict[str, Any]] = {}
|
|
315
304
|
for t in settled:
|
|
316
305
|
day = (t.timestamp or "")[:10]
|
|
317
306
|
d = by_day.setdefault(day, {"date": day, "expense": 0.0, "income": 0.0,
|
|
@@ -376,14 +365,12 @@ class FinanceRuntime:
|
|
|
376
365
|
def import_csv(self, path: str) -> int:
|
|
377
366
|
"""从 CSV 导入交易。列:amount,category,entity,account,timestamp,tags
|
|
378
367
|
真实银行 API 接口可在此处替换/扩展。"""
|
|
379
|
-
|
|
368
|
+
csv_path = Path(path)
|
|
380
369
|
count = 0
|
|
381
|
-
with open(
|
|
370
|
+
with open(csv_path, encoding="utf-8") as f:
|
|
382
371
|
reader = csv.DictReader(f)
|
|
383
372
|
for row in reader:
|
|
384
373
|
tags = [t.strip() for t in (row.get("tags") or "").split("|") if t.strip()]
|
|
385
|
-
# 可选的 currency 列:外币行按当天记账汇率折成本位币再入账,
|
|
386
|
-
# 否则「100 美元」会和「100 元」一起被求和。
|
|
387
374
|
cur = (row.get("currency") or row.get("币种") or "CNY").strip().upper()
|
|
388
375
|
raw = float(row["amount"])
|
|
389
376
|
base, rate = self.fx.to_base(abs(raw), cur)
|
|
@@ -401,11 +388,10 @@ class FinanceRuntime:
|
|
|
401
388
|
self.add_transaction(txn, run_gatekeeper=False, save=False)
|
|
402
389
|
count += 1
|
|
403
390
|
if count:
|
|
404
|
-
self.save()
|
|
391
|
+
self.save()
|
|
405
392
|
return count
|
|
406
393
|
|
|
407
|
-
|
|
408
|
-
def analyze(self, period: str = None, current_balance: float = None,
|
|
394
|
+
def analyze(self, period: str | None = None, current_balance: float | None = None,
|
|
409
395
|
calibrate: bool = True) -> dict:
|
|
410
396
|
"""一次性产出完整财务洞察:预算/风险/预测/守门人裁决。
|
|
411
397
|
|
|
@@ -413,17 +399,12 @@ class FinanceRuntime:
|
|
|
413
399
|
再裁决 —— 让阈值随消费水平自己长,而不是永远停在出厂拍的经验值上。
|
|
414
400
|
"""
|
|
415
401
|
period = period or datetime.now().strftime("%Y-%m")
|
|
416
|
-
# 快照:分析期间可能有并发录入(持久化后 import 端点就是可并发的写方),
|
|
417
|
-
# 直接遍历 self.transactions 会撞上 "list changed size during iteration"。
|
|
418
|
-
# 计划支出(明天/下个月)必须排除:还没发生的钱不能算进"本月已花",
|
|
419
|
-
# 否则「明天交房租 3000」会立刻把这个月的预算打爆。
|
|
420
402
|
txns = [t for t in self.transactions if not getattr(t, "is_planned", False)]
|
|
421
403
|
budgets = list(self.budgets)
|
|
422
404
|
bal = current_balance
|
|
423
405
|
if bal is None and self.account_balances:
|
|
424
406
|
bal = sum(dict(self.account_balances).values())
|
|
425
407
|
|
|
426
|
-
# 阈值自校准:必须在裁决**之前**,否则这次分析用的还是上一轮的旧阈值
|
|
427
408
|
calibration = (self.policy.apply_calibration(
|
|
428
409
|
self.reasoning.calibrate_thresholds(txns)) if calibrate else [])
|
|
429
410
|
|
|
@@ -435,21 +416,16 @@ class FinanceRuntime:
|
|
|
435
416
|
cashflow = (self.reasoning.cashflow_projection(txns, bal)
|
|
436
417
|
if bal is not None else None)
|
|
437
418
|
|
|
438
|
-
# 超支预测(对每个有预算的分类)—— 提到裁决之前算,好一并交给守门人
|
|
439
419
|
forecasts = [self.reasoning.overspend_forecast(txns, b, period)
|
|
440
420
|
for b in budgets]
|
|
441
421
|
renewals = self.reasoning.detect_upcoming_renewals(txns)
|
|
442
422
|
pending_refunds = self.reasoning.detect_pending_refunds(txns)
|
|
443
423
|
|
|
444
|
-
# 守门人对整体状态裁决
|
|
445
|
-
# duplicates/forecasts 此前已被算出来、却只进报告不进裁决(检测到了却不吭声);
|
|
446
|
-
# renewals/pending_refunds 为新增信号。现在一并交给守门人。
|
|
447
424
|
state_verdicts = self.policy.evaluate_state(
|
|
448
425
|
subscription_total=sub_creep["monthly_total"], anomalies=anomalies,
|
|
449
426
|
duplicates=duplicates, forecasts=forecasts,
|
|
450
427
|
renewals=renewals, pending_refunds=pending_refunds)
|
|
451
428
|
|
|
452
|
-
# 结构化证据链(可解释决策,可沉淀到 ThoughtChain)
|
|
453
429
|
from .evidence import FinanceExplainer
|
|
454
430
|
evidence_chains = [
|
|
455
431
|
FinanceExplainer.explain_budget_risk(budget_status).to_dict(),
|
|
@@ -467,11 +443,10 @@ class FinanceRuntime:
|
|
|
467
443
|
"burn_rate": burn,
|
|
468
444
|
"cashflow_projection": cashflow,
|
|
469
445
|
"gatekeeper_verdicts": [v.to_dict() for v in state_verdicts],
|
|
470
|
-
"threshold_calibration": calibration,
|
|
446
|
+
"threshold_calibration": calibration,
|
|
471
447
|
"evidence_chains": evidence_chains,
|
|
472
448
|
}
|
|
473
449
|
|
|
474
|
-
# ── 守门人审批流(不自动动钱)─────────────────────────
|
|
475
450
|
def request_action(self, action_type: str, detail: dict) -> dict:
|
|
476
451
|
"""请求一个资金操作(如取消订阅、转账)。
|
|
477
452
|
不自动执行——返回审批请求,标记需用户确认 / 需接真实支付 API。"""
|
|
@@ -489,7 +464,6 @@ class FinanceRuntime:
|
|
|
489
464
|
"entities": len(self.entities),
|
|
490
465
|
"accounts": self.account_balances,
|
|
491
466
|
"policy": self.policy.summary(),
|
|
492
|
-
# 落盘状态一并回给界面:否则"数据到底存哪了/为什么重启就没了"只能靠猜
|
|
493
467
|
"persist": {"path": str(self._data_path),
|
|
494
468
|
"exists": self._data_path.exists(),
|
|
495
469
|
"load_error": self.load_error,
|
|
@@ -513,7 +487,6 @@ class FinanceRuntime:
|
|
|
513
487
|
返回:匹配/差异分类 + 审计日志。
|
|
514
488
|
"""
|
|
515
489
|
from .reconcile import Reconciler, ExternalRecord
|
|
516
|
-
# 容忍 dict 输入,自动转 ExternalRecord
|
|
517
490
|
ext = []
|
|
518
491
|
for r in external_records:
|
|
519
492
|
if isinstance(r, ExternalRecord):
|
|
@@ -13,25 +13,25 @@ import functools
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import uuid
|
|
15
15
|
from dataclasses import dataclass, field
|
|
16
|
-
from typing import Optional
|
|
16
|
+
from typing import Optional, cast
|
|
17
17
|
|
|
18
18
|
from ..harness.harness import Harness
|
|
19
19
|
from ..harness.loop_detection import ToolLoopDetector
|
|
20
|
-
from ..core.thought_chain import ThoughtChain, ThoughtNode
|
|
20
|
+
from ..core.thought_chain import NodeType, ThoughtChain, ThoughtNode
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
@dataclass
|
|
24
24
|
class GovResult:
|
|
25
25
|
"""一步治理判定的结果(框架无关,不泄露 Foxclaw 内部类型)。"""
|
|
26
|
-
status: str
|
|
26
|
+
status: str
|
|
27
27
|
blocked: bool
|
|
28
28
|
warned: bool
|
|
29
29
|
delta: float
|
|
30
|
-
dimension: str
|
|
30
|
+
dimension: str
|
|
31
31
|
reason: Optional[str]
|
|
32
32
|
node_id: str
|
|
33
|
-
scores: dict = field(default_factory=dict)
|
|
34
|
-
loop: Optional[dict] = None
|
|
33
|
+
scores: dict = field(default_factory=dict)
|
|
34
|
+
loop: Optional[dict] = None
|
|
35
35
|
|
|
36
36
|
@property
|
|
37
37
|
def tool_violation(self) -> bool:
|
|
@@ -74,8 +74,8 @@ class GovernanceSession:
|
|
|
74
74
|
|
|
75
75
|
def __init__(self, goal: str, allowed_tools=None, entities=None,
|
|
76
76
|
agent_id: str = "agent", shadow_rounds: int = 0,
|
|
77
|
-
db_path: str = None, policy_path: str = None,
|
|
78
|
-
embedding_model: str = None):
|
|
77
|
+
db_path: str | None = None, policy_path: str | None = None,
|
|
78
|
+
embedding_model: str | None = None):
|
|
79
79
|
self.agent_id = agent_id
|
|
80
80
|
self._session_id = f"gov_{uuid.uuid4().hex[:10]}"
|
|
81
81
|
self.thought_chain = ThoughtChain(
|
|
@@ -88,17 +88,13 @@ class GovernanceSession:
|
|
|
88
88
|
goal_text=goal,
|
|
89
89
|
allowed_tool_categories=set(allowed_tools or {"read"}),
|
|
90
90
|
baseline_entities=set(entities or []))
|
|
91
|
-
# shadow_rounds<=0:中间件默认**即时按默认阈值判定**(warn 0.15 / block 0.25),
|
|
92
|
-
# 不做"拿第一个样本自校准"(那会让首步逃逸并用单样本污染阈值)。想用真实分布
|
|
93
|
-
# 校准的调用方,显式传 shadow_rounds>0 走观测期。
|
|
94
91
|
if shadow_rounds <= 0:
|
|
95
92
|
self.harness._shadow_mode = False
|
|
96
93
|
self._loop = ToolLoopDetector()
|
|
97
94
|
self._blocked_count = 0
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
node_type: str = None, conf: float = 0.9,
|
|
96
|
+
def observe(self, content: str, tool: str | None = None, entities=None,
|
|
97
|
+
node_type: str | None = None, conf: float = 0.9,
|
|
102
98
|
semantic: bool = True) -> GovResult:
|
|
103
99
|
"""观测一个推理/工具步骤,返回治理判定。不抛异常(判定在结果里)。
|
|
104
100
|
|
|
@@ -106,7 +102,7 @@ class GovernanceSession:
|
|
|
106
102
|
工具调用串对目标的语义距离是噪声。"""
|
|
107
103
|
node = ThoughtNode.create(
|
|
108
104
|
session_id=self._session_id, agent_id=self.agent_id,
|
|
109
|
-
type=node_type or ("tool_intent" if tool else "reasoning"),
|
|
105
|
+
type=cast(NodeType, node_type or ("tool_intent" if tool else "reasoning")),
|
|
110
106
|
content=content, conf=conf, tool_name=tool, entities=entities or [])
|
|
111
107
|
self.thought_chain.add_node(node)
|
|
112
108
|
r = self.harness.check(node, ignore_semantic=not semantic)
|
|
@@ -121,14 +117,13 @@ class GovernanceSession:
|
|
|
121
117
|
delta=r.delta, dimension=r.dominant_dim, reason=r.reason,
|
|
122
118
|
node_id=node.id, scores=dict(r.scores), loop=loop_warn)
|
|
123
119
|
|
|
124
|
-
def guard(self, content: str, tool: str = None, entities=None) -> GovResult:
|
|
120
|
+
def guard(self, content: str, tool: str | None = None, entities=None) -> GovResult:
|
|
125
121
|
"""与 observe 相同,但被 BLOCK 时**抛 GovernanceBlocked**(fail-closed 风格)。"""
|
|
126
122
|
r = self.observe(content, tool=tool, entities=entities)
|
|
127
123
|
if r.blocked:
|
|
128
124
|
raise GovernanceBlocked(r)
|
|
129
125
|
return r
|
|
130
126
|
|
|
131
|
-
# ── 审计 ────────────────────────────────────────────────
|
|
132
127
|
def audit(self) -> list[dict]:
|
|
133
128
|
"""取回本会话的完整可审计推理链(带每步 Harness 判定)。"""
|
|
134
129
|
return [n.to_dict() for n in
|
|
@@ -148,7 +143,7 @@ class GovernanceSession:
|
|
|
148
143
|
return False
|
|
149
144
|
|
|
150
145
|
|
|
151
|
-
def governed(session: GovernanceSession, tool_name: str = None, entities=None):
|
|
146
|
+
def governed(session: GovernanceSession, tool_name: str | None = None, entities=None):
|
|
152
147
|
"""装饰器:把一个工具函数包成"调用前先过 Harness"。
|
|
153
148
|
|
|
154
149
|
命中越权/跑题/范围蔓延 → 抛 GovernanceBlocked,函数不执行。同步/异步函数都支持。
|
|
@@ -158,9 +153,6 @@ def governed(session: GovernanceSession, tool_name: str = None, entities=None):
|
|
|
158
153
|
def delete_row(...): ... # 调用即被拦(delete 不在 read 允许集)
|
|
159
154
|
"""
|
|
160
155
|
def _check(name):
|
|
161
|
-
# 工具装饰器只对"这个工具授权了吗"负责 → semantic=False 只按 tool/scope 判定
|
|
162
|
-
# (语义是 observe() 的职责;工具名对目标的语义距离是噪声,跨语言时更会饱和,
|
|
163
|
-
# 不能作为工具授权依据)。这样中间件语言无关、行为确定、审计不被语义噪声污染。
|
|
164
156
|
r = session.observe(f"tool call: {name}", tool=name, entities=entities,
|
|
165
157
|
semantic=False)
|
|
166
158
|
if r.blocked or r.tool_violation or r.scope_violation:
|
|
@@ -8,11 +8,6 @@ import numpy as np
|
|
|
8
8
|
from ..core.thought_chain import HarnessStatus
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
# 目标基线是**每任务**语义(register_goal 每次任务设一次),却存在 Harness 实例上——
|
|
12
|
-
# 并发顶层任务打同一 Agent 的 harness 时会互相覆盖,导致 check 对着错误的目标算漂移。
|
|
13
|
-
# 与 base.py 同构:放进单个 module 级 contextvar 承载的 dict、按实例 token 分槽。
|
|
14
|
-
# 累积学习态(weights/_policy/_delta_history/_run_count/_shadow_mode)仍留在实例上——
|
|
15
|
-
# 那本就该按 Agent 累积,且同步 check 内无 await,协作调度下读改写原子安全。
|
|
16
11
|
_harness_ctx: contextvars.ContextVar = contextvars.ContextVar(
|
|
17
12
|
"foxclaw_harness_ctx", default=None)
|
|
18
13
|
|
|
@@ -27,7 +22,6 @@ def _harness_ctx_set(token: str, baseline):
|
|
|
27
22
|
_harness_ctx.set(d)
|
|
28
23
|
|
|
29
24
|
|
|
30
|
-
# ── 工具类别白名单 ─────────────────────────────────────────
|
|
31
25
|
TOOL_CATEGORIES: dict[str, set[str]] = {
|
|
32
26
|
"read": {"read_file", "search", "fetch", "list", "get", "query"},
|
|
33
27
|
"write": {"write_file", "create", "update", "save", "append"},
|
|
@@ -43,8 +37,8 @@ TOOL_CATEGORIES: dict[str, set[str]] = {
|
|
|
43
37
|
class HarnessResult:
|
|
44
38
|
status: HarnessStatus
|
|
45
39
|
delta: float
|
|
46
|
-
dominant_dim: str
|
|
47
|
-
scores: dict
|
|
40
|
+
dominant_dim: str
|
|
41
|
+
scores: dict
|
|
48
42
|
reason: Optional[str] = None
|
|
49
43
|
|
|
50
44
|
@property
|