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
@@ -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
- HKC / HMR 内嵌在 vendor/、运行期按 `Path(__file__)/../../vendor` 找;而 setuptools
14
- 只收 `foxclaw*`,故 **wheel/sdist 不含 vendor/**:装上是能 import、但知识/记忆
15
- 子系统降级为 None 的**占位包**(与 npm/index.js 占位同一性质)。本脚本对"缺 vendor"
16
- 只 warn 不 error —— 既定事实不是事故,但发布说明必须写明。
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
- if not any("/vendor/" in e for e in entries):
282
- issues.append(Issue("warn", "no_vendor",
283
- f"{whl.name} 不含 vendor/(hkc/hmr)→ 装上是占位包,"
284
- f"知识/记忆子系统降级;发布说明需写明"))
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) and cos > 0.35:
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: