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.
- package/index.js +1 -1
- package/package.json +1 -1
- package/python/.github/workflows/release.yml +5 -0
- package/python/CHANGELOG.md +43 -0
- package/python/CHANGELOG_CN.md +43 -0
- package/python/README.md +6 -0
- package/python/README_CN.md +6 -0
- package/python/docs/CONTENT_GUARD.md +189 -0
- package/python/docs/CONTENT_GUARD_CN.md +189 -0
- package/python/docs/RELEASE.md +74 -34
- package/python/foxclaw/__init__.py +41 -1
- package/python/foxclaw/agents/base.py +17 -0
- package/python/foxclaw/agents/mixins/memory.py +16 -2
- package/python/foxclaw/agents/mixins/prompt.py +59 -6
- package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
- package/python/foxclaw/api/bootstrap.py +20 -2
- package/python/foxclaw/api/routes/memory.py +22 -0
- 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/core/content_guard.py +341 -0
- package/python/foxclaw/core/memory_adapter.py +24 -4
- package/python/foxclaw/core/model_router.py +38 -2
- package/python/foxclaw/core/sensitive_write.py +283 -0
- package/python/frontend/planet/planet.js +18 -0
- package/python/frontend/v3/assets/{index-D5nIsjms.css → index-JKfHlb-a.css} +1 -1
- package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
- package/python/frontend/v3/index.html +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -2
- package/python/frontend_src/src/stores/panels.js +16 -3
- package/python/pyproject.toml +12 -1
- package/python/tests/run_all.py +1 -0
- package/python/tests/test_agent_rules.py +81 -0
- package/python/tests/test_behavior_spec.py +198 -0
- package/python/tests/test_conflict_dismiss.py +143 -0
- package/python/tests/test_content_guard.py +177 -0
- package/python/tests/test_sensitive_write.py +181 -0
- package/python/tools/build_content_lexicon.py +218 -0
- package/python/tools/bundle_web.py +98 -0
- package/python/tools/recheck_conflicts.py +156 -0
- package/python/tools/release_check.py +17 -8
- 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/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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/v3/assets/index-
|
|
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
|
<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
|
-
|
|
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({
|
package/python/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "foxclaw"
|
|
7
|
-
version = "0.1.
|
|
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",
|
package/python/tests/run_all.py
CHANGED
|
@@ -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误判冲突关闭 + 否定判定 回归通过")
|