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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""复核存量冲突卡:用当前判定规则重算,把已经不成立的卡批量标记为误判。
|
|
3
|
+
|
|
4
|
+
默认 dry-run,只打印统计与样本;加 --apply 才真正调 /conflicts/bulk-dismiss。
|
|
5
|
+
写入必须走运行中的服务——只有它才持有 open 冲突集合与向量索引,直接改库会让
|
|
6
|
+
服务内的缓存与库不一致(那些因冲突被置 disputed 的 claim 也不会回到检索)。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
python tools/recheck_conflicts.py # 只看统计
|
|
10
|
+
python tools/recheck_conflicts.py --apply # 确认后批量关闭误判
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
sys.path.insert(0, str(ROOT / "vendor" / "hkc"))
|
|
21
|
+
|
|
22
|
+
from hkc_kee.kee import _SimpleEmbedder # noqa: E402
|
|
23
|
+
|
|
24
|
+
THRESHOLD = 0.30 # 与 KnowledgeEvolutionEngine.similarity_threshold 一致
|
|
25
|
+
DEFAULT_DB = ROOT / "foxclaw_data" / "hkc" / "hkc.db"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_open(conn):
|
|
29
|
+
cols = [r[1] for r in conn.execute("pragma table_info(conflict_cards)")]
|
|
30
|
+
idx = {c: i for i, c in enumerate(cols)}
|
|
31
|
+
rows = conn.execute(
|
|
32
|
+
"select * from conflict_cards where status='open' order by conflict_id"
|
|
33
|
+
).fetchall()
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
"conflict_id": r[idx["conflict_id"]],
|
|
37
|
+
"a": r[idx["claim_a_id"]],
|
|
38
|
+
"b": r[idx["claim_b_id"]],
|
|
39
|
+
"domain": r[idx["domain"]],
|
|
40
|
+
}
|
|
41
|
+
for r in rows
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_claims(conn):
|
|
46
|
+
cols = [r[1] for r in conn.execute("pragma table_info(knowledge_units)")]
|
|
47
|
+
idx = {c: i for i, c in enumerate(cols)}
|
|
48
|
+
out = {}
|
|
49
|
+
for r in conn.execute("select * from knowledge_units"):
|
|
50
|
+
if r[idx["ku_type"]] != "Claim":
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
extra = json.loads(r[idx["extra"]] or "{}")
|
|
54
|
+
except Exception:
|
|
55
|
+
extra = {}
|
|
56
|
+
out[r[idx["ku_id"]]] = {
|
|
57
|
+
"text": (extra.get("statement") or r[idx["name"]] or "").strip(),
|
|
58
|
+
"claim_status": extra.get("claim_status", "active"),
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def classify(cards, claims, emb):
|
|
64
|
+
buckets = {"claim_missing": [], "not_contradiction": [], "still_contradiction": []}
|
|
65
|
+
for card in cards:
|
|
66
|
+
ca, cb = claims.get(card["a"]), claims.get(card["b"])
|
|
67
|
+
if not ca or not cb or not ca["text"] or not cb["text"]:
|
|
68
|
+
buckets["claim_missing"].append(card)
|
|
69
|
+
continue
|
|
70
|
+
score = emb.score(ca["text"], cb["text"])
|
|
71
|
+
card["score"] = round(score, 3)
|
|
72
|
+
card["a_text"], card["b_text"] = ca["text"][:70], cb["text"][:70]
|
|
73
|
+
key = "still_contradiction" if score < -THRESHOLD else "not_contradiction"
|
|
74
|
+
buckets[key].append(card)
|
|
75
|
+
return buckets
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def post(base_url, path, payload, timeout=300):
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
base_url.rstrip("/") + path,
|
|
81
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
82
|
+
headers={"Content-Type": "application/json"},
|
|
83
|
+
method="POST",
|
|
84
|
+
)
|
|
85
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
86
|
+
return json.load(resp)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv=None):
|
|
90
|
+
ap = argparse.ArgumentParser(description="复核并批量关闭误判冲突卡")
|
|
91
|
+
ap.add_argument("--db", default=str(DEFAULT_DB), help="HKC sqlite 路径(只读)")
|
|
92
|
+
ap.add_argument("--base-url", default="http://127.0.0.1:8000",
|
|
93
|
+
help="运行中的服务地址(--apply 时用)")
|
|
94
|
+
ap.add_argument("--apply", action="store_true", help="真正提交,默认只统计")
|
|
95
|
+
ap.add_argument("--chunk", type=int, default=200, help="每次批量提交的张数")
|
|
96
|
+
ap.add_argument("--sample", type=int, default=5, help="每类打印的样本数")
|
|
97
|
+
ap.add_argument("--json", action="store_true", help="输出 JSON 摘要")
|
|
98
|
+
args = ap.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
db = Path(args.db)
|
|
101
|
+
if not db.exists():
|
|
102
|
+
print(f"找不到库文件:{db}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
conn = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True)
|
|
106
|
+
cards = load_open(conn)
|
|
107
|
+
claims = load_claims(conn)
|
|
108
|
+
if not cards:
|
|
109
|
+
print("没有 open 冲突卡,无需处理")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
buckets = classify(cards, claims, _SimpleEmbedder())
|
|
113
|
+
closed = buckets["not_contradiction"] + buckets["claim_missing"]
|
|
114
|
+
|
|
115
|
+
summary = {
|
|
116
|
+
"open_total": len(cards),
|
|
117
|
+
"not_contradiction": len(buckets["not_contradiction"]),
|
|
118
|
+
"claim_missing": len(buckets["claim_missing"]),
|
|
119
|
+
"still_contradiction": len(buckets["still_contradiction"]),
|
|
120
|
+
"to_close": len(closed),
|
|
121
|
+
}
|
|
122
|
+
if args.json:
|
|
123
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
124
|
+
else:
|
|
125
|
+
print(f"open 冲突卡 {summary['open_total']}")
|
|
126
|
+
print(f" 重算后不再矛盾 {summary['not_contradiction']} ← 可关闭")
|
|
127
|
+
print(f" claim 已不存在 {summary['claim_missing']} ← 可关闭")
|
|
128
|
+
print(f" 仍然矛盾 {summary['still_contradiction']} ← 留给人工裁决")
|
|
129
|
+
|
|
130
|
+
if args.sample:
|
|
131
|
+
print("\n仍然矛盾的样本(需人工看):")
|
|
132
|
+
for c in buckets["still_contradiction"][:args.sample]:
|
|
133
|
+
print(f" {c['conflict_id']} score={c['score']}")
|
|
134
|
+
print(f" A: {c['a_text']}")
|
|
135
|
+
print(f" B: {c['b_text']}")
|
|
136
|
+
|
|
137
|
+
if not args.apply:
|
|
138
|
+
if closed:
|
|
139
|
+
print(f"\ndry-run,未提交。确认后加 --apply 关闭 {len(closed)} 张。")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
ids = [c["conflict_id"] for c in closed]
|
|
143
|
+
done = 0
|
|
144
|
+
for i in range(0, len(ids), args.chunk):
|
|
145
|
+
batch = ids[i:i + args.chunk]
|
|
146
|
+
res = post(args.base_url, "/conflicts/bulk-dismiss",
|
|
147
|
+
{"conflict_ids": batch, "resolved_by": "recheck",
|
|
148
|
+
"note": "复核误判:两个观点并非同一命题的正反两面"})
|
|
149
|
+
done += int(res.get("dismissed", 0))
|
|
150
|
+
print(f" 已关闭 {done}/{len(ids)}")
|
|
151
|
+
print(f"完成:提交 {len(ids)} 张,实际关闭 {done} 张")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
sys.exit(main())
|
|
@@ -10,10 +10,11 @@ PyPI / npm 的版本号不可撤回:上传后只能 yank(仍占号、仍可被 p
|
|
|
10
10
|
|
|
11
11
|
两侧产物性质不同(别把"发布成功"当"装完能用")
|
|
12
12
|
------------------------------------------
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
后端是同一份代码:`hmr` 与 `hkc_*` 经 `packages.find` 的 `where=vendor` 被提升成
|
|
14
|
+
顶层包,已经真的在 wheel 里(0.1.2 起就是),pip 装完知识/记忆子系统完好。
|
|
15
|
+
**真正缺的是前端 UI** —— `frontend/` 在仓库根、不属于任何包,构建前必须先跑
|
|
16
|
+
`tools/bundle_web.py` 把它复制成 `foxclaw/web/`。漏跑则 wheel 里没有任何
|
|
17
|
+
html/js,`foxclaw serve` 首页 404,所以这里对"缺 foxclaw/web"判 **error**。
|
|
17
18
|
|
|
18
19
|
用法
|
|
19
20
|
----
|
|
@@ -278,10 +279,18 @@ def check_dist(dist: Path, name: str, version: str) -> list:
|
|
|
278
279
|
issues.append(Issue("error", "missing_entry", f"{sdist.name} 里没有 foxclaw/cli.py"))
|
|
279
280
|
|
|
280
281
|
issues += _scan_forbidden(whl) + _scan_forbidden(sdist)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
# HMR/HKC 在 wheel 里被"提升"成顶层包(hmr/、hkc_ace/…),没有 vendor/ 目录;
|
|
283
|
+
# 判据看顶层包,不看 vendor/ 路径,否则会把完好的 wheel 误判成占位包。
|
|
284
|
+
if not any(e.startswith("hmr/") for e in entries):
|
|
285
|
+
issues.append(Issue("error", "missing_hmr",
|
|
286
|
+
f"{whl.name} 里没有 hmr/ → 记忆子系统会降级;检查 packages.find"))
|
|
287
|
+
if not any(e.startswith("hkc_") for e in entries):
|
|
288
|
+
issues.append(Issue("error", "missing_hkc",
|
|
289
|
+
f"{whl.name} 里没有 hkc_* → 知识子系统会降级;检查 packages.find"))
|
|
290
|
+
if "foxclaw/web/v3/index.html" not in entries:
|
|
291
|
+
issues.append(Issue("error", "missing_web_ui",
|
|
292
|
+
f"{whl.name} 缺 foxclaw/web/v3/index.html → pip 装完首页 404;"
|
|
293
|
+
f"构建前先跑 python tools/bundle_web.py"))
|
|
285
294
|
return issues
|
|
286
295
|
|
|
287
296
|
|
|
@@ -8,6 +8,7 @@ from ..container import HKCContainer, get_container
|
|
|
8
8
|
from ..schemas import (
|
|
9
9
|
AbilityResponse, CoverageReportResponse,
|
|
10
10
|
ConflictResponse, ConflictResolveRequest,
|
|
11
|
+
ConflictDismissRequest, ConflictBulkDismissRequest,
|
|
11
12
|
)
|
|
12
13
|
|
|
13
14
|
router = APIRouter(tags=["ability"])
|
|
@@ -149,3 +150,33 @@ def resolve_conflict(
|
|
|
149
150
|
detail=f"裁决失败:冲突 {conflict_id} 不存在、已解决,或 winner_id 无效"
|
|
150
151
|
)
|
|
151
152
|
return {"conflict_id": conflict_id, "resolved": True, "winner": req.winner_id}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.post("/conflicts/bulk-dismiss")
|
|
156
|
+
def bulk_dismiss_conflicts(
|
|
157
|
+
req: ConflictBulkDismissRequest,
|
|
158
|
+
c: HKCContainer = Depends(get_container),
|
|
159
|
+
):
|
|
160
|
+
"""批量把误判冲突关闭(不判胜负、不废弃任何一方观点)。"""
|
|
161
|
+
n = c.kee.dismiss_conflicts(
|
|
162
|
+
req.conflict_ids, note=req.note, resolved_by=req.resolved_by
|
|
163
|
+
)
|
|
164
|
+
return {"requested": len(req.conflict_ids), "dismissed": n}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.post("/conflicts/{conflict_id}/dismiss")
|
|
168
|
+
def dismiss_conflict(
|
|
169
|
+
conflict_id: str,
|
|
170
|
+
req: ConflictDismissRequest,
|
|
171
|
+
c: HKCContainer = Depends(get_container),
|
|
172
|
+
):
|
|
173
|
+
"""把一张误判冲突标记为已解决(双方观点都保留)。"""
|
|
174
|
+
ok = c.kee.dismiss_conflict(
|
|
175
|
+
conflict_id, note=req.note, resolved_by=req.resolved_by
|
|
176
|
+
)
|
|
177
|
+
if not ok:
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=400,
|
|
180
|
+
detail=f"关闭失败:冲突 {conflict_id} 不存在或已解决"
|
|
181
|
+
)
|
|
182
|
+
return {"conflict_id": conflict_id, "dismissed": True}
|
|
@@ -138,6 +138,17 @@ class ConflictResolveRequest(BaseModel):
|
|
|
138
138
|
resolved_by: str = "human"
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
class ConflictDismissRequest(BaseModel):
|
|
142
|
+
note: str = ""
|
|
143
|
+
resolved_by: str = "human"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ConflictBulkDismissRequest(BaseModel):
|
|
147
|
+
conflict_ids: list[str]
|
|
148
|
+
note: str = ""
|
|
149
|
+
resolved_by: str = "human"
|
|
150
|
+
|
|
151
|
+
|
|
141
152
|
# ── Stats ────────────────────────────────────────────────────
|
|
142
153
|
|
|
143
154
|
class StatsResponse(BaseModel):
|
|
@@ -37,6 +37,10 @@ _NEG_CJK_FALSE = (
|
|
|
37
37
|
"非常", "非凡",
|
|
38
38
|
"不过", "不仅", "不但", "不断", "不只", "不妨", "不禁", "不由", "不时", "不外乎", "不失为",
|
|
39
39
|
"差不多", "差点",
|
|
40
|
+
# 描述性「不/无」:修饰名词或状态而非否定命题(不变/不可观测/不同/不管…),
|
|
41
|
+
# 是最主要的假阳性来源——技术文档几乎每句都带,却与立场无关。
|
|
42
|
+
"不变", "不同", "不论", "不管", "不再", "不需", "无需", "无须",
|
|
43
|
+
"不可观测", "不可见", "不可控", "不可用", "不可变", "不可能",
|
|
40
44
|
)
|
|
41
45
|
_NEG_ASCII_RE = _re.compile(r"\b(" + "|".join(sorted(_NEG_ASCII, key=len, reverse=True)) + r")\b")
|
|
42
46
|
_NEGATION = _NEG_ASCII | _NEG_CJK # 供 _score_keyword 的词集匹配(word-set,ASCII 本就安全)
|
|
@@ -57,6 +61,31 @@ class _SimpleEmbedder:
|
|
|
57
61
|
无 ST/加载失败 → 回退 v1 关键词重叠 + 否定词。接口不变。
|
|
58
62
|
"""
|
|
59
63
|
_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # 与 HKC LocalSTBackend 默认同名 → 共享一份
|
|
64
|
+
# 否定翻转的两个门槛,缺一不可:
|
|
65
|
+
# NEG_FLIP_MIN_COS —— 主题相关性(同领域才算得上对立);
|
|
66
|
+
# NEG_FLIP_MIN_OVERLAP —— 字面同命题(二元组 Jaccard)。
|
|
67
|
+
# 只用余弦时,"同领域不同命题"也会被判反向(实测 1063 张伪冲突的主因);而只用字面
|
|
68
|
+
# 重合又认不出换了说法的对立。两条一起过 = 讲同一件事且一方否定,才是真矛盾。
|
|
69
|
+
NEG_FLIP_MIN_COS = 0.35
|
|
70
|
+
NEG_FLIP_MIN_OVERLAP = 0.40
|
|
71
|
+
|
|
72
|
+
def __init__(self, neg_flip_min_cos: float | None = None,
|
|
73
|
+
neg_flip_min_overlap: float | None = None):
|
|
74
|
+
if neg_flip_min_cos is not None:
|
|
75
|
+
self.NEG_FLIP_MIN_COS = float(neg_flip_min_cos)
|
|
76
|
+
if neg_flip_min_overlap is not None:
|
|
77
|
+
self.NEG_FLIP_MIN_OVERLAP = float(neg_flip_min_overlap)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _bigrams(text: str) -> set:
|
|
81
|
+
t = "".join(ch for ch in (text or "").lower() if not ch.isspace())
|
|
82
|
+
return {t[i:i + 2] for i in range(len(t) - 1)}
|
|
83
|
+
|
|
84
|
+
def _same_proposition(self, text_a: str, text_b: str) -> bool:
|
|
85
|
+
ga, gb = self._bigrams(text_a), self._bigrams(text_b)
|
|
86
|
+
if not ga or not gb:
|
|
87
|
+
return False
|
|
88
|
+
return len(ga & gb) / len(ga | gb) >= self.NEG_FLIP_MIN_OVERLAP
|
|
60
89
|
|
|
61
90
|
@staticmethod
|
|
62
91
|
def _has_neg(text: str) -> bool:
|
|
@@ -81,8 +110,10 @@ class _SimpleEmbedder:
|
|
|
81
110
|
va = _encode_one(model, self._MODEL_NAME, text_a) # 缓存,不重复编码
|
|
82
111
|
vb = _encode_one(model, self._MODEL_NAME, text_b)
|
|
83
112
|
cos = float(np.dot(va, vb)) # 归一化后点积=余弦 ∈ [-1,1]
|
|
84
|
-
#
|
|
85
|
-
if self._has_neg(text_a) != self._has_neg(text_b)
|
|
113
|
+
# 否定方向:同一命题一方否定 → 立场相反 → 反号(矛盾)
|
|
114
|
+
if (self._has_neg(text_a) != self._has_neg(text_b)
|
|
115
|
+
and cos >= self.NEG_FLIP_MIN_COS
|
|
116
|
+
and self._same_proposition(text_a, text_b)):
|
|
86
117
|
return -abs(cos)
|
|
87
118
|
return max(-1.0, min(1.0, cos))
|
|
88
119
|
except Exception:
|
|
@@ -161,16 +192,23 @@ class KnowledgeEvolutionEngine:
|
|
|
161
192
|
id_gen: IDGenerator,
|
|
162
193
|
similarity_threshold: float = 0.30,
|
|
163
194
|
auto_resolve_gap: float = 0.20,
|
|
195
|
+
neg_flip_min_cos: float | None = None,
|
|
196
|
+
neg_flip_min_overlap: float | None = None,
|
|
164
197
|
):
|
|
165
198
|
self.store = graph_store
|
|
166
199
|
self.bus = event_bus
|
|
167
200
|
self.id_gen = id_gen
|
|
168
|
-
self.embedder = _SimpleEmbedder(
|
|
201
|
+
self.embedder = _SimpleEmbedder(
|
|
202
|
+
neg_flip_min_cos=neg_flip_min_cos,
|
|
203
|
+
neg_flip_min_overlap=neg_flip_min_overlap,
|
|
204
|
+
)
|
|
169
205
|
self.state_machine = ClaimStateMachine()
|
|
170
206
|
# 语义方向阈值(score∈[-1,1]):|score|>threshold 判同向(合并)/反向(冲突)。
|
|
171
207
|
# 默认 0.30 与既有硬编码一致(behavior-preserving);可经构造参数调严/调宽。
|
|
172
208
|
self.similarity_threshold = similarity_threshold
|
|
173
209
|
self.auto_resolve_gap = auto_resolve_gap
|
|
210
|
+
self.neg_flip_min_cos = self.embedder.NEG_FLIP_MIN_COS
|
|
211
|
+
self.neg_flip_min_overlap = self.embedder.NEG_FLIP_MIN_OVERLAP
|
|
174
212
|
|
|
175
213
|
# open 冲突卡的 (claim_a, claim_b) 对索引:替代 _handle_conflict 里每次
|
|
176
214
|
# list_conflicts 全扫去重(冲突多时 O(冲突²))。并发摄入会跨线程碰它 → 加锁。
|
|
@@ -539,3 +577,86 @@ class KnowledgeEvolutionEngine:
|
|
|
539
577
|
}
|
|
540
578
|
})
|
|
541
579
|
return True
|
|
580
|
+
|
|
581
|
+
def dismiss_conflict(
|
|
582
|
+
self,
|
|
583
|
+
conflict_id: str,
|
|
584
|
+
note: str = "",
|
|
585
|
+
resolved_by: str = "human",
|
|
586
|
+
) -> bool:
|
|
587
|
+
"""按「误判」关闭一张冲突卡:不判胜负,双方 claim_status 保持原样。"""
|
|
588
|
+
return self.dismiss_conflicts(
|
|
589
|
+
[conflict_id], note=note, resolved_by=resolved_by
|
|
590
|
+
) == 1
|
|
591
|
+
|
|
592
|
+
def dismiss_conflicts(
|
|
593
|
+
self,
|
|
594
|
+
conflict_ids,
|
|
595
|
+
note: str = "",
|
|
596
|
+
resolved_by: str = "human",
|
|
597
|
+
) -> int:
|
|
598
|
+
"""批量关闭误判冲突卡,返回真正关闭的张数。
|
|
599
|
+
|
|
600
|
+
与 manual_resolve 的差别:没有 winner,也不把任何一方标成 superseded。
|
|
601
|
+
唯一例外是因冲突被置为 disputed 的 claim——disputed 会被检索层剔除
|
|
602
|
+
(_sync_ku 分支 3),若它已不牵涉任何 open 冲突,这里恢复成 active;
|
|
603
|
+
否则关掉卡片等于让这条知识永久隐身。
|
|
604
|
+
"""
|
|
605
|
+
cards = []
|
|
606
|
+
for cid in dict.fromkeys(conflict_ids or []):
|
|
607
|
+
card = self.store.get_conflict(cid)
|
|
608
|
+
if card and card.status == ConflictStatus.OPEN:
|
|
609
|
+
cards.append(card)
|
|
610
|
+
if not cards:
|
|
611
|
+
return 0
|
|
612
|
+
|
|
613
|
+
note = note or "误判:双方观点并存,不构成对立"
|
|
614
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
615
|
+
touched = set()
|
|
616
|
+
for card in cards:
|
|
617
|
+
card.status = ConflictStatus.RESOLVED
|
|
618
|
+
card.resolved_by = resolved_by
|
|
619
|
+
card.resolved_at = ts
|
|
620
|
+
card.resolution_note = note
|
|
621
|
+
self.store.save_conflict(card)
|
|
622
|
+
self._forget_open_pair(card.claim_a_id, card.claim_b_id)
|
|
623
|
+
touched.update((card.claim_a_id, card.claim_b_id))
|
|
624
|
+
self.bus.publish({
|
|
625
|
+
"event": KEPEvents.CONFLICT_RESOLVED,
|
|
626
|
+
"source": "KEE",
|
|
627
|
+
"payload": {
|
|
628
|
+
"conflict_id": card.conflict_id,
|
|
629
|
+
"reason": "dismissed",
|
|
630
|
+
"by": resolved_by,
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
with self._conflict_lock:
|
|
635
|
+
self._ensure_open_pairs()
|
|
636
|
+
still_open = set(self._open_pairs or ())
|
|
637
|
+
|
|
638
|
+
restored = []
|
|
639
|
+
for ku_id in touched:
|
|
640
|
+
if any(ku_id in pair for pair in still_open):
|
|
641
|
+
continue
|
|
642
|
+
ku = self.store.get(ku_id)
|
|
643
|
+
if ku is None or getattr(ku, "claim_status", None) != ClaimStatus.DISPUTED:
|
|
644
|
+
continue
|
|
645
|
+
ku.claim_status = ClaimStatus.ACTIVE
|
|
646
|
+
if self.store.patch_ku(
|
|
647
|
+
ku_id, extra={"claim_status": ClaimStatus.ACTIVE.value}
|
|
648
|
+
) == 0:
|
|
649
|
+
self.store.put(ku)
|
|
650
|
+
restored.append(ku_id)
|
|
651
|
+
|
|
652
|
+
if restored:
|
|
653
|
+
self.bus.publish({
|
|
654
|
+
"event": KEPEvents.KNOWLEDGE_UPDATED,
|
|
655
|
+
"source": "KEE",
|
|
656
|
+
"payload": {
|
|
657
|
+
"ku_ids": restored,
|
|
658
|
+
"reason": "conflict_dismissed_restored",
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
return len(cards)
|
|
662
|
+
|
|
@@ -125,6 +125,28 @@ class BaseClient(ABC):
|
|
|
125
125
|
"""人工裁决冲突。"""
|
|
126
126
|
...
|
|
127
127
|
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def dismiss_conflict(
|
|
130
|
+
self,
|
|
131
|
+
conflict_id: str,
|
|
132
|
+
note: str = "",
|
|
133
|
+
resolved_by: str = "sdk",
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""把误判冲突关闭:不判胜负,双方观点都保留。"""
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
def dismiss_conflicts(
|
|
139
|
+
self,
|
|
140
|
+
conflict_ids: list[str],
|
|
141
|
+
note: str = "",
|
|
142
|
+
resolved_by: str = "sdk",
|
|
143
|
+
) -> int:
|
|
144
|
+
"""批量关闭误判冲突,返回真正关闭的张数。默认逐条调用。"""
|
|
145
|
+
return sum(
|
|
146
|
+
1 for cid in conflict_ids
|
|
147
|
+
if self.dismiss_conflict(cid, note=note, resolved_by=resolved_by)
|
|
148
|
+
)
|
|
149
|
+
|
|
128
150
|
# ── 系统 ─────────────────────────────────────────────────
|
|
129
151
|
|
|
130
152
|
@abstractmethod
|
|
@@ -143,6 +143,16 @@ class DirectClient(BaseClient):
|
|
|
143
143
|
note=note, resolved_by=resolved_by,
|
|
144
144
|
)
|
|
145
145
|
|
|
146
|
+
def dismiss_conflict(self, conflict_id, note="", resolved_by="sdk") -> bool:
|
|
147
|
+
return self.c.kee.dismiss_conflict(
|
|
148
|
+
conflict_id=conflict_id, note=note, resolved_by=resolved_by,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def dismiss_conflicts(self, conflict_ids, note="", resolved_by="sdk") -> int:
|
|
152
|
+
return self.c.kee.dismiss_conflicts(
|
|
153
|
+
list(conflict_ids or []), note=note, resolved_by=resolved_by,
|
|
154
|
+
)
|
|
155
|
+
|
|
146
156
|
# ── 系统 ─────────────────────────────────────────────────
|
|
147
157
|
|
|
148
158
|
def stats(self) -> dict:
|
|
@@ -152,6 +152,25 @@ class HTTPClient(BaseClient):
|
|
|
152
152
|
except HKCBadRequest:
|
|
153
153
|
return False
|
|
154
154
|
|
|
155
|
+
def dismiss_conflict(self, conflict_id, note="", resolved_by="sdk") -> bool:
|
|
156
|
+
from .exceptions import HKCBadRequest
|
|
157
|
+
try:
|
|
158
|
+
data = self._request("POST", f"/conflicts/{conflict_id}/dismiss", json={
|
|
159
|
+
"note": note, "resolved_by": resolved_by,
|
|
160
|
+
})
|
|
161
|
+
return data.get("dismissed", False)
|
|
162
|
+
except HKCBadRequest:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def dismiss_conflicts(self, conflict_ids, note="", resolved_by="sdk") -> int:
|
|
166
|
+
conflict_ids = list(conflict_ids or [])
|
|
167
|
+
if not conflict_ids:
|
|
168
|
+
return 0
|
|
169
|
+
data = self._request("POST", "/conflicts/bulk-dismiss", json={
|
|
170
|
+
"conflict_ids": conflict_ids, "note": note, "resolved_by": resolved_by,
|
|
171
|
+
})
|
|
172
|
+
return int(data.get("dismissed", 0))
|
|
173
|
+
|
|
155
174
|
# ── 系统 ─────────────────────────────────────────────────
|
|
156
175
|
|
|
157
176
|
def stats(self) -> dict:
|