增量更新与实体去重实现计划¶
面向代理型工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 来逐步实现此计划。步骤使用复选框(
- [ ])语法进行跟踪。
目标: 为 graphify extract 添加语义缓存与增量图更新,并新增 graphify/dedup.py 模块,该模块在每次运行的聚类之前执行 MinHash/LSH + Jaro-Winkler 实体去重。
架构: 两个独立功能接入同一条流水线:(1)__main__.py 的 extract 块使用 check_semantic_cache/save_semantic_cache + detect_incremental + build_merge 支持增量运行;(2)新的 graphify/dedup.py 实现完整去重流水线,在 build.py 中于图构建之后、cluster() 之前调用。
技术栈: Python 3.10+、datasketch(MinHash/LSH)、rapidfuzz(Jaro-Winkler)、networkx(通过 nx.utils.UnionFind 实现并查集)、现有 graphify.cache、graphify.detect、graphify.build。
文件映射¶
| 文件 | 操作 | 职责 |
|---|---|---|
graphify/dedup.py |
创建 | 完整去重流水线:熵门控 → MinHash/LSH → Jaro-Winkler → 社区增强 → 并查集合并 |
graphify/build.py |
修改 | 从 build() 和 build_merge() 调用 deduplicate_entities();接入休眠状态下的 deduplicate_by_label |
graphify/__main__.py |
修改 | 语义缓存包装、增量模式自动检测、build_merge 切换、清单保存、--dedup-llm 标志 |
pyproject.toml |
修改 | 将 datasketch、rapidfuzz 添加到基础依赖 |
tests/test_dedup.py |
创建 | 去重流水线的单元测试 + 集成测试 |
tests/test_incremental.py |
创建 | 增量 extract 的集成测试(缓存命中、清单、剪枝) |
任务 1:将 datasketch 和 rapidfuzz 添加到依赖¶
文件:
- 修改:pyproject.toml
- [ ] 步骤 1:向 pyproject.toml 添加依赖
打开 pyproject.toml。dependencies 列表大约在第 38 行结束。添加两个条目:
dependencies = [
"networkx",
"datasketch",
"rapidfuzz",
"tree-sitter>=0.23.0",
# ... rest unchanged
]
- [ ] 步骤 2:安装到 venv
预期:两者均能无错误安装。
- [ ] 步骤 3:验证导入
venv/bin/python -c "from datasketch import MinHash, MinHashLSH; from rapidfuzz import fuzz; print('ok')"
预期:ok
- [ ] 步骤 4:提交
任务 2:创建 graphify/dedup.py — 熵门控 + MinHash/LSH + Jaro-Winkler¶
文件:
- 创建:graphify/dedup.py
- 创建:tests/test_dedup.py
- [ ] 步骤 1:编写失败的测试
创建 tests/test_dedup.py:
"""Tests for graphify/dedup.py entity deduplication pipeline."""
from __future__ import annotations
import pytest
from graphify.dedup import deduplicate_entities, _entropy, _shingles
# ── entropy gate ─────────────────────────────────────────────────────────────
def test_entropy_short_label_low():
assert _entropy("AI") < 2.5
def test_entropy_normal_label_high():
assert _entropy("AuthenticationManager") >= 2.5
def test_entropy_empty_string():
assert _entropy("") == 0.0
# ── shingles ─────────────────────────────────────────────────────────────────
def test_shingles_produces_trigrams():
s = _shingles("hello")
assert "hel" in s
assert "ell" in s
assert "llo" in s
def test_shingles_short_string():
# strings shorter than 3 chars return single shingle of the string itself
assert _shingles("ab") == {"ab"}
# ── full pipeline ─────────────────────────────────────────────────────────────
def _make_nodes(*labels):
return [{"id": label.lower().replace(" ", "_"), "label": label, "source_file": "test.md"} for label in labels]
def _make_edges(src, tgt, relation="relates_to"):
return [{"source": src, "target": tgt, "relation": relation}]
def test_exact_duplicates_merged():
nodes = _make_nodes("UserService", "userservice", "User Service")
edges = []
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
labels = {n["label"] for n in result_nodes}
# All three are the same concept — only one survives
assert len(result_nodes) == 1
def test_typo_merged():
# "GraphExtractor" vs "Graph Extractor" — Jaro-Winkler >= 0.92
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 1
def test_unrelated_not_merged():
nodes = _make_nodes("UserService", "OrderService")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2
def test_short_low_entropy_not_merged():
# "AI" and "ML" are low-entropy — entropy gate skips them
nodes = _make_nodes("AI", "ML")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2
def test_edges_rewired_after_merge():
nodes = _make_nodes("GraphExtractor", "Graph Extractor", "Parser")
# edge from loser to Parser should be rewired to winner
edges = [{"source": "graph_extractor", "target": "parser", "relation": "uses"}]
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2 # merged + Parser
# edge should still exist (rewired to winner)
assert len(result_edges) == 1
def test_self_loops_dropped_after_merge():
# If both endpoints of an edge get merged into same node, drop the edge
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
edges = [{"source": "graphextractor", "target": "graph_extractor", "relation": "same"}]
_, result_edges = deduplicate_entities(nodes, edges, communities={})
assert result_edges == []
def test_community_boost_aids_merge():
# Two nodes in same community with score in 0.75-0.85 zone get boosted
# This is a structural test — use labels that would score ~0.80 without boost
# We verify that with communities set, they merge, without they don't
# Use labels that Jaro-Winkler scores ~0.88 (borderline)
nodes = _make_nodes("AuthManager", "Auth Manager")
edges = []
# Same community → boost → merge
communities = {"authmanager": 1, "auth_manager": 1}
result_with, _ = deduplicate_entities(nodes, edges, communities=communities)
# Different community → no boost
communities_diff = {"authmanager": 1, "auth_manager": 2}
result_without, _ = deduplicate_entities(nodes, edges, communities=communities_diff)
assert len(result_with) <= len(result_without)
def test_empty_inputs():
result_nodes, result_edges = deduplicate_entities([], [], communities={})
assert result_nodes == []
assert result_edges == []
def test_single_node_no_crash():
nodes = _make_nodes("UserService")
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
- [ ] 步骤 2:运行测试——验证它们全部失败
预期:全部失败,报错 ModuleNotFoundError: No module named 'graphify.dedup'
- [ ] 步骤 3:创建
graphify/dedup.py
"""Entity deduplication pipeline for graphify knowledge graphs.
Pipeline: exact normalization → entropy gate → MinHash/LSH blocking →
Jaro-Winkler verification → same-community boost → union-find merge.
"""
from __future__ import annotations
import math
import re
from collections import defaultdict
from typing import Any
from datasketch import MinHash, MinHashLSH
from rapidfuzz import fuzz
# ── helpers ───────────────────────────────────────────────────────────────────
def _norm(label: str) -> str:
"""Lowercase + collapse non-alphanumeric runs to space."""
return re.sub(r"[^a-z0-9]+", " ", label.lower()).strip()
def _entropy(label: str) -> float:
"""Shannon entropy in bits/char of the normalised label."""
s = _norm(label)
if not s:
return 0.0
freq: dict[str, int] = defaultdict(int)
for ch in s:
freq[ch] += 1
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in freq.values())
def _shingles(text: str, k: int = 3) -> set[str]:
"""Return k-gram character shingles of text."""
if len(text) < k:
return {text}
return {text[i : i + k] for i in range(len(text) - k + 1)}
def _make_minhash(text: str, num_perm: int = 128) -> MinHash:
m = MinHash(num_perm=num_perm)
for shingle in _shingles(text):
m.update(shingle.encode("utf-8"))
return m
# ── union-find ────────────────────────────────────────────────────────────────
class _UF:
def __init__(self) -> None:
self._parent: dict[str, str] = {}
def find(self, x: str) -> str:
self._parent.setdefault(x, x)
while self._parent[x] != x:
self._parent[x] = self._parent[self._parent[x]]
x = self._parent[x]
return x
def union(self, x: str, y: str) -> None:
self._parent.setdefault(x, x)
self._parent.setdefault(y, y)
rx, ry = self.find(x), self.find(y)
if rx != ry:
self._parent[ry] = rx
def components(self) -> dict[str, list[str]]:
groups: dict[str, list[str]] = defaultdict(list)
for x in self._parent:
groups[self.find(x)].append(x)
return dict(groups)
# ── main entry point ──────────────────────────────────────────────────────────
_ENTROPY_THRESHOLD = 2.5
_LSH_THRESHOLD = 0.7
_JW_THRESHOLD = 92.0 # rapidfuzz returns 0-100
_COMMUNITY_BOOST = 5.0 # added to score when both nodes share community
_MERGE_THRESHOLD = 92.0 # final threshold after boost
_NUM_PERM = 128
_CHUNK_SUFFIX = re.compile(r"_c\d+$")
def deduplicate_entities(
nodes: list[dict],
edges: list[dict],
*,
communities: dict[str, int],
) -> tuple[list[dict], list[dict]]:
"""Deduplicate near-identical entities in a knowledge graph.
Args:
nodes: list of node dicts with at minimum {"id": str, "label": str}
edges: list of edge dicts with {"source": str, "target": str, ...}
communities: mapping of node_id -> community_id (from cluster())
Returns:
(deduped_nodes, deduped_edges) with edges rewired to survivors
"""
if len(nodes) <= 1:
return nodes, edges
# ── pass 1: exact normalization (always runs) ─────────────────────────────
norm_to_nodes: dict[str, list[dict]] = defaultdict(list)
for node in nodes:
key = _norm(node.get("label", node.get("id", "")))
if key:
norm_to_nodes[key].append(node)
uf = _UF()
for key, group in norm_to_nodes.items():
if len(group) > 1:
winner = _pick_winner(group)
for node in group:
uf.union(winner["id"], node["id"])
exact_merges = sum(len(g) - 1 for g in norm_to_nodes.values() if len(g) > 1)
# ── pass 2: MinHash/LSH + Jaro-Winkler (high-entropy nodes only) ─────────
# Build candidate set: one representative per exact-norm group
candidates: list[dict] = []
seen_norms: set[str] = set()
for node in nodes:
key = _norm(node.get("label", node.get("id", "")))
if key and key not in seen_norms:
seen_norms.add(key)
if _entropy(node.get("label", "")) >= _ENTROPY_THRESHOLD:
candidates.append(node)
fuzzy_merges = 0
if len(candidates) >= 2:
lsh = MinHashLSH(threshold=_LSH_THRESHOLD, num_perm=_NUM_PERM)
minhashes: dict[str, MinHash] = {}
for node in candidates:
norm_label = _norm(node.get("label", node.get("id", "")))
m = _make_minhash(norm_label)
minhashes[node["id"]] = m
try:
lsh.insert(node["id"], m)
except ValueError:
pass # duplicate key in LSH — already inserted
for node in candidates:
node_id = node["id"]
norm_label = _norm(node.get("label", node.get("id", "")))
neighbors = lsh.query(minhashes[node_id])
for neighbor_id in neighbors:
if neighbor_id == node_id:
continue
if uf.find(node_id) == uf.find(neighbor_id):
continue # already merged
# Find the neighbour node
neighbor = next((n for n in candidates if n["id"] == neighbor_id), None)
if neighbor is None:
continue
neighbor_norm = _norm(neighbor.get("label", neighbor.get("id", "")))
score = fuzz.jaro_winkler_similarity(norm_label, neighbor_norm) * 100
# Same-community boost
c1 = communities.get(node_id)
c2 = communities.get(neighbor_id)
if c1 is not None and c2 is not None and c1 == c2:
score += _COMMUNITY_BOOST
if score >= _MERGE_THRESHOLD:
all_nodes_in_group = norm_to_nodes.get(norm_label, [node]) + \
norm_to_nodes.get(neighbor_norm, [neighbor])
winner = _pick_winner(all_nodes_in_group)
uf.union(winner["id"], node_id)
uf.union(winner["id"], neighbor_id)
fuzzy_merges += 1
# ── build remap table from union-find components ──────────────────────────
components = uf.components()
remap: dict[str, str] = {}
surviving_ids: set[str] = set()
for root, members in components.items():
if len(members) == 1:
surviving_ids.add(root)
continue
group_nodes = [n for n in nodes if n["id"] in members]
winner = _pick_winner(group_nodes) if group_nodes else {"id": root}
winner_id = winner["id"]
surviving_ids.add(winner_id)
for member in members:
if member != winner_id:
remap[member] = winner_id
# ── apply remap ───────────────────────────────────────────────────────────
if not remap:
return nodes, edges
total = len(remap)
msg = f"[graphify] Deduplicated {total} node(s)"
if exact_merges:
msg += f" ({exact_merges} exact"
if fuzzy_merges:
msg += f", {fuzzy_merges} fuzzy"
msg += ")"
print(msg + ".", flush=True)
deduped_nodes = [n for n in nodes if n["id"] not in remap]
deduped_edges = []
for edge in edges:
e = dict(edge)
e["source"] = remap.get(e["source"], e["source"])
e["target"] = remap.get(e["target"], e["target"])
if e["source"] != e["target"]:
deduped_edges.append(e)
return deduped_nodes, deduped_edges
def _pick_winner(nodes: list[dict]) -> dict:
"""Pick the canonical survivor: prefer no chunk suffix, then shorter ID."""
if not nodes:
raise ValueError("Cannot pick winner from empty list")
def _score(n: dict) -> tuple[int, int]:
has_suffix = bool(_CHUNK_SUFFIX.search(n["id"]))
return (1 if has_suffix else 0, len(n["id"]))
return min(nodes, key=_score)
- [ ] 步骤 4:运行测试 —— 验证测试通过
预期:所有测试均通过。
- [ ] 步骤 5:运行完整测试套件 —— 无回归
预期:通过数量与之前相同(532 通过,5 个 SQL 失败)。
- [ ] 步骤 6:提交
git add graphify/dedup.py tests/test_dedup.py
git commit -m "Add graphify/dedup.py: entropy gate + MinHash/LSH + Jaro-Winkler entity deduplication"
任务 3:将去重接入 build.py¶
涉及文件:
- 修改:graphify/build.py(第 119-137 行的 build(),第 191-244 行的 build_merge())
- [ ] 步骤 1:编写失败的测试
添加到 tests/test_dedup.py:
def test_build_calls_dedup():
"""build() should deduplicate near-identical nodes across extractions."""
from graphify.build import build
chunk1 = {
"nodes": [{"id": "graphextractor", "label": "GraphExtractor", "source_file": "a.py"}],
"edges": [],
}
chunk2 = {
"nodes": [{"id": "graph_extractor", "label": "Graph Extractor", "source_file": "b.py"}],
"edges": [],
}
G = build([chunk1, chunk2])
# Should have merged to 1 node
assert G.number_of_nodes() == 1
- [ ] 步骤 2:运行 —— 验证测试失败
预期:失败 —— 存在两个独立的节点(尚未接入去重)。
- [ ] 步骤 3:修改
build.py中的build()
找到第 119 行的 build()。当前代码:
def build(extractions: list[dict], *, directed: bool = False) -> nx.Graph:
...
combined: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
for ext in extractions:
combined["nodes"].extend(ext.get("nodes", []))
combined["edges"].extend(ext.get("edges", []))
combined["hyperedges"].extend(ext.get("hyperedges", []))
combined["input_tokens"] += ext.get("input_tokens", 0)
combined["output_tokens"] += ext.get("output_tokens", 0)
return build_from_json(combined, directed=directed)
替换为:
def build(extractions: list[dict], *, directed: bool = False, dedup: bool = True) -> nx.Graph:
"""Merge multiple extraction results into one graph.
directed=True produces a DiGraph. dedup=True (default) runs entity
deduplication before building the NetworkX graph.
"""
from graphify.dedup import deduplicate_entities
combined: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
for ext in extractions:
combined["nodes"].extend(ext.get("nodes", []))
combined["edges"].extend(ext.get("edges", []))
combined["hyperedges"].extend(ext.get("hyperedges", []))
combined["input_tokens"] += ext.get("input_tokens", 0)
combined["output_tokens"] += ext.get("output_tokens", 0)
if dedup and combined["nodes"]:
combined["nodes"], combined["edges"] = deduplicate_entities(
combined["nodes"], combined["edges"], communities={}
)
return build_from_json(combined, directed=directed)
- [ ] 步骤 4:修改
build.py中build_merge()的签名
找到第 191 行的 build_merge()。更新签名及对内部 build() 的调用:
def build_merge(
new_chunks: list[dict],
graph_path: str | Path = "graphify-out/graph.json",
prune_sources: list[str] | None = None,
*,
directed: bool = False,
dedup: bool = True,
) -> nx.Graph:
在 build_merge 内部,找到 G = build(all_chunks, directed=directed) 这一行(约第 222 行),替换为:
同时更新安全检查逻辑(约第 235-242 行)。当设置了 dedup=True 或 prune_sources 时,图体积可以合理地缩小 —— 因此跳过缩小保护:
if graph_path.exists() and not dedup and not prune_sources:
existing_n = len(existing_nodes)
new_n = G.number_of_nodes()
if new_n < existing_n:
raise ValueError(
f"graphify: build_merge would shrink graph from {existing_n} → {new_n} nodes. "
f"Pass prune_sources explicitly if you intend to remove nodes."
)
- [ ] 步骤 5:运行测试
预期:全部通过,包括 test_build_calls_dedup。
- [ ] 步骤 6:运行完整测试套件
预期:532 通过,5 个失败(与之前相同的 SQL 失败)。
- [ ] 步骤 7:提交
任务 4:增量更新 —— 在 __main__.py 中加入语义缓存与 manifest¶
涉及文件:
- 修改:graphify/__main__.py(第 1971–2117 行,extract 代码块)
- 创建:tests/test_incremental.py
- [ ] 步骤 1:编写失败的测试
创建 tests/test_incremental.py:
"""Integration tests for incremental graphify extract behavior."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
PYTHON = sys.executable
FIXTURES = Path(__file__).parent / "fixtures"
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[PYTHON, "-m", "graphify"] + args,
cwd=cwd,
capture_output=True,
text=True,
)
def _make_docs_corpus(tmp_path: Path) -> Path:
"""Create a minimal docs corpus with manifest + graph.json for incremental testing."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "intro.md").write_text("# Introduction\nThis doc introduces the system.")
(docs / "api.md").write_text("# API Reference\nThe API has endpoints.")
return docs
def test_manifest_written_after_extract(tmp_path):
"""After a full extract run, manifest.json must exist."""
# We can't do a real LLM extract in CI, but we can test that the manifest
# path is resolved correctly by checking that a missing API key exits early
# before writing the manifest — and that the path would be correct.
docs = _make_docs_corpus(tmp_path)
r = _run(["extract", str(docs)], tmp_path)
# Should fail with no API key — but NOT with a path error
assert "no LLM API key" in r.stderr or r.returncode != 0
# manifest should NOT exist (run failed before writing)
manifest = docs / "graphify-out" / "manifest.json"
assert not manifest.exists()
def test_incremental_mode_detected_via_manifest(tmp_path):
"""If manifest.json + graph.json exist, incremental mode message is shown."""
docs = _make_docs_corpus(tmp_path)
out = docs / "graphify-out"
out.mkdir()
# Fake a prior successful run
(out / "graph.json").write_text(json.dumps({"nodes": [], "links": []}))
(out / "manifest.json").write_text(json.dumps({"document": [str(docs / "intro.md")]}))
r = _run(["extract", str(docs)], tmp_path)
# Should show incremental scan message (even if it fails on API key)
assert "incremental" in r.stderr.lower() or "incremental" in r.stdout.lower() or r.returncode != 0
def test_no_incremental_without_manifest(tmp_path):
"""Without manifest.json, full scan message is shown."""
docs = _make_docs_corpus(tmp_path)
r = _run(["extract", str(docs)], tmp_path)
# Full scan message (not incremental), then fails on API key
assert "incremental" not in r.stdout
- [ ] 步骤 2:运行——验证测试失败或直接通过
注意更改前的当前行为。
- [ ] 步骤 3:更新
__main__.py中的elif cmd == "extract":块
找到第 1971 行(即 from graphify.detect import detect as _detect 行)。将 detect + 文件列表块(第 1971–1984 行)替换为:
from graphify.detect import (
detect as _detect,
detect_incremental as _detect_incremental,
save_manifest as _save_manifest,
)
manifest_path = graphify_out / "manifest.json"
existing_graph_path = graphify_out / "graph.json"
incremental_mode = manifest_path.exists() and existing_graph_path.exists()
if incremental_mode:
print(f"[graphify extract] incremental scan of {target}")
detection = _detect_incremental(target, manifest_path=str(manifest_path))
else:
print(f"[graphify extract] scanning {target}")
detection = _detect(target)
files_by_type = detection.get("files", {})
if incremental_mode:
new_by_type = detection.get("new_files", {})
code_files = [Path(p) for p in new_by_type.get("code", [])]
doc_files = [Path(p) for p in new_by_type.get("document", [])]
paper_files = [Path(p) for p in new_by_type.get("paper", [])]
image_files = [Path(p) for p in new_by_type.get("image", [])]
deleted_files = list(detection.get("deleted_files", []))
unchanged_total = sum(len(v) for v in detection.get("unchanged_files", {}).values())
else:
code_files = [Path(p) for p in files_by_type.get("code", [])]
doc_files = [Path(p) for p in files_by_type.get("document", [])]
paper_files = [Path(p) for p in files_by_type.get("paper", [])]
image_files = [Path(p) for p in files_by_type.get("image", [])]
deleted_files = []
unchanged_total = 0
semantic_files = doc_files + paper_files + image_files
if incremental_mode:
print(
f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, "
f"{len(paper_files)} papers, {len(image_files)} images changed; "
f"{unchanged_total} unchanged; {len(deleted_files)} deleted"
)
else:
print(
f"[graphify extract] found {len(code_files)} code, "
f"{len(doc_files)} docs, {len(paper_files)} papers, "
f"{len(image_files)} images"
)
- [ ] 步骤 4:使用语义缓存包装语义 LLM 调用
找到语义提取块(第 1998–2024 行)。替换为:
from graphify.cache import (
check_semantic_cache as _check_semantic_cache,
save_semantic_cache as _save_semantic_cache,
)
sem_result: dict = {
"nodes": [], "edges": [], "hyperedges": [],
"input_tokens": 0, "output_tokens": 0,
}
sem_cache_hits = 0
sem_cache_misses = 0
if semantic_files:
sem_paths_str = [str(p) for p in semantic_files]
cached_nodes, cached_edges, cached_hyperedges, uncached_paths = (
_check_semantic_cache(sem_paths_str, root=target)
)
sem_cache_hits = len(semantic_files) - len(uncached_paths)
sem_cache_misses = len(uncached_paths)
sem_result["nodes"].extend(cached_nodes)
sem_result["edges"].extend(cached_edges)
sem_result["hyperedges"].extend(cached_hyperedges)
if sem_cache_hits:
print(f"[graphify extract] semantic cache: {sem_cache_hits} hit / {sem_cache_misses} miss")
if uncached_paths:
print(f"[graphify extract] semantic extraction on {len(uncached_paths)} files via {backend}...")
try:
fresh = _extract_corpus_parallel(
[Path(p) for p in uncached_paths],
backend=backend,
root=target,
)
except ImportError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
except Exception as exc:
print(f"[graphify extract] semantic extraction failed: {exc}", file=sys.stderr)
fresh = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
try:
_save_semantic_cache(
fresh.get("nodes", []),
fresh.get("edges", []),
fresh.get("hyperedges", []),
root=target,
)
except Exception as exc:
print(f"[graphify extract] warning: could not write semantic cache: {exc}", file=sys.stderr)
sem_result["nodes"].extend(fresh.get("nodes", []))
sem_result["edges"].extend(fresh.get("edges", []))
sem_result["hyperedges"].extend(fresh.get("hyperedges", []))
sem_result["input_tokens"] += fresh.get("input_tokens", 0)
sem_result["output_tokens"] += fresh.get("output_tokens", 0)
- [ ] 步骤 5:在增量模式下用
build_merge替换build_from_json
找到大约第 2063 行开始的构建块。将:
替换为:
from graphify.build import (
build_from_json as _build_from_json,
build_merge as _build_merge,
)
from graphify.cluster import cluster as _cluster, score_all as _score_all
from graphify.export import to_json as _to_json
from graphify.analyze import god_nodes as _god_nodes, surprising_connections as _surprising
if incremental_mode:
G = _build_merge(
[merged],
graph_path=graph_json_path,
prune_sources=deleted_files or None,
dedup=True,
)
else:
G = _build_from_json(merged)
- [ ] 步骤 6:成功写入后添加 manifest 保存
找到 analysis_path.write_text(...) 这一行(大约在第 2101 行)。在它之后添加:
try:
_save_manifest(
detection.get("files", {}),
manifest_path=str(manifest_path),
)
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
同样在 --no-cluster 路径中,在 graph_json_path.write_text(...) 之后添加相同的代码块(大约在第 2043 行)。
- [ ] 步骤 7:添加
--dedup-llm标志解析
在参数解析 while 循环中(大约在第 1913–1928 行),添加:
并在循环之前初始化 dedup_llm = False。
- [ ] 步骤 8:更新末尾的摘要打印
将最后的摘要行(大约在第 2104–2116 行)替换为:
print(
f"[graphify extract] wrote {graph_json_path}: "
f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges, "
f"{len(communities)} communities"
)
print(f"[graphify extract] wrote {analysis_path}")
if incremental_mode:
print(
f"[graphify extract] incremental summary: "
f"{sem_cache_hits + unchanged_total} files cached/unchanged, "
f"{len(code_files) + sem_cache_misses} re-extracted, "
f"{len(deleted_files)} deleted"
)
elif sem_cache_hits:
print(f"[graphify extract] semantic cache: {sem_cache_hits} cached, {sem_cache_misses} re-extracted")
if merged["input_tokens"] or merged["output_tokens"]:
print(
f"[graphify extract] tokens: "
f"{merged['input_tokens']:,} in / "
f"{merged['output_tokens']:,} out, "
f"est. cost (~{backend}): ${cost:.4f}"
)
- [ ] 步骤 9:运行增量测试
预期:全部通过。
- [ ] 步骤 10:运行完整测试套件
预期:532+ 通过,5 失败(之前已存在的 SQL 问题)。
- [ ] 步骤 11:提交
git add graphify/__main__.py tests/test_incremental.py
git commit -m "Add incremental updates to graphify extract: semantic cache + build_merge + manifest"
任务 5:为 dedup.py 添加 --dedup-llm 决胜逻辑¶
涉及文件:
- 修改:graphify/dedup.py
- [ ] 步骤 1:编写失败的测试
添加到 tests/test_dedup.py:
def test_dedup_llm_flag_accepted():
"""deduplicate_entities accepts dedup_llm_backend without crashing when no ambiguous pairs exist."""
nodes = _make_nodes("UserService", "OrderService")
edges = []
# Should not crash even with dedup_llm_backend set — just nothing to resolve
result_nodes, _ = deduplicate_entities(nodes, edges, communities={}, dedup_llm_backend=None)
assert len(result_nodes) == 2
- [ ] 步骤 2:运行——验证其失败
预期:FAIL——deduplicate_entities 不接受 dedup_llm_backend 关键字参数。
- [ ] 步骤 3:向
deduplicate_entities添加dedup_llm_backend参数
更新 graphify/dedup.py 中的函数签名:
def deduplicate_entities(
nodes: list[dict],
edges: list[dict],
*,
communities: dict[str, int],
dedup_llm_backend: str | None = None,
) -> tuple[list[dict], list[dict]]:
在模糊合并循环之后(构建 remap 表之前),添加 LLM 决胜块:
# ── pass 3: LLM tiebreaker for ambiguous pairs (opt-in) ──────────────────
if dedup_llm_backend is not None:
_llm_tiebreak(candidates, uf, communities, backend=dedup_llm_backend)
在 dedup.py 底部添加辅助函数:
def _llm_tiebreak(
candidates: list[dict],
uf: _UF,
communities: dict[str, int],
*,
backend: str,
batch_size: int = 30,
low: float = 75.0,
high: float = 92.0,
) -> None:
"""Batch-resolve ambiguous pairs (score in [low, high)) via LLM."""
try:
from graphify.llm import extract_corpus_parallel as _llm # noqa: F401
import os
from graphify.llm import BACKENDS
env_key = BACKENDS.get(backend, {}).get("env_key", "")
if not os.environ.get(env_key):
print(f"[graphify] --dedup-llm: {env_key} not set, skipping LLM tiebreaker.", flush=True)
return
except ImportError:
return
# Collect ambiguous pairs
ambiguous: list[tuple[dict, dict, float]] = []
for i, node in enumerate(candidates):
norm_i = _norm(node.get("label", node.get("id", "")))
for j in range(i + 1, len(candidates)):
neighbor = candidates[j]
if uf.find(node["id"]) == uf.find(neighbor["id"]):
continue
norm_j = _norm(neighbor.get("label", neighbor.get("id", "")))
score = fuzz.jaro_winkler_similarity(norm_i, norm_j) * 100
c1 = communities.get(node["id"])
c2 = communities.get(neighbor["id"])
if c1 is not None and c2 is not None and c1 == c2:
score += _COMMUNITY_BOOST
if low <= score < high:
ambiguous.append((node, neighbor, score))
if not ambiguous:
return
# Batch into groups of batch_size and call LLM
try:
from graphify.llm import _call_llm
except ImportError:
return
for batch_start in range(0, len(ambiguous), batch_size):
batch = ambiguous[batch_start : batch_start + batch_size]
pairs_text = "\n".join(
f"{i+1}. \"{a['label']}\" vs \"{b['label']}\""
for i, (a, b, _) in enumerate(batch)
)
prompt = (
"For each pair below, answer only 'yes' or 'no': are they the same real-world concept?\n\n"
f"{pairs_text}\n\n"
"Reply with one line per pair: '1. yes', '2. no', etc."
)
try:
response = _call_llm(prompt, backend=backend, max_tokens=200)
lines = response.strip().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split(".", 1)
if len(parts) != 2:
continue
try:
idx = int(parts[0].strip()) - 1
except ValueError:
continue
if 0 <= idx < len(batch):
answer = parts[1].strip().lower()
if answer.startswith("yes"):
a, b, _ = batch[idx]
winner = _pick_winner([a, b])
uf.union(winner["id"], a["id"])
uf.union(winner["id"], b["id"])
except Exception as exc:
print(f"[graphify] --dedup-llm batch failed: {exc}", flush=True)
- [ ] 步骤 4:运行测试
预期:全部通过。
- [ ] 步骤 5:运行完整测试套件
预期:532+ 通过,5 失败。
- [ ] 步骤 6:提交
git add graphify/dedup.py tests/test_dedup.py
git commit -m "Add --dedup-llm LLM tiebreaker to dedup pipeline"
任务 6:更新 CHANGELOG 并将版本提升至 0.7.5¶
涉及文件:
- 修改:CHANGELOG.md
- 修改:pyproject.toml
- [ ] 步骤 1:添加变更日志条目
在 CHANGELOG.md 顶部添加:
## 0.7.5 (2026-05-04)
- Feat: `graphify extract` now runs incrementally — auto-detects prior `manifest.json` and re-extracts only changed/new files; semantic results cached by content hash so unchanged docs cost zero LLM tokens on repeat runs (#698)
- Feat: Entity deduplication pipeline runs on every build — entropy gate + MinHash/LSH blocking + Jaro-Winkler verification + same-community boost collapses near-duplicate entities (typos, spacing, plurals) before clustering
- Feat: `--dedup-llm` flag for `graphify extract` — optional LLM tiebreaker for ambiguous entity pairs (~$0.01 for 10k-node graphs), off by default
- Deps: `datasketch` and `rapidfuzz` added as base dependencies
- [ ] 步骤 2:升级版本号
在 pyproject.toml 中,将:
- [ ] 步骤 3:最后再运行一次完整测试套件
预期:532+ 通过,5 失败(仅为先前已有的 SQL 相关失败)。
- [ ] 步骤 4:提交
自检¶
规格覆盖情况:
- [x] 语义缓存包装 → 任务 4 步骤 3-4
- [x] 通过 manifest 进行增量自动检测 → 任务 4 步骤 3
- [x] 带 prune_sources 的 build_merge → 任务 4 步骤 5
- [x] 仅在成功时保存 Manifest → 任务 4 步骤 6
- [x] 摘要打印 → 任务 4 步骤 8
- [x] dedup.py 新模块 → 任务 2
- [x] 熵门控 → 任务 2 步骤 3
- [x] MinHash/LSH 分块 → 任务 2 步骤 3
- [x] Jaro-Winkler 验证 → 任务 2 步骤 3
- [x] 同社区提升 → 任务 2 步骤 3
- [x] 并查集合并 → 任务 2 步骤 3
- [x] --dedup-llm 决胜机制 → 任务 5
- [x] 将去重集成到 build() 和 build_merge() → 任务 3
- [x] datasketch + rapidfuzz 依赖 → 任务 1
- [x] 针对所有去重步骤的测试 → 任务 2 + 3
- [x] 针对增量功能的测试 → 任务 4
- [x] 更新 CHANGELOG 并升级版本 → 任务 6
占位符扫描: 未发现任何占位符。
类型一致性: deduplicate_entities(nodes, edges, *, communities, dedup_llm_backend=None) 在任务 2、任务 3、任务 5 中一致使用。build(extractions, *, directed, dedup) 在任务 3 中保持一致。build_merge(..., dedup=True) 在任务 3 和任务 4 中保持一致。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 graph/graphify