foxclaw 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/CHANGELOG.md +57 -0
  6. package/python/CHANGELOG_CN.md +57 -0
  7. package/python/README.md +11 -2
  8. package/python/README_CN.md +32 -20
  9. package/python/bench/bm25_audit.py +1 -14
  10. package/python/bench/bm25_regression.py +0 -1
  11. package/python/bench/bm25_stress.py +0 -4
  12. package/python/bench/cd_audit.py +0 -23
  13. package/python/bench/chaos.py +6 -8
  14. package/python/bench/e2e_real_models.py +0 -1
  15. package/python/bench/llm_retry_test.py +2 -9
  16. package/python/bench/load_test.py +5 -8
  17. package/python/bench/vector_ivf_upgrade.py +1 -7
  18. package/python/docs/RELEASE.md +4 -3
  19. package/python/docs/RUN_CN.md +2 -2
  20. package/python/evals/governance/cases.py +0 -37
  21. package/python/evals/governance/run_evals.py +5 -37
  22. package/python/examples/governance_middleware_demo.py +1 -6
  23. package/python/examples/langgraph_governance_demo.py +7 -11
  24. package/python/foxclaw/__init__.py +34 -152
  25. package/python/foxclaw/agents/base.py +65 -136
  26. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  27. package/python/foxclaw/agents/impl/finance.py +4 -21
  28. package/python/foxclaw/agents/impl/office.py +7 -24
  29. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  30. package/python/foxclaw/agents/mixins/_context.py +0 -23
  31. package/python/foxclaw/agents/mixins/augment.py +10 -16
  32. package/python/foxclaw/agents/mixins/llm.py +7 -6
  33. package/python/foxclaw/agents/mixins/memory.py +7 -1
  34. package/python/foxclaw/agents/mixins/preference.py +58 -8
  35. package/python/foxclaw/agents/mixins/prompt.py +0 -6
  36. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  37. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  38. package/python/foxclaw/api/__init__.py +18 -77
  39. package/python/foxclaw/api/_state.py +43 -0
  40. package/python/foxclaw/api/{app.py → bootstrap.py} +5 -32
  41. package/python/foxclaw/api/middleware.py +5 -28
  42. package/python/foxclaw/api/routes/ahs.py +60 -73
  43. package/python/foxclaw/api/routes/attachments.py +5 -6
  44. package/python/foxclaw/api/routes/browser.py +7 -11
  45. package/python/foxclaw/api/routes/domain.py +18 -26
  46. package/python/foxclaw/api/routes/knowledge.py +3 -21
  47. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  48. package/python/foxclaw/api/routes/memory.py +28 -12
  49. package/python/foxclaw/api/routes/orchestration.py +3 -5
  50. package/python/foxclaw/api/routes/recovery.py +4 -3
  51. package/python/foxclaw/api/routes/review.py +2 -11
  52. package/python/foxclaw/api/routes/search.py +3 -7
  53. package/python/foxclaw/api/routes/system.py +33 -49
  54. package/python/foxclaw/api/routes/task.py +31 -49
  55. package/python/foxclaw/api/routes/ws.py +2 -6
  56. package/python/foxclaw/api/spine.py +1 -4
  57. package/python/foxclaw/browser/action/action_executor.py +1 -5
  58. package/python/foxclaw/browser/action/action_types.py +0 -1
  59. package/python/foxclaw/browser/action/url_policy.py +1 -5
  60. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  61. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  62. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  63. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  64. package/python/foxclaw/browser/service.py +7 -11
  65. package/python/foxclaw/capability/embedding_service.py +4 -6
  66. package/python/foxclaw/channels/__init__.py +9 -11
  67. package/python/foxclaw/channels/discord.py +4 -13
  68. package/python/foxclaw/channels/dispatcher.py +2 -27
  69. package/python/foxclaw/channels/feishu.py +10 -20
  70. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  71. package/python/foxclaw/channels/signing.py +9 -9
  72. package/python/foxclaw/channels/telegram.py +3 -4
  73. package/python/foxclaw/channels/weixin.py +7 -17
  74. package/python/foxclaw/cli.py +42 -13
  75. package/python/foxclaw/cognitive/reasoner.py +9 -10
  76. package/python/foxclaw/cognitive/state.py +18 -24
  77. package/python/foxclaw/core/audit_chain.py +1 -3
  78. package/python/foxclaw/core/audit_sink.py +3 -3
  79. package/python/foxclaw/core/compaction.py +8 -26
  80. package/python/foxclaw/core/config.py +2 -2
  81. package/python/foxclaw/core/confirm_nonce.py +6 -12
  82. package/python/foxclaw/core/envfile.py +2 -2
  83. package/python/foxclaw/core/failure_policy.py +17 -26
  84. package/python/foxclaw/core/gateway.py +6 -10
  85. package/python/foxclaw/core/governance_audit.py +3 -7
  86. package/python/foxclaw/core/idempotency.py +15 -22
  87. package/python/foxclaw/core/injection_guard.py +10 -29
  88. package/python/foxclaw/core/memory_adapter.py +30 -57
  89. package/python/foxclaw/core/metrics.py +4 -5
  90. package/python/foxclaw/core/model_router.py +36 -148
  91. package/python/foxclaw/core/paths.py +1 -1
  92. package/python/foxclaw/core/recovery.py +21 -34
  93. package/python/foxclaw/core/scheduler.py +23 -26
  94. package/python/foxclaw/core/secrets.py +0 -3
  95. package/python/foxclaw/core/sessions.py +4 -6
  96. package/python/foxclaw/core/settings.py +16 -32
  97. package/python/foxclaw/core/shared_write.py +11 -23
  98. package/python/foxclaw/core/steering_queue.py +12 -11
  99. package/python/foxclaw/core/system_bus.py +1 -1
  100. package/python/foxclaw/core/thought_chain.py +30 -32
  101. package/python/foxclaw/core/token_meter.py +9 -13
  102. package/python/foxclaw/core/tools.py +4 -12
  103. package/python/foxclaw/core/tracing.py +25 -40
  104. package/python/foxclaw/core/truth_pipeline.py +5 -9
  105. package/python/foxclaw/core/update_check.py +149 -0
  106. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  107. package/python/foxclaw/core/verify.py +6 -14
  108. package/python/foxclaw/core/web_search.py +4 -28
  109. package/python/foxclaw/finance/evidence.py +14 -18
  110. package/python/foxclaw/finance/fx.py +1 -3
  111. package/python/foxclaw/finance/graph.py +9 -18
  112. package/python/foxclaw/finance/models.py +20 -24
  113. package/python/foxclaw/finance/nlparse.py +12 -72
  114. package/python/foxclaw/finance/policy.py +8 -36
  115. package/python/foxclaw/finance/reasoner.py +3 -11
  116. package/python/foxclaw/finance/reasoning.py +13 -34
  117. package/python/foxclaw/finance/reconcile.py +5 -10
  118. package/python/foxclaw/finance/runtime.py +19 -46
  119. package/python/foxclaw/governance/middleware.py +13 -21
  120. package/python/foxclaw/harness/_base.py +2 -8
  121. package/python/foxclaw/harness/_policy.py +19 -38
  122. package/python/foxclaw/harness/drift.py +1 -13
  123. package/python/foxclaw/harness/event_bus.py +2 -5
  124. package/python/foxclaw/harness/harness.py +9 -97
  125. package/python/foxclaw/harness/loop_detection.py +11 -18
  126. package/python/foxclaw/itinerary/amap_client.py +2 -5
  127. package/python/foxclaw/itinerary/models.py +7 -7
  128. package/python/foxclaw/itinerary/reasoner.py +1 -6
  129. package/python/foxclaw/itinerary/reasoning.py +15 -21
  130. package/python/foxclaw/itinerary/runtime.py +8 -12
  131. package/python/foxclaw/kernel/capabilities.py +5 -6
  132. package/python/foxclaw/kernel/policy_gate.py +4 -10
  133. package/python/foxclaw/kernel/process_manager.py +17 -36
  134. package/python/foxclaw/kernel/scheduler.py +3 -9
  135. package/python/foxclaw/kernel/sentinel.py +13 -23
  136. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  137. package/python/foxclaw/kernel/spine.py +9 -9
  138. package/python/foxclaw/knowledge/__init__.py +4 -20
  139. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  140. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  141. package/python/foxclaw/learning/models.py +13 -13
  142. package/python/foxclaw/learning/reasoner.py +1 -4
  143. package/python/foxclaw/learning/runtime.py +19 -31
  144. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  145. package/python/foxclaw/mcp_server.py +2 -3
  146. package/python/foxclaw/orchestration/__init__.py +16 -33
  147. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  148. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  149. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  150. package/python/foxclaw/planning/llm_planner.py +3 -3
  151. package/python/foxclaw/planning/planner.py +6 -11
  152. package/python/foxclaw/search/agent_port.py +7 -11
  153. package/python/foxclaw/search/evidence.py +0 -4
  154. package/python/foxclaw/search/internal_search.py +3 -5
  155. package/python/foxclaw/search/models.py +11 -13
  156. package/python/foxclaw/search/planner.py +3 -3
  157. package/python/foxclaw/search/retriever.py +1 -1
  158. package/python/foxclaw/search/runtime.py +1 -8
  159. package/python/foxclaw/skills/__init__.py +46 -100
  160. package/python/foxclaw/skills/archive.py +111 -0
  161. package/python/foxclaw/skills/fetch.py +226 -0
  162. package/python/foxclaw/skills/hooks_spec.py +21 -25
  163. package/python/foxclaw/skills/install_cmd.py +216 -0
  164. package/python/foxclaw/skills/lifecycle.py +16 -42
  165. package/python/foxclaw/skills/markdown_skill.py +10 -9
  166. package/python/foxclaw/skills/mcp_client.py +5 -11
  167. package/python/foxclaw/skills/mcp_store.py +0 -3
  168. package/python/foxclaw/skills/permission.py +13 -48
  169. package/python/foxclaw/skills/pipeline.py +21 -53
  170. package/python/foxclaw/skills/policy.py +4 -10
  171. package/python/foxclaw/skills/review.py +3 -3
  172. package/python/frontend/deck/auth.js +0 -7
  173. package/python/frontend/deck/chat_model_picker.js +0 -54
  174. package/python/frontend/deck/domain.js +0 -20
  175. package/python/frontend/deck/main.js +0 -144
  176. package/python/frontend/deck/markdown.js +0 -4
  177. package/python/frontend/deck/settings.js +1 -18
  178. package/python/frontend/deck/state.js +0 -1
  179. package/python/frontend/deck/util.js +0 -2
  180. package/python/frontend/deck/visuals.js +0 -22
  181. package/python/frontend/planet/planet.js +0 -201
  182. package/python/frontend/v3/assets/index-CQSBdxk4.js +34 -0
  183. package/python/frontend/v3/assets/index-D5nIsjms.css +1 -0
  184. package/python/frontend/v3/index.html +3 -3
  185. package/python/frontend/widget_runtime.js +0 -43
  186. package/python/frontend/widgets.js +0 -57
  187. package/python/frontend_src/index.html +1 -1
  188. package/python/frontend_src/package-lock.json +2 -2
  189. package/python/frontend_src/package.json +1 -1
  190. package/python/frontend_src/src/App.vue +3 -7
  191. package/python/frontend_src/src/api.js +0 -7
  192. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  193. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  194. package/python/frontend_src/src/components/Inspector.vue +0 -6
  195. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  196. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  197. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  198. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  199. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  200. package/python/frontend_src/src/components/Stage.vue +71 -25
  201. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  202. package/python/frontend_src/src/components/TopBar.vue +38 -10
  203. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  204. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  205. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  206. package/python/frontend_src/src/components/panels/MemoryPanel.vue +0 -1
  207. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  208. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  209. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  210. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  211. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  212. package/python/frontend_src/src/lib/fmt.js +0 -4
  213. package/python/frontend_src/src/lib/i18n.js +53 -0
  214. package/python/frontend_src/src/lib/locales/en.js +173 -0
  215. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  216. package/python/frontend_src/src/lib/md.js +0 -14
  217. package/python/frontend_src/src/main.js +2 -0
  218. package/python/frontend_src/src/stores/app.js +17 -32
  219. package/python/frontend_src/src/stores/chat.js +0 -34
  220. package/python/frontend_src/src/stores/model.js +0 -8
  221. package/python/frontend_src/src/stores/panels.js +0 -41
  222. package/python/frontend_src/src/stores/profile.js +31 -0
  223. package/python/frontend_src/src/stores/system.js +25 -0
  224. package/python/frontend_src/vite.config.js +0 -7
  225. package/python/pyproject.toml +6 -3
  226. package/python/requirements.txt +311 -0
  227. package/python/tests/conftest.py +0 -22
  228. package/python/tests/run_all.py +3 -66
  229. package/python/tests/test_agent_lifecycle.py +4 -38
  230. package/python/tests/test_ahs_p2.py +4 -25
  231. package/python/tests/test_anthropic_tools.py +1 -15
  232. package/python/tests/test_api.py +19 -9
  233. package/python/tests/test_api_auth.py +1 -5
  234. package/python/tests/test_attachments.py +9 -25
  235. package/python/tests/test_audit_chain.py +2 -7
  236. package/python/tests/test_audit_sink.py +0 -6
  237. package/python/tests/test_browser.py +2 -6
  238. package/python/tests/test_browser_request_guard.py +0 -4
  239. package/python/tests/test_capabilities.py +0 -6
  240. package/python/tests/test_channel_signing.py +2 -9
  241. package/python/tests/test_channels.py +1 -11
  242. package/python/tests/test_config_layering.py +0 -4
  243. package/python/tests/test_confirm_nonce.py +1 -12
  244. package/python/tests/test_context_compaction_wiring.py +1 -7
  245. package/python/tests/test_dispatch.py +0 -6
  246. package/python/tests/test_domain_evals.py +11 -80
  247. package/python/tests/test_domain_import.py +0 -6
  248. package/python/tests/test_domain_reasoners.py +0 -14
  249. package/python/tests/test_e2e.py +0 -17
  250. package/python/tests/test_emb_cache.py +7 -17
  251. package/python/tests/test_embedding_singleton.py +1 -3
  252. package/python/tests/test_evals_smoke.py +0 -24
  253. package/python/tests/test_event_spine.py +10 -18
  254. package/python/tests/test_evidence_semantic.py +1 -14
  255. package/python/tests/test_evidence_sse.py +3 -6
  256. package/python/tests/test_file_ingest.py +0 -11
  257. package/python/tests/test_finance.py +0 -9
  258. package/python/tests/test_finance_ledger.py +5 -12
  259. package/python/tests/test_finance_persist.py +8 -12
  260. package/python/tests/test_gateway.py +3 -22
  261. package/python/tests/test_governance.py +5 -52
  262. package/python/tests/test_governance_audit.py +4 -39
  263. package/python/tests/test_governance_middleware.py +1 -7
  264. package/python/tests/test_hkc.py +6 -24
  265. package/python/tests/test_hkc_index_build_manager.py +0 -33
  266. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  267. package/python/tests/test_hkc_search_event_sync.py +3 -54
  268. package/python/tests/test_import_path_guard.py +0 -6
  269. package/python/tests/test_injection_guard.py +18 -36
  270. package/python/tests/test_integration.py +5 -29
  271. package/python/tests/test_itinerary.py +0 -3
  272. package/python/tests/test_kee_semantic.py +0 -5
  273. package/python/tests/test_kernel.py +1 -11
  274. package/python/tests/test_learning.py +3 -12
  275. package/python/tests/test_llm_admin.py +4 -19
  276. package/python/tests/test_llm_planner.py +1 -23
  277. package/python/tests/test_markdown_skills.py +3 -14
  278. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  279. package/python/tests/test_mcp_connectors.py +3 -15
  280. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  281. package/python/tests/test_mcp_no_memory.py +3 -3
  282. package/python/tests/test_mcp_policy_gate.py +0 -1
  283. package/python/tests/test_mcp_server.py +6 -9
  284. package/python/tests/test_memory_recall.py +1 -4
  285. package/python/tests/test_metrics.py +0 -8
  286. package/python/tests/test_multi_chain.py +1 -13
  287. package/python/tests/test_multimodal.py +5 -18
  288. package/python/tests/test_negotiation.py +3 -13
  289. package/python/tests/test_orchestration.py +1 -11
  290. package/python/tests/test_orchestration_decoupled.py +0 -6
  291. package/python/tests/test_p3_p5_hardening.py +3 -17
  292. package/python/tests/test_parallel_dispatch.py +4 -12
  293. package/python/tests/test_pipeline.py +17 -58
  294. package/python/tests/test_plan_visible.py +4 -4
  295. package/python/tests/test_planner.py +0 -7
  296. package/python/tests/test_policy_gate.py +2 -3
  297. package/python/tests/test_profile.py +67 -0
  298. package/python/tests/test_readme_claims.py +163 -0
  299. package/python/tests/test_reasoner.py +2 -17
  300. package/python/tests/test_release_check.py +2 -13
  301. package/python/tests/test_resume_checkpoint.py +7 -15
  302. package/python/tests/test_routing.py +2 -12
  303. package/python/tests/test_scheduler.py +7 -10
  304. package/python/tests/test_search.py +1 -11
  305. package/python/tests/test_secrets.py +4 -10
  306. package/python/tests/test_semantic.py +1 -10
  307. package/python/tests/test_sessions.py +3 -4
  308. package/python/tests/test_settings.py +2 -18
  309. package/python/tests/test_shadow_learning.py +3 -43
  310. package/python/tests/test_shared_write.py +0 -7
  311. package/python/tests/test_skill_taint_gate.py +1 -10
  312. package/python/tests/test_skills.py +2 -18
  313. package/python/tests/test_skills_fetch.py +282 -0
  314. package/python/tests/test_stats_and_events.py +2 -5
  315. package/python/tests/test_streaming.py +2 -7
  316. package/python/tests/test_supervisor_pipeline.py +4 -5
  317. package/python/tests/test_supervisor_triage.py +4 -10
  318. package/python/tests/test_taint.py +2 -7
  319. package/python/tests/test_task_stream.py +1 -12
  320. package/python/tests/test_thinking_sse.py +0 -1
  321. package/python/tests/test_tool_center.py +1 -17
  322. package/python/tests/test_tool_parallel.py +2 -2
  323. package/python/tests/test_tool_params.py +1 -5
  324. package/python/tests/test_tool_use.py +0 -13
  325. package/python/tests/test_toplevel_concurrency.py +3 -4
  326. package/python/tests/test_tracing.py +7 -41
  327. package/python/tests/test_truth_crystallizer.py +4 -6
  328. package/python/tests/test_truth_flow.py +2 -7
  329. package/python/tests/test_update_check.py +95 -0
  330. package/python/tests/test_verify_evidence.py +8 -21
  331. package/python/tests/test_version_sync.py +35 -6
  332. package/python/tests/test_web_trigger.py +0 -17
  333. package/python/tools/check_faiss_type.py +0 -1
  334. package/python/tools/check_log.ps1 +0 -8
  335. package/python/tools/mypy_baseline.txt +12 -4
  336. package/python/tools/mypy_ratchet.py +52 -6
  337. package/python/tools/pack.py +0 -5
  338. package/python/tools/release_check.py +7 -7
  339. package/python/tools/restart_foxclaw.ps1 +5 -8
  340. package/python/tools/shared_write_audit.py +1 -15
  341. package/python/tools/size_check.py +1 -1
  342. package/python/tools/strip_comments.py +228 -0
  343. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  344. 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
- # [12] 校准随消费水平伸缩 —— 这正是固定 500 做不到的事
237
- rich = FR.calibrate_thresholds(_real(scale=2.0)) # 消费翻倍的人
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
- # [15]【最重要】校准**绝不踩人的决定** —— 否则用户会发现自己改的设置莫名变回去,
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() # (b) builtin 但用户改过 yaml 阈值
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() # (c) 我校准过,之后用户又手改
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): # 落在 2026-07 的一笔支出
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) # 下次在约 20 天后
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() # 用户想提前 25 天提醒
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"))
@@ -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" # 调小以便测 LRU 淘汰(须在 import 前设)
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"]) # 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"]) # 2 miss
75
- encode_cached(m7, "fake", ["p", "q"]) # 2 hit
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", "同一句话") # KEE 侧编码
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)] # 模拟 5 个 Agent 的 Harness
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