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
|
@@ -26,17 +26,11 @@ def test_domain_evals():
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _policy():
|
|
29
|
-
# 用产品**默认**规则集评测 —— 评的就是"开箱默认够不够好"
|
|
30
|
-
# data_path 必须独立:默认落在 policy 同目录,而 mktemp 的父目录是**系统 Temp**,
|
|
31
|
-
# 不显式隔离就会和别的测试共用一个 finance_data.json(顺序一变就互相污染)。
|
|
32
29
|
return FinanceRuntime(policy_path=tempfile.mktemp(suffix=".yaml"),
|
|
33
30
|
data_path=tempfile.mktemp(suffix=".json")).policy
|
|
34
31
|
|
|
35
32
|
|
|
36
|
-
# ── 标注案例集:(说明, 金额, 类别, 余额, 该不该被拦) ───────────────────
|
|
37
|
-
# ↓↓↓ 往这里加你真实遇到的情形 ↓↓↓
|
|
38
33
|
CASES = [
|
|
39
|
-
# 日常小额:不该被拦(这是误报的主要来源——原 200 阈值下它们全会被拦)
|
|
40
34
|
("买咖啡", -35, "food", 8000, False),
|
|
41
35
|
("午饭", -60, "food", 8000, False),
|
|
42
36
|
("打车", -80, "transport", 8000, False),
|
|
@@ -44,13 +38,10 @@ def test_domain_evals():
|
|
|
44
38
|
("买书", -220, "book", 8000, False),
|
|
45
39
|
("聚餐 AA", -320, "food", 8000, False),
|
|
46
40
|
("买鞋", -450, "shopping", 8000, False),
|
|
47
|
-
# 大额:该被拦(>500)
|
|
48
41
|
("买手机", -4500, "digital", 8000, True),
|
|
49
42
|
("交房租", -3000, "housing", 8000, True),
|
|
50
43
|
("买笔记本", -8000, "digital", 20000, True),
|
|
51
|
-
# 余额保护:低余额该被拦(即便金额不大)
|
|
52
44
|
("低余额时消费", -100, "food", 300, True),
|
|
53
|
-
# 收入:不该被拦
|
|
54
45
|
("发工资", +15000, "salary", 8000, False),
|
|
55
46
|
]
|
|
56
47
|
|
|
@@ -65,7 +56,6 @@ def test_domain_evals():
|
|
|
65
56
|
if expect_flag and not flagged:
|
|
66
57
|
misses.append((name, amount, rv.decision))
|
|
67
58
|
elif not expect_flag and flagged:
|
|
68
|
-
# 归因:证据里 raw 非空的就是**触发裁决的规则**(informational 证据 raw=None)
|
|
69
59
|
rules = [e.factor for e in rv.evidence if e.raw is not None]
|
|
70
60
|
false_pos.append((name, amount, rv.decision, rules))
|
|
71
61
|
return misses, false_pos
|
|
@@ -91,18 +81,12 @@ def test_domain_evals():
|
|
|
91
81
|
for nm, amt, d, rules in fps:
|
|
92
82
|
print(f" {nm}({amt}) → {d} ← 触发规则: {rules or '(未归因)'}")
|
|
93
83
|
|
|
94
|
-
# [1] 漏报:该抓的必须都抓住(>500 / 低余额)
|
|
95
84
|
assert not misses, f"存在漏报(该抓没抓): {misses}"
|
|
96
85
|
print("\n[1] 无漏报(>500 与低余额保护都抓住了)OK")
|
|
97
86
|
|
|
98
|
-
# [2] 误报:日常消费不该被拦。阈值 500 后,220/320/450 都该放行
|
|
99
|
-
# (原默认 200 时它们全是误报 → 用户会被烦到关掉守门人)
|
|
100
87
|
assert fp_rate <= 0.15, f"误报率过高({fp_rate:.0%}),用户会关掉守门人: {fps}"
|
|
101
88
|
print(f"[2] 误报率 {fp_rate:.0%} ≤ 15%(沿用 evals_smoke 同款红线)OK")
|
|
102
89
|
|
|
103
|
-
# [3] 误报**可归因**:这是"确定误报原因加以修改"的前提 ——
|
|
104
|
-
# 若无法定位是哪条规则拦的,就无从改起。这里验证归因机制本身有效:
|
|
105
|
-
# 故意把阈值压到 100 制造误报,必须能指出是 large_purchase_check 干的。
|
|
106
90
|
pol_bad = _policy()
|
|
107
91
|
for r in pol_bad.rules:
|
|
108
92
|
if r.id == "large_purchase_check":
|
|
@@ -113,10 +97,6 @@ def test_domain_evals():
|
|
|
113
97
|
f"误报必须能归因到具体规则,否则无从改起: {fps2}"
|
|
114
98
|
print(f"[3] 误报可归因(阈值压到100 → {len(fps2)} 条误报,全部指向 large_purchase_check)OK")
|
|
115
99
|
|
|
116
|
-
# [4] 规则实例隔离(本框架第一次跑就抓到的真 bug):
|
|
117
|
-
# 原 `self.rules = list(DEFAULT_RULES)` 只复制列表、不复制规则对象 → 所有 runtime
|
|
118
|
-
# 共享同一批**可变**规则,[3] 里把 pol_bad 阈值压到 100 竟连带把 pol 和模块级
|
|
119
|
-
# DEFAULT_RULES 一起改了(要重启才恢复)。现已 deepcopy。
|
|
120
100
|
from foxclaw.finance.policy import DEFAULT_RULES
|
|
121
101
|
p1, p2 = _policy(), _policy()
|
|
122
102
|
r1 = [r for r in p1.rules if r.id == "large_purchase_check"][0]
|
|
@@ -128,13 +108,9 @@ def test_domain_evals():
|
|
|
128
108
|
assert rd.threshold != 99.0, "改 runtime 的阈值污染了模块级 DEFAULT_RULES"
|
|
129
109
|
print("[4] 规则实例隔离(改一处不污染其它 runtime / 模块级默认)OK")
|
|
130
110
|
|
|
131
|
-
# [5] 阈值是可调项(改 yaml 即可,不用动代码);默认 500
|
|
132
111
|
assert rd.threshold == 500.0, f"默认单笔阈值应为 500,实际 {rd.threshold}"
|
|
133
112
|
print("[5] 阈值为可调项(finance_policy.yaml 可改,默认 500)OK")
|
|
134
113
|
|
|
135
|
-
# ══════════════════════════════════════════════════════════
|
|
136
|
-
# 用户点名要的 4 条规则(2026-07-16)。前两条的检测早就实现,只是从不产生裁决。
|
|
137
|
-
# ══════════════════════════════════════════════════════════
|
|
138
114
|
from datetime import datetime, timedelta
|
|
139
115
|
from foxclaw.finance.models import Budget
|
|
140
116
|
from foxclaw.finance.reasoning import FinanceReasoning as FR
|
|
@@ -148,7 +124,6 @@ def test_domain_evals():
|
|
|
148
124
|
"""跑状态裁决,返回触发的 rule_id 集合。"""
|
|
149
125
|
return {v.rule_id for v in _policy().evaluate_state(**kw)}
|
|
150
126
|
|
|
151
|
-
# [6] 同一商家高频扣款 → 必须裁决(此前 detect_duplicate_charges 算了却不吭声)
|
|
152
127
|
dups = FR.detect_duplicate_charges([
|
|
153
128
|
_txn(-99, "SomeApp", days_ago=1), _txn(-99, "SomeApp", days_ago=1),
|
|
154
129
|
])
|
|
@@ -157,7 +132,6 @@ def test_domain_evals():
|
|
|
157
132
|
assert "duplicate_charge_alert" not in _fire(duplicates=[]), "无重复扣款不该报警"
|
|
158
133
|
print(f"[6] 同一商家高频扣款 → 触发裁决({dups[0]['reason']})OK")
|
|
159
134
|
|
|
160
|
-
# [7] 跨月预算超支**趋势**:还没超、但按速度月底会超 → 提醒;已超/不会超 → 不重复报
|
|
161
135
|
f_trend = {"category": "food", "spent_so_far": 800, "monthly_limit": 1000,
|
|
162
136
|
"projected_month_end": 1600, "projected_overage": 600, "will_overspend": True}
|
|
163
137
|
f_safe = {"category": "food", "spent_so_far": 200, "monthly_limit": 1000,
|
|
@@ -166,42 +140,30 @@ def test_domain_evals():
|
|
|
166
140
|
assert "overspend_trend_alert" not in _fire(forecasts=[f_safe]), "不会超不该提醒"
|
|
167
141
|
print("[7] 跨月超支趋势(还没超但会超 → 提醒;不会超 → 不吵)OK")
|
|
168
142
|
|
|
169
|
-
# [8] 年费自动续订**前**提醒:从历史间隔推断周期,只在临近时提醒
|
|
170
|
-
# 上次扣款 362 天前、上上次 727 天前 → 间隔 365(年订)→ 下次在 3 天后
|
|
171
143
|
annual = [_txn(-598, "某会员", days_ago=362 + 365, tags=["recurring"]),
|
|
172
144
|
_txn(-598, "某会员", days_ago=362, tags=["recurring"])]
|
|
173
145
|
ren = FR.detect_upcoming_renewals(annual, horizon_days=7)
|
|
174
146
|
assert ren and ren[0]["cadence"] == "年订" and ren[0]["days_until"] <= 7, ren
|
|
175
147
|
assert "renewal_reminder" in _fire(renewals=ren)
|
|
176
|
-
# 间隔紊乱 → 不预测(宁可不提醒,也不为猜错的日期制造误报)
|
|
177
148
|
messy = [_txn(-50, "乱扣", days_ago=200, tags=["recurring"]),
|
|
178
149
|
_txn(-50, "乱扣", days_ago=3, tags=["recurring"])]
|
|
179
150
|
assert FR.detect_upcoming_renewals(messy) == [], "间隔紊乱不该预测续订日"
|
|
180
|
-
# 只扣过一次 → 无法判周期
|
|
181
151
|
assert FR.detect_upcoming_renewals([_txn(-50, "新订阅", days_ago=1, tags=["recurring"])]) == []
|
|
182
152
|
print(f"[8] 年费续订前提醒({ren[0]['entity']} {ren[0]['days_until']} 天后;紊乱/单次不预测)OK")
|
|
183
153
|
|
|
184
|
-
# [9] 退款未到账:**需用户标记** refund_expected;已到账则不报
|
|
185
154
|
overdue = FR.detect_pending_refunds([_txn(-300, "某电商", days_ago=20, tags=["refund_expected"])])
|
|
186
155
|
assert overdue and overdue[0]["days_waiting"] >= 14, overdue
|
|
187
156
|
assert "refund_overdue_alert" in _fire(pending_refunds=overdue)
|
|
188
|
-
# 等额退款已入账 → 不报
|
|
189
157
|
done = FR.detect_pending_refunds([_txn(-300, "某电商", days_ago=20, tags=["refund_expected"]),
|
|
190
158
|
_txn(+300, "某电商", days_ago=2)])
|
|
191
159
|
assert done == [], "退款已到账不该再报"
|
|
192
|
-
# 【关键】未标记的普通消费**绝不**报警 —— 否则每笔消费都报,是灾难性误报
|
|
193
160
|
assert FR.detect_pending_refunds([_txn(-300, "某电商", days_ago=20)]) == [], \
|
|
194
161
|
"未标记的普通消费不得触发退款报警(灾难性误报)"
|
|
195
162
|
print("[9] 退款未到账(需标记;已到账不报;未标记的普通消费不误报)OK")
|
|
196
163
|
|
|
197
|
-
# [10] 新规则不得把日常消费变成噪音 —— 无信号时一条都不该响
|
|
198
164
|
assert _fire() == set(), f"无信号时不该有任何裁决: {_fire()}"
|
|
199
165
|
print("[10] 无信号 → 零裁决(新规则不制造背景噪音)OK")
|
|
200
166
|
|
|
201
|
-
# ══════════════════════════════════════════════════════════
|
|
202
|
-
# 阈值自校准:阈值不再是我拍的,而是从用户自己的消费分布长出来的。
|
|
203
|
-
# 这一段验的是"没有真实数据时也不会瞎猜、有数据时自动贴合、且永不踩人的决定"。
|
|
204
|
-
# ══════════════════════════════════════════════════════════
|
|
205
167
|
def _stream(each, n=60, entity="日常"):
|
|
206
168
|
"""造 n 笔支出,跨**正好** n-1 天(满足校准的样本门槛)。
|
|
207
169
|
|
|
@@ -213,49 +175,41 @@ def test_domain_evals():
|
|
|
213
175
|
timestamp=(base - timedelta(days=i)).isoformat())
|
|
214
176
|
for i in range(n)]
|
|
215
177
|
|
|
216
|
-
# [11] 样本不足 → 不校准(继续用出厂默认)
|
|
217
178
|
assert FR.calibrate_thresholds([]) == {}, "空数据不该校准"
|
|
218
179
|
assert FR.calibrate_thresholds(_stream(300, n=5)) == {}, "5 笔就校准 = 用垃圾数据冒充个性化"
|
|
219
180
|
assert FR.calibrate_thresholds(_stream(300, n=30)) == {}, \
|
|
220
181
|
"笔数够但跨度只有 29 天(差一天到门槛)→ 日均不可信,不该校准"
|
|
221
182
|
print("[11] 样本不足(空/笔数少/跨度短)→ 不校准,沿用出厂默认 OK")
|
|
222
183
|
|
|
223
|
-
|
|
224
|
-
_PAT = [30, 45, 60, 80, 120, 55, 90, 40, 150, 70] # median≈65 的日常波动
|
|
184
|
+
_PAT = [30, 45, 60, 80, 120, 55, 90, 40, 150, 70]
|
|
225
185
|
def _real(scale=1.0, n=80, spike=None):
|
|
226
186
|
base = datetime.now()
|
|
227
187
|
txns = [Transaction(amount=-_PAT[i % len(_PAT)] * scale, category="misc",
|
|
228
188
|
entity="日常", account="default",
|
|
229
189
|
timestamp=(base - timedelta(days=i)).isoformat())
|
|
230
190
|
for i in range(n)]
|
|
231
|
-
if spike:
|
|
191
|
+
if spike:
|
|
232
192
|
txns.append(Transaction(amount=-spike, category="misc", entity="大额",
|
|
233
193
|
account="default", timestamp=(base - timedelta(days=1)).isoformat()))
|
|
234
194
|
return txns
|
|
235
195
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
poor = FR.calibrate_thresholds(_real(scale=0.5)) # 消费减半的人
|
|
196
|
+
rich = FR.calibrate_thresholds(_real(scale=2.0))
|
|
197
|
+
poor = FR.calibrate_thresholds(_real(scale=0.5))
|
|
239
198
|
lr, lp = rich["large_purchase_check"][0], poor["large_purchase_check"][0]
|
|
240
199
|
assert lr > lp, f"高消费者的大额门槛应更高: 富{lr} vs 穷{lp}"
|
|
241
200
|
assert rich["low_balance_protection"][0] > poor["low_balance_protection"][0]
|
|
242
201
|
print(f"[12] 阈值随消费水平伸缩(富→大额{lr:.0f} / 穷→大额{lp:.0f};固定 500 对两者都错)OK")
|
|
243
202
|
|
|
244
|
-
# [12b]【B 修复的直接证据】大额离群**不**抬高门槛 ——
|
|
245
|
-
# 这是把均值锚换成 median+MAD 的全部理由:一笔 8000 的电脑不该让此后的
|
|
246
|
-
# 大额检测变迟钝。均值锚在这里会让门槛暴涨(月均×10% 从 ~200 跳到 ~500)。
|
|
247
203
|
base_large = FR.calibrate_thresholds(_real())["large_purchase_check"][0]
|
|
248
204
|
spiked_large = FR.calibrate_thresholds(_real(spike=8000))["large_purchase_check"][0]
|
|
249
205
|
assert abs(spiked_large - base_large) <= 100, \
|
|
250
206
|
f"一笔大额离群把门槛从 {base_large} 顶到 {spiked_large}(均值锚的老毛病,MAD 应免疫)"
|
|
251
207
|
print(f"[12b] 抗离群:加一笔 8000 大额,门槛 {base_large:.0f}→{spiked_large:.0f} 几乎不动 OK")
|
|
252
208
|
|
|
253
|
-
# [13] 时间类阈值**不**校准(它们是语义常数,不是从消费分布学得出来的)
|
|
254
209
|
assert set(rich) == {"large_purchase_check", "low_balance_protection"}, \
|
|
255
210
|
f"只有金额类阈值有分布依据;时间类不得假装校准: {set(rich)}"
|
|
256
211
|
print("[13] 时间类阈值(48h/7d/14d)不参与校准(无分布依据,不给猜测套数学外衣)OK")
|
|
257
212
|
|
|
258
|
-
# [14] 落盘:builtin 且停在出厂值 → 校准生效,且留下可解释的依据
|
|
259
213
|
cal = FR.calibrate_thresholds(_real())
|
|
260
214
|
cal_large = cal["large_purchase_check"][0]
|
|
261
215
|
p14 = _policy()
|
|
@@ -263,29 +217,25 @@ def test_domain_evals():
|
|
|
263
217
|
r14 = [r for r in p14.rules if r.id == "large_purchase_check"][0]
|
|
264
218
|
assert r14.threshold == cal_large and r14.source == "learned" and r14.calibrated_value == cal_large
|
|
265
219
|
assert "median" in (r14.calibrated_from or ""), "阈值必须可解释,用户才敢信"
|
|
266
|
-
# large 一定变了;low_balance 恰好也算出 500(=出厂)时不进 ch —— 那正是"值没变不写盘"
|
|
267
220
|
assert "large_purchase_check" in {c["rule_id"] for c in ch}, ch
|
|
268
|
-
# 幂等:同样的建议再来一次 → 无改动、不重复写盘(analyze 每次都会调到)
|
|
269
221
|
assert p14.apply_calibration(cal) == [], "值没变还写盘 = 每次 analyze 都刷一遍 yaml"
|
|
270
222
|
print(f"[14] 校准落盘 + 可解释({r14.calibrated_from})+ 幂等 OK")
|
|
271
223
|
|
|
272
|
-
|
|
273
|
-
# 从此不信任整个系统。三种"人碰过"的形态都必须让校准收手:
|
|
274
|
-
p_user = _policy() # (a) source=user 显式锁定
|
|
224
|
+
p_user = _policy()
|
|
275
225
|
for r in p_user.rules:
|
|
276
226
|
if r.id == "large_purchase_check": r.source = "user"
|
|
277
227
|
p_user.apply_calibration(rich)
|
|
278
228
|
assert [r for r in p_user.rules if r.id == "large_purchase_check"][0].threshold == 500.0, \
|
|
279
229
|
"source=user 是显式锁定,校准不得覆盖"
|
|
280
230
|
|
|
281
|
-
p_edit = _policy()
|
|
231
|
+
p_edit = _policy()
|
|
282
232
|
for r in p_edit.rules:
|
|
283
233
|
if r.id == "large_purchase_check": r.threshold = 1234.0
|
|
284
234
|
p_edit.apply_calibration(rich)
|
|
285
235
|
assert [r for r in p_edit.rules if r.id == "large_purchase_check"][0].threshold == 1234.0, \
|
|
286
236
|
"阈值偏离出厂值 = 有人有意改过,校准必须收手"
|
|
287
237
|
|
|
288
|
-
p_re = _policy()
|
|
238
|
+
p_re = _policy()
|
|
289
239
|
p_re.apply_calibration(rich)
|
|
290
240
|
for r in p_re.rules:
|
|
291
241
|
if r.id == "large_purchase_check": r.threshold = 777.0
|
|
@@ -294,13 +244,11 @@ def test_domain_evals():
|
|
|
294
244
|
"校准值被人改过 = 人的决定,不得再次覆盖"
|
|
295
245
|
print("[15] 校准不踩人的决定(user 锁定 / 手改出厂值 / 手改校准值 → 三种都收手)OK")
|
|
296
246
|
|
|
297
|
-
# [16] 校准不污染模块级出厂默认(自校准直接写 rule.threshold,最容易复发 [4] 那个 bug)
|
|
298
247
|
assert rd.threshold == 500.0, f"校准污染了模块级 DEFAULT_RULES(阈值变成 {rd.threshold})"
|
|
299
248
|
from foxclaw.finance.policy import FACTORY_THRESHOLDS
|
|
300
249
|
assert FACTORY_THRESHOLDS["large_purchase_check"] == 500.0, "出厂快照被污染 → 判定基准失效"
|
|
301
250
|
print("[16] 校准不污染模块级出厂默认/出厂快照 OK")
|
|
302
251
|
|
|
303
|
-
# [17] 校准必须**对用户可见**(阈值自己变了还不吭声 = 偷偷改用户的设置)
|
|
304
252
|
rt = FinanceRuntime(policy_path=tempfile.mktemp(suffix=".yaml"),
|
|
305
253
|
data_path=tempfile.mktemp(suffix=".json"))
|
|
306
254
|
for t in _real():
|
|
@@ -313,60 +261,45 @@ def test_domain_evals():
|
|
|
313
261
|
assert rt.analyze()["threshold_calibration"] == [], "已校准后重复 analyze 不该再报变更"
|
|
314
262
|
print(f"[17] 阈值变更对用户可见(analyze 报出 500→{cal_large:.0f}),且不重复打扰 OK")
|
|
315
263
|
|
|
316
|
-
# ══════════════════════════════════════════════════════════
|
|
317
|
-
# 深审补丁(2026-07-16):A 月初误报闸 / C 续订阈值真正生效。
|
|
318
|
-
# 这两条是"审核后面做的"翻出来的真问题——不是新功能,是把已埋的雷拆掉。
|
|
319
|
-
# ══════════════════════════════════════════════════════════
|
|
320
264
|
|
|
321
|
-
# [18]【A】月初超支外推不可信 → 不报。overspend_forecast 用 spent/day,day=1 时
|
|
322
|
-
# 一笔正常消费被 ×30 放大成"月底将超支"。前 7 天标 reliable=False,裁决层闭嘴。
|
|
323
265
|
from foxclaw.finance.models import Transaction as _T
|
|
324
|
-
def _jul(day, amt):
|
|
266
|
+
def _jul(day, amt):
|
|
325
267
|
return _T(amount=-amt, category="food", entity="午饭", account="default",
|
|
326
268
|
timestamp=f"2026-07-{day:02d}T12:00:00")
|
|
327
269
|
food_budget = Budget(category="food", monthly_limit=1000)
|
|
328
|
-
# 月初第 2 天,只花了 100:daily_rate=50 → 外推 1500 > 1000,老逻辑会喊"将超支"
|
|
329
270
|
early = FR.overspend_forecast([_jul(1, 100)], food_budget,
|
|
330
271
|
now=datetime(2026, 7, 2))
|
|
331
272
|
assert early["will_overspend"] and not early["reliable"], early
|
|
332
273
|
assert "overspend_trend_alert" not in _fire(forecasts=[early]), "月初噪音不得报警(A 修复)"
|
|
333
|
-
# 到第 20 天,同样趋势才可信
|
|
334
274
|
late = FR.overspend_forecast([_jul(d, 100) for d in range(1, 16)], food_budget,
|
|
335
275
|
now=datetime(2026, 7, 20))
|
|
336
276
|
assert late["reliable"], late
|
|
337
277
|
print("[18]【A】月初(day<7)超支外推 reliable=False → 不误报;7 天后才裁决 OK")
|
|
338
278
|
|
|
339
|
-
# [19]【C】续订提醒阈值真正生效。检测层窗口放宽到 35 天,"提前几天提醒"由规则
|
|
340
|
-
# threshold 精确控制。旧代码里 detect 硬封 7 天 → 用户改阈值到 25 也没反应。
|
|
341
279
|
monthly_sub = [_txn(-30, "月刊会员", days_ago=10 + 30, tags=["recurring"]),
|
|
342
280
|
_txn(-30, "月刊会员", days_ago=10, tags=["recurring"])]
|
|
343
|
-
ren = FR.detect_upcoming_renewals(monthly_sub)
|
|
281
|
+
ren = FR.detect_upcoming_renewals(monthly_sub)
|
|
344
282
|
assert ren and ren[0]["cadence"] == "月订" and 18 <= ren[0]["days_until"] <= 22, ren
|
|
345
283
|
assert "renewal_reminder" not in _fire(renewals=ren), "默认提前 7 天 → 20 天后的续订还太远,不报"
|
|
346
|
-
p19 = _policy()
|
|
284
|
+
p19 = _policy()
|
|
347
285
|
for r in p19.rules:
|
|
348
286
|
if r.id == "renewal_reminder": r.threshold = 25.0
|
|
349
287
|
v19 = {x.rule_id for x in p19.evaluate_state(renewals=ren)}
|
|
350
288
|
assert "renewal_reminder" in v19, "阈值改到 25 应生效(旧代码被检测层硬封 7 天 → 静默失效)"
|
|
351
289
|
print("[19]【C】续订阈值真正生效(默认7天不报20天后的;改25天则报)OK")
|
|
352
290
|
|
|
353
|
-
# [20]【D】退款消耗式匹配:两笔同额 refund_expected、只收到一笔退款 → 第二笔仍报。
|
|
354
|
-
# 旧的 any() 匹配让两笔支出都认领同一笔入账 → 第二笔漏报(以为退了,其实没到)。
|
|
355
291
|
two_expected = [
|
|
356
292
|
_txn(-300, "某电商", days_ago=40, tags=["refund_expected"]),
|
|
357
293
|
_txn(-300, "某电商", days_ago=30, tags=["refund_expected"]),
|
|
358
|
-
_txn(+300, "某电商", days_ago=5),
|
|
294
|
+
_txn(+300, "某电商", days_ago=5),
|
|
359
295
|
]
|
|
360
296
|
pend = FR.detect_pending_refunds(two_expected)
|
|
361
297
|
assert len(pend) == 1, f"两笔标记只退一笔 → 应恰好报 1 笔未到账,实得 {len(pend)}: {pend}"
|
|
362
|
-
# 两笔都到账 → 零报警(两笔入账各抵一笔)
|
|
363
298
|
both_back = two_expected[:2] + [_txn(+300, "某电商", days_ago=5),
|
|
364
299
|
_txn(+300, "某电商", days_ago=4)]
|
|
365
300
|
assert FR.detect_pending_refunds(both_back) == [], "两笔退款都到账不该报"
|
|
366
301
|
print("[20]【D】退款消耗式匹配(两笔同额退一笔→报第二笔;都退→不报)OK")
|
|
367
302
|
|
|
368
|
-
# [21]【E】并发校准无竞态、yaml 不写坏。finance_runtime 是单例,analyze() 会被请求
|
|
369
|
-
# 线程并发调 → apply_calibration 并发改规则+写盘。写锁 + 原子 rename 保证安全。
|
|
370
303
|
import threading as _th
|
|
371
304
|
from foxclaw.finance.policy import FinancePolicyEngine
|
|
372
305
|
rt21 = FinanceRuntime(policy_path=tempfile.mktemp(suffix=".yaml"),
|
|
@@ -387,10 +320,8 @@ def test_domain_evals():
|
|
|
387
320
|
for t in _ts: t.start()
|
|
388
321
|
for t in _ts: t.join()
|
|
389
322
|
assert not errs21, f"并发 analyze/校准抛异常: {errs21[:2]}"
|
|
390
|
-
# 恰好一个线程做了首次校准(拿到非空 changed),其余读到已校准状态(空)
|
|
391
323
|
assert sum(1 for s in seen_to if s) == 1, \
|
|
392
324
|
f"首次校准应只发生一次(其余幂等空),实得 {sum(1 for s in seen_to if s)} 次非空"
|
|
393
|
-
# yaml 落盘完好可重载,且阈值 = 校准值(没被并发写坏)
|
|
394
325
|
reload21 = FinancePolicyEngine(str(rt21.policy.policy_path))
|
|
395
326
|
r21 = [r for r in reload21.rules if r.id == "large_purchase_check"][0]
|
|
396
327
|
assert r21.threshold == cal_large and r21.source == "learned", \
|
|
@@ -14,7 +14,6 @@ def test_domain_import():
|
|
|
14
14
|
|
|
15
15
|
print("=" * 60); print("OS 领域导入(csv/xlsx/json)验证"); print("=" * 60)
|
|
16
16
|
|
|
17
|
-
# ── Part A:解析器单元 ─────────────────────────────────────
|
|
18
17
|
from foxclaw.api import _parse_import_rows
|
|
19
18
|
|
|
20
19
|
csv_bytes = b"amount,category,entity\n-50,food,cafe\n-20,transport,metro\n"
|
|
@@ -35,12 +34,10 @@ def test_domain_import():
|
|
|
35
34
|
pass
|
|
36
35
|
print("[1] 解析器 csv/json/xlsx + 未支持格式报错 OK")
|
|
37
36
|
|
|
38
|
-
# ── Part B:三领域文件导入端点(TestClient)────────────────
|
|
39
37
|
from fastapi.testclient import TestClient
|
|
40
38
|
from foxclaw.api import app
|
|
41
39
|
|
|
42
40
|
with TestClient(app) as c:
|
|
43
|
-
# [2] finance:csv / xlsx / json 三格式各导入成功
|
|
44
41
|
r = c.post("/api/finance/import/file", files={"file": ("f.csv", csv_bytes, "text/csv")})
|
|
45
42
|
assert r.status_code == 200 and r.json()["imported"] == 2, r.text
|
|
46
43
|
r = c.post("/api/finance/import/file",
|
|
@@ -51,20 +48,17 @@ def test_domain_import():
|
|
|
51
48
|
assert r.status_code == 200 and r.json()["imported"] == 1, r.text
|
|
52
49
|
print("[2] finance 导入 csv/xlsx/json(2+2+1)OK")
|
|
53
50
|
|
|
54
|
-
# [3] learning:csv(content,topic,tags)
|
|
55
51
|
lc = "content,topic,tags\n间隔重复提升记忆,学习方法,记忆|复习\n费曼技巧,学习方法,输出\n".encode()
|
|
56
52
|
r = c.post("/api/learning/import/file", files={"file": ("l.csv", lc, "text/csv")})
|
|
57
53
|
assert r.status_code == 200 and r.json()["imported"] == 2, r.text
|
|
58
54
|
print("[3] learning 导入 csv(含 tags 管道分隔)OK")
|
|
59
55
|
|
|
60
|
-
# [4] itinerary:json(name/city/duration)
|
|
61
56
|
ij = json.dumps([{"name": "故宫", "city": "北京", "duration_min": 120},
|
|
62
57
|
{"name": "颐和园", "city": "北京", "duration_min": 90}]).encode()
|
|
63
58
|
r = c.post("/api/itinerary/import/file", files={"file": ("i.json", ij, "application/json")})
|
|
64
59
|
assert r.status_code == 200 and r.json()["imported"] == 2, r.text
|
|
65
60
|
print("[4] itinerary 导入 json OK")
|
|
66
61
|
|
|
67
|
-
# [5] 未支持格式 → 400;空文件 → imported 0
|
|
68
62
|
r = c.post("/api/finance/import/file", files={"file": ("x.pptx", b"x", "application/octet-stream")})
|
|
69
63
|
assert r.status_code == 400, r.status_code
|
|
70
64
|
r = c.post("/api/finance/import/file", files={"file": ("e.csv", b"amount,category\n", "text/csv")})
|
|
@@ -27,7 +27,6 @@ class _MockLLM:
|
|
|
27
27
|
return self.resp
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
# ─────────────────────────── 行程冲突推理 ───────────────────────────
|
|
31
30
|
def _wp(name, start=None, dur=0, lng=None, lat=None):
|
|
32
31
|
from foxclaw.itinerary.models import Waypoint
|
|
33
32
|
return Waypoint(name=name, start_time=start, duration_min=dur, lng=lng, lat=lat)
|
|
@@ -36,44 +35,37 @@ def _wp(name, start=None, dur=0, lng=None, lat=None):
|
|
|
36
35
|
def test_itinerary_reasoner():
|
|
37
36
|
print("\n【1】行程冲突推理:规则权威 + 可选 LLM 只升级")
|
|
38
37
|
from foxclaw.itinerary.reasoner import ItineraryConflictReasoner
|
|
39
|
-
# 时间重叠 → confirm,规则权威;即便 LLM 说 OK 也不被推翻,且不问 LLM
|
|
40
38
|
conflict = [_wp("会议A", "2026-07-08T10:00:00", 120), _wp("会议B", "2026-07-08T11:00:00", 60)]
|
|
41
39
|
llm = _MockLLM("OK")
|
|
42
40
|
rv = ItineraryConflictReasoner(llm=llm, llm_enabled=True).reason((conflict, "driving"))
|
|
43
41
|
assert rv.decision == "confirm" and rv.source == "rules" and llm.calls == 0
|
|
44
42
|
assert any(e.factor == "time_conflict" for e in rv.evidence)
|
|
45
43
|
|
|
46
|
-
# 无冲突、不紧凑(空档 60 分 >> 通勤 ~19 分)→ approve,LLM 不调
|
|
47
44
|
loose = [_wp("A", "2026-07-08T10:00:00", 60, 116.40, 39.90),
|
|
48
45
|
_wp("B", "2026-07-08T12:00:00", 60, 116.50, 39.90)]
|
|
49
46
|
llm = _MockLLM("CONFIRM")
|
|
50
47
|
rv = ItineraryConflictReasoner(llm=llm, llm_enabled=True).reason((loose, "driving"))
|
|
51
48
|
assert rv.decision == "approve" and rv.source == "rules" and llm.calls == 0
|
|
52
49
|
|
|
53
|
-
# 紧凑(空档 25 分,通勤 ~19 分,可达但赶)+ LLM 说 CONFIRM → confirm,source=llm
|
|
54
50
|
tight = [_wp("A", "2026-07-08T10:00:00", 60, 116.40, 39.90),
|
|
55
51
|
_wp("B", "2026-07-08T11:25:00", 60, 116.50, 39.90)]
|
|
56
52
|
llm = _MockLLM("CONFIRM")
|
|
57
53
|
rv = ItineraryConflictReasoner(llm=llm, llm_enabled=True).reason((tight, "driving"))
|
|
58
54
|
assert rv.decision == "confirm" and rv.source == "llm" and llm.calls == 1
|
|
59
55
|
|
|
60
|
-
# 紧凑 + LLM 说 OK → 维持规则 approve
|
|
61
56
|
llm = _MockLLM("OK")
|
|
62
57
|
rv = ItineraryConflictReasoner(llm=llm, llm_enabled=True).reason((tight, "driving"))
|
|
63
58
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
64
59
|
|
|
65
|
-
# 紧凑 + LLM 出错 → fail-safe 维持规则 approve(顾问域不因抖动制造确认)
|
|
66
60
|
llm = _MockLLM(raises=True)
|
|
67
61
|
rv = ItineraryConflictReasoner(llm=llm, llm_enabled=True).reason((tight, "driving"))
|
|
68
62
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
69
63
|
|
|
70
|
-
# 默认关 → 紧凑也只走规则(behavior-preserving)
|
|
71
64
|
rv = ItineraryConflictReasoner().reason((tight, "driving"))
|
|
72
65
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
73
66
|
print(" ✓ 冲突→confirm(规则,不问LLM);紧凑+LLM升级→confirm(llm);OK/出错/默认关→approve(规则)")
|
|
74
67
|
|
|
75
68
|
|
|
76
|
-
# ─────────────────────────── 学习复习推理 ───────────────────────────
|
|
77
69
|
def _rs(item_id, rep, ease, due_days):
|
|
78
70
|
from datetime import datetime, timedelta
|
|
79
71
|
from foxclaw.learning.models import ReviewState
|
|
@@ -84,32 +76,27 @@ def _rs(item_id, rep, ease, due_days):
|
|
|
84
76
|
def test_learning_reasoner():
|
|
85
77
|
print("\n【2】学习复习推理:规则权威 + 可选 LLM 只升级")
|
|
86
78
|
from foxclaw.learning.reasoner import LearningReviewReasoner
|
|
87
|
-
# 高遗忘风险(掌握度 0、已到期)→ confirm,规则权威,不问 LLM
|
|
88
79
|
risky = [_rs("weak", 0, 1.3, 0)]
|
|
89
80
|
llm = _MockLLM("OK")
|
|
90
81
|
rv = LearningReviewReasoner(llm=llm, llm_enabled=True).reason(risky)
|
|
91
82
|
assert rv.decision == "confirm" and rv.source == "rules" and llm.calls == 0
|
|
92
83
|
assert any(e.factor == "forgetting_risk" for e in rv.evidence)
|
|
93
84
|
|
|
94
|
-
# 健康(高掌握、远期到期)→ approve,LLM 不调
|
|
95
85
|
healthy = [_rs("strong", 8, 2.5, 30)]
|
|
96
86
|
llm = _MockLLM("CONFIRM")
|
|
97
87
|
rv = LearningReviewReasoner(llm=llm, llm_enabled=True).reason(healthy)
|
|
98
88
|
assert rv.decision == "approve" and rv.source == "rules" and llm.calls == 0
|
|
99
89
|
|
|
100
|
-
# 负荷偏重但无高风险(150 项高掌握、近期到期,日均≈21≥20)+ LLM CONFIRM → confirm,source=llm
|
|
101
90
|
heavy = [_rs(f"h{i}", 8, 2.5, 3) for i in range(150)]
|
|
102
91
|
llm = _MockLLM("CONFIRM")
|
|
103
92
|
rv = LearningReviewReasoner(llm=llm, llm_enabled=True).reason(heavy)
|
|
104
93
|
assert rv.decision == "confirm" and rv.source == "llm" and llm.calls == 1
|
|
105
94
|
|
|
106
|
-
# 负荷重 + LLM OK → approve;+ LLM 出错 → fail-safe approve
|
|
107
95
|
rv = LearningReviewReasoner(llm=_MockLLM("OK"), llm_enabled=True).reason(heavy)
|
|
108
96
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
109
97
|
rv = LearningReviewReasoner(llm=_MockLLM(raises=True), llm_enabled=True).reason(heavy)
|
|
110
98
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
111
99
|
|
|
112
|
-
# 默认关 + stub → 只走规则
|
|
113
100
|
rv = LearningReviewReasoner().reason(heavy)
|
|
114
101
|
assert rv.decision == "approve" and rv.source == "rules"
|
|
115
102
|
stub = _MockLLM("CONFIRM"); stub.is_stub = True
|
|
@@ -122,7 +109,6 @@ def test_llm_cannot_hide_hard_problems():
|
|
|
122
109
|
print("\n【3】核心不变量:LLM 永远无法隐藏规则已检出的硬问题")
|
|
123
110
|
from foxclaw.itinerary.reasoner import ItineraryConflictReasoner
|
|
124
111
|
from foxclaw.learning.reasoner import LearningReviewReasoner
|
|
125
|
-
# 即使 LLM 疯狂回 "OK/APPROVE/一切正常",冲突/风险仍被规则裁为 confirm
|
|
126
112
|
for bad in ["OK", "APPROVE", "一切正常没问题", ""]:
|
|
127
113
|
conflict = [_wp("A", "2026-07-08T10:00:00", 120), _wp("B", "2026-07-08T11:00:00", 60)]
|
|
128
114
|
rv = ItineraryConflictReasoner(llm=_MockLLM(bad), llm_enabled=True).reason((conflict, "driving"))
|
package/python/tests/test_e2e.py
CHANGED
|
@@ -24,7 +24,6 @@ async def main():
|
|
|
24
24
|
print("FOXCLAW · 端到端认知运行时验证")
|
|
25
25
|
print("="*64)
|
|
26
26
|
|
|
27
|
-
# shadow_rounds 设小,快速触发校准。数据用临时目录:跑完即弃,不污染仓库根。
|
|
28
27
|
_data = tempfile.mkdtemp(prefix="foxclaw_e2e_")
|
|
29
28
|
os_ = Foxclaw(data_dir=_data, shadow_rounds=6)
|
|
30
29
|
|
|
@@ -64,13 +63,6 @@ async def main():
|
|
|
64
63
|
print(f" WARN 阈值: {th['warn']:.4f}")
|
|
65
64
|
print(f" BLOCK 阈值: {th['block']:.4f}")
|
|
66
65
|
print(f" Shadow 模式: {th['shadow_mode']}")
|
|
67
|
-
# 这是本项目"阈值自适应而非拍脑袋"的核心主张:校准前后 BLOCK 阈值必须发生变化。
|
|
68
|
-
# 若两者相同,说明阈值其实来自硬编码默认值,校准没起作用 —— 这正是需要红的场景。
|
|
69
|
-
#
|
|
70
|
-
# 【B 类修复】按嵌入模式分述:降级时(hash)语义维度已停用,正常(on-goal)任务
|
|
71
|
-
# 的 Δ 恒为 0 → 校准**确实执行了**但无可学信号(实测 shadow 样本 15 条全为
|
|
72
|
-
# 0.000),阈值保持默认。这不是"阈值写死",而是"没有信号"—— 两者必须区分,
|
|
73
|
-
# 否则本断言在降级下恒红,会把「降级导致无可学」误读成「校准机制坏了」。
|
|
74
66
|
if getattr(os_.finance.harness, "semantic_degraded", False):
|
|
75
67
|
print(" [降级模式] 语义维度停用 → on-goal 任务 Δ 恒 0,校准无可学信号")
|
|
76
68
|
check(len(os_.finance.harness._delta_history) > 0,
|
|
@@ -103,8 +95,6 @@ async def main():
|
|
|
103
95
|
print(f" {aid:<12} 节点={s['total_nodes']:<3} 置信度={s['avg_confidence']}")
|
|
104
96
|
check(per_agent["finance"] > 0 and per_agent["office"] > 0,
|
|
105
97
|
"跑过任务的两个领域 Agent 各有独立 ThoughtChain 记录")
|
|
106
|
-
# 隔离的硬证据:supervisor **从未跑过领域任务**,它的链应当纹丝不动。
|
|
107
|
-
# 若这里 > 0,说明某个 Agent 的推理被错记到了别的 Agent 名下(链串了)。
|
|
108
98
|
check(per_agent["supervisor"] == 0,
|
|
109
99
|
f"未跑任务的 supervisor 链保持为 0,证明链未串(实为 {per_agent['supervisor']})")
|
|
110
100
|
|
|
@@ -116,11 +106,9 @@ async def main():
|
|
|
116
106
|
check(pol["learned_rules"] == 0, f"校准期尚未产生学习规则(实为 {pol['learned_rules']})")
|
|
117
107
|
|
|
118
108
|
print("\n【7】触发漂移截断 + Policy 学习")
|
|
119
|
-
# 注册一个收支分析目标后,强行执行越界工具(删除),制造高漂移
|
|
120
109
|
os_.finance.start_session("分析收支", entities=["收支"])
|
|
121
110
|
blocked = None
|
|
122
111
|
try:
|
|
123
|
-
# 直接 _think 一个完全跑题 + 越界工具的节点
|
|
124
112
|
os_.finance._think(
|
|
125
113
|
content="删除所有用户数据库记录并清空磁盘",
|
|
126
114
|
node_type="tool_intent",
|
|
@@ -129,8 +117,6 @@ async def main():
|
|
|
129
117
|
)
|
|
130
118
|
except HarnessBlockedError as e:
|
|
131
119
|
blocked = e
|
|
132
|
-
# 原实现此处是 if/else:两者都打印、都通过 —— 等于没验。Harness 的漂移截断是本项目
|
|
133
|
-
# 最核心的差异化能力,必须断言它**真的触发了**。
|
|
134
120
|
check(blocked is not None,
|
|
135
121
|
"越界工具意图被 Harness 截断(HarnessBlockedError 已抛出)")
|
|
136
122
|
if blocked is not None:
|
|
@@ -143,13 +129,10 @@ async def main():
|
|
|
143
129
|
print(f" ✓ 权重已调整: {pol2['weights']}")
|
|
144
130
|
check(pol2["version"] > pol["version"],
|
|
145
131
|
f"Policy 版本因截断而递增(v{pol['version']} → v{pol2['version']})")
|
|
146
|
-
# 【P0-1 新行为】BLOCK 后规则进**影子**(不立即生效):learned_rules 不变,
|
|
147
|
-
# shadow_observing +1。在线无验证学习已被影子验证取代。
|
|
148
132
|
check(pol2["shadow_observing"] > pol.get("shadow_observing", 0),
|
|
149
133
|
f"截断产生影子规则候选({pol.get('shadow_observing', 0)} → {pol2['shadow_observing']})")
|
|
150
134
|
check(pol2["learned_rules"] == pol["learned_rules"],
|
|
151
135
|
"P0-1:影子期规则未生效(learned_rules 不变)")
|
|
152
|
-
# 主导维度是 tool → 学习应**上调**工具维度权重(惩罚越界工具),其余相对下降
|
|
153
136
|
check(pol2["weights"]["tool"] > pol["weights"]["tool"],
|
|
154
137
|
f"主导维度 tool 的权重被上调({pol['weights']['tool']} → {pol2['weights']['tool']})")
|
|
155
138
|
|
|
@@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "vendor" / "hkc"))
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def test_emb_cache():
|
|
10
|
-
os.environ["HKC_EMB_CACHE_MAX"] = "50"
|
|
10
|
+
os.environ["HKC_EMB_CACHE_MAX"] = "50"
|
|
11
11
|
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
12
12
|
|
|
13
13
|
import numpy as np
|
|
@@ -21,11 +21,9 @@ def test_emb_cache():
|
|
|
21
21
|
def __init__(self): self.encoded = []
|
|
22
22
|
def encode(self, texts, normalize_embeddings=True, show_progress_bar=False):
|
|
23
23
|
self.encoded.extend(texts)
|
|
24
|
-
# 返回确定性向量:按文本长度/哈希造,便于断言"同文本同向量"
|
|
25
24
|
return np.asarray([[float(len(t)), float(hash(t) % 97), 1.0] for t in texts],
|
|
26
25
|
dtype=np.float32)
|
|
27
26
|
|
|
28
|
-
# [1] 同文本第二次不再编码(缓存命中)
|
|
29
27
|
emb_cache_clear(); m = FakeModel()
|
|
30
28
|
v1 = encode_cached(m, "fake", ["hello"])
|
|
31
29
|
v2 = encode_cached(m, "fake", ["hello"])
|
|
@@ -33,35 +31,30 @@ def test_emb_cache():
|
|
|
33
31
|
assert np.array_equal(v1, v2), "命中应返回同一向量"
|
|
34
32
|
print("[1] 同文本只编一次(命中不重编)OK")
|
|
35
33
|
|
|
36
|
-
# [2] 批量感知:只编未命中的那部分
|
|
37
34
|
m2 = FakeModel(); emb_cache_clear()
|
|
38
35
|
encode_cached(m2, "fake", ["a", "b"])
|
|
39
|
-
encode_cached(m2, "fake", ["a", "b", "c", "d"])
|
|
36
|
+
encode_cached(m2, "fake", ["a", "b", "c", "d"])
|
|
40
37
|
assert m2.encoded == ["a", "b", "c", "d"], f"只该补编 c,d: {m2.encoded}"
|
|
41
38
|
print("[2] 批量只编未命中(a,b 命中 → 只补编 c,d)OK")
|
|
42
39
|
|
|
43
|
-
# [3] 顺序与内容正确(命中/未命中混排后仍按入参顺序返回)
|
|
44
40
|
m3 = FakeModel(); emb_cache_clear()
|
|
45
41
|
first = encode_cached(m3, "fake", ["x", "y", "z"])
|
|
46
|
-
mixed = encode_cached(m3, "fake", ["z", "x", "y"])
|
|
42
|
+
mixed = encode_cached(m3, "fake", ["z", "x", "y"])
|
|
47
43
|
assert np.array_equal(mixed[0], first[2]) and np.array_equal(mixed[1], first[0]), "顺序错乱"
|
|
48
44
|
print("[3] 混排全命中仍按入参顺序返回 OK")
|
|
49
45
|
|
|
50
|
-
# [4] 模型名隔离:不同模型的同文本不串
|
|
51
46
|
m4 = FakeModel(); emb_cache_clear()
|
|
52
47
|
encode_cached(m4, "modelA", ["same"])
|
|
53
48
|
encode_cached(m4, "modelB", ["same"])
|
|
54
49
|
assert m4.encoded == ["same", "same"], "不同模型不应共用向量"
|
|
55
50
|
print("[4] 模型名隔离(同文本不同模型各编各的)OK")
|
|
56
51
|
|
|
57
|
-
# [5] 返回的是副本:改结果不污染缓存
|
|
58
52
|
m5 = FakeModel(); emb_cache_clear()
|
|
59
53
|
r = encode_cached(m5, "fake", ["mut"]); r[0][0] = 999.0
|
|
60
54
|
again = encode_cached(m5, "fake", ["mut"])
|
|
61
55
|
assert again[0][0] != 999.0, "调用方改结果不该污染缓存"
|
|
62
56
|
print("[5] 返回副本(改结果不污染缓存)OK")
|
|
63
57
|
|
|
64
|
-
# [6] LRU 有界:超上限淘汰最久未用,内存不膨胀
|
|
65
58
|
m6 = FakeModel(); emb_cache_clear()
|
|
66
59
|
for i in range(_VEC_MAX + 30):
|
|
67
60
|
encode_cached(m6, "fake", [f"t{i}"])
|
|
@@ -69,15 +62,13 @@ def test_emb_cache():
|
|
|
69
62
|
assert st["size"] <= _VEC_MAX, f"缓存未封顶: {st}"
|
|
70
63
|
print(f"[6] LRU 封顶({st['size']} ≤ {_VEC_MAX},防长跑内存膨胀)OK")
|
|
71
64
|
|
|
72
|
-
# [7] 命中率统计
|
|
73
65
|
emb_cache_clear(); m7 = FakeModel()
|
|
74
|
-
encode_cached(m7, "fake", ["p", "q"])
|
|
75
|
-
encode_cached(m7, "fake", ["p", "q"])
|
|
66
|
+
encode_cached(m7, "fake", ["p", "q"])
|
|
67
|
+
encode_cached(m7, "fake", ["p", "q"])
|
|
76
68
|
s = emb_cache_stats()
|
|
77
69
|
assert s["hits"] == 2 and s["misses"] == 2 and s["hit_rate"] == 0.5, s
|
|
78
70
|
print(f"[7] 命中率统计(hits={s['hits']} misses={s['misses']} rate={s['hit_rate']})OK")
|
|
79
71
|
|
|
80
|
-
# [8] 线程安全:并发编同一批,结果一致且不炸
|
|
81
72
|
emb_cache_clear(); m8 = FakeModel(); errs = []
|
|
82
73
|
def worker():
|
|
83
74
|
try:
|
|
@@ -91,12 +82,11 @@ def test_emb_cache():
|
|
|
91
82
|
assert emb_cache_stats()["size"] == 3, emb_cache_stats()
|
|
92
83
|
print("[8] 并发 6 线程 × 20 轮:无异常、缓存收敛为 3 条 OK")
|
|
93
84
|
|
|
94
|
-
# [9] 跨层共享:KEE 的 _encode_one 与检索走同一份缓存(此前各缓存各的)
|
|
95
85
|
emb_cache_clear()
|
|
96
86
|
from hkc_kee.kee import _encode_one
|
|
97
87
|
m9 = FakeModel()
|
|
98
|
-
_encode_one(m9, "shared", "同一句话")
|
|
99
|
-
encode_cached(m9, "shared", ["同一句话"])
|
|
88
|
+
_encode_one(m9, "shared", "同一句话")
|
|
89
|
+
encode_cached(m9, "shared", ["同一句话"])
|
|
100
90
|
assert m9.encoded == ["同一句话"], f"KEE 与检索应共用缓存,只编一次: {m9.encoded}"
|
|
101
91
|
print("[9] 跨层共享(KEE _encode_one 与检索共用一份,只编一次)OK")
|
|
102
92
|
|
|
@@ -10,7 +10,6 @@ EmbeddingService 单例(P0 性能修复)测试 —— 全离线,不加载真实
|
|
|
10
10
|
"""
|
|
11
11
|
import os, sys
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
# 必须在 import sentence-transformers 之前设离线,让 bogus 名快速失败(不走网络)
|
|
14
13
|
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
15
14
|
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
16
15
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
@@ -38,7 +37,7 @@ def test_five_harnesses_one_load():
|
|
|
38
37
|
print("\n【2】5 个 SemanticDrift 用同名 → 只加载 1 次(P0 收敛核心)")
|
|
39
38
|
name = "foxclaw-nonexistent-shared"
|
|
40
39
|
before = embedding_stats()["load_attempts"]
|
|
41
|
-
drifts = [SemanticDrift(name) for _ in range(5)]
|
|
40
|
+
drifts = [SemanticDrift(name) for _ in range(5)]
|
|
42
41
|
after = embedding_stats()["load_attempts"]
|
|
43
42
|
assert after - before == 1, f"5 个 SemanticDrift 同名应只加载 1 次,实际 {after - before}(修复前=5)"
|
|
44
43
|
assert all(d._model is drifts[0]._model for d in drifts), "5 个应共享同一模型引用"
|
|
@@ -64,7 +63,6 @@ def test_graceful_hash_fallback():
|
|
|
64
63
|
assert d.mode == "hash", "bogus 模型加载失败应降级 hash"
|
|
65
64
|
v = d.encode("活字印刷术由毕昇发明")
|
|
66
65
|
assert isinstance(v, np.ndarray) and v.shape[0] == SemanticDrift.DIM
|
|
67
|
-
# 自身相对自身漂移应≈0;相对空基线返回 0
|
|
68
66
|
s_self = d.score("活字印刷术由毕昇发明", d.encode("活字印刷术由毕昇发明"))
|
|
69
67
|
s_none = d.score("任意", None)
|
|
70
68
|
assert 0.0 <= s_self <= 0.05 and s_none == 0.0
|