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.
- java_codebase_rag/absence/__init__.py +0 -0
- java_codebase_rag/absence/absence_diagnosis.py +700 -0
- java_codebase_rag/absence/absence_types.py +124 -0
- java_codebase_rag/absence/absence_vocab.py +455 -0
- java_codebase_rag/analysis/__init__.py +0 -0
- pr_analysis.py → java_codebase_rag/analysis/pr_analysis.py +1 -1
- resolve_service.py → java_codebase_rag/analysis/resolve_service.py +73 -6
- java_codebase_rag/ast/__init__.py +0 -0
- ast_java.py → java_codebase_rag/ast/ast_java.py +5 -5
- java_codebase_rag/cli.py +13 -18
- java_codebase_rag/config.py +116 -0
- java_codebase_rag/graph/__init__.py +0 -0
- build_ast_graph.py → java_codebase_rag/graph/build_ast_graph.py +89 -11
- graph_enrich.py → java_codebase_rag/graph/graph_enrich.py +248 -3
- graph_types.py → java_codebase_rag/graph/graph_types.py +6 -2
- java_ontology.py → java_codebase_rag/graph/java_ontology.py +1 -1
- ladybug_queries.py → java_codebase_rag/graph/ladybug_queries.py +6 -6
- java_codebase_rag/index/__init__.py +0 -0
- java_index_flow_lancedb.py → java_codebase_rag/index/java_index_flow_lancedb.py +30 -10
- java_codebase_rag/install_data/__init__.py +0 -0
- java_codebase_rag/jrag.py +71 -16
- java_codebase_rag/jrag_envelope.py +13 -4
- java_codebase_rag/jrag_hints.py +1 -1
- java_codebase_rag/jrag_render.py +67 -3
- java_codebase_rag/mcp/__init__.py +0 -0
- mcp_hints.py → java_codebase_rag/mcp/mcp_hints.py +1 -1
- mcp_v2.py → java_codebase_rag/mcp/mcp_v2.py +280 -81
- server.py → java_codebase_rag/mcp/server.py +138 -54
- java_codebase_rag/pipeline.py +26 -7
- java_codebase_rag/search/__init__.py +0 -0
- search_lancedb.py → java_codebase_rag/search/search_lancedb.py +53 -314
- java_codebase_rag/search/search_lexical.py +329 -0
- java_codebase_rag/search/search_scoring.py +338 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/METADATA +2 -2
- java_codebase_rag-0.9.6.dist-info/RECORD +57 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/entry_points.txt +1 -1
- java_codebase_rag-0.9.6.dist-info/top_level.txt +1 -0
- java_codebase_rag-0.9.4.dist-info/RECORD +0 -44
- java_codebase_rag-0.9.4.dist-info/top_level.txt +0 -19
- /brownfield_events.py → /java_codebase_rag/ast/brownfield_events.py +0 -0
- /chunk_heuristics.py → /java_codebase_rag/ast/chunk_heuristics.py +0 -0
- /path_filtering.py → /java_codebase_rag/graph/path_filtering.py +0 -0
- /java_index_v1_common.py → /java_codebase_rag/index/java_index_v1_common.py +0 -0
- /index_common.py → /java_codebase_rag/search/index_common.py +0 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/WHEEL +0 -0
- {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
|