wire --dedup-llm through build pipeline and fix fresh-extract dedup bypass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-04 18:51:14 +01:00
co-authored by Claude Sonnet 4.6
parent 913feca6a6
commit 579e1cc744
4 changed files with 385 additions and 8 deletions
+3 -3
View File
@@ -221,7 +221,7 @@ The MCP server gives your assistant structured access: `query_graph`, `get_node`
- **Code files** — processed locally via tree-sitter. Nothing leaves your machine.
- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` or `MOONSHOT_API_KEY`.
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` (Claude) or `MOONSHOT_API_KEY` (Kimi). The `--dedup-llm` flag uses the same key.
- No telemetry, no usage tracking, no analytics.
---
@@ -274,9 +274,9 @@ graphify kiro install / uninstall
graphify antigravity install / uninstall
graphify extract ./docs # headless LLM extraction for CI (no IDE needed)
graphify extract ./docs --backend claude # explicit backend (auto-detected from env by default)
graphify extract ./docs --backend claude # explicit backend: claude (ANTHROPIC_API_KEY) or kimi (MOONSHOT_API_KEY)
graphify extract ./docs --no-cluster # raw extraction only, skip clustering
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key)
graphify clone https://github.com/karpathy/nanoGPT
graphify merge-graphs a.json b.json --out merged.json
+365
View File
@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Graphify Deduplication Architecture</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'SF Mono', 'Fira Code', monospace;
background: #0d1117;
color: #e6edf3;
padding: 40px 20px;
min-height: 100vh;
}
h1 {
text-align: center;
font-size: 22px;
font-weight: 600;
color: #58a6ff;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.subtitle {
text-align: center;
font-size: 13px;
color: #8b949e;
margin-bottom: 40px;
}
.diagram {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0;
}
/* ── generic block ── */
.block {
border-radius: 8px;
padding: 14px 20px;
position: relative;
}
.block-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.block-body {
font-size: 13px;
line-height: 1.7;
color: #c9d1d9;
}
.block-body code {
background: rgba(255,255,255,0.08);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
color: #ffa657;
}
.block-body .dim { color: #8b949e; }
/* ── colours ── */
.entry { background: #161b22; border: 1px solid #30363d; }
.pass0 { background: #0d2840; border: 1px solid #1f6feb; }
.pass1 { background: #1a1f2e; border: 1px solid #3d5a80; }
.pass2 { background: #1a2d1a; border: 1px solid #3fb950; }
.pass2b { background: #1a2d1a; border: 1px dashed #3fb950; }
.pass3 { background: #2d1f1a; border: 1px dashed #f0883e; }
.output { background: #1e1a2d; border: 1px solid #8957e5; }
.callers { background: #161b22; border: 1px solid #30363d; }
/* ── arrows ── */
.arrow {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
color: #8b949e;
font-size: 20px;
position: relative;
}
.arrow-label {
position: absolute;
right: 60px;
font-size: 11px;
color: #6e7681;
font-style: italic;
}
/* ── pass 2 sub-steps ── */
.subflow {
display: flex;
flex-direction: row;
gap: 0;
margin: 4px 0 0 0;
align-items: stretch;
}
.sub-arrow {
display: flex;
align-items: center;
color: #3fb950;
font-size: 18px;
padding: 0 6px;
}
.substep {
background: rgba(63,185,80,0.08);
border: 1px solid #238636;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
flex: 1;
line-height: 1.5;
color: #c9d1d9;
}
.substep strong {
display: block;
color: #3fb950;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.substep code {
background: rgba(255,255,255,0.07);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
color: #ffa657;
}
.substep .note {
color: #6e7681;
font-size: 11px;
margin-top: 3px;
}
/* ── bypass branch ── */
.bypass {
background: rgba(240,136,62,0.08);
border: 1px dashed #f0883e;
border-radius: 6px;
padding: 8px 14px;
font-size: 12px;
color: #c9d1d9;
margin-top: 4px;
}
.bypass strong { color: #f0883e; }
/* ── callers row ── */
.callers-row {
display: flex;
gap: 12px;
margin-bottom: 4px;
}
.caller-box {
flex: 1;
background: #21262d;
border: 1px solid #30363d;
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
color: #c9d1d9;
}
.caller-box strong {
display: block;
color: #58a6ff;
margin-bottom: 4px;
font-size: 12px;
}
.caller-box code {
background: rgba(255,255,255,0.07);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
color: #ffa657;
}
.caller-box .path {
color: #8b949e;
font-size: 11px;
margin-bottom: 4px;
}
/* ── threshold legend ── */
.legend {
max-width: 900px;
margin: 28px auto 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.legend-item {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 8px 14px;
font-size: 12px;
color: #c9d1d9;
}
.legend-item span {
font-weight: 700;
color: #58a6ff;
}
.legend-title {
max-width: 900px;
margin: 20px auto 8px;
font-size: 12px;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 1px;
}
</style>
</head>
<body>
<h1>Graphify — Deduplication Pipeline</h1>
<p class="subtitle">graphify/dedup.py · called from build.py before graph construction · v0.7.5</p>
<div class="diagram">
<!-- CALLERS -->
<div class="block callers">
<div class="block-title" style="color:#8b949e">Entry Points</div>
<div class="callers-row">
<div class="caller-box">
<strong>build()</strong>
<div class="path">graphify/build.py:119</div>
<div>Merges multiple extractions, then calls <code>deduplicate_entities(nodes, edges, communities={})</code> before <code>build_from_json()</code></div>
<div style="margin-top:5px;color:#6e7681;font-size:11px">Flag: <code>dedup=True</code> (default)</div>
</div>
<div class="caller-box">
<strong>build_merge()</strong>
<div class="path">graphify/build.py:197</div>
<div>Incremental mode: loads existing <code>graph.json</code>, merges new chunks, calls <code>build()</code> with <code>dedup=True</code></div>
<div style="margin-top:5px;color:#6e7681;font-size:11px">Shrink-guard skipped when dedup is active</div>
</div>
<div class="caller-box">
<strong>__main__.py extract</strong>
<div class="path">graphify/__main__.py</div>
<div>Passes <code>--dedup-llm</code> flag through to enable LLM tiebreaker in Pass 3</div>
<div style="margin-top:5px;color:#6e7681;font-size:11px">Also triggers via <code>/graphify</code> skill</div>
</div>
</div>
</div>
<div class="arrow"><span class="arrow-label">nodes: list[dict], edges: list[dict], communities: dict</span></div>
<!-- PASS 0 -->
<div class="block pass0">
<div class="block-title" style="color:#58a6ff">Pre-pass — ID Deduplication</div>
<div class="block-body">
Collapse nodes with identical <code>id</code> fields (last-wins). Prevents AST extractors generating <code>"UserService"</code> and <code>"userservice"</code> as separate nodes (both normalize to the same id) from confusing the union-find. <span class="dim">O(n) dict pass.</span>
</div>
</div>
<div class="arrow"></div>
<!-- PASS 1 -->
<div class="block pass1">
<div class="block-title" style="color:#79c0ff">Pass 1 — Exact Normalization</div>
<div class="block-body">
For every node, compute <code>_norm(label)</code>: lowercase, strip all non-alphanumeric characters, collapse whitespace.
Group nodes sharing the same norm key into union-find clusters. <span class="dim">O(n).</span>
<br><br>
Examples: <code>"HTTP Client"</code> = <code>"http client"</code> = <code>"HTTPClient"</code> = <code>"http_client"</code>
</div>
</div>
<div class="arrow"><span class="arrow-label">unmerged pairs only</span></div>
<!-- PASS 2 header -->
<div class="block pass2">
<div class="block-title" style="color:#3fb950">Pass 2 — Fuzzy Matching (per candidate pair via MinHash/LSH blocking)</div>
<div class="block-body" style="margin-bottom:10px">
Candidate pairs are generated by MinHash LSH (not all-pairs), then scored by Jaro-Winkler. Community membership boosts the score.
</div>
<div class="subflow">
<div class="substep">
<strong>① Entropy Gate</strong>
<code>_entropy(label)</code><br>
Shannon bits/char &lt; <code>2.5</code> → skip fuzzy<br>
<span class="note">Short/low-info labels like "A", "get", "fn" would generate false positives at scale</span>
</div>
<div class="sub-arrow"></div>
<div class="substep">
<strong>② MinHash / LSH Blocking</strong>
3-gram shingles (spaces stripped), 128 permutations, Jaccard threshold <code>0.7</code><br>
<code>datasketch.MinHashLSH</code><br>
<span class="note">Space-stripping: "graph extractor" ≡ "graphextractor" at shingling level</span>
</div>
<div class="sub-arrow"></div>
<div class="substep">
<strong>③ Jaro-Winkler Score</strong>
<code>JaroWinkler.normalized_similarity(a,b) × 100</code><br>
<code>rapidfuzz.distance.JaroWinkler</code><br>
Merge if score ≥ <code>92.0</code> after boost
</div>
<div class="sub-arrow"></div>
<div class="substep">
<strong>④ Community Boost</strong>
Same community (from clustering) → +<code>5.0</code> pts<br>
<span class="note">Entities in the same module/cluster are more likely to be the same concept</span>
</div>
<div class="sub-arrow"></div>
<div class="substep">
<strong>⑤ Union-Find Merge</strong>
<code>_UF</code> class, path compression<br>
All connected pairs → single cluster<br>
<span class="note">Transitivity: if A~B and B~C then A,B,C all merge</span>
</div>
</div>
</div>
<div class="arrow"><span class="arrow-label">ambiguous zone 7592 pts (only with --dedup-llm)</span></div>
<!-- PASS 3 -->
<div class="block pass3">
<div class="block-title" style="color:#f0883e">Pass 3 — LLM Tiebreaker (optional, <code>--dedup-llm</code>)</div>
<div class="block-body">
Pairs scoring <code>75.092.0</code> after community boost are batched in groups of <code>30</code> and sent to Claude for a semantic judgement call. One API call per batch. LLM-approved pairs are fed back into the union-find for merging.
<br><br>
<span class="dim">Disabled by default — catches cases like "Synchronous HTTP client." vs "Asynchronous HTTP client." (JW=98.6) where string similarity is high but meaning differs. Without this flag, such pairs merge at Pass 2; with it, the LLM rejects the merge.</span>
</div>
</div>
<div class="arrow"></div>
<!-- REMAP -->
<div class="block pass2b">
<div class="block-title" style="color:#3fb950">Remap — Winner Selection &amp; Edge Rewiring</div>
<div class="block-body">
For each cluster of merged nodes, <code>_pick_winner(cluster)</code> selects the canonical id:
<br>1. Prefer ids <em>without</em> chunk suffix (<code>_c\d+</code>)
<br>2. Prefer shorter id on tie
<br><br>
All edges are rewritten: <code>source/target</code> remapped to winner ids. Self-loops created by the merge are dropped. Surviving nodes list uses only winners.
</div>
</div>
<div class="arrow"></div>
<!-- OUTPUT -->
<div class="block output">
<div class="block-title" style="color:#8957e5">Output → build_from_json()</div>
<div class="block-body">
Returns <code>(deduped_nodes, deduped_edges)</code> — passed directly into <code>build_from_json()</code> to construct the NetworkX graph. On a 17,497-node corpus: <strong>4,938 nodes merged</strong> (3,831 exact + 1,107 fuzzy) → <strong>12,559 nodes</strong> in final graph.
</div>
</div>
</div>
<!-- LEGEND -->
<p class="legend-title">Thresholds &amp; Constants</p>
<div class="legend">
<div class="legend-item"><span>_ENTROPY_THRESHOLD</span> = 2.5 bits/char — below this, skip fuzzy (short/generic labels)</div>
<div class="legend-item"><span>_LSH_THRESHOLD</span> = 0.7 Jaccard — MinHash blocking gate</div>
<div class="legend-item"><span>_MERGE_THRESHOLD</span> = 92.0 JW — auto-merge above this score</div>
<div class="legend-item"><span>_COMMUNITY_BOOST</span> = +5.0 pts — same community bonus</div>
<div class="legend-item"><span>LLM tiebreak zone</span> = 75.092.0 JW (only with <code>--dedup-llm</code>)</div>
<div class="legend-item"><span>MinHash permutations</span> = 128, shingle size = 3-gram (spaces stripped)</div>
</div>
</body>
</html>
+4 -2
View File
@@ -2126,22 +2126,24 @@ def main() -> None:
# Build graph + cluster + score + write.
from graphify.build import (
build as _build,
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
dedup_backend = backend if dedup_llm else None
if incremental_mode:
G = _build_merge(
[merged],
graph_path=existing_graph_path,
prune_sources=deleted_files or None,
dedup=True,
dedup_llm_backend=dedup_backend,
)
else:
G = _build_from_json(merged)
G = _build([merged], dedup=True, dedup_llm_backend=dedup_backend)
if G.number_of_nodes() == 0:
print(
"[graphify extract] graph is empty — extraction produced no nodes. "
+13 -3
View File
@@ -116,12 +116,20 @@ def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph:
return G
def build(extractions: list[dict], *, directed: bool = False, dedup: bool = True) -> nx.Graph:
def build(
extractions: list[dict],
*,
directed: bool = False,
dedup: bool = True,
dedup_llm_backend: str | None = None,
) -> nx.Graph:
"""Merge multiple extraction results into one graph.
directed=True produces a DiGraph that preserves edge direction (sourcetarget).
directed=False (default) produces an undirected Graph for backward compatibility.
dedup=True (default) runs entity deduplication before building the graph.
dedup_llm_backend: if set (e.g. "claude" or "kimi"), uses LLM to resolve
ambiguous pairs in the 7592 Jaro-Winkler score zone.
Extractions are merged in order. For nodes with the same ID, the last
extraction's attributes win (NetworkX add_node overwrites). Pass AST
@@ -138,7 +146,8 @@ def build(extractions: list[dict], *, directed: bool = False, dedup: bool = True
combined["output_tokens"] += ext.get("output_tokens", 0)
if dedup and combined["nodes"]:
combined["nodes"], combined["edges"] = deduplicate_entities(
combined["nodes"], combined["edges"], communities={}
combined["nodes"], combined["edges"], communities={},
dedup_llm_backend=dedup_llm_backend,
)
return build_from_json(combined, directed=directed)
@@ -201,6 +210,7 @@ def build_merge(
*,
directed: bool = False,
dedup: bool = True,
dedup_llm_backend: str | None = None,
) -> nx.Graph:
"""Load existing graph.json, merge new chunks into it, and save back.
@@ -226,7 +236,7 @@ def build_merge(
base = []
all_chunks = base + list(new_chunks)
G = build(all_chunks, directed=directed, dedup=dedup)
G = build(all_chunks, directed=directed, dedup=dedup, dedup_llm_backend=dedup_llm_backend)
# Prune nodes from deleted source files
if prune_sources: