foxclaw 0.1.3 → 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.
Files changed (49) hide show
  1. package/index.js +1 -1
  2. package/package.json +1 -1
  3. package/python/.github/workflows/release.yml +5 -0
  4. package/python/CHANGELOG.md +43 -0
  5. package/python/CHANGELOG_CN.md +43 -0
  6. package/python/README.md +6 -0
  7. package/python/README_CN.md +6 -0
  8. package/python/docs/CONTENT_GUARD.md +189 -0
  9. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  10. package/python/docs/RELEASE.md +74 -34
  11. package/python/foxclaw/__init__.py +41 -1
  12. package/python/foxclaw/agents/base.py +17 -0
  13. package/python/foxclaw/agents/mixins/memory.py +16 -2
  14. package/python/foxclaw/agents/mixins/prompt.py +59 -6
  15. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  16. package/python/foxclaw/api/bootstrap.py +20 -2
  17. package/python/foxclaw/api/routes/memory.py +22 -0
  18. package/python/foxclaw/behavior/__init__.py +20 -0
  19. package/python/foxclaw/behavior/behavior.yaml +62 -0
  20. package/python/foxclaw/behavior/spec.py +287 -0
  21. package/python/foxclaw/core/content_guard.py +341 -0
  22. package/python/foxclaw/core/memory_adapter.py +24 -4
  23. package/python/foxclaw/core/model_router.py +38 -2
  24. package/python/foxclaw/core/sensitive_write.py +283 -0
  25. package/python/frontend/planet/planet.js +18 -0
  26. package/python/frontend/v3/assets/{index-D5nIsjms.css → index-JKfHlb-a.css} +1 -1
  27. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  28. package/python/frontend/v3/index.html +2 -2
  29. package/python/frontend_src/package.json +1 -1
  30. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -2
  31. package/python/frontend_src/src/stores/panels.js +16 -3
  32. package/python/pyproject.toml +12 -1
  33. package/python/tests/run_all.py +1 -0
  34. package/python/tests/test_agent_rules.py +81 -0
  35. package/python/tests/test_behavior_spec.py +198 -0
  36. package/python/tests/test_conflict_dismiss.py +143 -0
  37. package/python/tests/test_content_guard.py +177 -0
  38. package/python/tests/test_sensitive_write.py +181 -0
  39. package/python/tools/build_content_lexicon.py +218 -0
  40. package/python/tools/bundle_web.py +98 -0
  41. package/python/tools/recheck_conflicts.py +156 -0
  42. package/python/tools/release_check.py +17 -8
  43. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  44. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  45. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  46. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  47. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  48. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  49. package/python/frontend/v3/assets/index-CQSBdxk4.js +0 -34
@@ -6,8 +6,8 @@
6
6
  <title>Foxclaw</title>
7
7
  <!-- 内联 SVG 图标:不新增静态文件,也避免浏览器探 /favicon.ico 报 404 -->
8
8
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='9' fill='%23179d5f'/><circle cx='16' cy='16' r='6.5' fill='%23f4fbf0'/></svg>" />
9
- <script type="module" crossorigin src="/v3/assets/index-CQSBdxk4.js"></script>
10
- <link rel="stylesheet" crossorigin href="/v3/assets/index-D5nIsjms.css">
9
+ <script type="module" crossorigin src="/v3/assets/index-tN90dpUH.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/v3/assets/index-JKfHlb-a.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="app"></div>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "foxclaw-deck-next",
3
3
  "private": true,
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "type": "module",
6
6
  "description": "Foxclaw Deck v3 - Vue 3 rendering layer (builds into frontend/v3)",
7
7
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, onMounted, ref } from 'vue'
3
3
  import PanelBox from './PanelBox.vue'
4
- import { panels, loadPanel, refreshPanel, dropPreference } from '../../stores/panels.js'
4
+ import { panels, loadPanel, refreshPanel, dropPreference, approveSensitiveWrite, rejectSensitiveWrite } from '../../stores/panels.js'
5
5
  import { openDoc } from '../../stores/app.js'
6
6
  import { fmtTime, fmtNum, clip } from '../../lib/fmt.js'
7
7
 
@@ -31,6 +31,16 @@ async function drop(pref) {
31
31
  try { await dropPreference(pref.id) } catch (e) { err.value = String(e.message || e) }
32
32
  busy.value = ''
33
33
  }
34
+
35
+ async function decide(item, keep) {
36
+ busy.value = item.id
37
+ err.value = ''
38
+ try {
39
+ if (keep) await approveSensitiveWrite(item.id)
40
+ else await rejectSensitiveWrite(item.id)
41
+ } catch (e) { err.value = String(e.message || e) }
42
+ busy.value = ''
43
+ }
34
44
  </script>
35
45
 
36
46
  <template>
@@ -47,10 +57,32 @@ async function drop(pref) {
47
57
  <div><b>{{ fmtNum(p.stats.shared ?? 0) }}</b><span>共享</span></div>
48
58
  <div><b>{{ fmtNum(p.stats.private ?? 0) }}</b><span>私有</span></div>
49
59
  <div><b>{{ fmtNum(p.prefs.length) }}</b><span>用户偏好</span></div>
60
+ <div v-if="p.pending.length"><b>{{ fmtNum(p.pending.length) }}</b><span>待确认</span></div>
50
61
  </div>
51
62
 
52
63
  <div v-if="err" class="pn-st err">{{ err }}</div>
53
64
 
65
+ <div v-if="p.pending.length" class="pn-sec pending">
66
+ <div class="pn-h">待确认写入 <em>{{ p.pending.length }}</em></div>
67
+ <div class="pn-empty">
68
+ 这几条含凭据、证件、健康或账户信息,按规矩没有直接写进长期记忆。确认无误再点「记住」。
69
+ </div>
70
+ <div v-for="it in p.pending" :key="it.id" class="pn-row">
71
+ <span class="m">
72
+ <b>{{ clip(it.content, 60) }}</b>
73
+ <span class="s">
74
+ {{ (it.categories || []).join(' · ') }} · {{ it.agent_id || '—' }} · {{ it.id }}
75
+ </span>
76
+ </span>
77
+ <button class="act ok" :disabled="busy === it.id" title="确认写入长期记忆" @click.stop="decide(it, true)">
78
+ 记住
79
+ </button>
80
+ <button class="act" :disabled="busy === it.id" title="丢弃,不写入" @click.stop="decide(it, false)">
81
+ 丢弃
82
+ </button>
83
+ </div>
84
+ </div>
85
+
54
86
  <div class="pn-cols">
55
87
  <div class="pn-sec">
56
88
  <div class="pn-h">最近记忆 <em>{{ p.recent.length }}</em></div>
@@ -100,13 +132,21 @@ async function drop(pref) {
100
132
  <div class="pn-note">
101
133
  后端 <code>{{ p.stats.backend || '—' }}</code> v{{ p.stats.version || '—' }} ·
102
134
  向量 <code>{{ p.stats.embedding || '—' }}</code>。
103
- 数据来自 <code>/api/memory/stats</code>、<code>/api/memory/recent</code>、<code>/api/preferences</code>。
135
+ 数据来自 <code>/api/memory/stats</code>、<code>/api/memory/recent</code>、<code>/api/preferences</code>、<code>/api/memory/pending</code>。
104
136
  </div>
105
137
  </PanelBox>
106
138
  </template>
107
139
 
108
140
  <style scoped>
109
141
  .sec-gap { margin-top: 14px; }
142
+ .pending .pn-h em { color: var(--amber, #d99a2b); }
143
+ .act {
144
+ flex-shrink: 0; padding: 4px 10px; border-radius: var(--r-pill);
145
+ font-size: 11px; font-weight: 600; color: var(--dim);
146
+ background: var(--surface-2); border: 1px solid var(--line-soft);
147
+ }
148
+ .act.ok { color: var(--accent, #3b82f6); border-color: currentColor; }
149
+ .act:disabled { opacity: 0.5; }
110
150
  .del {
111
151
  flex-shrink: 0; padding: 4px 10px; border-radius: var(--r-pill);
112
152
  font-size: 11px; font-weight: 600; color: var(--dim);
@@ -15,7 +15,7 @@ export const panels = reactive({
15
15
  recent: [], recentTotal: 0,
16
16
  ingest: 'idle', ingestMsg: '', ingestErr: '',
17
17
  },
18
- memory: { state: 'idle', stats: {}, recent: [], prefs: [] },
18
+ memory: { state: 'idle', stats: {}, recent: [], prefs: [], pending: [] },
19
19
  review: {
20
20
  state: 'idle',
21
21
  stats: { cards: 0, learned: 0, fresh: 0, due: 0, avg_mastery: 0, load7: {}, risk: [] },
@@ -121,15 +121,17 @@ const LOADERS = {
121
121
  },
122
122
 
123
123
  async memory(p) {
124
- const [st, rc, pf] = await Promise.all([
124
+ const [st, rc, pf, pd] = await Promise.all([
125
125
  safe(getJSON('/memory/stats')),
126
126
  safe(getJSON('/memory/recent?limit=12')),
127
127
  safe(getJSON('/preferences')),
128
+ safe(getJSON('/memory/pending')),
128
129
  ])
129
130
  p.stats = st || {}
130
131
  p.recent = rc?.memories || []
131
132
  p.prefs = pf?.preferences || []
132
- return !!(st || rc || pf)
133
+ p.pending = pd?.pending || []
134
+ return !!(st || rc || pf || pd)
133
135
  },
134
136
  }
135
137
 
@@ -215,6 +217,17 @@ export async function dropPreference(id) {
215
217
  await refreshPanel('memory')
216
218
  }
217
219
 
220
+ // 高敏感内容被闸门拦下后停在这里,用户点头才落库(见 foxclaw/core/sensitive_write.py)
221
+ export async function approveSensitiveWrite(id) {
222
+ await postJSON(`/memory/pending/${encodeURIComponent(id)}/approve`, {})
223
+ await refreshPanel('memory')
224
+ }
225
+
226
+ export async function rejectSensitiveWrite(id) {
227
+ await postJSON(`/memory/pending/${encodeURIComponent(id)}/reject`, {})
228
+ await refreshPanel('memory')
229
+ }
230
+
218
231
  export async function openMdSkill(id) {
219
232
  const d = await getJSON(`/ahs/md-skills/${encodeURIComponent(id)}`)
220
233
  openDoc({
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "foxclaw"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Foxclaw - a personal agent with memory, built on the HMR memory runtime"
9
9
  requires-python = ">=3.11"
10
10
 
@@ -70,6 +70,13 @@ foxclaw = "foxclaw.cli:main"
70
70
  # 这里把它们真正打进 wheel,并让 package-data 一并带上 LICENSE/JSON 等非 .py 资源。
71
71
  include-package-data = true
72
72
 
73
+ [tool.setuptools.package-data]
74
+ # frontend/ 在仓库根、不属于任何包,packages.find 收不到;构建前由
75
+ # tools/bundle_web.py 复制成 foxclaw/web/(已 gitignore),这里再把它带进 wheel/sdist。
76
+ # 不复制的话 pip install 之后 foxclaw serve 首页 404(只有 API/CLI)。
77
+ "foxclaw.web" = ["**/*"]
78
+
79
+
73
80
  [tool.setuptools.packages.find]
74
81
  # foxclaw 在仓库根;hmr 在 vendor/ 下;hkc_* 在 vendor/hkc/ 下 —— 三个搜索根都要给。
75
82
  where = [".", "vendor", "vendor/hkc"]
@@ -146,6 +153,10 @@ testpaths = ["tests"]
146
153
  # `python -m pytest <file> -q` 隔离)。本白名单确保任意单文件可被 pytest 收集/运行。
147
154
  python_files = [
148
155
  "test_agent_lifecycle.py",
156
+ "test_sensitive_write.py",
157
+ "test_content_guard.py",
158
+ "test_behavior_spec.py",
159
+ "test_agent_rules.py",
149
160
  "test_ahs_p2.py",
150
161
  "test_anthropic_tools.py",
151
162
  "test_api.py",
@@ -93,6 +93,7 @@ TESTS = [
93
93
  ("test_negotiation.py", "智能体层·跨域协商闭环(被拒→让步→再裁决·有界·拒绝不降级)"),
94
94
  ("test_tool_use.py", "P2·function-calling 工具化(多轮工具循环/注册表/降级)"),
95
95
  ("test_injection_guard.py", "提示词注入纵深防御(检出/wrap_untrusted/内核前缀/隔离)"),
96
+ ("test_agent_rules.py", "Agent 规则文件启动注入(按 AGENT_ID 读盘/顺序/回退开关/包内路径)"),
96
97
  ("test_sessions.py", "会话存储(增删改查/标题提炼/路径穿越/并发追加)"),
97
98
  ("test_stats_and_events.py","记忆统计 + 活动事件(hmr.stats 计数修复/chat.completed)"),
98
99
  ("test_llm_planner.py", "P0-1·LLM 驱动规划器(llm_plan/replan·opt-in+fail-safe+sv_replan)"),
@@ -0,0 +1,81 @@
1
+ """
2
+ Agent 规则文件加载测试 —— 全离线。
3
+
4
+ 覆盖:
5
+ 1. agents/rules/<AGENT_ID>/*.md 在组装系统提示词时被读入(启动即生效,不靠代码常驻)。
6
+ 2. 叠加顺序:内核前缀 → 角色职责 → 本 Agent 规则;规则位于最后。
7
+ 3. 回退开关 FOXCLAW_AGENT_RULES=0 可整体关闭;无 AGENT_ID / 无目录时不注入。
8
+ 4. 规则目录必须落在 foxclaw 包内(随 wheel 分发,不能像仓库根 skills/ 那样漏包)。
9
+ """
10
+ import os, sys, types
11
+ from pathlib import Path
12
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
13
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
14
+ os.environ["FOXCLAW_EMBEDDING_MODEL"] = "foxclaw-nonexistent-fast"
15
+ sys.path.insert(0, str(Path(__file__).parent.parent))
16
+
17
+ from foxclaw.agents.base import KERNEL_PROMPT
18
+ from foxclaw.agents.mixins.prompt import PromptMixin, RULES_DIR, load_agent_rules
19
+
20
+
21
+ def _fake(role="我是财务角色ROLEXYZ。", agent_id="finance"):
22
+ return types.SimpleNamespace(_role_prompt=role, AGENT_ID=agent_id)
23
+
24
+
25
+ def test_finance_rules_file_is_loaded():
26
+ text = load_agent_rules("finance")
27
+ assert "Personal Finance Agent Rules v1.0" in text
28
+ assert "不得编造" in text
29
+ assert "储蓄率" in text
30
+
31
+
32
+ def test_unknown_agent_has_no_rules():
33
+ assert load_agent_rules("no_such_agent") == ""
34
+ assert load_agent_rules("") == ""
35
+
36
+
37
+ def test_system_prompt_order_kernel_role_rules():
38
+ out = PromptMixin.system_prompt.fget(_fake())
39
+ assert KERNEL_PROMPT in out
40
+ assert out.index("认知操作系统") < out.index("ROLEXYZ") < out.index("Personal Finance Agent Rules")
41
+
42
+
43
+ def test_rules_follow_kernel_when_role_is_short():
44
+ out = PromptMixin.system_prompt.fget(_fake(role="ROLEXYZ"))
45
+ assert "本 Agent 专业规则(必须遵守)" in out
46
+ assert "月度结余" in out
47
+
48
+
49
+ def test_switch_disables_rules(monkeypatch):
50
+ monkeypatch.setenv("FOXCLAW_AGENT_RULES", "0")
51
+ out = PromptMixin.system_prompt.fget(_fake())
52
+ assert "ROLEXYZ" in out and "Personal Finance Agent Rules" not in out
53
+
54
+
55
+ def test_switch_disables_rules_even_without_kernel(monkeypatch):
56
+ monkeypatch.setenv("FOXCLAW_KERNEL_PROMPT", "0")
57
+ monkeypatch.setenv("FOXCLAW_AGENT_RULES", "0")
58
+ monkeypatch.setenv("FOXCLAW_BEHAVIOR", "0")
59
+ assert PromptMixin.system_prompt.fget(_fake(role="ROLEXYZ")) == "ROLEXYZ"
60
+
61
+
62
+ def test_rules_survive_kernel_off(monkeypatch):
63
+ monkeypatch.setenv("FOXCLAW_KERNEL_PROMPT", "0")
64
+ out = PromptMixin.system_prompt.fget(_fake(role="ROLEXYZ"))
65
+ assert KERNEL_PROMPT not in out
66
+ assert "Personal Finance Agent Rules" in out
67
+ # 行为规范是硬边界,不随内核前缀一起关掉
68
+ assert out.index("[行为规范") < out.index("ROLEXYZ")
69
+
70
+
71
+ def test_agent_without_id_gets_no_rules():
72
+ fake = types.SimpleNamespace(_role_prompt="ROLEXYZ")
73
+ out = PromptMixin.system_prompt.fget(fake)
74
+ assert "ROLEXYZ" in out and "Personal Finance Agent Rules" not in out
75
+
76
+
77
+ def test_rules_dir_lives_inside_package():
78
+ """规则文件必须在 foxclaw 包内:仓库根的 skills/ 就没被打进 wheel,别重演。"""
79
+ pkg = Path(RULES_DIR).resolve()
80
+ assert pkg.name == "rules" and pkg.parent.name == "agents"
81
+ assert (pkg / "finance" / "RULES.md").is_file()
@@ -0,0 +1,198 @@
1
+ """
2
+ 行为规范(Behavior Spec)测试 —— 全离线。
3
+
4
+ 覆盖:
5
+ 1. 内置 behavior.yaml 可加载,id 唯一,每个 code_check 都解析到真实可调用对象。
6
+ 2. 检查点不是装饰:policy_gate.check_action / confirms.consume / verify.verify 在业务代码里真有调用。
7
+ 3. prompt 注入:行为规范在最前段(Markdown 内核之前、persona 之前),带 spec_version,可整体关闭。
8
+ 4. 本地覆盖:可新增 / 可收紧;放宽或省略删除严格层规则 → BehaviorLoadError。
9
+ 5. 本地覆盖的语法错、版本过期 → 降级为 warning 并忽略,不把应用锁死(内置文件的问题仍然硬失败)。
10
+ """
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
18
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
19
+ os.environ["FOXCLAW_EMBEDDING_MODEL"] = "foxclaw-nonexistent-fast"
20
+ sys.path.insert(0, str(Path(__file__).parent.parent))
21
+
22
+ from foxclaw.agents.mixins.prompt import PromptMixin
23
+ from foxclaw.behavior import BehaviorLoadError, load, local_path, prompt_block
24
+
25
+ ROOT = Path(__file__).parent.parent
26
+
27
+ BASE_YAML = """
28
+ version: 1
29
+ spec_id: test-behavior
30
+ prohibitions:
31
+ - id: no_x
32
+ rule: 不做 X
33
+ enforce: both
34
+ code_check: "foxclaw.core.verify:verify"
35
+ confirmations:
36
+ - id: confirm_y
37
+ rule: 做 Y 前先确认
38
+ enforce: prompt
39
+ uncertainty:
40
+ - id: zzz
41
+ rule: 不确定就问
42
+ enforce: prompt
43
+ """
44
+
45
+
46
+ def _write(tmp_path, name, text):
47
+ p = tmp_path / name
48
+ p.write_text(text, encoding="utf-8")
49
+ return p
50
+
51
+
52
+ def _loaded(tmp_path, local_text):
53
+ return load(_write(tmp_path, "behavior.yaml", BASE_YAML),
54
+ _write(tmp_path, "behavior.local.yaml", local_text))
55
+
56
+
57
+ def test_builtin_spec_loads_with_unique_ids():
58
+ spec = load()
59
+ ids = [r.id for r in spec.rules]
60
+ assert len(ids) == len(set(ids))
61
+ assert spec.version == 1 and spec.spec_version
62
+ assert {"confirm_side_effect", "no_silent_failure"} <= set(ids)
63
+
64
+
65
+ def test_every_code_check_resolves_to_callable():
66
+ spec = load()
67
+ checks = spec.checkpoints()
68
+ assert checks, "内置规范至少要登记一个真实检查点"
69
+ assert all(callable(fn) for fn in checks.values())
70
+
71
+
72
+ def test_checkpoints_are_really_wired_in_business_code():
73
+ """反漂移:登记了检查点,业务代码里就必须真有调用,否则清单会退化成装饰。"""
74
+ blob = "\n".join(p.read_text(encoding="utf-8")
75
+ for p in (ROOT / "foxclaw").rglob("*.py")
76
+ if p.parts[len(ROOT.parts)] != "behavior")
77
+ assert "policy_gate.check_action(" in blob
78
+ assert "confirms.consume(" in blob
79
+ assert "verify.verify(" in blob
80
+
81
+
82
+ def test_prompt_block_leads_with_spec_and_version():
83
+ spec = load()
84
+ block = prompt_block(spec)
85
+ assert block.startswith("[行为规范")
86
+ assert spec.spec_version in block
87
+ for rule in spec.injectable():
88
+ assert f"[{rule.id}]" in block
89
+ assert "不可协商" in block
90
+
91
+
92
+ def test_behavior_precedes_kernel_and_role():
93
+ import types
94
+ fake = types.SimpleNamespace(_role_prompt="我是角色ROLEXYZ。", AGENT_ID="finance")
95
+ out = PromptMixin.system_prompt.fget(fake)
96
+ assert out.index("[行为规范") < out.index("认知操作系统") < out.index("ROLEXYZ")
97
+
98
+
99
+ def test_switch_disables_behavior_block(monkeypatch):
100
+ monkeypatch.setenv("FOXCLAW_BEHAVIOR", "0")
101
+ assert prompt_block() == ""
102
+
103
+
104
+ def test_local_can_add_and_tighten(tmp_path):
105
+ spec = _loaded(tmp_path, """
106
+ version: 1
107
+ confirmations:
108
+ - id: confirm_y
109
+ rule: 做 Y 前先确认(收紧)
110
+ enforce: both
111
+ code_check: "foxclaw.core.verify:verify"
112
+ uncertainty:
113
+ - id: new_rule
114
+ rule: 新加的更严规则
115
+ enforce: prompt
116
+ """)
117
+ assert spec.by_id("confirm_y").enforce == "both"
118
+ assert spec.by_id("new_rule") is not None
119
+ assert spec.by_id("no_x").enforce == "both" # 未提及的保持不变
120
+
121
+
122
+ def test_local_cannot_weaken_enforce(tmp_path):
123
+ with pytest.raises(BehaviorLoadError, match="不能放宽"):
124
+ _loaded(tmp_path, """
125
+ prohibitions:
126
+ - id: no_x
127
+ rule: 不做 X
128
+ enforce: prompt
129
+ """)
130
+
131
+
132
+ def test_local_cannot_drop_strict_layer_rule(tmp_path):
133
+ with pytest.raises(BehaviorLoadError, match="不能通过省略删除"):
134
+ _loaded(tmp_path, "confirmations: []\n")
135
+
136
+
137
+ def test_local_syntax_error_is_warning_not_fatal(tmp_path):
138
+ spec = _loaded(tmp_path, "prohibitions: [oops\n")
139
+ assert spec.warnings and "已忽略" in spec.warnings[0]
140
+ assert spec.by_id("no_x") is not None
141
+
142
+
143
+ def test_local_stale_version_is_ignored(tmp_path):
144
+ spec = _loaded(tmp_path, "version: 99\nuncertainty: []\n")
145
+ assert any("已过期" in w for w in spec.warnings)
146
+ assert spec.by_id("zzz") is not None
147
+
148
+
149
+ def test_builtin_version_mismatch_is_fatal(tmp_path):
150
+ p = _write(tmp_path, "behavior.yaml", "version: 99\nprohibitions: []\n")
151
+ with pytest.raises(BehaviorLoadError, match="version"):
152
+ load(p, tmp_path / "none.yaml")
153
+
154
+
155
+ def test_enforce_without_code_check_fails(tmp_path):
156
+ p = _write(tmp_path, "behavior.yaml", """
157
+ version: 1
158
+ prohibitions:
159
+ - id: no_x
160
+ rule: 不做 X
161
+ enforce: both
162
+ """)
163
+ with pytest.raises(BehaviorLoadError, match="没有 code_check"):
164
+ load(p, tmp_path / "none.yaml")
165
+
166
+
167
+ def test_unknown_checkpoint_fails(tmp_path):
168
+ p = _write(tmp_path, "behavior.yaml", """
169
+ version: 1
170
+ prohibitions:
171
+ - id: no_x
172
+ rule: 不做 X
173
+ enforce: code
174
+ code_check: "foxclaw.core.verify:no_such_function"
175
+ """)
176
+ with pytest.raises(BehaviorLoadError, match="不存在"):
177
+ load(p, tmp_path / "none.yaml")
178
+
179
+
180
+ def test_duplicate_id_fails(tmp_path):
181
+ p = _write(tmp_path, "behavior.yaml", """
182
+ version: 1
183
+ prohibitions:
184
+ - id: no_x
185
+ rule: A
186
+ enforce: prompt
187
+ uncertainty:
188
+ - id: no_x
189
+ rule: B
190
+ enforce: prompt
191
+ """)
192
+ with pytest.raises(BehaviorLoadError, match="重复"):
193
+ load(p, tmp_path / "none.yaml")
194
+
195
+
196
+ def test_local_path_follows_data_dir(monkeypatch, tmp_path):
197
+ monkeypatch.setenv("FOXCLAW_DATA_DIR", str(tmp_path))
198
+ assert local_path() == tmp_path / "behavior.local.yaml"
@@ -0,0 +1,143 @@
1
+ """误判冲突关闭(dismiss)与否定判定收紧的回归测试。
2
+
3
+ 背景:技术文档里「不变/不可观测/不同」这类描述性「不」被当成立场否定,只要两句
4
+ 同领域(cos>0.35)就判「矛盾」,实测攒下 1063 张伪冲突卡。dismiss 用于把这些卡关掉
5
+ 且**不废弃任何一方观点**;这里锁住三件事:
6
+ 1. dismiss 后双方 claim_status 不变(尤其不能被标 superseded);
7
+ 2. 因冲突被置 disputed 的一方,在没有其它 open 冲突时恢复 active(否则检索永久丢);
8
+ 3. 仍牵涉其它 open 冲突的 claim 保持 disputed。
9
+ """
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent.parent))
14
+ sys.path.insert(0, str(Path(__file__).parent.parent / "vendor" / "hkc"))
15
+
16
+ from hkc_core.graph.sqlite_store import SQLiteGraphStore
17
+ from hkc_core.models.enums import ClaimStatus, ConflictStatus
18
+ from hkc_core.models.ku import ClaimKU, ConflictCard
19
+ from hkc_core.utils.id_gen import IDGenerator
20
+ from hkc_kee.kee import KnowledgeEvolutionEngine, _SimpleEmbedder
21
+ from hkc_kep.event_bus import EventBus
22
+
23
+
24
+ def _engine(tmp_path):
25
+ store = SQLiteGraphStore(str(tmp_path / "hkc.db"))
26
+ bus = EventBus(str(tmp_path / "events.db"))
27
+ gen = IDGenerator(str(tmp_path / "ids.db"))
28
+ return store, KnowledgeEvolutionEngine(store, bus, gen)
29
+
30
+
31
+ def _claim(store, ku_id, status=ClaimStatus.ACTIVE, conf=0.8):
32
+ store.put(ClaimKU(
33
+ ku_id=ku_id, name=ku_id, statement=f"{ku_id} 的观点",
34
+ confidence=conf, domain="测试", claim_status=status,
35
+ ))
36
+
37
+
38
+ def test_dismiss_keeps_both_claims_and_restores_disputed(tmp_path):
39
+ store, kee = _engine(tmp_path)
40
+ _claim(store, "CLM_A")
41
+ _claim(store, "CLM_B", status=ClaimStatus.DISPUTED)
42
+ store.save_conflict(ConflictCard(
43
+ conflict_id="CFT_1", claim_a_id="CLM_A", claim_b_id="CLM_B", domain="测试"))
44
+
45
+ assert kee.dismiss_conflict("CFT_1", note="误判") is True
46
+
47
+ card = store.get_conflict("CFT_1")
48
+ assert card.status == ConflictStatus.RESOLVED
49
+ assert card.resolved_by == "human"
50
+ assert card.resolution_note == "误判"
51
+ assert card.resolved_at
52
+ assert store.get("CLM_A").claim_status == ClaimStatus.ACTIVE
53
+ assert store.get("CLM_B").claim_status == ClaimStatus.ACTIVE, \
54
+ "disputed 且无其它 open 冲突 → 必须恢复 active,否则检索永久剔除"
55
+
56
+
57
+ def test_dismiss_leaves_claim_still_disputed_if_other_conflict_open(tmp_path):
58
+ store, kee = _engine(tmp_path)
59
+ _claim(store, "CLM_A")
60
+ _claim(store, "CLM_B", status=ClaimStatus.DISPUTED)
61
+ _claim(store, "CLM_C")
62
+ store.save_conflict(ConflictCard(
63
+ conflict_id="CFT_1", claim_a_id="CLM_A", claim_b_id="CLM_B", domain="测试"))
64
+ store.save_conflict(ConflictCard(
65
+ conflict_id="CFT_2", claim_a_id="CLM_B", claim_b_id="CLM_C", domain="测试"))
66
+
67
+ assert kee.dismiss_conflict("CFT_1") is True
68
+ assert store.get("CLM_B").claim_status == ClaimStatus.DISPUTED, \
69
+ "还牵涉 CFT_2,不能提前恢复"
70
+ assert store.get_conflict("CFT_2").status == ConflictStatus.OPEN
71
+
72
+
73
+ def test_dismiss_batch_and_idempotent(tmp_path):
74
+ store, kee = _engine(tmp_path)
75
+ _claim(store, "CLM_A")
76
+ _claim(store, "CLM_B")
77
+ store.save_conflict(ConflictCard(
78
+ conflict_id="CFT_1", claim_a_id="CLM_A", claim_b_id="CLM_B"))
79
+ store.save_conflict(ConflictCard(
80
+ conflict_id="CFT_2", claim_a_id="CLM_A", claim_b_id="CLM_B"))
81
+
82
+ assert kee.dismiss_conflicts(["CFT_1", "CFT_2", "CFT_2"]) == 2
83
+ assert kee.dismiss_conflicts(["CFT_1"]) == 0, "重复关闭应为 0 而非异常"
84
+ assert kee.dismiss_conflict("CFT_NOPE") is False
85
+
86
+
87
+ def test_has_neg_ignores_descriptive_bu():
88
+ assert _SimpleEmbedder._has_neg("现有测试行为不变") is False
89
+ assert _SimpleEmbedder._has_neg("Director 不可观测") is False
90
+ assert _SimpleEmbedder._has_neg("这是「不管调度」原则的延续") is False
91
+ assert _SimpleEmbedder._has_neg("不同模块用不同 mock") is False
92
+ assert _SimpleEmbedder._has_neg("无论怎么改都不影响") is True, "无论剔除后残留的「不」仍算否定"
93
+ assert _SimpleEmbedder._has_neg("这个方案不好") is True
94
+ assert _SimpleEmbedder._has_neg("not good") is True
95
+
96
+
97
+ def test_neg_flip_defaults_are_configurable(tmp_path):
98
+ store, _ = _engine(tmp_path)
99
+ emb = _SimpleEmbedder()
100
+ assert emb.NEG_FLIP_MIN_COS == 0.35
101
+ assert emb.NEG_FLIP_MIN_OVERLAP == 0.40
102
+ tight = _SimpleEmbedder(neg_flip_min_cos=0.9, neg_flip_min_overlap=0.8)
103
+ assert (tight.NEG_FLIP_MIN_COS, tight.NEG_FLIP_MIN_OVERLAP) == (0.9, 0.8)
104
+ engine = KnowledgeEvolutionEngine(
105
+ store, EventBus(":memory:"), IDGenerator(str(tmp_path / "ids2.db")),
106
+ neg_flip_min_cos=0.6, neg_flip_min_overlap=0.7)
107
+ assert (engine.neg_flip_min_cos, engine.neg_flip_min_overlap) == (0.6, 0.7)
108
+
109
+
110
+ def test_same_proposition_requires_lexical_overlap():
111
+ emb = _SimpleEmbedder()
112
+ assert emb._same_proposition("这个方案是可行的,风险可控", "这个方案不可行,风险不可控") is True
113
+ assert emb._same_proposition(
114
+ "现有编译测试的输入都是高置信度正例,预期 open_questions 为空,行为不变。",
115
+ "依赖注入优于 spy-per-module 式模块级 mock") is False
116
+
117
+
118
+ def test_score_ignores_same_domain_different_propositions():
119
+ from hkc_search.embedding_backends import get_st_model
120
+ if get_st_model(_SimpleEmbedder._MODEL_NAME) is None:
121
+ import pytest
122
+ pytest.skip("语义模型未缓存,跳过 score 级验证")
123
+
124
+ emb = _SimpleEmbedder()
125
+ near = ("现有编译测试的输入都是高置信度正例,预期 open_questions 为空,行为不变。",
126
+ "依赖注入优于 spy-per-module 式模块级 mock")
127
+ assert emb.score(*near) > -0.30, "同领域不同命题曾被判成矛盾(1063 张伪冲突的来源)"
128
+
129
+ contra = ("这个方案是可行的,风险可控", "这个方案不可行,风险不可控")
130
+ assert emb.score(*contra) < -0.30, "同一命题的正反两面仍须判成矛盾"
131
+
132
+
133
+ if __name__ == "__main__":
134
+ import tempfile
135
+ for name, fn in list(globals().items()):
136
+ if name.startswith("test_") and callable(fn):
137
+ with tempfile.TemporaryDirectory() as d:
138
+ try:
139
+ fn(Path(d))
140
+ except TypeError:
141
+ fn()
142
+ print(f"✓ {name}")
143
+ print("\n误判冲突关闭 + 否定判定 回归通过")