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
File without changes
@@ -0,0 +1,700 @@
1
+ """Stateless absence diagnosis (PR-ABS-2) — the feature's core.
2
+
3
+ ``diagnose(...)`` is the single place empty-MCP-result logic lives. It classifies
4
+ an empty exploration result by cause and emits cause-specific help. Pure function
5
+ of its inputs (incl. the :class:`VocabularyIndex`); no I/O, no mutation. Consumed
6
+ by PR-ABS-3 (MCP wiring) and PR-ABS-4 (CLI).
7
+
8
+ Similarity metric
9
+ -----------------
10
+ Identifier did-you-mean uses ``difflib.SequenceMatcher(None, a, b).ratio()``
11
+ (stdlib, ∈ [0,1]) on the query's normalized name vs each candidate's
12
+ ``normalized_name``; ``distance = 1.0 - similarity``. ``difflib`` is stdlib and
13
+ adequate for identifier typo/misremember detection — Jaro-Winkler would be
14
+ marginally better but is not stdlib and not worth a dependency. See the task
15
+ brief's resolution 1.
16
+
17
+ Conservative absence
18
+ --------------------
19
+ False-absent (declaring a real symbol absent) is the catastrophic failure mode.
20
+ The two-band threshold policy defaults the middle band to ``refine_query`` and
21
+ commits to ``not_in_project`` only when best similarity < ``absence_absent_floor``
22
+ AND the query is identifier-shaped. ``closest_symbols``/``distances`` are ALWAYS
23
+ populated regardless of verdict.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from collections import Counter
30
+ from difflib import SequenceMatcher
31
+ from typing import Any, Literal
32
+
33
+ from java_codebase_rag.absence.absence_types import (
34
+ AbsenceDiagnosis,
35
+ AbsenceProof,
36
+ ExternalIdentity,
37
+ FilterRelaxation,
38
+ FilterRelaxationDim,
39
+ VocabularyContext,
40
+ )
41
+ from java_codebase_rag.absence.absence_vocab import SymbolRecord, VocabularyIndex, _normalize_name
42
+ from java_codebase_rag.graph.graph_types import NodeRef
43
+ from java_codebase_rag.mcp.mcp_hints import _IDENTIFIER_FILTER_FIELDS
44
+
45
+ log = logging.getLogger(__name__)
46
+
47
+ __all__ = ["diagnose"]
48
+
49
+ # Dimensions whose relaxation _filter_relaxation can probe on the graph. These
50
+ # mirror the single-dim filters handled by ``_zero_result_guidance`` (jrag.py).
51
+ _RELAXABLE_DIMS: tuple[str, ...] = ("role", "microservice", "module")
52
+
53
+ # Node-id prefixes (graph_types._node_kind_from_id). Used to tell a describe
54
+ # node_id miss apart from an FQN lookup.
55
+ _NODE_ID_PREFIXES: tuple[str, ...] = (
56
+ "ucs:", "sym:", "route:", "r:", "client:", "c:", "producer:", "p:",
57
+ )
58
+
59
+ # Small English stopword set; a single stopword is treated as NL, not identifier.
60
+ _STOPWORDS: frozenset[str] = frozenset({
61
+ "the", "a", "an", "and", "or", "of", "in", "to", "for", "with", "on", "at",
62
+ "by", "is", "are", "be", "was", "were", "how", "does", "do", "what", "where",
63
+ "which", "who", "why", "when", "find", "show", "get", "list", "all", "any",
64
+ "this", "that", "these", "those", "from", "into", "use", "using", "used",
65
+ })
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Public entry point #
70
+ # --------------------------------------------------------------------------- #
71
+
72
+
73
+ def diagnose(
74
+ *,
75
+ tool: Literal["search", "find", "neighbors", "describe", "resolve"],
76
+ query: str | None,
77
+ filt: dict | None, # find's model_dump'd filter
78
+ filter_kind: str | None, # find's kind, for identifier-shape test
79
+ root_node: NodeRef | None, # neighbors/describe subject
80
+ scope: dict[str, str], # {"microservice":..,"module":..}
81
+ vocab: VocabularyIndex,
82
+ graph: Any, # LadybugGraph
83
+ cfg: Any, # ResolvedOperatorConfig (thresholds)
84
+ ) -> AbsenceDiagnosis | None:
85
+ """Classify an empty result and emit cause-specific help.
86
+
87
+ Returns ``None`` when the master toggle is off or on unrecoverable error.
88
+ Never raises: any exception is logged and degrades to a minimal
89
+ ``refine_query`` (or ``None`` if even that cannot be built).
90
+ """
91
+ try:
92
+ if not getattr(cfg, "absence_diag_enabled", True):
93
+ return None
94
+ return _diagnose_inner(
95
+ tool=tool,
96
+ query=query,
97
+ filt=filt,
98
+ filter_kind=filter_kind,
99
+ root_node=root_node,
100
+ scope=scope,
101
+ vocab=vocab,
102
+ graph=graph,
103
+ cfg=cfg,
104
+ )
105
+ except Exception: # noqa: BLE001 — diagnosis must never fail the tool
106
+ log.exception("absence diagnosis failed; degrading to refine_query")
107
+ return _fallback_refine()
108
+
109
+
110
+ # --------------------------------------------------------------------------- #
111
+ # Decision procedure #
112
+ # --------------------------------------------------------------------------- #
113
+
114
+
115
+ def _diagnose_inner(
116
+ *,
117
+ tool: str,
118
+ query: str | None,
119
+ filt: dict | None,
120
+ filter_kind: str | None,
121
+ root_node: NodeRef | None,
122
+ scope: dict[str, str],
123
+ vocab: VocabularyIndex,
124
+ graph: Any,
125
+ cfg: Any,
126
+ ) -> AbsenceDiagnosis | None:
127
+ # --- External-wins: emit external_dependency first for any external target.
128
+ ext = _detect_external(query, filt, filter_kind, root_node, vocab)
129
+ if ext is not None:
130
+ return AbsenceDiagnosis(
131
+ verdict="external_dependency",
132
+ cause="external",
133
+ message=(
134
+ f"`{ext.fqn}` is referenced by this project but not defined in it "
135
+ f"({ext.reason}). It is an external dependency."
136
+ ),
137
+ external_identity=ext,
138
+ )
139
+
140
+ # --- neighbors/describe subject present.
141
+ if root_node is not None:
142
+ return _diagnose_neighbors(root_node, graph)
143
+
144
+ # --- describe by node_id (not fqn): an unknown id, not a misspelled name.
145
+ if tool == "describe" and query and _looks_like_node_id(query):
146
+ return AbsenceDiagnosis(
147
+ verdict="refine_query",
148
+ cause="identifier_miss",
149
+ message=(
150
+ f"No node with id `{query}`. Run `resolve` to map a name/FQN to an id, "
151
+ "or `search` to discover symbols."
152
+ ),
153
+ )
154
+
155
+ # --- find (filter) path.
156
+ if filt is not None and filter_kind is not None:
157
+ return _diagnose_find(filt, filter_kind, scope, vocab, graph, cfg)
158
+
159
+ # --- search/resolve/describe-by-fqn (query) path.
160
+ if query:
161
+ return _diagnose_query(query, vocab, graph, cfg)
162
+
163
+ # Nothing to classify on (no query, no filt, no root_node). Be conservative.
164
+ return _fallback_refine()
165
+
166
+
167
+ def _diagnose_query(
168
+ query: str, vocab: VocabularyIndex, graph: Any, cfg: Any,
169
+ ) -> AbsenceDiagnosis:
170
+ # Empty vocab guard: never declare not_in_project on an unindexed/empty graph
171
+ if vocab.symbol_count == 0:
172
+ return AbsenceDiagnosis(
173
+ verdict="refine_query",
174
+ cause="identifier_miss",
175
+ message=(
176
+ "Index appears empty/unindexed — verify the project was indexed "
177
+ "before concluding a symbol is absent."
178
+ ),
179
+ )
180
+
181
+ if _is_identifier_shaped(query):
182
+ closest, distances, best_sim = _did_you_mean(query, vocab, cfg)
183
+ verdict, cause, proof = _threshold_verdict(best_sim, cfg, identifier=True)
184
+ # False-absent guard: if the query exactly resolves to a real project
185
+ # symbol (simple name OR FQN, case-insensitive), never declare not_in_project.
186
+ if verdict == "not_in_project" and _exact_symbol_exists(query, vocab):
187
+ verdict, proof = "refine_query", None
188
+ if proof is not None:
189
+ proof.symbol_count_scanned = vocab.symbol_count
190
+ message = _identifier_message(query, verdict, closest)
191
+ return AbsenceDiagnosis(
192
+ verdict=verdict,
193
+ cause=cause,
194
+ message=message,
195
+ closest_symbols=closest,
196
+ distances=distances,
197
+ proof=proof,
198
+ )
199
+ # Natural language → assemble vocabulary context, no did-you-mean.
200
+ ctx = _build_vocabulary_context(graph, vocab)
201
+ return AbsenceDiagnosis(
202
+ verdict="refine_query",
203
+ cause="nl_miss",
204
+ message=(
205
+ f"No symbol matches `{query}`. Refine the query — try an identifier "
206
+ "(class/method/FQN) or browse the project vocabulary below."
207
+ ),
208
+ vocabulary_context=ctx,
209
+ )
210
+
211
+
212
+ def _diagnose_find(
213
+ filt: dict,
214
+ filter_kind: str,
215
+ scope: dict[str, str],
216
+ vocab: VocabularyIndex,
217
+ graph: Any,
218
+ cfg: Any,
219
+ ) -> AbsenceDiagnosis:
220
+ # Empty vocab guard: never declare not_in_project on an unindexed/empty graph
221
+ if vocab.symbol_count == 0:
222
+ return AbsenceDiagnosis(
223
+ verdict="refine_query",
224
+ cause="identifier_miss",
225
+ message=(
226
+ "Index appears empty/unindexed — verify the project was indexed "
227
+ "before concluding a symbol is absent."
228
+ ),
229
+ )
230
+
231
+ identifier = _extract_identifier(filt, filter_kind)
232
+
233
+ if identifier is not None:
234
+ # Identifier-shaped filter: run did-you-mean on the identifier value.
235
+ closest, distances, best_sim = _did_you_mean(identifier, vocab, cfg)
236
+ if best_sim >= cfg.absence_close_threshold:
237
+ # Close hit exists → the filter (or scope) excluded it. Show where it lives.
238
+ relax = _filter_relaxation(filt, filter_kind, scope, graph, identifier)
239
+ return AbsenceDiagnosis(
240
+ verdict="refine_query",
241
+ cause="filter_miss",
242
+ message=(
243
+ f"No results for `{identifier}` under the current filter. "
244
+ "Close matches exist — try relaxing a dimension (see filter_relaxation)."
245
+ ),
246
+ closest_symbols=closest,
247
+ distances=distances,
248
+ filter_relaxation=relax,
249
+ )
250
+ # No close hit → identifier miss; apply the conservative threshold.
251
+ verdict, cause, proof = _threshold_verdict(best_sim, cfg, identifier=True)
252
+ if verdict == "not_in_project" and _exact_symbol_exists(identifier, vocab):
253
+ verdict, proof = "refine_query", None
254
+ if proof is not None:
255
+ proof.symbol_count_scanned = vocab.symbol_count
256
+ return AbsenceDiagnosis(
257
+ verdict=verdict,
258
+ cause=cause,
259
+ message=_identifier_message(identifier, verdict, closest),
260
+ closest_symbols=closest,
261
+ distances=distances,
262
+ proof=proof,
263
+ )
264
+
265
+ # Broad / non-identifier filter → filter_miss with relaxation suggestions.
266
+ relax = _filter_relaxation(filt, filter_kind, scope, graph, None)
267
+ return AbsenceDiagnosis(
268
+ verdict="refine_query",
269
+ cause="filter_miss",
270
+ message=(
271
+ "No results under the current filter. Matches exist under other values "
272
+ "(see filter_relaxation)."
273
+ ),
274
+ filter_relaxation=relax,
275
+ )
276
+
277
+
278
+ def _diagnose_neighbors(root_node: NodeRef, graph: Any) -> AbsenceDiagnosis:
279
+ if _neighbors_meaningful_empty(root_node, graph):
280
+ return AbsenceDiagnosis(
281
+ verdict="correct_empty",
282
+ cause="meaningful_empty",
283
+ message=(
284
+ f"`{root_node.fqn or root_node.id}` has no neighbors of the requested "
285
+ "type here — this is a genuine leaf / external entrypoint, not an error."
286
+ ),
287
+ )
288
+ return AbsenceDiagnosis(
289
+ verdict="refine_query",
290
+ cause="identifier_miss",
291
+ message=(
292
+ f"No neighbors for `{root_node.fqn or root_node.id}` with the requested "
293
+ "edge type/direction. Run `describe` and inspect `edge_summary` for the "
294
+ "edge types this node actually participates in."
295
+ ),
296
+ )
297
+
298
+
299
+ # --------------------------------------------------------------------------- #
300
+ # Did-you-mean + thresholds #
301
+ # --------------------------------------------------------------------------- #
302
+
303
+
304
+ def _did_you_mean(
305
+ identifier: str, vocab: VocabularyIndex, cfg: Any,
306
+ ) -> tuple[list[NodeRef], list[float], float]:
307
+ """Rank vocabulary candidates by SequenceMatcher similarity to ``identifier``.
308
+
309
+ Returns (closest_symbols, distances, best_similarity). When n-gram lookup
310
+ yields no candidates (no q-gram overlap at all), falls back to a bounded
311
+ linear scan over all records so ``closest_symbols`` is still populated —
312
+ this backs the "nearest-by-name" guarantee on the ``not_in_project`` path.
313
+ """
314
+ limit = int(getattr(cfg, "absence_candidate_count", 5))
315
+ query_norm = _normalize_name(identifier)
316
+
317
+ candidates = vocab.lookup(identifier, limit=limit)
318
+ if not candidates and vocab.records:
319
+ # Rare: totally novel token with zero q-gram overlap. Bounded scan for
320
+ # the nearest-by-name records so not_in_project still shows nearest names.
321
+ candidates = vocab.records
322
+
323
+ scored: list[tuple[SymbolRecord, float]] = [
324
+ (rec, _similarity(query_norm, rec.normalized_name)) for rec in candidates
325
+ ]
326
+ scored.sort(key=lambda pair: pair[1], reverse=True)
327
+
328
+ top = scored[:limit]
329
+ closest = [_build_node_ref(rec) for rec, _ in top]
330
+ distances = [round(1.0 - sim, 4) for _, sim in top]
331
+ best_sim = top[0][1] if top else 0.0
332
+ return closest, distances, best_sim
333
+
334
+
335
+ def _threshold_verdict(
336
+ best_sim: float, cfg: Any, *, identifier: bool,
337
+ ) -> tuple[str, str, AbsenceProof | None]:
338
+ """Conservative threshold policy with a single deciding band.
339
+
340
+ Returns (verdict, cause, proof). ``not_in_project`` only when best
341
+ similarity < ``absence_absent_floor`` AND the query is identifier-shaped;
342
+ everything else (a close hit OR the middle band) → ``refine_query``, so a
343
+ real symbol is never falsely declared absent. ``absence_close_threshold``
344
+ is NOT a decider on this path (it decides the *find* path's "close hit
345
+ excluded by filter/scope" branch at the ``best_sim >= close`` check) — it
346
+ is recorded in ``proof.thresholds_applied`` for transparency only.
347
+ """
348
+ close = float(getattr(cfg, "absence_close_threshold", 0.85))
349
+ floor = float(getattr(cfg, "absence_absent_floor", 0.40))
350
+ if best_sim < floor and identifier:
351
+ proof = AbsenceProof(
352
+ nearest_distance=round(1.0 - best_sim, 4),
353
+ symbol_count_scanned=0, # filled by caller via vocab
354
+ thresholds_applied={"absence_close_threshold": close, "absence_absent_floor": floor},
355
+ query_shape="identifier",
356
+ )
357
+ return "not_in_project", "identifier_miss", proof
358
+ # close band OR middle band → refine_query (conservative; never false-absent).
359
+ return "refine_query", "identifier_miss", None
360
+
361
+
362
+ def _identifier_message(
363
+ query: str, verdict: str, closest: list[NodeRef],
364
+ ) -> str:
365
+ if verdict == "not_in_project":
366
+ return (
367
+ f"No symbol matching `{query}` was found in the project vocabulary. "
368
+ "It does not appear to be defined here."
369
+ )
370
+ if closest:
371
+ names = ", ".join(s.name or s.fqn for s in closest[:3])
372
+ return (
373
+ f"No exact match for `{query}`. Closest symbols: {names}. "
374
+ "Refine the query (typo? scope?) and retry."
375
+ )
376
+ return f"No match for `{query}`. Refine the query and retry."
377
+
378
+
379
+ # --------------------------------------------------------------------------- #
380
+ # External detection #
381
+ # --------------------------------------------------------------------------- #
382
+
383
+
384
+ def _detect_external(
385
+ query: str | None,
386
+ filt: dict | None,
387
+ filter_kind: str | None,
388
+ root_node: NodeRef | None,
389
+ vocab: VocabularyIndex,
390
+ ) -> ExternalIdentity | None:
391
+ """External-wins: if the target is external/phantom, emit its identity first."""
392
+ # root_node (neighbors/describe subject).
393
+ if root_node is not None:
394
+ if root_node.kind == "unresolved_call_site":
395
+ return ExternalIdentity(fqn=root_node.fqn, reason="unresolved-call")
396
+ if root_node.fqn:
397
+ ext = _external_identity_for(root_node.fqn, vocab)
398
+ if ext is not None:
399
+ return ext
400
+
401
+ # free-text query (search/resolve/describe-by-fqn).
402
+ if query:
403
+ ext = _external_identity_for(query, vocab)
404
+ if ext is not None:
405
+ return ext
406
+
407
+ # find identifier filter value.
408
+ identifier = _extract_identifier(filt, filter_kind) if filt is not None else None
409
+ if identifier:
410
+ ext = _external_identity_for(identifier, vocab)
411
+ if ext is not None:
412
+ return ext
413
+
414
+ return None
415
+
416
+
417
+ def _external_identity_for(name: str, vocab: VocabularyIndex) -> ExternalIdentity | None:
418
+ is_ext, reason = vocab.is_external(name)
419
+ if is_ext and reason in ("prefix", "phantom"):
420
+ # Prefer the corpus FQN when we can resolve it (richer than the bare query).
421
+ fqn = name
422
+ for rec in vocab.records:
423
+ if rec.simple_name == name or rec.fqn == name:
424
+ fqn = rec.fqn or name
425
+ break
426
+ return ExternalIdentity(fqn=fqn, reason=reason)
427
+ return None
428
+
429
+
430
+ # --------------------------------------------------------------------------- #
431
+ # Neighbors meaningful-empty detection #
432
+ # --------------------------------------------------------------------------- #
433
+
434
+
435
+ def _neighbors_meaningful_empty(root_node: NodeRef, graph: Any) -> bool:
436
+ """A genuine leaf or external entrypoint → ``correct_empty``.
437
+
438
+ Reuses the conditions behind ``is_external_entrypoint`` (jrag.py:2452): an
439
+ HTTP ``http_endpoint`` route with inbound handlers is an external entrypoint,
440
+ so zero callers is meaningful. A symbol with no edges at all is an isolated
441
+ leaf. Everything else (has edges, just not the requested type) → refine.
442
+
443
+ Kafka topics are not considered external entrypoints: their empty-callers
444
+ semantics differ from HTTP routes (per is_external_entrypoint precedent).
445
+ """
446
+ try:
447
+ if root_node.kind == "route":
448
+ # Fetch the route's kind property (http_endpoint vs kafka_topic).
449
+ kind_rows = graph._rows(
450
+ "MATCH (r:Route) WHERE r.id = $id RETURN r.kind AS k",
451
+ {"id": root_node.id},
452
+ )
453
+ route_kind = kind_rows[0].get("k") if kind_rows else ""
454
+ # Only http_endpoint routes with handlers are external entrypoints.
455
+ if route_kind == "http_endpoint":
456
+ handlers = graph.find_route_handlers(route_id=root_node.id)
457
+ if handlers:
458
+ return True
459
+ # kafka_topic routes and handler-less routes are NOT meaningful empty.
460
+ return False
461
+ # Symbol/other: meaningful empty only if it has zero edges (isolated leaf).
462
+ rows = graph._rows( # noqa: SLF001 - same pattern as graph_types helpers
463
+ "MATCH (n)--(m) WHERE n.id = $id RETURN count(*) AS c",
464
+ {"id": root_node.id},
465
+ )
466
+ if rows:
467
+ return int(rows[0].get("c") or 0) == 0
468
+ except Exception: # noqa: BLE001 - degrade to refine_query on graph error
469
+ log.debug("neighbors meaningful-empty probe failed", exc_info=True)
470
+ return False
471
+
472
+
473
+ # --------------------------------------------------------------------------- #
474
+ # Filter relaxation (ported from _zero_result_guidance, jrag.py:4187) #
475
+ # --------------------------------------------------------------------------- #
476
+
477
+
478
+ def _filter_relaxation(
479
+ filt: dict | None,
480
+ filter_kind: str | None,
481
+ scope: dict[str, str],
482
+ graph: Any,
483
+ identifier: str | None,
484
+ ) -> FilterRelaxation:
485
+ """For each constrained scope dim, tally where identifier/all matches live.
486
+
487
+ Ports ``_zero_result_guidance``'s tally-and-suggest-most-common logic into the
488
+ structured ``FilterRelaxation`` payload, parameterized on a filter-dims dict
489
+ + graph (NOT ``argparse.Namespace``).
490
+ """
491
+ constrained: dict[str, str] = {}
492
+ for source in (filt or {}, scope or {}):
493
+ for dim in _RELAXABLE_DIMS:
494
+ val = source.get(dim) if isinstance(source, dict) else None
495
+ if isinstance(val, str) and val.strip() and dim not in constrained:
496
+ constrained[dim] = val.strip()
497
+
498
+ per_dimension: list[FilterRelaxationDim] = []
499
+ for dim, val in constrained.items():
500
+ try:
501
+ total, suggested = _tally_dim(graph, dim, identifier)
502
+ except Exception: # noqa: BLE001 - relaxation is best-effort
503
+ log.debug("filter relaxation tally failed for dim=%s", dim, exc_info=True)
504
+ total, suggested = 0, None
505
+ per_dimension.append(
506
+ FilterRelaxationDim(
507
+ dimension=dim,
508
+ constrained_value=val,
509
+ matches_under_relaxation=total,
510
+ suggested_value=suggested,
511
+ )
512
+ )
513
+ return FilterRelaxation(per_dimension=per_dimension)
514
+
515
+
516
+ def _tally_dim(
517
+ graph: Any, dim: str, identifier: str | None,
518
+ ) -> tuple[int, str | None]:
519
+ """Count symbols (optionally matching ``identifier``) grouped by ``dim``.
520
+
521
+ Returns (total_matches, most_common_bucket). Mirrors the probe+tally+top-3
522
+ shape of ``_zero_result_guidance`` but reads the graph directly (no mcp_v2
523
+ import) and returns structured values instead of a human string.
524
+ """
525
+ params: dict[str, Any] = {}
526
+ where = ["s.module IS NOT NULL"] if dim == "module" else []
527
+ # module_counts/microservice_counts count resolved type-symbols; mirror that
528
+ # by restricting to resolved symbols for scope dims so suggestions are stable.
529
+ if dim in ("module", "microservice", "role"):
530
+ where.append("s.resolved = true")
531
+ if identifier:
532
+ where.append(
533
+ "(toLower(s.name) CONTAINS toLower($needle) "
534
+ "OR toLower(s.fqn) CONTAINS toLower($needle))"
535
+ )
536
+ params["needle"] = identifier
537
+ where_clause = ("WHERE " + " AND ".join(where)) if where else ""
538
+ query = (
539
+ f"MATCH (s:Symbol) {where_clause} "
540
+ f"RETURN s.{dim} AS bucket, count(*) AS n ORDER BY n DESC LIMIT 10"
541
+ )
542
+ rows = graph._rows(query, params) # noqa: SLF001
543
+ buckets = [
544
+ (str(r.get("bucket") or ""), int(r.get("n") or 0))
545
+ for r in rows
546
+ if r.get("bucket")
547
+ ]
548
+ total = sum(n for _, n in buckets)
549
+ suggested = buckets[0][0] if buckets else None
550
+ return total, suggested
551
+
552
+
553
+ # --------------------------------------------------------------------------- #
554
+ # Vocabulary context (nl_miss) #
555
+ # --------------------------------------------------------------------------- #
556
+
557
+
558
+ def _build_vocabulary_context(graph: Any, vocab: VocabularyIndex) -> VocabularyContext:
559
+ """Assemble project vocabulary stats to inform query refinement."""
560
+ top_modules = sorted(graph.module_counts().items(), key=lambda kv: -kv[1])[:5]
561
+ top_microservices = sorted(graph.microservice_counts().items(), key=lambda kv: -kv[1])[:5]
562
+
563
+ role_counts: Counter = Counter()
564
+ token_counts: Counter = Counter()
565
+ for rec in vocab.records:
566
+ if rec.role:
567
+ role_counts[rec.role] += 1
568
+ for tok in _camel_tokens(rec.simple_name):
569
+ token_counts[tok] += 1
570
+
571
+ roles = sorted(role_counts.items(), key=lambda kv: -kv[1])[:5]
572
+ tokens = [tok for tok, _ in token_counts.most_common(10)]
573
+ return VocabularyContext(
574
+ top_modules=[(k, int(v)) for k, v in top_modules],
575
+ top_microservices=[(k, int(v)) for k, v in top_microservices],
576
+ roles_present=[(k, int(v)) for k, v in roles],
577
+ frequent_name_tokens=tokens,
578
+ )
579
+
580
+
581
+ # --------------------------------------------------------------------------- #
582
+ # Small helpers #
583
+ # --------------------------------------------------------------------------- #
584
+
585
+
586
+ def _similarity(a: str, b: str) -> float:
587
+ """difflib SequenceMatcher ratio on normalized names (∈ [0,1])."""
588
+ return SequenceMatcher(None, a, b).ratio()
589
+
590
+
591
+ def _is_identifier_shaped(query: str) -> bool:
592
+ """Predicate: does ``query`` look like an identifier (not natural language)?
593
+
594
+ Identifier-shaped = a CamelCase token, dotted FQN, or ``Cls#member`` with no
595
+ spaces, no NL punctuation, at least one alphanumeric, and not a lone stopword.
596
+ Extends the spirit of ``_find_has_identifier_shaped_filter`` to free text.
597
+ """
598
+ q = query.strip()
599
+ if not q or " " in q:
600
+ return False
601
+ if any(ch in q for ch in "?,;:!'\""):
602
+ return False
603
+ if not any(ch.isalnum() for ch in q):
604
+ return False
605
+ if q.lower() in _STOPWORDS:
606
+ return False
607
+ return True
608
+
609
+
610
+ def _looks_like_node_id(s: str) -> bool:
611
+ """True if ``s`` carries a Ladybug node-id prefix (sym:/route:/ucs:/...)."""
612
+ return any(s.startswith(pfx) for pfx in _NODE_ID_PREFIXES)
613
+
614
+
615
+ def _extract_identifier(filt: dict | None, filter_kind: str | None) -> str | None:
616
+ """Pull the identifier filter value (fqn_contains/path_contains/...) out of filt."""
617
+ if not filt or not filter_kind:
618
+ return None
619
+ for fname in _IDENTIFIER_FILTER_FIELDS.get(filter_kind, ()):
620
+ val = filt.get(fname)
621
+ if isinstance(val, str) and val.strip():
622
+ return val.strip()
623
+ return None
624
+
625
+
626
+ def _exact_symbol_exists(query: str, vocab: VocabularyIndex) -> bool:
627
+ """True if ``query`` exactly resolves to a real (resolved) project symbol.
628
+
629
+ Conservative false-absent guard: compares the query verbatim AND normalized
630
+ against every record's simple_name and fqn. Only invoked on the
631
+ ``not_in_project`` path (rare), so the O(n) scan is acceptable; it makes
632
+ declaring a real symbol absent impossible. Resolved-only because a phantom
633
+ match is handled as ``external`` upstream.
634
+ """
635
+ q = query.strip()
636
+ if not q:
637
+ return False
638
+ q_norm = _normalize_name(q)
639
+ for rec in vocab.records:
640
+ if not rec.resolved:
641
+ continue
642
+ if rec.simple_name == q or rec.fqn == q:
643
+ return True
644
+ if _normalize_name(rec.simple_name) == q_norm or _normalize_name(rec.fqn) == q_norm:
645
+ return True
646
+ return False
647
+
648
+
649
+ def _build_node_ref(rec: SymbolRecord) -> NodeRef:
650
+ """Build a NodeRef from a SymbolRecord (per the brief's field mapping)."""
651
+ return NodeRef(
652
+ id=rec.node_id,
653
+ kind="symbol",
654
+ fqn=rec.fqn,
655
+ name=rec.simple_name,
656
+ symbol_kind=rec.kind or None,
657
+ module=rec.module,
658
+ microservice=rec.microservice,
659
+ role=rec.role,
660
+ )
661
+
662
+
663
+ def _camel_tokens(name: str) -> list[str]:
664
+ """Split a CamelCase identifier into tokens for vocabulary statistics."""
665
+ if not name:
666
+ return []
667
+ tokens: list[str] = []
668
+ cur = ""
669
+ prev_lower = False
670
+ for ch in name:
671
+ if ch.isupper():
672
+ if prev_lower and cur:
673
+ tokens.append(cur.lower())
674
+ cur = ch
675
+ else:
676
+ cur += ch
677
+ prev_lower = False
678
+ elif ch.isalnum():
679
+ cur += ch
680
+ prev_lower = ch.islower()
681
+ else:
682
+ if cur:
683
+ tokens.append(cur.lower())
684
+ cur = ""
685
+ prev_lower = False
686
+ if cur:
687
+ tokens.append(cur.lower())
688
+ return [t for t in tokens if len(t) > 1]
689
+
690
+
691
+ def _fallback_refine() -> AbsenceDiagnosis | None:
692
+ """Minimal refine_query when diagnosis cannot complete (never raises)."""
693
+ try:
694
+ return AbsenceDiagnosis(
695
+ verdict="refine_query",
696
+ cause="identifier_miss",
697
+ message="Unable to diagnose the empty result; refine the query and retry.",
698
+ )
699
+ except Exception: # noqa: BLE001
700
+ return None