java-codebase-rag 0.9.4__py3-none-any.whl → 0.9.6__py3-none-any.whl

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 (46) hide show
  1. java_codebase_rag/absence/__init__.py +0 -0
  2. java_codebase_rag/absence/absence_diagnosis.py +700 -0
  3. java_codebase_rag/absence/absence_types.py +124 -0
  4. java_codebase_rag/absence/absence_vocab.py +455 -0
  5. java_codebase_rag/analysis/__init__.py +0 -0
  6. pr_analysis.py → java_codebase_rag/analysis/pr_analysis.py +1 -1
  7. resolve_service.py → java_codebase_rag/analysis/resolve_service.py +73 -6
  8. java_codebase_rag/ast/__init__.py +0 -0
  9. ast_java.py → java_codebase_rag/ast/ast_java.py +5 -5
  10. java_codebase_rag/cli.py +13 -18
  11. java_codebase_rag/config.py +116 -0
  12. java_codebase_rag/graph/__init__.py +0 -0
  13. build_ast_graph.py → java_codebase_rag/graph/build_ast_graph.py +89 -11
  14. graph_enrich.py → java_codebase_rag/graph/graph_enrich.py +248 -3
  15. graph_types.py → java_codebase_rag/graph/graph_types.py +6 -2
  16. java_ontology.py → java_codebase_rag/graph/java_ontology.py +1 -1
  17. ladybug_queries.py → java_codebase_rag/graph/ladybug_queries.py +6 -6
  18. java_codebase_rag/index/__init__.py +0 -0
  19. java_index_flow_lancedb.py → java_codebase_rag/index/java_index_flow_lancedb.py +30 -10
  20. java_codebase_rag/install_data/__init__.py +0 -0
  21. java_codebase_rag/jrag.py +71 -16
  22. java_codebase_rag/jrag_envelope.py +13 -4
  23. java_codebase_rag/jrag_hints.py +1 -1
  24. java_codebase_rag/jrag_render.py +67 -3
  25. java_codebase_rag/mcp/__init__.py +0 -0
  26. mcp_hints.py → java_codebase_rag/mcp/mcp_hints.py +1 -1
  27. mcp_v2.py → java_codebase_rag/mcp/mcp_v2.py +280 -81
  28. server.py → java_codebase_rag/mcp/server.py +138 -54
  29. java_codebase_rag/pipeline.py +26 -7
  30. java_codebase_rag/search/__init__.py +0 -0
  31. search_lancedb.py → java_codebase_rag/search/search_lancedb.py +53 -314
  32. java_codebase_rag/search/search_lexical.py +329 -0
  33. java_codebase_rag/search/search_scoring.py +338 -0
  34. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/METADATA +2 -2
  35. java_codebase_rag-0.9.6.dist-info/RECORD +57 -0
  36. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/entry_points.txt +1 -1
  37. java_codebase_rag-0.9.6.dist-info/top_level.txt +1 -0
  38. java_codebase_rag-0.9.4.dist-info/RECORD +0 -44
  39. java_codebase_rag-0.9.4.dist-info/top_level.txt +0 -19
  40. /brownfield_events.py → /java_codebase_rag/ast/brownfield_events.py +0 -0
  41. /chunk_heuristics.py → /java_codebase_rag/ast/chunk_heuristics.py +0 -0
  42. /path_filtering.py → /java_codebase_rag/graph/path_filtering.py +0 -0
  43. /java_index_v1_common.py → /java_codebase_rag/index/java_index_v1_common.py +0 -0
  44. /index_common.py → /java_codebase_rag/search/index_common.py +0 -0
  45. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/WHEEL +0 -0
  46. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,124 @@
1
+ """Absence diagnosis data transfer objects.
2
+
3
+ These types define the contract for explaining empty MCP tool results.
4
+ Later PRs (ABS-2, ABS-3) populate these fields; ABS-0 only declares them.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from java_codebase_rag.graph.graph_types import NodeRef
12
+
13
+ __all__ = [
14
+ "AbsenceVerdict",
15
+ "AbsenceCause",
16
+ "ExternalReason",
17
+ "AbsenceProof",
18
+ "ExternalIdentity",
19
+ "VocabularyContext",
20
+ "FilterRelaxationDim",
21
+ "FilterRelaxation",
22
+ "AbsenceDiagnosis",
23
+ ]
24
+
25
+ # Literal types for verdicts and causes
26
+ AbsenceVerdict = Literal["refine_query", "not_in_project", "external_dependency", "correct_empty"]
27
+ AbsenceCause = Literal["identifier_miss", "nl_miss", "filter_miss", "external", "meaningful_empty"]
28
+ ExternalReason = Literal["prefix", "phantom", "unresolved-call"]
29
+
30
+
31
+ class AbsenceProof(BaseModel):
32
+ """Evidence backing a hard 'not_in_project' verdict.
33
+
34
+ Attributes:
35
+ nearest_distance: Distance to the closest symbol found (0-1)
36
+ symbol_count_scanned: Total symbols examined during search
37
+ thresholds_applied: The similarity thresholds used in the decision
38
+ query_shape: Shape of the original query (currently only "identifier")
39
+ """
40
+ nearest_distance: float
41
+ symbol_count_scanned: int
42
+ thresholds_applied: dict[str, float]
43
+ query_shape: Literal["identifier"]
44
+
45
+
46
+ class ExternalIdentity(BaseModel):
47
+ """Identifies an external dependency that caused the empty result.
48
+
49
+ Attributes:
50
+ fqn: Fully qualified name of the external symbol
51
+ reason: Why we believe this is external (prefix, phantom, unresolved call)
52
+ source: Optional source name (e.g., "maven", "gradle")
53
+ """
54
+ fqn: str
55
+ reason: ExternalReason
56
+ source: str | None = None
57
+
58
+
59
+ class VocabularyContext(BaseModel):
60
+ """Project vocabulary statistics to inform query refinement.
61
+
62
+ Attributes:
63
+ top_modules: Most frequent modules with counts
64
+ top_microservices: Most frequent microservices with counts
65
+ roles_present: Symbol roles present with counts
66
+ frequent_name_tokens: Common tokens in symbol names
67
+ """
68
+ top_modules: list[tuple[str, int]]
69
+ top_microservices: list[tuple[str, int]]
70
+ roles_present: list[tuple[str, int]]
71
+ frequent_name_tokens: list[str]
72
+
73
+
74
+ class FilterRelaxationDim(BaseModel):
75
+ """Relaxation analysis for a single filter dimension.
76
+
77
+ Attributes:
78
+ dimension: The filter dimension (e.g., "microservice", "role")
79
+ constrained_value: The value that constrained results
80
+ matches_under_relaxation: Results if this dimension were relaxed
81
+ suggested_value: Optional alternative value to try
82
+ """
83
+ dimension: str
84
+ constrained_value: str | None
85
+ matches_under_relaxation: int
86
+ suggested_value: str | None
87
+
88
+
89
+ class FilterRelaxation(BaseModel):
90
+ """Analysis of how relaxing filters would affect results.
91
+
92
+ Attributes:
93
+ per_dimension: List of relaxation options per dimension
94
+ """
95
+ per_dimension: list[FilterRelaxationDim]
96
+
97
+
98
+ class AbsenceDiagnosis(BaseModel):
99
+ """Explains why an MCP tool returned no results.
100
+
101
+ This is the main DTO that the 5 MCP output models optionally carry.
102
+ In PR-ABS-0, the `absence` field stays None everywhere — later PRs
103
+ populate it with diagnosis logic.
104
+
105
+ Attributes:
106
+ verdict: High-level judgment on the empty result
107
+ cause: Specific cause that led to this verdict
108
+ message: Human-readable explanation
109
+ closest_symbols: Symbols closest to the query (if any)
110
+ distances: Corresponding distance values
111
+ proof: Evidence for not_in_project verdict
112
+ external_identity: External dependency info
113
+ vocabulary_context: Project vocabulary for refinement
114
+ filter_relaxation: Filter relaxation suggestions
115
+ """
116
+ verdict: AbsenceVerdict
117
+ cause: AbsenceCause
118
+ message: str
119
+ closest_symbols: list[NodeRef] = Field(default_factory=list)
120
+ distances: list[float] = Field(default_factory=list)
121
+ proof: AbsenceProof | None = None
122
+ external_identity: ExternalIdentity | None = None
123
+ vocabulary_context: VocabularyContext | None = None
124
+ filter_relaxation: FilterRelaxation | None = None
@@ -0,0 +1,455 @@
1
+ """Vocabulary index for absence diagnosis (PR-ABS-1).
2
+
3
+ A VocabularyIndex builds a search-optimized projection from a LadybugGraph's
4
+ Symbol nodes, persisting as a versioned JSON sidecar. It provides bounded-time
5
+ lookup for did-you-mean candidates and external membership checks.
6
+
7
+ Consumed by PR-ABS-2 (diagnosis ranking) and PR-ABS-3 (MCP tools).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ __all__ = [
21
+ "SymbolRecord",
22
+ "VocabularyIndex",
23
+ "VocabIndexStale",
24
+ "get_vocabulary_index",
25
+ "reset_cache",
26
+ "VOCAB_INDEX_FILENAME",
27
+ ]
28
+
29
+ VOCAB_INDEX_FILENAME = "vocab_index.json"
30
+
31
+ # Sidecar schema version. Bump when the on-disk JSON shape changes; load() rejects
32
+ # a mismatch as stale (→ rebuild) so an old-format sidecar is never misread.
33
+ FORMAT_VERSION = 1
34
+
35
+
36
+ class VocabIndexStale(Exception):
37
+ """Raised when loading a vocab index with stale ontology_version."""
38
+
39
+ pass
40
+
41
+
42
+ @dataclass
43
+ class SymbolRecord:
44
+ """A single symbol record from the graph.
45
+
46
+ Attributes:
47
+ node_id: Ladybug node ID
48
+ fqn: Fully qualified name
49
+ simple_name: Simple name (last segment)
50
+ normalized_name: Lowercased simple name with signatures stripped
51
+ kind: Symbol kind (class, method, field, etc.)
52
+ module: Maven module (if available)
53
+ microservice: Microservice label (if available)
54
+ role: Symbol role (Controller, Service, Repository, etc.)
55
+ resolved: Whether the symbol resolved to a source location
56
+ """
57
+ node_id: str
58
+ fqn: str
59
+ simple_name: str
60
+ normalized_name: str
61
+ kind: str
62
+ module: str | None
63
+ microservice: str | None
64
+ role: str | None
65
+ resolved: bool
66
+
67
+
68
+ class VocabularyIndex:
69
+ """Search-optimized vocabulary index built from LadybugGraph Symbol nodes.
70
+
71
+ The index stores a flat list of SymbolRecords and an n-gram inverted index
72
+ mapping q-grams to record indexes. This allows bounded-time lookup for
73
+ did-you-mean candidates without scanning the entire vocabulary.
74
+
75
+ Built at the end of graph build; persisted as a sidecar JSON; lazily rebuilt
76
+ if missing or stale (ontology_version mismatch).
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ records: list[SymbolRecord],
82
+ ngram_index: dict[str, list[int]],
83
+ q: int,
84
+ _name_index: dict[str, list[int]] | None = None,
85
+ ) -> None:
86
+ self.records = records
87
+ self.ngram_index = ngram_index
88
+ self.q = q
89
+ # Build name index for O(1) exact lookups (key: normalized_name -> record indices)
90
+ if _name_index is None:
91
+ self._name_index: dict[str, list[int]] = {}
92
+ for idx, record in enumerate(records):
93
+ norm = record.normalized_name
94
+ if norm not in self._name_index:
95
+ self._name_index[norm] = []
96
+ self._name_index[norm].append(idx)
97
+ else:
98
+ self._name_index = _name_index
99
+
100
+ @property
101
+ def symbol_count(self) -> int:
102
+ return len(self.records)
103
+
104
+ @classmethod
105
+ def build(cls, graph: Any, *, q: int) -> "VocabularyIndex":
106
+ """Build a vocabulary index from a LadybugGraph.
107
+
108
+ Enumerates all Symbol nodes, builds SymbolRecords with normalized names,
109
+ and constructs a q-gram inverted index for candidate lookup.
110
+
111
+ Args:
112
+ graph: LadybugGraph instance
113
+ q: N-gram length (typically 3)
114
+
115
+ Returns:
116
+ VocabularyIndex ready for queries
117
+ """
118
+ # Query all Symbol nodes with proper column aliases
119
+ query = """
120
+ MATCH (s:Symbol)
121
+ RETURN s.id AS id, s.kind AS kind, s.name AS name, s.fqn AS fqn,
122
+ s.package AS package, s.module AS module, s.microservice AS microservice,
123
+ s.filename AS filename, s.start_line AS start_line, s.end_line AS end_line,
124
+ s.start_byte AS start_byte, s.end_byte AS end_byte, s.modifiers AS modifiers,
125
+ s.annotations AS annotations, s.capabilities AS capabilities, s.role AS role,
126
+ s.signature AS signature, s.parent_id AS parent_id, s.resolved AS resolved
127
+ """
128
+ rows = graph._rows(query, {})
129
+
130
+ records: list[SymbolRecord] = []
131
+ for row in rows:
132
+ record = _row_to_symbol_record(row)
133
+ records.append(record)
134
+
135
+ # Build n-gram index from normalized names
136
+ ngram_index: dict[str, list[int]] = {}
137
+ for idx, record in enumerate(records):
138
+ grams = _qgrams(record.normalized_name, q)
139
+ for gram in grams:
140
+ if gram not in ngram_index:
141
+ ngram_index[gram] = []
142
+ ngram_index[gram].append(idx)
143
+
144
+ return cls(records=records, ngram_index=ngram_index, q=q)
145
+
146
+ def save(self, path: Path, *, ontology_version: int) -> None:
147
+ """Save the vocabulary index to a JSON sidecar.
148
+
149
+ Args:
150
+ path: Destination path for the sidecar
151
+ ontology_version: Current graph ontology version (for staleness detection)
152
+ """
153
+ import time
154
+
155
+ data = {
156
+ "format_version": FORMAT_VERSION,
157
+ "ontology_version": ontology_version,
158
+ "built_at": int(time.time()),
159
+ "symbol_count": self.symbol_count,
160
+ "q": self.q,
161
+ "records": [
162
+ {
163
+ "node_id": r.node_id,
164
+ "fqn": r.fqn,
165
+ "simple_name": r.simple_name,
166
+ "normalized_name": r.normalized_name,
167
+ "kind": r.kind,
168
+ "module": r.module,
169
+ "microservice": r.microservice,
170
+ "role": r.role,
171
+ "resolved": r.resolved,
172
+ }
173
+ for r in self.records
174
+ ],
175
+ "ngrams": self.ngram_index,
176
+ # _name_index is intentionally NOT persisted: it is derivable from
177
+ # records and rebuilt in __init__ on load (single source of truth,
178
+ # no sidecar bloat).
179
+ }
180
+
181
+ path.parent.mkdir(parents=True, exist_ok=True)
182
+ # Atomic write — dump to a temp sibling, then os.replace onto the target.
183
+ # A crash mid-write leaves either the previous complete file or the new
184
+ # complete file, never a truncated/corrupt sidecar (readers see one or
185
+ # the other atomically; os.replace is atomic on the same filesystem).
186
+ tmp_path = path.with_name(path.name + ".tmp")
187
+ with open(tmp_path, "w", encoding="utf-8") as f:
188
+ json.dump(data, f, ensure_ascii=False)
189
+ os.replace(tmp_path, path)
190
+
191
+ log.debug(f"VocabularyIndex saved to {path} ({self.symbol_count} symbols)")
192
+
193
+ @classmethod
194
+ def load(cls, path: Path) -> "VocabularyIndex":
195
+ """Load a vocabulary index from a JSON sidecar.
196
+
197
+ Args:
198
+ path: Path to the sidecar file
199
+
200
+ Returns:
201
+ VocabularyIndex
202
+
203
+ Raises:
204
+ VocabIndexStale: If sidecar format_version or ontology_version
205
+ doesn't match expected
206
+ """
207
+ from java_codebase_rag.ast.ast_java import ONTOLOGY_VERSION
208
+
209
+ with open(path, encoding="utf-8") as f:
210
+ data = json.load(f)
211
+
212
+ # Check format version (sidecar JSON schema) first.
213
+ if data.get("format_version") != FORMAT_VERSION:
214
+ raise VocabIndexStale(
215
+ f"Vocab index format_version {data.get('format_version')} "
216
+ f"does not match expected {FORMAT_VERSION}"
217
+ )
218
+
219
+ # Check ontology version (graph schema the index was built against).
220
+ if data.get("ontology_version") != ONTOLOGY_VERSION:
221
+ raise VocabIndexStale(
222
+ f"Vocab index ontology version {data.get('ontology_version')} "
223
+ f"does not match expected {ONTOLOGY_VERSION}"
224
+ )
225
+
226
+ records = [
227
+ SymbolRecord(
228
+ node_id=r["node_id"],
229
+ fqn=r["fqn"],
230
+ simple_name=r["simple_name"],
231
+ normalized_name=r["normalized_name"],
232
+ kind=r["kind"],
233
+ module=r.get("module"),
234
+ microservice=r.get("microservice"),
235
+ role=r.get("role"),
236
+ resolved=r["resolved"],
237
+ )
238
+ for r in data["records"]
239
+ ]
240
+
241
+ # _name_index is rebuilt from records in __init__ (not persisted).
242
+ return cls(
243
+ records=records,
244
+ ngram_index=data["ngrams"],
245
+ q=data["q"],
246
+ )
247
+
248
+ def lookup(self, name: str, *, limit: int) -> list[SymbolRecord]:
249
+ """Lookup candidate records by name using n-gram overlap.
250
+
251
+ This returns candidate records ONLY; ranking by similarity is done
252
+ in PR-ABS-2 (absence_diagnosis module) to avoid circular imports.
253
+
254
+ Args:
255
+ name: Query name (can be typoed)
256
+ limit: Maximum number of candidates to return
257
+
258
+ Returns:
259
+ List of candidate SymbolRecord (up to limit), ordered by n-gram overlap count
260
+ """
261
+ # First, check for exact match on simple_name using O(1) dict lookup (fast path)
262
+ # Return ALL matching records since overloaded names matter
263
+ normalized = _normalize_name(name)
264
+ if normalized in self._name_index:
265
+ exact_matches = [self.records[idx] for idx in self._name_index[normalized]]
266
+ # Filter to only those where simple_name matches exactly (case-sensitive)
267
+ exact_simple_matches = [r for r in exact_matches if r.simple_name == name]
268
+ if exact_simple_matches:
269
+ log.debug(f"lookup({name}): exact match found")
270
+ return exact_simple_matches
271
+
272
+ # No exact match, use n-gram overlap
273
+ # Extract q-grams from query
274
+ grams = _qgrams(normalized, self.q)
275
+
276
+ # Count n-gram matches per record index
277
+ match_counts: dict[int, int] = {}
278
+ for gram in grams:
279
+ if gram in self.ngram_index:
280
+ for idx in self.ngram_index[gram]:
281
+ match_counts[idx] = match_counts.get(idx, 0) + 1
282
+
283
+ # Sort by match count (descending) to get candidates with most overlap first
284
+ sorted_idxs = sorted(match_counts.keys(), key=lambda idx: match_counts[idx], reverse=True)
285
+
286
+ # Debug logging
287
+ log.debug(f"lookup({name}): normalized={normalized}, grams={grams[:5]}, candidates={len(sorted_idxs)}")
288
+
289
+ # Return top candidates
290
+ candidates = [self.records[idx] for idx in sorted_idxs[:limit]]
291
+ return candidates
292
+
293
+ def is_external(self, name: str) -> tuple[bool, str | None]:
294
+ """Check if a name refers to an external symbol.
295
+
296
+ Returns (is_external, reason) where reason is one of:
297
+ - "prefix": FQN matches an external library prefix (java.*, javax.*, etc.)
298
+ - "phantom": Symbol exists in graph but is unresolved (phantom)
299
+ - None: Symbol is a real project symbol
300
+
301
+ Args:
302
+ name: Simple name or FQN to check
303
+
304
+ Returns:
305
+ (is_external, reason) tuple
306
+ """
307
+ from java_codebase_rag.graph.ladybug_queries import _is_external_fqn, _EXTERNAL_PREFIXES
308
+
309
+ # First, check if it's an external prefix (highest priority)
310
+ if _is_external_fqn(name):
311
+ return (True, "prefix")
312
+
313
+ # Also check simple name against external prefixes
314
+ for prefix in _EXTERNAL_PREFIXES:
315
+ if name.startswith(prefix):
316
+ return (True, "prefix")
317
+
318
+ # Check if name matches any record in our vocabulary using O(1) dict lookup
319
+ normalized = _normalize_name(name)
320
+ matching_indices = self._name_index.get(normalized, [])
321
+ matching_record = None
322
+ for idx in matching_indices:
323
+ rec = self.records[idx]
324
+ if rec.simple_name == name or rec.fqn == name:
325
+ matching_record = rec
326
+ break
327
+
328
+ if matching_record:
329
+ # If the symbol is unresolved, it's a phantom
330
+ if not matching_record.resolved:
331
+ return (True, "phantom")
332
+ # Otherwise it's a real project symbol
333
+ return (False, None)
334
+
335
+ # Not found and doesn't look external
336
+ return (False, None)
337
+
338
+
339
+ # Module-level cache for get_vocabulary_index
340
+ _vocab_cache: dict[str, VocabularyIndex] = {}
341
+
342
+
343
+ def get_vocabulary_index(graph: Any, cfg: Any) -> VocabularyIndex:
344
+ """Get or build a vocabulary index for the given graph.
345
+
346
+ This is the primary entry point for the diagnosis layer (PR-ABS-2) and
347
+ tools (PR-ABS-3). It implements lazy backfill: tries to load from sidecar,
348
+ builds from graph on miss/stale, and caches the result.
349
+
350
+ Args:
351
+ graph: LadybugGraph instance
352
+ cfg: ResolvedOperatorConfig instance
353
+
354
+ Returns:
355
+ VocabularyIndex (cached or newly built)
356
+ """
357
+ from java_codebase_rag.ast.ast_java import ONTOLOGY_VERSION
358
+
359
+ # Determine graph db path for cache key
360
+ db_path = graph.db_path if hasattr(graph, 'db_path') else str(cfg.ladybug_path)
361
+ sidecar_path = Path(db_path).parent / VOCAB_INDEX_FILENAME
362
+
363
+ # Check cache
364
+ if db_path in _vocab_cache:
365
+ return _vocab_cache[db_path]
366
+
367
+ # Try loading from sidecar
368
+ try:
369
+ index = VocabularyIndex.load(sidecar_path)
370
+ _vocab_cache[db_path] = index
371
+ log.debug(f"Loaded vocabulary index from {sidecar_path}")
372
+ return index
373
+ except Exception as e:
374
+ # Stale (VocabIndexStale), missing (FileNotFoundError), or corrupt
375
+ # (JSONDecodeError/KeyError) — all subsumed by Exception; rebuild.
376
+ log.debug(f"Vocab index missing/stale/corrupt ({e}), rebuilding from graph")
377
+
378
+ # Build from graph
379
+ index = VocabularyIndex.build(graph, q=cfg.absence_ngram_q)
380
+
381
+ # Save to sidecar (best-effort)
382
+ try:
383
+ index.save(sidecar_path, ontology_version=ONTOLOGY_VERSION)
384
+ except Exception as save_err:
385
+ log.warning(f"Failed to save vocab index to {sidecar_path}: {save_err}")
386
+
387
+ # Cache and return
388
+ _vocab_cache[db_path] = index
389
+ return index
390
+
391
+
392
+ def reset_cache() -> None:
393
+ """Reset the module-level vocabulary index cache.
394
+
395
+ Exposed for tests that need to simulate a fresh start or different graph paths.
396
+ """
397
+ global _vocab_cache
398
+ _vocab_cache = {}
399
+
400
+
401
+ # ---- Helper functions ----
402
+
403
+
404
+ def _row_to_symbol_record(row: dict[str, Any]) -> SymbolRecord:
405
+ """Convert a Ladybug row to a SymbolRecord."""
406
+ from java_codebase_rag.graph.ladybug_queries import _type_part_fqn
407
+
408
+ fqn = row.get("fqn") or ""
409
+ name = row.get("name") or ""
410
+
411
+ return SymbolRecord(
412
+ node_id=row.get("id") or "",
413
+ fqn=fqn,
414
+ simple_name=name,
415
+ normalized_name=_normalize_name(name),
416
+ kind=row.get("kind") or "",
417
+ module=row.get("module"),
418
+ microservice=row.get("microservice"),
419
+ role=row.get("role"),
420
+ resolved=bool(row.get("resolved", True)),
421
+ )
422
+
423
+
424
+ def _normalize_name(name: str) -> str:
425
+ """Normalize a symbol name for n-gram indexing.
426
+
427
+ Strips:
428
+ - Generic signatures (e.g., List<String> → List)
429
+ - Method signatures (e.g., method(Param) → method)
430
+ - Parentheses and angle brackets
431
+
432
+ Returns lowercase result.
433
+ """
434
+ # Remove generic signatures
435
+ normalized = name.split("<")[0]
436
+ # Remove method signatures
437
+ normalized = normalized.split("(")[0]
438
+ # Remove hash suffix (e.g., method#signature)
439
+ normalized = normalized.split("#")[0]
440
+ return normalized.lower()
441
+
442
+
443
+ def _qgrams(text: str, q: int) -> list[str]:
444
+ """Extract q-grams from text.
445
+
446
+ Args:
447
+ text: Input string
448
+ q: Gram length
449
+
450
+ Returns:
451
+ List of q-grams (substrings of length q)
452
+ """
453
+ if len(text) < q:
454
+ return [text] if text else []
455
+ return [text[i:i + q] for i in range(len(text) - q + 1)]
File without changes
@@ -12,7 +12,7 @@ from typing import Any
12
12
  from unidiff import PatchSet
13
13
  from unidiff.errors import UnidiffParseError
14
14
 
15
- from ladybug_queries import SymbolHit, find_symbols_in_file_range, _row_to_symbol
15
+ from java_codebase_rag.graph.ladybug_queries import SymbolHit, find_symbols_in_file_range, _row_to_symbol
16
16
 
17
17
 
18
18
  @dataclass