scripts/dedupe_memory_fact_keys.py
Rama main · réplica de lectura del repositorio original.
"""记忆库 fact_key 归并清理(一次性 / 可重复执行).
背景
----
2026-10-05审计发现:「用户要求禁止使用 file 工具和执行命令」这一条语义相同的
偏好,在 memory.db 中并存 4 条 **active** 记录,fact_key 各不相同
(excel_desktop_automation / tool_usage / desktop_tool / tool_restriction)。
根因是模型每次抽取现编新key,导致 store.add 的冲突失效与 _dedup 的语义去重
同时失效。
本脚本把**同义 fact_key 的多条 active 记忆归并为一条**:保留最新 + 重要度最高
的那条,其余标记为 ``deprecated``(不物理删除,符合"矛盾事实不要直接删除"的
既有原则,保留可追溯)。
用法::
# 干跑(只看会改什么,不落库)—— 默认
python scripts/dedupe_memory_fact_keys.py --db "D:/.scout/memory.db"
# 确认后真正执行
python scripts/dedupe_memory_fact_keys.py --db "D:/.scout/memory.db" --apply
# 顺带把所有旧式 key 重写为受控词表规范 key
python scripts/dedupe_memory_fact_keys.py --db "D:/.scout/memory.db" --rewrite-keys --apply
脚本对 db 只做写操作前会先备份到 ``<db>.bak-dedupe-<ts>``。
"""
from __future__ import annotations
import argparse
import logging
import os
import shutil
import sys
import time
from collections import defaultdict
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scout.context.fact_key_registry import ( # noqa: E402
MERGE_SIM_THRESHOLD,
_key_similarity,
canonicalize_fact_key,
)
from scout.context.memory_extract import _jaccard, _tokenize # noqa: E402
logger = logging.getLogger("dedupe_fact_keys")
def _connect(db_path: str):
import sqlite3
return sqlite3.connect(db_path)
def _table_columns(conn) -> set[str]:
return {r[1] for r in conn.execute("PRAGMA table_info(memories)")}
def plan(conn, threshold: float, rewrite_keys: bool):
"""计算归并计划,不改库.
Returns:
``(clusters, rewrite_pairs)``:
- clusters: 同义 key 簇,每簇是一个 dict(keeper_id, dup_ids, canonical_key)
- rewrite_pairs: (id, old_key, new_key) —— 仅 --rewrite-keys 时非空
"""
cols = _table_columns(conn)
has_fk = "fact_key" in cols
has_status = "status" in cols
has_vt = "valid_to" in cols
if not has_fk:
logger.error("memories 表无 fact_key 列,无法归并。")
return [], []
sel = "id, content, importance, fact_key, created_at"
sel += ", status" if has_status else ", 'active' AS status"
rows = conn.execute(f"SELECT {sel} FROM memories WHERE status = 'active'").fetchall()
rewrite_pairs = []
if rewrite_keys:
for row in rows:
rid, fk = row[0], row[3]
if not fk:
continue
ck = canonicalize_fact_key(fk)
if ck != fk:
rewrite_pairs.append((rid, fk, ck))
# 按 fact_key 分组(非空)
by_key: dict[str, list[tuple]] = defaultdict(list)
for r in rows:
if r[3] and r[3].strip():
by_key[r[3].strip()].append(r)
# 找出语义等价的 key 簇
keys = list(by_key.keys())
parent: dict[str, str] = {k: k for k in keys}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a: str, b: str) -> None:
ra, rb = find(a), find(b)
if ra != rb:
# 保留字典序小的作为簇代表(确定性)
if rb < ra:
ra, rb = rb, ra
parent[rb] = ra
for i in range(len(keys)):
for j in range(i + 1, len(keys)):
a, b = keys[i], keys[j]
if find(a) == find(b):
continue
if _key_similarity(canonicalize_fact_key(a), canonicalize_fact_key(b)) >= threshold:
union(a, b)
clusters = []
groups: dict[str, list[str]] = defaultdict(list)
for k in keys:
groups[find(k)].append(k)
for _root, members in groups.items():
if len(members) < 2:
continue
# 收集簇内所有记忆
entries = []
for mk in members:
entries.extend(by_key[mk])
if len(entries) < 2:
continue
# 保留:active 优先(这里都是 active)→ 最新 created_at → 最高 importance → 最小 id
entries.sort(
key=lambda r: (r[4] or "", float(r[2] or 0), -r[0]),
reverse=True,
)
keeper = entries[0]
dups = [e[0] for e in entries[1:]]
clusters.append({
"keeper_id": keeper[0],
"dup_ids": dups,
"canonical_key": canonicalize_fact_key(members[0]),
"member_keys": members,
"n": len(entries),
})
clusters.sort(key=lambda c: c["n"], reverse=True)
_ = has_vt # valid_to 列在 apply 阶段按存在性处理
return clusters, rewrite_pairs
def apply_plan(conn, clusters, rewrite_pairs) -> int:
from datetime import datetime as _dt
cols = _table_columns(conn)
has_vt = "valid_to" in cols
now = _dt.now().isoformat()
n = 0
for rid, _old, new in rewrite_pairs:
conn.execute("UPDATE memories SET fact_key = ? WHERE id = ?", (new, rid))
n += 1
for c in clusters:
dup_sql = "UPDATE memories SET status = 'deprecated'"
params: list = []
if has_vt:
dup_sql += ", valid_to = ?"
params.append(now)
dup_sql += " WHERE id IN (%s)" % ",".join("?" * len(c["dup_ids"]))
params.extend(c["dup_ids"])
conn.execute(dup_sql, params)
n += len(c["dup_ids"])
conn.commit()
return n
def report_keyless(conn, clusters, threshold: float = 0.25) -> list[dict]:
"""报告「落在同义簇语义范围内、但没有 fact_key」的疑似重复条目.
为什么只报告不自动归并
----------------------
实测(2026-10-05):id=1618「用户要求桌面自动化操作必须在真实窗口上点击和键入,
不接受用 file 工具或 shell/COM 等方式替代」与保留项 1666「用户要求禁止使用 file
工具和执行命令」**语义明显是同一条偏好**,但字符 Jaccard 仅 **0.178**。
这正好证明**内容相似度不能用作归并依据**——字符级 Jaccard 对"换一种说法表达
同一约束"几乎无能为力(此前实测同簇条目也只到 0.53~0.68)。若据此自动合并,
就会退化成"下调 dedup_threshold"那条已被验证会误杀合法事实更新的老路。
因此这类条目只做提示,由人工/LLM 复核后再决定是否回填 fact_key。
"""
if not clusters:
return []
cols = _table_columns(conn)
has_created = "created_at" in cols
sel = "id, content, importance, fact_key" + (", created_at" if has_created else "")
rows = conn.execute(f"SELECT {sel} FROM memories WHERE status = 'active'").fetchall()
flagged = []
for c in clusters:
keeper_row = conn.execute(
"SELECT content FROM memories WHERE id = ?", (c["keeper_id"],)
).fetchone()
if not keeper_row:
continue
keeper_tokens = _tokenize(keeper_row[0])
for r in rows:
rid, content = r[0], r[1]
if rid == c["keeper_id"] or rid in c["dup_ids"]:
continue
if r[3] and r[3].strip():
continue # 只看无 key 的
sim = _jaccard(keeper_tokens, _tokenize(content))
if sim >= threshold:
flagged.append({
"id": rid,
"suspect_cluster": c["canonical_key"],
"keeper_id": c["keeper_id"],
"jaccard": sim,
"content": content,
})
flagged.sort(key=lambda x: x["jaccard"], reverse=True)
return flagged
def main() -> int:
ap = argparse.ArgumentParser(description="归并同义 fact_key 的重复记忆")
ap.add_argument("--db", default=r"D:\.scout\memory.db", help="memory.db 路径")
ap.add_argument("--apply", action="store_true", help="真正写库(默认干跑)")
ap.add_argument("--rewrite-keys", action="store_true",
help="同时把旧式 key 重写为受控词表规范 key")
ap.add_argument("--threshold", type=float, default=MERGE_SIM_THRESHOLD,
help=f"key 语义归并阈值(默认 {MERGE_SIM_THRESHOLD})")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(levelname)s %(message)s",
)
if not os.path.exists(args.db):
logger.error("找不到 db: %s", args.db)
return 2
conn = _connect(args.db)
clusters, rewrites = plan(conn, args.threshold, args.rewrite_keys)
print(f"db: {args.db}")
print(f"可归并簇: {len(clusters)} 涉及重复条目: {sum(len(c['dup_ids']) for c in clusters)}")
if args.rewrite_keys:
print(f"待重写 key 的条目: {len(rewrites)}")
print()
for c in clusters[:20]:
print(f" 簇({c['n']} 条) 规范键={c['canonical_key']}")
print(f" 成员键: {', '.join(c['member_keys'])}")
print(f" 保留 id={c['keeper_id']},废弃 id={c['dup_ids']}")
if len(clusters) > 20:
print(f" ...另有 {len(clusters) - 20} 簇")
print()
# 无 key 疑似重复:只报告,不自动归并(理由见 report_keyless docstring)
try:
flagged = report_keyless(conn, clusters)
except Exception as exc: # noqa: BLE001 — 报告失败不影响主流程
logger.debug("keyless 报告跳过: %s", exc)
flagged = []
if flagged:
print(f"⚠ 另有 {len(flagged)} 条**无 fact_key** 的疑似重复(语义同簇,"
f"但字符相似度不足以自动判定,仅提示不处理):")
for f in flagged[:10]:
print(f" id={f['id']} 疑似同簇={f['suspect_cluster']} "
f"(jaccard={f['jaccard']:.2f})")
print(f" {f['content'][:70]}")
if len(flagged) > 10:
print(f" ...另有 {len(flagged) - 10} 条")
print()
if not args.apply:
print("干跑模式,未写库。确认无误后加 --apply 执行。")
return 0
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = f"{args.db}.bak-dedupe-{ts}"
# 备份需连同 -wal/-shm 一起,否则 SQLite 打开备份会丢未 checkpoint 的数据
conn.commit()
conn.close()
for suffix in ("", "-wal", "-shm"):
src = args.db + suffix
if os.path.exists(src):
shutil.copy2(src, backup + suffix)
logger.info("已备份到 %s(含 -wal/-shm)", backup)
conn = _connect(args.db)
n = apply_plan(conn, clusters, rewrites)
conn.close()
print(f"完成:更新 {n} 条记录。备份 {backup}")
return 0
if __name__ == "__main__":
raise SystemExit(main())