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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""新版本检查:默认关、版本比较、缓存 TTL、失败静默。
|
|
2
|
+
|
|
3
|
+
全部离线:``_fetch_latest`` 一律用 monkeypatch 替换,不发真实网络请求。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from foxclaw.core import update_check as uc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _reset() -> None:
|
|
14
|
+
uc._inflight = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_default_disabled(monkeypatch, tmp_path):
|
|
18
|
+
"""不设 FOXCLAW_UPDATE_CHECK → 不查、不写缓存、不起线程。"""
|
|
19
|
+
_reset()
|
|
20
|
+
monkeypatch.delenv("FOXCLAW_UPDATE_CHECK", raising=False)
|
|
21
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
22
|
+
|
|
23
|
+
assert uc.status()["enabled"] is False
|
|
24
|
+
uc.maybe_refresh()
|
|
25
|
+
assert uc._inflight is False
|
|
26
|
+
assert not (tmp_path / "update_check.json").exists()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_version_compare():
|
|
30
|
+
assert uc.parse_version("0.1.10") == (0, 1, 10)
|
|
31
|
+
assert uc.parse_version("0.2.0rc1") == (0, 2, 0)
|
|
32
|
+
assert uc.parse_version("") == ()
|
|
33
|
+
assert uc.is_newer("0.1.10", "0.1.2") is True
|
|
34
|
+
assert uc.is_newer("0.1.2", "0.1.2") is False
|
|
35
|
+
assert uc.is_newer("0.1.2", "0.1.10") is False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_refresh_writes_cache(monkeypatch, tmp_path):
|
|
39
|
+
_reset()
|
|
40
|
+
monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
|
|
41
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
42
|
+
monkeypatch.setattr(uc, "_fetch_latest", lambda: "9.9.9")
|
|
43
|
+
|
|
44
|
+
uc._refresh()
|
|
45
|
+
|
|
46
|
+
st = uc.status()
|
|
47
|
+
assert st["latest"] == "9.9.9"
|
|
48
|
+
assert st["update_available"] is True
|
|
49
|
+
assert st["error"] is None
|
|
50
|
+
assert (tmp_path / "update_check.json").exists()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_fresh_cache_skips_refresh(monkeypatch, tmp_path):
|
|
54
|
+
"""24h 内不重复查 PyPI。"""
|
|
55
|
+
_reset()
|
|
56
|
+
monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
|
|
57
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
58
|
+
uc._write_cache({"latest": "9.9.9", "checked_at": time.time(), "current": "0.1.2"})
|
|
59
|
+
|
|
60
|
+
calls: list[int] = []
|
|
61
|
+
monkeypatch.setattr(uc, "_fetch_latest", lambda: calls.append(1) or "9.9.9")
|
|
62
|
+
uc.maybe_refresh()
|
|
63
|
+
time.sleep(0.05)
|
|
64
|
+
|
|
65
|
+
assert calls == []
|
|
66
|
+
assert uc._inflight is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_failure_is_silent(monkeypatch, tmp_path):
|
|
70
|
+
"""网络失败不抛异常,只记 error;update_available 仍为 False。"""
|
|
71
|
+
_reset()
|
|
72
|
+
monkeypatch.setenv("FOXCLAW_UPDATE_CHECK", "1")
|
|
73
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
74
|
+
|
|
75
|
+
def boom():
|
|
76
|
+
raise OSError("offline")
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(uc, "_fetch_latest", boom)
|
|
79
|
+
uc._refresh()
|
|
80
|
+
|
|
81
|
+
st = uc.status()
|
|
82
|
+
assert st["update_available"] is False
|
|
83
|
+
assert "offline" in (st["error"] or "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_disabled_never_reports_update(monkeypatch, tmp_path):
|
|
87
|
+
"""缓存里就算有更新的版本,没启用也不提示(默认不外呼,也不拿旧缓存骚扰)。"""
|
|
88
|
+
_reset()
|
|
89
|
+
monkeypatch.delenv("FOXCLAW_UPDATE_CHECK", raising=False)
|
|
90
|
+
monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
|
|
91
|
+
uc._write_cache({"latest": "99.0.0", "checked_at": time.time(), "current": "0.1.2"})
|
|
92
|
+
|
|
93
|
+
st = uc.status()
|
|
94
|
+
assert st["enabled"] is False
|
|
95
|
+
assert st["update_available"] is False
|
|
@@ -38,8 +38,6 @@ def _agent():
|
|
|
38
38
|
return a
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
# ── ① 失败策略表 ─────────────────────────────────────────────
|
|
42
|
-
|
|
43
41
|
@pytest.mark.parametrize("text,kind", [
|
|
44
42
|
(TIMEOUT_TEXT, policy.TIMEOUT),
|
|
45
43
|
("(工具 shell 已被策略禁用)", policy.DENIED),
|
|
@@ -47,8 +45,8 @@ def _agent():
|
|
|
47
45
|
("(入参不合法:缺少必填 query)", policy.INVALID_ARGS),
|
|
48
46
|
("(联网搜索未返回结果)", policy.EMPTY),
|
|
49
47
|
("(工具 web_search 执行异常:boom)", policy.EXCEPTION),
|
|
50
|
-
("中国未来十年的产业趋势如何", policy.OK),
|
|
51
|
-
("", policy.UNKNOWN),
|
|
48
|
+
("中国未来十年的产业趋势如何", policy.OK),
|
|
49
|
+
("", policy.UNKNOWN),
|
|
52
50
|
("一切正常", policy.OK),
|
|
53
51
|
])
|
|
54
52
|
def test_classify(text, kind) -> None:
|
|
@@ -58,10 +56,8 @@ def test_classify(text, kind) -> None:
|
|
|
58
56
|
def test_retry_only_for_read_only() -> None:
|
|
59
57
|
read = policy.decide(TIMEOUT_TEXT, capability={"read_only": True}, attempt=1)
|
|
60
58
|
assert read.action == policy.RETRY
|
|
61
|
-
# 第二次还失败 → 不再重试,交人
|
|
62
59
|
assert policy.decide(TIMEOUT_TEXT, capability={"read_only": True}, attempt=2).action \
|
|
63
60
|
== policy.HAND_TO_HUMAN
|
|
64
|
-
# 有副作用:第一次就不许自动重试
|
|
65
61
|
write = policy.decide(TIMEOUT_TEXT, capability={"read_only": False}, attempt=1)
|
|
66
62
|
assert write.action == policy.HAND_TO_HUMAN
|
|
67
63
|
assert "重复执行" in write.hint
|
|
@@ -70,7 +66,6 @@ def test_retry_only_for_read_only() -> None:
|
|
|
70
66
|
def test_decide_never_optimistic() -> None:
|
|
71
67
|
assert policy.decide("(联网搜索未配置)").action == policy.DEGRADE
|
|
72
68
|
assert policy.decide("(工具 x 已被策略禁用)").action == policy.STOP
|
|
73
|
-
# 形如失败但聊不归类 → 保守交人,不乐观当成功
|
|
74
69
|
assert policy.decide("(看不懂的回注").kind == policy.UNKNOWN
|
|
75
70
|
assert policy.decide("(看不懂的回注").action == policy.HAND_TO_HUMAN
|
|
76
71
|
|
|
@@ -84,8 +79,6 @@ def test_annotate_leaves_success_untouched() -> None:
|
|
|
84
79
|
assert "[执行策略]" in out
|
|
85
80
|
|
|
86
81
|
|
|
87
|
-
# ── ② 执行后核验 ─────────────────────────────────────────────
|
|
88
|
-
|
|
89
82
|
def test_failure_is_failed_not_verified() -> None:
|
|
90
83
|
v = vf.verify("web_search", {}, TIMEOUT_TEXT, {"read_only": True})
|
|
91
84
|
assert v.status == vf.FAILED
|
|
@@ -110,7 +103,6 @@ def test_side_effect_without_postcondition_is_unverified() -> None:
|
|
|
110
103
|
v = vf.verify("send_mail", {}, "已发送", {"read_only": False})
|
|
111
104
|
assert v.status == vf.UNVERIFIED
|
|
112
105
|
assert "后置条件" in v.reason
|
|
113
|
-
# 只读工具拿到内容就算这一步发生过(内容可信度归污点层)
|
|
114
106
|
assert vf.verify("recall_memory", {}, "【召回的相关记忆】\n- a", {"read_only": True}).status \
|
|
115
107
|
== vf.VERIFIED
|
|
116
108
|
|
|
@@ -144,14 +136,12 @@ def test_note_for_model() -> None:
|
|
|
144
136
|
ok = vf.Verification(vf.VERIFIED)
|
|
145
137
|
assert vf.note_for_model(ok) == ""
|
|
146
138
|
un_read = vf.Verification(vf.UNVERIFIED, "无来源")
|
|
147
|
-
assert vf.note_for_model(un_read, read_only=True) == ""
|
|
139
|
+
assert vf.note_for_model(un_read, read_only=True) == ""
|
|
148
140
|
assert "不要断言" in vf.note_for_model(un_read, read_only=False)
|
|
149
141
|
bad = vf.Verification(vf.FAILED, "失败类型:timeout")
|
|
150
142
|
assert "不要向用户声称" in vf.note_for_model(bad)
|
|
151
143
|
|
|
152
144
|
|
|
153
|
-
# ── ③ 接进工具轮 ─────────────────────────────────────────────
|
|
154
|
-
|
|
155
145
|
def test_retries_read_only_once_then_gives_up(monkeypatch) -> None:
|
|
156
146
|
agent = _agent()
|
|
157
147
|
batches: list = []
|
|
@@ -164,9 +154,9 @@ def test_retries_read_only_once_then_gives_up(monkeypatch) -> None:
|
|
|
164
154
|
monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": True})
|
|
165
155
|
out = tool_exec._run_tool_calls(agent, [{"name": "web_search", "args": {"q": "x"}}])
|
|
166
156
|
|
|
167
|
-
assert len(batches) == 2
|
|
157
|
+
assert len(batches) == 2
|
|
168
158
|
assert "[执行策略]" in out[0] and "不要" in out[0]
|
|
169
|
-
assert agent._recovery.steps == []
|
|
159
|
+
assert agent._recovery.steps == []
|
|
170
160
|
ev = agent._evidence_list
|
|
171
161
|
assert ev and ev[0]["status"] == vf.FAILED
|
|
172
162
|
|
|
@@ -178,7 +168,7 @@ def test_side_effect_failure_is_not_retried(monkeypatch) -> None:
|
|
|
178
168
|
lambda a, calls: (batches.append(len(calls)), [TIMEOUT_TEXT] * len(calls))[1])
|
|
179
169
|
monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": False})
|
|
180
170
|
out = tool_exec._run_tool_calls(agent, [{"name": "send_mail", "args": {}}])
|
|
181
|
-
assert batches == [1]
|
|
171
|
+
assert batches == [1]
|
|
182
172
|
assert "重复执行" in out[0]
|
|
183
173
|
|
|
184
174
|
|
|
@@ -189,8 +179,8 @@ def test_success_text_untouched_and_evidence_recorded(monkeypatch) -> None:
|
|
|
189
179
|
monkeypatch.setattr(core_tools, "tool_capability", lambda n: {"read_only": True})
|
|
190
180
|
out = tool_exec._run_tool_calls(agent, [{"name": "web_search", "args": {"q": "x"}}])
|
|
191
181
|
|
|
192
|
-
assert out[0] == "【联网搜索结果】结论 https://example.com/a"
|
|
193
|
-
assert len(agent._recovery.steps) == 1
|
|
182
|
+
assert out[0] == "【联网搜索结果】结论 https://example.com/a"
|
|
183
|
+
assert len(agent._recovery.steps) == 1
|
|
194
184
|
ev = agent._evidence_list[0]
|
|
195
185
|
assert ev["status"] == vf.VERIFIED
|
|
196
186
|
assert ev["evidence"][0]["ref"] == "https://example.com/a"
|
|
@@ -206,8 +196,6 @@ def test_unverified_side_effect_tells_model_not_to_claim_done(monkeypatch) -> No
|
|
|
206
196
|
assert agent._evidence_list[0]["status"] == vf.UNVERIFIED
|
|
207
197
|
|
|
208
198
|
|
|
209
|
-
# ── ④ 证据不串台 ─────────────────────────────────────────────
|
|
210
|
-
|
|
211
199
|
def test_evidence_is_per_agent() -> None:
|
|
212
200
|
from foxclaw.agents.mixins import _context
|
|
213
201
|
|
|
@@ -216,7 +204,6 @@ def test_evidence_is_per_agent() -> None:
|
|
|
216
204
|
_context._evidence_reset(b)
|
|
217
205
|
_context._evidence_add(a, {"tool": "x", "status": "verified"})
|
|
218
206
|
assert _context._evidence_take(a) and not _context._evidence_take(b)
|
|
219
|
-
# take 之后清空:下一轮不会重复上报上一轮的证据
|
|
220
207
|
assert _context._evidence_take(a) == []
|
|
221
208
|
|
|
222
209
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"""版本号单一来源校验。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
版本号目前硬编码在五个地方,任何一处漏改都会造成「本地是 0.2.0、npm 上
|
|
4
4
|
还是 0.1.0」这类对外不一致(`npm/index.js` 的 version 是发布物里用户
|
|
5
5
|
`import` 直接读到的值,最容易漏):
|
|
6
6
|
|
|
7
|
-
- ``pyproject.toml`` 的 ``project.version``
|
|
8
|
-
- ``foxclaw/__init__.py`` 的 ``__version__``
|
|
9
|
-
- ``npm/package.json`` 的 ``version``
|
|
10
|
-
- ``npm/index.js`` 的 ``export const version``
|
|
7
|
+
- ``pyproject.toml`` 的 ``project.version`` —— PyPI 包版本
|
|
8
|
+
- ``foxclaw/__init__.py`` 的 ``__version__`` —— 运行时 ``foxclaw --version``
|
|
9
|
+
- ``npm/package.json`` 的 ``version`` —— npm 包版本
|
|
10
|
+
- ``npm/index.js`` 的 ``export const version`` —— npm 包对外导出的版本
|
|
11
|
+
- ``frontend_src/package.json`` 的 ``version`` —— 前端构建工程版本
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
本测试只做「五处必须相等」,不校验具体数值,发版时改哪一处都不用动它。
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
from __future__ import annotations
|
|
@@ -57,6 +58,17 @@ def test_npm_index_export_version_agrees() -> None:
|
|
|
57
58
|
assert match.group(1) == _npm_package()["version"]
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def test_frontend_package_version_agrees() -> None:
|
|
62
|
+
"""frontend_src/package.json 与 PyPI 侧版本一致。
|
|
63
|
+
|
|
64
|
+
这个工程是 ``private`` 的构建工程,不发布,版本号曾长期停在 3.0.0
|
|
65
|
+
(Vue3 渲染层代号),与后端 0.1.2 对不上,容易误读成产品版本。
|
|
66
|
+
"""
|
|
67
|
+
data = json.loads(
|
|
68
|
+
(REPO_ROOT / "frontend_src" / "package.json").read_text(encoding="utf-8"))
|
|
69
|
+
assert data["version"] == _project_version()
|
|
70
|
+
|
|
71
|
+
|
|
60
72
|
def test_package_names_are_foxclaw() -> None:
|
|
61
73
|
"""PyPI 与 npm 两侧包名必须同名,且为 foxclaw。"""
|
|
62
74
|
assert _project_name() == "foxclaw"
|
|
@@ -69,3 +81,20 @@ def test_npm_index_export_name_agrees() -> None:
|
|
|
69
81
|
match = re.search(r'export\s+const\s+name\s*=\s*["\']([^"\']+)["\']', source)
|
|
70
82
|
assert match, "npm/index.js 里找不到 `export const name = \"...\"`"
|
|
71
83
|
assert match.group(1) == _npm_package()["name"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_frontend_does_not_hardcode_version() -> None:
|
|
87
|
+
"""顶部栏版本号必须来自后端,不能在 Vue 里写死。
|
|
88
|
+
|
|
89
|
+
2026-11-10:左上角硬编码 ``v3.0``,而后端已发到 0.1.2,用户看到的版本
|
|
90
|
+
与仓库/PyPI 对不上。取值链路是 ``stores/system.js`` 请求
|
|
91
|
+
``/api/system/info``,TopBar 只读 ``system.version``。
|
|
92
|
+
"""
|
|
93
|
+
topbar = (REPO_ROOT / "frontend_src" / "src" / "components" / "TopBar.vue").read_text(
|
|
94
|
+
encoding="utf-8")
|
|
95
|
+
assert not re.search(r"\bv\d+\.\d+", topbar), "TopBar.vue 里出现了硬编码版本号"
|
|
96
|
+
assert "system.version" in topbar, "TopBar.vue 应从 system store 读 version"
|
|
97
|
+
|
|
98
|
+
store = (REPO_ROOT / "frontend_src" / "src" / "stores" / "system.js").read_text(
|
|
99
|
+
encoding="utf-8")
|
|
100
|
+
assert "/system/info" in store, "system store 应从 /api/system/info 取 version"
|
|
@@ -11,8 +11,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
13
|
def test_web_trigger():
|
|
14
|
-
# ISOLATION_GUARD:本用例依赖 import 时固化的模块级单例(网关/限流器/路由配置按 env 在 import 时定型),单进程全量跑会被前序用例污染 →
|
|
15
|
-
# 假红。全量请用 tests/run_all.py(每文件独立子进程)。
|
|
16
14
|
if "foxclaw.core.web_search" in sys.modules:
|
|
17
15
|
pytest.skip("需独立进程运行(模块级单例已固化),请用 tests/run_all.py")
|
|
18
16
|
from foxclaw.core.web_search import (needs_search, TOPIC_TRIGGERS,
|
|
@@ -20,51 +18,39 @@ def test_web_trigger():
|
|
|
20
18
|
|
|
21
19
|
print("=" * 60); print("联网触发判定验证"); print("=" * 60)
|
|
22
20
|
|
|
23
|
-
# [1] 个人数据 → 绝不联网(答案在本地领域内核里,不在网上)
|
|
24
21
|
personal = ["分析一下本月支出", "本月餐饮花了多少", "今天的行程安排",
|
|
25
22
|
"本周要复习什么知识点", "账户余额还剩多少", "这个月消费记录"]
|
|
26
23
|
for q in personal:
|
|
27
24
|
assert needs_search(q) is False, f"个人数据不该联网: {q}"
|
|
28
25
|
print(f"[1] 个人数据不联网({len(personal)} 例,含「本月支出」这类原 bug 用例)OK")
|
|
29
26
|
|
|
30
|
-
# [2] 个人数据 + 话题词 → 仍不联网(否决优先级最高)
|
|
31
|
-
# 「本月支出多少钱」含话题词「多少钱」,但问的是自己的账 → 必须否决
|
|
32
27
|
assert needs_search("本月支出多少钱") is False, "个人数据否决应压过话题触发"
|
|
33
28
|
assert needs_search("我的预算还剩多少钱") is False
|
|
34
29
|
print("[2] 个人数据否决压过话题触发(「本月支出多少钱」不联网)OK")
|
|
35
30
|
|
|
36
|
-
# [3] 外部世界话题 → 正常联网(不能因为修 bug 把真需求也砍了)
|
|
37
31
|
external = ["今天天气怎么样", "最新的AI新闻", "iPhone现在多少钱",
|
|
38
32
|
"特斯拉股价", "python最新版本", "美元汇率"]
|
|
39
33
|
for q in external:
|
|
40
34
|
assert needs_search(q) is True, f"外部话题应联网: {q}"
|
|
41
35
|
print(f"[3] 外部话题正常联网({len(external)} 例)OK")
|
|
42
36
|
|
|
43
|
-
# [4] 「我的」不算个人标记 —— 「我的股票现在多少钱」是真要联网的
|
|
44
37
|
assert needs_search("我的股票现在多少钱") is True, "「我的」过宽会误杀真实联网需求"
|
|
45
38
|
print("[4] 「我的股票现在多少钱」仍联网(个人标记不能放「我的」这类过宽词)OK")
|
|
46
39
|
|
|
47
|
-
# [5] 纯时间词不触发(本 bug 的核心)
|
|
48
40
|
for q in ["现在几点", "最近怎么样", "今天", "本月"]:
|
|
49
41
|
assert needs_search(q) is False, f"纯时间词不该单独触发: {q}"
|
|
50
42
|
assert not (set(TIME_WORDS) & set(TOPIC_TRIGGERS)), "时间词不得混入话题触发表(否则 bug 复发)"
|
|
51
43
|
print(f"[5] 纯时间词不单独触发({len(TIME_WORDS)} 个时间词与话题表零交集)OK")
|
|
52
44
|
|
|
53
|
-
# [6] 闲聊/编码 → 不联网
|
|
54
45
|
for q in ["你好", "帮我写个函数", ""]:
|
|
55
46
|
assert needs_search(q) is False, q
|
|
56
47
|
print("[6] 闲聊/编码/空串 不联网 OK")
|
|
57
48
|
|
|
58
|
-
# [7] 英文词边界仍生效(旧修复不能退化:now⊄knowledge, current⊄concurrent)
|
|
59
49
|
assert needs_search("knowledge base") is False, "now 不该子串命中 knowledge"
|
|
60
50
|
assert needs_search("concurrent tasks") is False, "current 不该子串命中 concurrent"
|
|
61
51
|
assert needs_search("latest news") is True
|
|
62
52
|
print("[7] 英文词边界不退化(knowledge/concurrent 不误命中)OK")
|
|
63
53
|
|
|
64
|
-
# [8] 【防复发·跨路径】个人数据保护必须是**单一真源**,所有"要不要发到外部"的路径都得遵守。
|
|
65
|
-
# 真实教训:这条保护原先只写在 needs_search 里;技能派发默认开启后,
|
|
66
|
-
# 「查一下最新的本月支出」经**技能路由**照样把私人财务问题发去 external 的 web_search
|
|
67
|
-
# —— 同一个 bug 从没设防的旁路回来了,还多了数据外泄。
|
|
68
54
|
from foxclaw.core.web_search import is_personal_query
|
|
69
55
|
from foxclaw.pipeline import stage_route
|
|
70
56
|
from foxclaw.pipeline.cognitive_pipeline import PipelineContext
|
|
@@ -81,14 +67,11 @@ def test_web_trigger():
|
|
|
81
67
|
stage_route(ctx)
|
|
82
68
|
return ctx.route["target"], ctx.route.get("skill_id")
|
|
83
69
|
|
|
84
|
-
# 私人数据 + 足够高的置信 → 仍不得路由到 **external** 技能(否则就是外泄)
|
|
85
70
|
for q in ["查一下最新的本月支出", "查一下最新的预算结余", "今天的行程 最新 查一下"]:
|
|
86
71
|
t, sid = _route_target(q)
|
|
87
72
|
assert t == "self", f"私人数据不得自动发给外部技能: {q} → {t}/{sid}"
|
|
88
|
-
# 但真实的外部问题照常走技能(不能因为修 bug 把功能砍了)
|
|
89
73
|
t, sid = _route_target("查一下最新新闻")
|
|
90
74
|
assert (t, sid) == ("skill", "web_search"), (t, sid)
|
|
91
|
-
# 本地只读技能不受影响
|
|
92
75
|
t, sid = _route_target("帮我计算 (12+8)*5")
|
|
93
76
|
assert (t, sid) == ("skill", "calculator"), (t, sid)
|
|
94
77
|
print("[8] 跨路径防复发:技能路由也遵守个人数据保护(不外泄)+ 不误伤真实外部需求 OK")
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""把外部敏感词库(如 houbb/sensitive-word)转成 ContentGuard 可挂载的词表格式。
|
|
3
|
+
|
|
4
|
+
为什么要转换而不是直接挂
|
|
5
|
+
──────────────────────
|
|
6
|
+
`ContentGuard` 载入时会对每个词做一次 `compact()`(去空白标点、全半角归一、小写),
|
|
7
|
+
但那只保证「扫描时能对上」,不保证词表本身干净:同一批词里 `敏感词`、`敏 感 词`、
|
|
8
|
+
`SENSITIVE` 会各占一条,白白撑大自动机。转换期先统一形态,词表体积和重复率
|
|
9
|
+
都能压下来。
|
|
10
|
+
|
|
11
|
+
输出格式就是 `FOXCLAW_CONTENT_LEXICON` 认的那一种:一行一条 `词<TAB>类别`,`#` 开头为
|
|
12
|
+
注释。类别会原样出现在命中结果里,便于审计归类。
|
|
13
|
+
|
|
14
|
+
词库形态
|
|
15
|
+
────────
|
|
16
|
+
两种都认,不用改文件:
|
|
17
|
+
* 单文件纯词列表(houbb 主词库):文件名不带中文时用 `--category`(默认 platform)。
|
|
18
|
+
* 一个文件一个类别(`政治类.txt` / `色情类.txt` / ...):中文文件名直接当类别名。
|
|
19
|
+
|
|
20
|
+
哪些类别挂上去由你决定。脚本默认照单全收 —— 排除政治 / 色情 / 暴力这些大类是部署决策,
|
|
21
|
+
不该由转换工具替你拍板。用 `--exclude` / `--include`(支持 `通配*`)自己取舍。
|
|
22
|
+
|
|
23
|
+
用法
|
|
24
|
+
────
|
|
25
|
+
# 主词库,单一类别
|
|
26
|
+
python tools/build_content_lexicon.py ../houbb/sensitive_word_dict.txt
|
|
27
|
+
|
|
28
|
+
# 分类词库,只留自己需要的
|
|
29
|
+
python tools/build_content_lexicon.py dict/*.txt --exclude 政治类,色情类 --exclude "暴力*"
|
|
30
|
+
|
|
31
|
+
# 指定类别名与输出路径,转换后自动验证能否载入
|
|
32
|
+
python tools/build_content_lexicon.py dict/垃圾广告类.txt --category ads -o ads.txt
|
|
33
|
+
|
|
34
|
+
# 挂上去用
|
|
35
|
+
# FOXCLAW_CONTENT_LEXICON=content_lexicon.txt
|
|
36
|
+
|
|
37
|
+
挂上去之前先跑几天 audit
|
|
38
|
+
────────────────────────
|
|
39
|
+
六万条量级的词表里必然有大量双字词,直接挂 enforce 会明显影响正常对话。建议先
|
|
40
|
+
置 FOXCLAW_CONTENT_GUARD_MODE=audit 观察一段时间的命中记录,确认误伤可接受再转
|
|
41
|
+
enforce —— 这层是兜底,不是让产品变得不好用。
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import fnmatch
|
|
47
|
+
import importlib.util
|
|
48
|
+
import sys
|
|
49
|
+
from collections import Counter, OrderedDict
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_guard_module():
|
|
56
|
+
"""按文件路径载入 core/content_guard.py —— 它只依赖标准库,不必触发包的 __init__。"""
|
|
57
|
+
path = ROOT / "foxclaw" / "core" / "content_guard.py"
|
|
58
|
+
spec = importlib.util.spec_from_file_location("_foxclaw_content_guard", path)
|
|
59
|
+
if spec is None or spec.loader is None:
|
|
60
|
+
raise SystemExit(f"找不到 {path}")
|
|
61
|
+
mod = importlib.util.module_from_spec(spec)
|
|
62
|
+
sys.modules["_foxclaw_content_guard"] = mod
|
|
63
|
+
spec.loader.exec_module(mod)
|
|
64
|
+
return mod
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_text(path: Path, encoding: str) -> str:
|
|
68
|
+
data = path.read_bytes()
|
|
69
|
+
for enc in ([encoding] if encoding != "auto" else ["utf-8-sig", "gb18030"]):
|
|
70
|
+
try:
|
|
71
|
+
return data.decode(enc)
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
continue
|
|
74
|
+
raise SystemExit(f"{path}: 无法解码,用 --encoding 指定(试过 utf-8-sig, gb18030)")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _category_of(path: Path, fallback: str) -> str:
|
|
78
|
+
stem = path.stem.strip()
|
|
79
|
+
if any("\u4e00" <= ch <= "\u9fff" for ch in stem):
|
|
80
|
+
return _clean_category(stem)
|
|
81
|
+
return _clean_category(fallback)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _clean_category(name: str) -> str:
|
|
85
|
+
# TAB 是词与类别的分隔符,不能出现在类别里;顺手压掉换行等控制字符。
|
|
86
|
+
s = " ".join(str(name or "").split())
|
|
87
|
+
return s.replace("\t", "/") or "platform"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _match_any(cat: str, patterns: list[str]) -> bool:
|
|
91
|
+
return any(fnmatch.fnmatchcase(cat, p) for p in patterns)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _collect(paths: list[Path], args) -> tuple[OrderedDict, Counter]:
|
|
95
|
+
"""返回 (词 -> [类别...], 统计)。词已 compact,类别按出现顺序去重。"""
|
|
96
|
+
words: OrderedDict[str, list[str]] = OrderedDict()
|
|
97
|
+
stats = Counter()
|
|
98
|
+
for p in paths:
|
|
99
|
+
text = _read_text(p, args.encoding)
|
|
100
|
+
default_cat = _category_of(p, args.category)
|
|
101
|
+
for line in text.splitlines():
|
|
102
|
+
s = line.strip()
|
|
103
|
+
if not s or s.startswith("#"):
|
|
104
|
+
continue
|
|
105
|
+
stats["lines"] += 1
|
|
106
|
+
word, _, cat = s.partition("\t")
|
|
107
|
+
cat = _clean_category(cat) if cat.strip() else default_cat
|
|
108
|
+
if args.include and not _match_any(cat, args.include):
|
|
109
|
+
stats["skipped_by_include"] += 1
|
|
110
|
+
continue
|
|
111
|
+
if args.exclude and _match_any(cat, args.exclude):
|
|
112
|
+
stats["skipped_by_exclude"] += 1
|
|
113
|
+
continue
|
|
114
|
+
word = args.compact(word)
|
|
115
|
+
if len(word) < args.min_len:
|
|
116
|
+
stats["skipped_too_short"] += 1
|
|
117
|
+
continue
|
|
118
|
+
if args.max_len and len(word) > args.max_len:
|
|
119
|
+
stats["skipped_too_long"] += 1
|
|
120
|
+
continue
|
|
121
|
+
cats = words.setdefault(word, [])
|
|
122
|
+
if cat not in cats:
|
|
123
|
+
if cats:
|
|
124
|
+
stats["merged"] += 1
|
|
125
|
+
cats.append(cat)
|
|
126
|
+
else:
|
|
127
|
+
stats["duplicated"] += 1
|
|
128
|
+
return words, stats
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_lexicon(path: Path, words: OrderedDict, sources: list[Path], stats: Counter) -> None:
|
|
132
|
+
counts = Counter(c for cats in words.values() for c in cats)
|
|
133
|
+
header = [
|
|
134
|
+
"# Foxclaw 内容词表 —— 由 tools/build_content_lexicon.py 生成,勿手改",
|
|
135
|
+
"# 源: " + ", ".join(p.name for p in sources),
|
|
136
|
+
"# 条数: %d" % len(words),
|
|
137
|
+
"# 类别: " + ", ".join(f"{c} {n}" for c, n in counts.most_common()),
|
|
138
|
+
"",
|
|
139
|
+
]
|
|
140
|
+
lines = sorted((f"{w}\t{'/'.join(cats)}" for w, cats in words.items()), key=str.casefold)
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
path.write_text("\n".join(header + lines) + "\n", encoding="utf-8", newline="\n")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _verify(path: Path, guard_mod, sample: int) -> bool:
|
|
146
|
+
guard = guard_mod.ContentGuard(mode="enforce", lexicon_paths=[str(path)])
|
|
147
|
+
if not guard.lexicon_size:
|
|
148
|
+
return False
|
|
149
|
+
probe = [ln.split("\t")[0] for ln in path.read_text(encoding="utf-8").splitlines()
|
|
150
|
+
if ln and not ln.startswith("#")][:max(1, sample)]
|
|
151
|
+
hits = sum(1 for w in probe if guard.scan(w))
|
|
152
|
+
print(f"验证: ContentGuard 载入 {guard.lexicon_size} 条, 抽样 {hits}/{len(probe)} 命中")
|
|
153
|
+
return hits == len(probe)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv: list[str] | None = None) -> int:
|
|
157
|
+
ap = argparse.ArgumentParser(
|
|
158
|
+
description="把外部敏感词库转成 FOXCLAW_CONTENT_LEXICON 可用的词表格式",
|
|
159
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
160
|
+
epilog="类别筛选支持通配符,例如 --exclude '政治*'。",
|
|
161
|
+
)
|
|
162
|
+
ap.add_argument("inputs", nargs="+", type=Path, help="词库文件,可多个")
|
|
163
|
+
ap.add_argument("-o", "--output", type=Path, default=Path("content_lexicon.txt"),
|
|
164
|
+
help="输出词表路径(默认 content_lexicon.txt)")
|
|
165
|
+
ap.add_argument("--category", default="platform",
|
|
166
|
+
help="文件名不带中文时使用的类别名(默认 platform)")
|
|
167
|
+
ap.add_argument("--include", action="append", default=[],
|
|
168
|
+
help="只导出匹配的类别,逗号分隔,可重复,支持通配")
|
|
169
|
+
ap.add_argument("--exclude", action="append", default=[],
|
|
170
|
+
help="排除匹配的类别,逗号分隔,可重复,支持通配")
|
|
171
|
+
ap.add_argument("--encoding", default="auto", help="auto / utf-8 / gb18030 等,默认 auto")
|
|
172
|
+
ap.add_argument("--min-len", type=int, default=2,
|
|
173
|
+
help="短于该长度的词丢弃(默认 2,与加载器一致)")
|
|
174
|
+
ap.add_argument("--max-len", type=int, default=0,
|
|
175
|
+
help="长于该长度的词丢弃(默认 0 = 不限)")
|
|
176
|
+
ap.add_argument("--sample", type=int, default=5, help="转换后抽样验证条数(默认 5)")
|
|
177
|
+
ap.add_argument("--no-verify", action="store_true", help="跳过载入验证")
|
|
178
|
+
args = ap.parse_args(argv)
|
|
179
|
+
|
|
180
|
+
def _split(values: list[str]) -> list[str]:
|
|
181
|
+
out: list[str] = []
|
|
182
|
+
for v in values:
|
|
183
|
+
out.extend(x.strip() for x in v.split(",") if x.strip())
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
args.include = _split(args.include)
|
|
187
|
+
args.exclude = _split(args.exclude)
|
|
188
|
+
|
|
189
|
+
missing = [str(p) for p in args.inputs if not p.is_file()]
|
|
190
|
+
if missing:
|
|
191
|
+
raise SystemExit("找不到词库文件: " + ", ".join(missing))
|
|
192
|
+
|
|
193
|
+
guard_mod = _load_guard_module()
|
|
194
|
+
args.compact = guard_mod.compact
|
|
195
|
+
|
|
196
|
+
words, stats = _collect(args.inputs, args)
|
|
197
|
+
if not words:
|
|
198
|
+
raise SystemExit("没有可导出的词 —— 检查类别筛选或 --min-len")
|
|
199
|
+
|
|
200
|
+
_write_lexicon(args.output, words, args.inputs, stats)
|
|
201
|
+
|
|
202
|
+
counts = Counter(c for cats in words.values() for c in cats)
|
|
203
|
+
print(f"读取 {len(args.inputs)} 个文件, {stats['lines']} 行")
|
|
204
|
+
print("过滤: 类别排除 %d / 不在 include %d / 过短 %d / 过长 %d / 重复 %d / 跨类别归并 %d"
|
|
205
|
+
% (stats["skipped_by_exclude"], stats["skipped_by_include"],
|
|
206
|
+
stats["skipped_too_short"], stats["skipped_too_long"],
|
|
207
|
+
stats["duplicated"], stats["merged"]))
|
|
208
|
+
print(f"输出 {len(words)} 条 → {args.output}")
|
|
209
|
+
print("类别: " + ", ".join(f"{c} {n}" for c, n in counts.most_common()))
|
|
210
|
+
|
|
211
|
+
if not args.no_verify and not _verify(args.output, guard_mod, args.sample):
|
|
212
|
+
print("警告: 载入验证未通过,词表可能不可用", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""把仓库根的 frontend/ 复制进包内 foxclaw/web/,让 wheel/sdist 带上 Web UI。
|
|
2
|
+
|
|
3
|
+
为什么要复制
|
|
4
|
+
------------
|
|
5
|
+
`frontend/` 在仓库根、不属于任何 Python 包,`packages.find` 只收
|
|
6
|
+
`foxclaw*` / `hmr*` / `hkc_*`,所以它一直进不了 wheel:`pip install` 之后
|
|
7
|
+
`foxclaw serve` 首页 404,只有 API/CLI。这里在构建前把同一份产物搬进
|
|
8
|
+
`foxclaw/web/`,再由 pyproject 的 `[tool.setuptools.package-data]` 带进 wheel。
|
|
9
|
+
产物已 gitignore,不入版本库(源头仍是 `frontend/`)。
|
|
10
|
+
|
|
11
|
+
用法
|
|
12
|
+
----
|
|
13
|
+
python tools/bundle_web.py # 复制(幂等:先清空 foxclaw/web)
|
|
14
|
+
python tools/bundle_web.py --check # 只校验是否缺失/过期,不复制
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
SRC = ROOT / "frontend"
|
|
26
|
+
DST = ROOT / "foxclaw" / "web"
|
|
27
|
+
|
|
28
|
+
# 与 npm/build.cjs 的跳过规则对齐:依赖、缓存、日志不进包
|
|
29
|
+
SKIP_DIRS = {"node_modules", ".vite", "__pycache__", ".git"}
|
|
30
|
+
SKIP_SUFFIX = {".log", ".err", ".map"}
|
|
31
|
+
|
|
32
|
+
INIT = '''"""Web UI 静态资源 —— 构建期由 tools/bundle_web.py 从仓库根 frontend/ 复制。
|
|
33
|
+
|
|
34
|
+
不要手改本目录:改动落在 frontend/(或改 frontend_src/ 后重新构建),再跑
|
|
35
|
+
`python tools/bundle_web.py`。本目录已 gitignore,不在版本库里。
|
|
36
|
+
"""
|
|
37
|
+
'''
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iter_files(root: Path):
|
|
41
|
+
"""按相对路径列出要复制的文件(已排除依赖/缓存/日志)。"""
|
|
42
|
+
for p in sorted(root.rglob("*")):
|
|
43
|
+
if not p.is_file():
|
|
44
|
+
continue
|
|
45
|
+
rel = p.relative_to(root)
|
|
46
|
+
if any(part in SKIP_DIRS for part in rel.parts):
|
|
47
|
+
continue
|
|
48
|
+
if p.suffix in SKIP_SUFFIX:
|
|
49
|
+
continue
|
|
50
|
+
yield rel
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def bundle() -> int:
|
|
54
|
+
if not SRC.is_dir():
|
|
55
|
+
print(f"[bundle_web] 找不到源目录 {SRC}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
if DST.exists():
|
|
58
|
+
shutil.rmtree(DST)
|
|
59
|
+
DST.mkdir(parents=True)
|
|
60
|
+
(DST / "__init__.py").write_text(INIT, encoding="utf-8")
|
|
61
|
+
count = 0
|
|
62
|
+
total = 0
|
|
63
|
+
for rel in iter_files(SRC):
|
|
64
|
+
target = DST / rel
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
shutil.copy2(SRC / rel, target)
|
|
67
|
+
count += 1
|
|
68
|
+
total += target.stat().st_size
|
|
69
|
+
print(f"[bundle_web] {count} files / {total / 1024:.0f} KB -> foxclaw/web/")
|
|
70
|
+
if not (DST / "v3" / "index.html").is_file():
|
|
71
|
+
print("[bundle_web] 提示:没有 v3/index.html,首页会退回旧 deck_v2",
|
|
72
|
+
file=sys.stderr)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check() -> int:
|
|
77
|
+
if not DST.is_dir():
|
|
78
|
+
print("[bundle_web] foxclaw/web/ 不存在,构建前先跑 python tools/bundle_web.py",
|
|
79
|
+
file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
missing = [str(r) for r in iter_files(SRC) if not (DST / r).is_file()]
|
|
82
|
+
if missing:
|
|
83
|
+
print(f"[bundle_web] foxclaw/web/ 已过期({len(missing)} 个文件缺失,"
|
|
84
|
+
f"如 {missing[0]}),先跑 python tools/bundle_web.py", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
print("[bundle_web] foxclaw/web/ 与 frontend/ 一致")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv=None) -> int:
|
|
91
|
+
ap = argparse.ArgumentParser(description="把 frontend/ 复制进包内 foxclaw/web/")
|
|
92
|
+
ap.add_argument("--check", action="store_true", help="只校验,不复制")
|
|
93
|
+
args = ap.parse_args(argv)
|
|
94
|
+
return check() if args.check else bundle()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|