Skip to content

Commit 02b7924

Browse files
committed
fix(graphrag): filter graph noise from wiki evidence
1 parent aa96162 commit 02b7924

11 files changed

Lines changed: 230 additions & 43 deletions

File tree

backend/app/services/chunk_builder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33

44
from backend.app.database import CodeChunkRecord
5+
from backend.app.services.file_roles import is_wiki_noise_file
56
from backend.app.services.graph import CodeGraphNode
67
from backend.app.services.graphrag.constants import SOURCE_NODE_TYPES
78
from backend.app.services.graphrag.utils import estimate_tokens, stable_id
@@ -28,6 +29,8 @@ def build_source_chunks(
2829
for node in sorted(nodes, key=lambda item: item.type == "file"):
2930
if node.type not in CHUNK_SOURCE_NODE_TYPES or not node.file_path:
3031
continue
32+
if is_wiki_noise_file(node.file_path):
33+
continue
3134
lines = line_cache.get(node.file_path)
3235
if lines is None:
3336
file_path = (root / node.file_path).resolve()

backend/app/services/file_roles.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
from fnmatch import fnmatch
4+
from pathlib import PurePosixPath
5+
6+
from backend.app.services.graph.models import CodeGraphEdge, CodeGraphNode
7+
8+
LOCKFILE_NAMES = {"uv.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"}
9+
GENERATED_DIR_NAMES = {
10+
".next",
11+
".nuxt",
12+
".svelte-kit",
13+
"build",
14+
"coverage",
15+
"dist",
16+
"htmlcov",
17+
"out",
18+
"target",
19+
}
20+
GENERATED_FILE_SUFFIXES = {
21+
".bundle.js",
22+
".bundle.css",
23+
".d.ts",
24+
".generated.py",
25+
".generated.ts",
26+
".generated.tsx",
27+
".min.css",
28+
".min.js",
29+
}
30+
GENERATED_FILE_NAMES = LOCKFILE_NAMES
31+
VENDOR_DIR_NAMES = {
32+
".git",
33+
".hg",
34+
".svn",
35+
".venv",
36+
"node_modules",
37+
"site-packages",
38+
"vendor",
39+
"vendors",
40+
"venv",
41+
}
42+
TEST_DIR_NAMES = {"__tests__", "e2e", "spec", "specs", "test", "tests"}
43+
44+
45+
def normalize_file_path(file_path: str) -> str:
46+
return file_path.replace("\\", "/").strip("/")
47+
48+
49+
def is_test_file(file_path: str, test_glob: str | None = None) -> bool:
50+
normalized = normalize_file_path(file_path)
51+
lowered = normalized.lower()
52+
if test_glob and fnmatch(normalized, test_glob):
53+
return True
54+
name = lowered.rsplit("/", 1)[-1]
55+
parts = set(PurePosixPath(lowered).parts)
56+
return (
57+
name.startswith("test_")
58+
or name.endswith("_test.py")
59+
or name.endswith("_test.go")
60+
or ".test." in name
61+
or ".spec." in name
62+
or bool(parts & TEST_DIR_NAMES)
63+
)
64+
65+
66+
def is_generated_file(file_path: str) -> bool:
67+
normalized = normalize_file_path(file_path)
68+
lowered = normalized.lower()
69+
name = lowered.rsplit("/", 1)[-1]
70+
parts = set(PurePosixPath(lowered).parts)
71+
return (
72+
name in GENERATED_FILE_NAMES
73+
or any(name.endswith(suffix) for suffix in GENERATED_FILE_SUFFIXES)
74+
or bool(parts & GENERATED_DIR_NAMES)
75+
)
76+
77+
78+
def is_vendor_file(file_path: str) -> bool:
79+
parts = set(PurePosixPath(normalize_file_path(file_path).lower()).parts)
80+
return bool(parts & VENDOR_DIR_NAMES)
81+
82+
83+
def is_wiki_noise_file(file_path: str) -> bool:
84+
return is_test_file(file_path) or is_generated_file(file_path) or is_vendor_file(file_path)
85+
86+
87+
def is_wiki_noise_node(node: CodeGraphNode) -> bool:
88+
if node.metadata.get("external"):
89+
return True
90+
return bool(node.file_path and is_wiki_noise_file(node.file_path))
91+
92+
93+
def filter_wiki_graph(
94+
nodes: list[CodeGraphNode],
95+
edges: list[CodeGraphEdge],
96+
) -> tuple[list[CodeGraphNode], list[CodeGraphEdge]]:
97+
filtered_nodes = [node for node in nodes if not is_wiki_noise_node(node)]
98+
node_ids = {node.id for node in filtered_nodes}
99+
filtered_edges = [
100+
edge for edge in edges if edge.source_id in node_ids and edge.target_id in node_ids
101+
]
102+
return filtered_nodes, filtered_edges

backend/app/services/graph/affected.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

33
from collections import deque
4-
from fnmatch import fnmatch
54
from typing import Any
65

6+
from backend.app.services.file_roles import is_test_file as _is_test_file
77
from backend.app.services.graph.models import CodeGraphEdge, CodeGraphNode
88

99

@@ -48,22 +48,7 @@ def transitive_file_dependents(
4848

4949

5050
def is_test_file(file_path: str, test_glob: str | None) -> bool:
51-
normalized = file_path.lower().replace("\\", "/")
52-
if test_glob:
53-
return fnmatch(file_path, test_glob)
54-
name = normalized.rsplit("/", 1)[-1]
55-
return (
56-
name.startswith("test_")
57-
or ".test." in name
58-
or ".spec." in name
59-
or name.endswith("_test.go")
60-
or name.endswith("_test.py")
61-
or "/tests/" in normalized
62-
or "/test/" in normalized
63-
or "/__tests__/" in normalized
64-
or "/e2e/" in normalized
65-
or "/spec/" in normalized
66-
)
51+
return _is_test_file(file_path, test_glob)
6752

6853

6954
def page_source_files(source_refs: list[dict[str, Any]]) -> set[str]:

backend/app/services/graph/call_resolver.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from backend.app.services.ast_parser import AstSymbol
44
from backend.app.services.graph.confidence import EdgeResolution, edge_resolution
55
from backend.app.services.graph.models import CodeGraphNode
6-
from backend.app.services.graph.node_factory import module_node
76

87

98
def build_call_index(
@@ -97,9 +96,7 @@ def resolve_type_reference(
9796
return None
9897
if not name:
9998
return None
100-
node = module_node(repo_id, name, kind="type_reference")
101-
add_node(node)
102-
return edge_resolution(node.id, "global", is_inferred=True)
99+
return None
103100

104101

105102
def file_exports(file_symbols: list[AstSymbol]) -> set[str]:

backend/app/services/graph/import_resolver.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
from pathlib import PurePosixPath
44

55
from backend.app.services.graph.confidence import CONFIDENCE_TIERS, TIER_REASONS
6-
from backend.app.services.graph.ids import module_node_id
76
from backend.app.services.graph.models import CodeGraphNode
8-
from backend.app.services.graph.node_factory import module_node
97

108

119
def resolve_import_target(
@@ -114,17 +112,3 @@ def add_import_edges(
114112
},
115113
)
116114
continue
117-
add_node(module_node(repo_id, import_name))
118-
add_edge(
119-
file_node_id,
120-
module_node_id(repo_id, import_name),
121-
"imports",
122-
confidence=CONFIDENCE_TIERS["global"],
123-
is_inferred=True,
124-
reason=TIER_REASONS["global"],
125-
metadata={
126-
"import": import_name,
127-
"resolved": False,
128-
"resolution_tier": "global",
129-
},
130-
)

backend/app/services/graphrag/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from backend.app.database import CodeChunkSearchHit, CodeWikiStore, GraphCommunityRecord
2+
from backend.app.services.file_roles import is_wiki_noise_file
23
from backend.app.services.graph import CodeGraphEdge, CodeGraphNode
34
from backend.app.services.graph_provenance import edge_provenance, node_confidence, node_provenance
45
from backend.app.services.graphrag.models import ChunkHit
@@ -15,7 +16,7 @@
1516

1617
def _is_ignored_source_file(file_path: str) -> bool:
1718
normalized = file_path.replace("\\", "/")
18-
return normalized.rsplit("/", 1)[-1] in IGNORED_SOURCE_FILES
19+
return normalized.rsplit("/", 1)[-1] in IGNORED_SOURCE_FILES or is_wiki_noise_file(file_path)
1920

2021

2122
def select_source_chunks(

backend/app/services/graphrag/retriever.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from backend.app.config import Settings, get_settings
22
from backend.app.database import CodeChunkRecord, CodeWikiStore, get_store
3+
from backend.app.services.file_roles import filter_wiki_graph
34
from backend.app.services.graph import CodeGraphNode
45
from backend.app.services.graphrag.constants import (
56
DEFAULT_CONTEXT_TOKENS,
@@ -67,9 +68,10 @@ async def retrieve(
6768
if repo is None:
6869
raise ValueError(f"Repository not found: {repo_id}")
6970

70-
nodes, edges = self.store.get_graph(repo_id)
71-
if not nodes:
71+
graph_nodes, graph_edges = self.store.get_graph(repo_id)
72+
if not graph_nodes:
7273
raise ValueError("Run analysis before GraphRAG retrieval.")
74+
nodes, edges = filter_wiki_graph(graph_nodes, graph_edges)
7375

7476
query = query.strip() or "repository overview"
7577
max_hops = max(0, min(max_hops, 4))

backend/app/services/graphrag/search.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from backend.app.database import CodeChunkRecord, CodeChunkSearchHit, CodeWikiStore
22
from backend.app.services.embedding_index import EmbeddingIndex
3+
from backend.app.services.file_roles import is_wiki_noise_file, is_wiki_noise_node
34
from backend.app.services.graph import CodeGraphNode
45
from backend.app.services.graphrag.constants import SEED_NODE_TYPES
56
from backend.app.services.graphrag.models import NodeHit
@@ -27,13 +28,17 @@ def seed_from_symbols(
2728
types=sorted(SEED_NODE_TYPES),
2829
limit=32,
2930
):
31+
if is_wiki_noise_node(search_hit.node):
32+
continue
3033
hits[search_hit.node.id] = NodeHit(
3134
node_id=search_hit.node.id,
3235
score=min(1.3, max(0.35, search_hit.score)),
3336
reasons={*search_hit.reasons, "symbol_fts"},
3437
)
3538

3639
for node in nodes:
40+
if is_wiki_noise_node(node):
41+
continue
3742
if node.type not in SEED_NODE_TYPES:
3843
continue
3944
haystack = node_haystack(node)
@@ -100,9 +105,13 @@ def merge_chunk_hits_into_seeds(
100105
if node.type == "file" and node.file_path
101106
}
102107
for index, chunk_hit in enumerate(chunk_hits):
108+
if is_wiki_noise_file(chunk_hit.chunk.file_path):
109+
continue
103110
node_id = chunk_hit.chunk.node_id or file_nodes_by_path.get(chunk_hit.chunk.file_path)
104111
if node_id not in node_by_id:
105112
continue
113+
if is_wiki_noise_node(node_by_id[node_id]):
114+
continue
106115
score = max(0.25, chunk_hit.score - index * 0.01)
107116
existing = seed_hits.get(node_id)
108117
if existing:
@@ -117,10 +126,14 @@ def add_overview_fallback_seeds(
117126
nodes: list[CodeGraphNode],
118127
) -> None:
119128
for node in nodes:
129+
if is_wiki_noise_node(node):
130+
continue
120131
if node.type == "repository":
121132
seed_hits[node.id] = NodeHit(node_id=node.id, score=0.4, reasons={"overview"})
122133
break
123134
for node in sorted(nodes, key=lambda item: item.file_path):
135+
if is_wiki_noise_node(node):
136+
continue
124137
if node.type == "file":
125138
seed_hits[node.id] = NodeHit(node_id=node.id, score=0.35, reasons={"overview"})
126139
if len(seed_hits) >= 6:

backend/app/services/wiki/catalog_generator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any
22

33
from backend.app.database import DocCatalogRecord, CodeWikiStore
4+
from backend.app.services.file_roles import filter_wiki_graph
45
from backend.app.services.graphrag import GraphRAGRetriever
56
from backend.app.services.graph import CodeGraphEdge, CodeGraphNode
67
from backend.app.services.llm.gateway import LLMGateway
@@ -50,7 +51,8 @@ async def generate_catalog(
5051
raise ValueError(f"Repository not found: {repo_id}")
5152

5253
trace = await self.retriever.retrieve(repo_id, "repository overview", max_hops=3)
53-
nodes, edges = self.store.get_graph(repo.id)
54+
graph_nodes, graph_edges = self.store.get_graph(repo.id)
55+
nodes, edges = filter_wiki_graph(graph_nodes, graph_edges)
5456
catalog_limits = catalog_limits_for_repo(
5557
nodes,
5658
edges,

tests/backend/test_analysis_service.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ def test_analyze_persists_first_code_graph(tmp_path: Path) -> None:
4747
assert sum(result.community_count_by_level.values()) == result.community_count
4848
assert result.community_count >= 1
4949
assert {node.type for node in nodes} >= {"repository", "file", "class", "function", "method"}
50-
assert any(node.name == "os" and node.type == "module" for node in nodes)
50+
assert not any(node.name == "os" and node.type == "module" for node in nodes)
5151
assert all("provenance" in node.metadata for node in nodes)
5252
assert any(edge.type == "contains" for edge in edges)
53-
assert any(edge.type == "imports" for edge in edges)
53+
assert not any(edge.type == "imports" and edge.metadata.get("resolved") is False for edge in edges)
5454
assert any(edge.type == "calls" for edge in edges)
5555
assert all("provenance" in edge.metadata for edge in edges)
5656
assert all("confidence_level" in edge.metadata for edge in edges)
@@ -264,6 +264,27 @@ def test_analyze_resolves_local_python_imports(tmp_path: Path) -> None:
264264
assert any(edge.source_id.endswith(":file:api.py") and edge.target_id.endswith(":file:service.py") for edge in local_import_edges)
265265

266266

267+
def test_analyze_skips_unresolved_external_type_reference_nodes(tmp_path: Path) -> None:
268+
repo_dir = tmp_path / "repo"
269+
repo_dir.mkdir()
270+
(repo_dir / "worker.ts").write_text("export class Worker extends ExternalBase {}\n")
271+
272+
store = SQLiteStore(tmp_path / "codewiki.sqlite3")
273+
repo = store.upsert_repo(RepoScanner().describe(str(repo_dir)))
274+
275+
AnalysisService(store=store).analyze(repo.id)
276+
nodes, edges = store.get_graph(repo.id)
277+
278+
worker_node = next(node for node in nodes if node.type == "class" and node.name == "Worker")
279+
assert not any(node.type == "module" and node.name == "ExternalBase" for node in nodes)
280+
assert not any(
281+
edge.type == "inherits"
282+
and edge.source_id == worker_node.id
283+
and edge.metadata.get("base") == "ExternalBase"
284+
for edge in edges
285+
)
286+
287+
267288
def test_analyze_records_confidence_tiers_and_edge_reasons(tmp_path: Path) -> None:
268289
repo_dir = tmp_path / "repo"
269290
repo_dir.mkdir()

0 commit comments

Comments
 (0)