java-codebase-rag 0.6.6__py3-none-any.whl → 0.8.0__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.
- ast_java.py +8 -3
- build_ast_graph.py +72 -16
- graph_enrich.py +2 -1
- graph_types.py +133 -0
- java_codebase_rag/_fdlimit.py +10 -2
- java_codebase_rag/_stdio.py +32 -0
- java_codebase_rag/cli.py +135 -24
- java_codebase_rag/config.py +128 -9
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
- java_codebase_rag/installer.py +438 -103
- java_codebase_rag/jrag.py +4300 -0
- java_codebase_rag/jrag_envelope.py +1085 -0
- java_codebase_rag/jrag_hints.py +204 -0
- java_codebase_rag/jrag_render.py +688 -0
- java_codebase_rag/pipeline.py +20 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
- java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
- java_index_flow_lancedb.py +34 -19
- java_ontology.py +12 -0
- ladybug_queries.py +233 -52
- mcp_hints.py +6 -6
- mcp_v2.py +205 -617
- resolve_service.py +649 -0
- search_lancedb.py +10 -1
- server.py +20 -12
- java_codebase_rag-0.6.6.dist-info/RECORD +0 -34
- {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/licenses/LICENSE +0 -0
resolve_service.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""Resolve service for mapping identifiers to graph nodes.
|
|
2
|
+
|
|
3
|
+
Transport-agnostic resolve pipeline extracted from mcp_v2.py for reuse
|
|
4
|
+
by the CLI layer. Provides resolve_v2(identifier, hint_kind, graph) -> ResolveOutput.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
from graph_types import (
|
|
14
|
+
NodeRef,
|
|
15
|
+
StructuredHint,
|
|
16
|
+
_hints_or_skip,
|
|
17
|
+
_node_ref_from_row,
|
|
18
|
+
_to_structured_hints,
|
|
19
|
+
set_hints_enabled,
|
|
20
|
+
)
|
|
21
|
+
from java_ontology import ResolveReason
|
|
22
|
+
from ladybug_queries import LadybugGraph
|
|
23
|
+
from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"resolve_v2",
|
|
27
|
+
"ResolveOutput",
|
|
28
|
+
"ResolveCandidate",
|
|
29
|
+
"ResolveStatus",
|
|
30
|
+
"set_hints_enabled",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ResolveStatus = Literal["one", "many", "none"]
|
|
35
|
+
|
|
36
|
+
_RESOLVE_CANDIDATE_CAP = 10
|
|
37
|
+
|
|
38
|
+
_RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = {
|
|
39
|
+
"exact_id": 0,
|
|
40
|
+
"exact_fqn": 1,
|
|
41
|
+
"route_method_path": 1,
|
|
42
|
+
"client_target_path": 1,
|
|
43
|
+
"producer_topic_prefix": 1,
|
|
44
|
+
"fqn_suffix": 2,
|
|
45
|
+
"route_template": 2,
|
|
46
|
+
"client_fqn": 2,
|
|
47
|
+
"short_name": 3,
|
|
48
|
+
"client_target": 3,
|
|
49
|
+
"client_name": 3,
|
|
50
|
+
"producer_topic": 3,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_SYMBOL_RESOLVE_RETURN = (
|
|
54
|
+
"s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
|
|
55
|
+
"s.module AS module, s.role AS role, s.kind AS symbol_kind"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_ROUTE_RESOLVE_RETURN = (
|
|
59
|
+
"r.id AS id, r.kind AS kind, r.framework AS framework, r.method AS method, "
|
|
60
|
+
"r.path AS path, r.path_template AS path_template, r.path_regex AS path_regex, "
|
|
61
|
+
"r.topic AS topic, r.broker AS broker, r.feign_name AS feign_name, r.feign_url AS feign_url, "
|
|
62
|
+
"r.microservice AS microservice, r.module AS module, r.filename AS filename, "
|
|
63
|
+
"r.start_line AS start_line, r.end_line AS end_line, r.resolved AS resolved"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_CLIENT_RESOLVE_RETURN = (
|
|
67
|
+
"c.id AS id, c.client_kind AS client_kind, c.target_service AS target_service, "
|
|
68
|
+
"c.method AS method, c.path AS path, c.path_template AS path_template, "
|
|
69
|
+
"c.path_regex AS path_regex, c.member_fqn AS member_fqn, c.member_id AS member_id, "
|
|
70
|
+
"c.microservice AS microservice, c.module AS module, c.filename AS filename, "
|
|
71
|
+
"c.start_line AS start_line, c.end_line AS end_line, c.resolved AS resolved, "
|
|
72
|
+
"c.source_layer AS source_layer"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_PRODUCER_RESOLVE_RETURN = (
|
|
76
|
+
"p.id AS id, p.producer_kind AS producer_kind, p.topic AS topic, p.broker AS broker, "
|
|
77
|
+
"p.direction AS direction, p.member_fqn AS member_fqn, p.member_id AS member_id, "
|
|
78
|
+
"p.microservice AS microservice, p.module AS module, p.filename AS filename, "
|
|
79
|
+
"p.start_line AS start_line, p.end_line AS end_line, p.resolved AS resolved, "
|
|
80
|
+
"p.source_layer AS source_layer"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
_RESOLVE_PRE_DEDUP_LIMIT = 50
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _scope_clause(
|
|
87
|
+
alias: str,
|
|
88
|
+
microservice: str = "",
|
|
89
|
+
module: str = "",
|
|
90
|
+
) -> tuple[str, dict[str, str]]:
|
|
91
|
+
"""Build a Cypher AND-clause scoping a node alias by microservice/module.
|
|
92
|
+
|
|
93
|
+
Returns ``(clause, params)`` where ``clause`` is ``""`` or
|
|
94
|
+
``" AND <alias>.microservice = $ms AND <alias>.module = $mod"`` and
|
|
95
|
+
``params`` carries only the bound scope values. Used by the candidate
|
|
96
|
+
matchers to push ``--service``/``--module`` down into resolve so they act
|
|
97
|
+
as resolve-time filters (not just traversal post-filters).
|
|
98
|
+
"""
|
|
99
|
+
preds: list[str] = []
|
|
100
|
+
params: dict[str, str] = {}
|
|
101
|
+
if microservice:
|
|
102
|
+
preds.append(f"{alias}.microservice = $ms")
|
|
103
|
+
params["ms"] = microservice
|
|
104
|
+
if module:
|
|
105
|
+
preds.append(f"{alias}.module = $mod")
|
|
106
|
+
params["mod"] = module
|
|
107
|
+
clause = (" AND " + " AND ".join(preds)) if preds else ""
|
|
108
|
+
return clause, params
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ResolveCandidate(BaseModel):
|
|
112
|
+
model_config = ConfigDict(extra="forbid")
|
|
113
|
+
|
|
114
|
+
node: NodeRef
|
|
115
|
+
score: float
|
|
116
|
+
reason: ResolveReason
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ResolveOutput(BaseModel):
|
|
120
|
+
model_config = ConfigDict(extra="forbid")
|
|
121
|
+
|
|
122
|
+
success: bool
|
|
123
|
+
status: ResolveStatus
|
|
124
|
+
node: NodeRef | None = None
|
|
125
|
+
candidates: list[ResolveCandidate] = Field(default_factory=list)
|
|
126
|
+
message: str | None = None
|
|
127
|
+
resolved_identifier: str | None = None
|
|
128
|
+
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
129
|
+
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]:
|
|
133
|
+
trimmed = raw.strip()
|
|
134
|
+
if not trimmed:
|
|
135
|
+
detail = "empty string" if raw == "" else "whitespace only"
|
|
136
|
+
return None, f"Invalid identifier: {detail}"
|
|
137
|
+
return trimmed, None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_kinds_to_search(
|
|
141
|
+
hint_kind: Literal["symbol", "route", "client", "producer"] | None,
|
|
142
|
+
) -> list[Literal["symbol", "route", "client", "producer"]]:
|
|
143
|
+
if hint_kind is None:
|
|
144
|
+
return ["symbol", "route", "client", "producer"]
|
|
145
|
+
return [hint_kind]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve_parse_route_method_path(identifier: str) -> tuple[str, str] | None:
|
|
149
|
+
parts = identifier.split(None, 1)
|
|
150
|
+
if len(parts) != 2:
|
|
151
|
+
return None
|
|
152
|
+
method, path = parts[0].upper(), parts[1].strip()
|
|
153
|
+
if not method.isalpha() or not path.startswith("/"):
|
|
154
|
+
return None
|
|
155
|
+
return method, path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_parse_microservice_route(identifier: str) -> tuple[str, str, str] | None:
|
|
159
|
+
parts = identifier.split(None, 2)
|
|
160
|
+
if len(parts) != 3:
|
|
161
|
+
return None
|
|
162
|
+
microservice, method, path = parts[0], parts[1].upper(), parts[2].strip()
|
|
163
|
+
if not method.isalpha() or not path.startswith("/"):
|
|
164
|
+
return None
|
|
165
|
+
return microservice, method, path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_symbol_candidates(
|
|
169
|
+
g: LadybugGraph,
|
|
170
|
+
identifier: str,
|
|
171
|
+
*,
|
|
172
|
+
microservice: str = "",
|
|
173
|
+
module: str = "",
|
|
174
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
175
|
+
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
176
|
+
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
177
|
+
scope, scope_params = _scope_clause("s", microservice, module)
|
|
178
|
+
|
|
179
|
+
rows = g._rows( # noqa: SLF001
|
|
180
|
+
f"MATCH (s:Symbol) WHERE s.id = $id{scope} RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
181
|
+
{"id": identifier, "lim": lim, **scope_params},
|
|
182
|
+
)
|
|
183
|
+
for row in rows:
|
|
184
|
+
out.append((_node_ref_from_row("symbol", row), "exact_id", len(identifier)))
|
|
185
|
+
|
|
186
|
+
rows = g._rows( # noqa: SLF001
|
|
187
|
+
f"MATCH (s:Symbol) WHERE s.fqn = $fqn{scope} RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
188
|
+
{"fqn": identifier, "lim": lim, **scope_params},
|
|
189
|
+
)
|
|
190
|
+
for row in rows:
|
|
191
|
+
out.append((_node_ref_from_row("symbol", row), "exact_fqn", len(identifier)))
|
|
192
|
+
|
|
193
|
+
# Method FQN without arg signature (e.g. "pkg.Cls#method"): the stored method
|
|
194
|
+
# fqn is "pkg.Cls#method(Type,Type)", so an argless identifier misses the
|
|
195
|
+
# exact match above. Prefix-match on "<identifier>(" so the agent doesn't
|
|
196
|
+
# have to type the exact "(Type,Type)" signature. Multiple overloads → the
|
|
197
|
+
# resolve "many" path surfaces them honestly as ambiguous candidates.
|
|
198
|
+
if "#" in identifier and "(" not in identifier:
|
|
199
|
+
rows = g._rows( # noqa: SLF001
|
|
200
|
+
f"MATCH (s:Symbol) WHERE s.fqn STARTS WITH $mp{scope} "
|
|
201
|
+
f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
202
|
+
{"mp": identifier + "(", "lim": lim, **scope_params},
|
|
203
|
+
)
|
|
204
|
+
for row in rows:
|
|
205
|
+
out.append((_node_ref_from_row("symbol", row), "fqn_suffix", len(identifier) + 1))
|
|
206
|
+
# Short "Cls#method" form (no package): the identifier is "<Class>#<method>"
|
|
207
|
+
# with no dot in the class part. Match symbols whose fqn contains
|
|
208
|
+
# "<Class>#<method>(" so overloads surface honestly as ambiguous.
|
|
209
|
+
if "." not in identifier.split("#", 1)[0]:
|
|
210
|
+
class_part, _, method_part = identifier.partition("#")
|
|
211
|
+
if class_part and method_part:
|
|
212
|
+
contains = f".{class_part}#{method_part}("
|
|
213
|
+
rows = g._rows( # noqa: SLF001
|
|
214
|
+
f"MATCH (s:Symbol) WHERE s.fqn CONTAINS $mp{scope} "
|
|
215
|
+
f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
216
|
+
{"mp": contains, "lim": lim, **scope_params},
|
|
217
|
+
)
|
|
218
|
+
for row in rows:
|
|
219
|
+
fqn = str(row.get("fqn") or "")
|
|
220
|
+
out.append(
|
|
221
|
+
(_node_ref_from_row("symbol", row), "fqn_suffix", len(identifier) + 1)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
suffix = f".{identifier}"
|
|
225
|
+
rows = g._rows( # noqa: SLF001
|
|
226
|
+
f"MATCH (s:Symbol) WHERE s.fqn = $ident OR s.fqn ENDS WITH $suffix{scope} "
|
|
227
|
+
f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
228
|
+
{"ident": identifier, "suffix": suffix, "lim": lim, **scope_params},
|
|
229
|
+
)
|
|
230
|
+
for row in rows:
|
|
231
|
+
fqn = str(row.get("fqn") or "")
|
|
232
|
+
spec = len(fqn)
|
|
233
|
+
out.append((_node_ref_from_row("symbol", row), "fqn_suffix", spec))
|
|
234
|
+
|
|
235
|
+
rows = g._rows( # noqa: SLF001
|
|
236
|
+
f"MATCH (s:Symbol) WHERE s.name = $name{scope} RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
237
|
+
{"name": identifier, "lim": lim, **scope_params},
|
|
238
|
+
)
|
|
239
|
+
for row in rows:
|
|
240
|
+
out.append((_node_ref_from_row("symbol", row), "short_name", len(identifier)))
|
|
241
|
+
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _resolve_route_candidates(
|
|
246
|
+
g: LadybugGraph,
|
|
247
|
+
identifier: str,
|
|
248
|
+
*,
|
|
249
|
+
microservice: str = "",
|
|
250
|
+
module: str = "",
|
|
251
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
252
|
+
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
253
|
+
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
254
|
+
scope, scope_params = _scope_clause("r", microservice, module)
|
|
255
|
+
|
|
256
|
+
rows = g._rows( # noqa: SLF001
|
|
257
|
+
f"MATCH (r:Route) WHERE r.id = $id{scope} RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
258
|
+
{"id": identifier, "lim": lim, **scope_params},
|
|
259
|
+
)
|
|
260
|
+
for row in rows:
|
|
261
|
+
out.append((_node_ref_from_row("route", row), "exact_id", len(identifier)))
|
|
262
|
+
|
|
263
|
+
ms_route = _resolve_parse_microservice_route(identifier)
|
|
264
|
+
if ms_route is not None:
|
|
265
|
+
microservice_ms, method, path = ms_route
|
|
266
|
+
rows = g._rows( # noqa: SLF001
|
|
267
|
+
f"MATCH (r:Route) WHERE r.microservice = $ms AND r.method = $method "
|
|
268
|
+
f"AND (r.path = $path OR r.path_template = $path){scope} "
|
|
269
|
+
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
270
|
+
{"ms": microservice_ms, "method": method, "path": path, "lim": lim, **scope_params},
|
|
271
|
+
)
|
|
272
|
+
for row in rows:
|
|
273
|
+
spec = len(path)
|
|
274
|
+
out.append((_node_ref_from_row("route", row), "route_method_path", spec))
|
|
275
|
+
|
|
276
|
+
method_path = _resolve_parse_route_method_path(identifier)
|
|
277
|
+
if method_path is not None:
|
|
278
|
+
method, path = method_path
|
|
279
|
+
rows = g._rows( # noqa: SLF001
|
|
280
|
+
f"MATCH (r:Route) WHERE r.method = $method "
|
|
281
|
+
f"AND (r.path = $path OR r.path_template = $path){scope} "
|
|
282
|
+
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
283
|
+
{"method": method, "path": path, "lim": lim, **scope_params},
|
|
284
|
+
)
|
|
285
|
+
for row in rows:
|
|
286
|
+
out.append((_node_ref_from_row("route", row), "route_method_path", len(path)))
|
|
287
|
+
|
|
288
|
+
if identifier.startswith("/"):
|
|
289
|
+
rows = g._rows( # noqa: SLF001
|
|
290
|
+
f"MATCH (r:Route) WHERE r.path = $path OR r.path_template = $path{scope} "
|
|
291
|
+
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
292
|
+
{"path": identifier, "lim": lim, **scope_params},
|
|
293
|
+
)
|
|
294
|
+
for row in rows:
|
|
295
|
+
path_val = str(row.get("path_template") or row.get("path") or "")
|
|
296
|
+
out.append((_node_ref_from_row("route", row), "route_template", len(path_val)))
|
|
297
|
+
|
|
298
|
+
return _drop_route_mirrors(g, out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _drop_route_mirrors(
|
|
302
|
+
g: LadybugGraph,
|
|
303
|
+
cands: list[tuple[NodeRef, ResolveReason, int]],
|
|
304
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
305
|
+
"""Drop client-side Route mirrors that collide with a server-exposed route.
|
|
306
|
+
|
|
307
|
+
A path can resolve to TWO Route nodes: the server route (exposed by a
|
|
308
|
+
controller via an inbound ``EXPOSES`` edge, ``microservice`` set) and a
|
|
309
|
+
client-side mirror (no ``EXPOSES``, often ``microservice=''``) created when
|
|
310
|
+
a Client's HTTP call couldn't be linked to the server route. The mirror is
|
|
311
|
+
an artifact — drop it when a server-exposed route shares the same
|
|
312
|
+
``(method, path_template)`` so the no-flags ``jrag callers '/path'`` flow
|
|
313
|
+
resolves to the single server route instead of stalling on "ambiguous".
|
|
314
|
+
GENUINE ambiguity (two server-exposed routes in different microservices
|
|
315
|
+
sharing a path) is preserved — both have ``EXPOSES`` and survive.
|
|
316
|
+
"""
|
|
317
|
+
if len(cands) < 2:
|
|
318
|
+
return cands
|
|
319
|
+
ids = [c[0].id for c in cands if c[0].id]
|
|
320
|
+
if not ids:
|
|
321
|
+
return cands
|
|
322
|
+
id_list = ", ".join("'" + cid.replace("'", "''") + "'" for cid in ids)
|
|
323
|
+
exposed_rows = g._rows( # noqa: SLF001
|
|
324
|
+
"MATCH (s:Symbol)-[:EXPOSES]->(r:Route) "
|
|
325
|
+
f"WHERE r.id IN [{id_list}] "
|
|
326
|
+
"RETURN r.id AS rid",
|
|
327
|
+
{},
|
|
328
|
+
)
|
|
329
|
+
exposed_ids = {str(r.get("rid") or "") for r in exposed_rows}
|
|
330
|
+
if not exposed_ids:
|
|
331
|
+
return cands
|
|
332
|
+
|
|
333
|
+
# Group candidates by their route fqn ("METHOD path"); within a colliding
|
|
334
|
+
# group, drop non-exposed (mirror) entries only when an exposed entry exists.
|
|
335
|
+
groups: dict[str, list[tuple[NodeRef, ResolveReason, int]]] = {}
|
|
336
|
+
for node, reason, spec in cands:
|
|
337
|
+
groups.setdefault(str(node.fqn or ""), []).append((node, reason, spec))
|
|
338
|
+
|
|
339
|
+
keep: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
340
|
+
for group in groups.values():
|
|
341
|
+
has_exposed = any(c[0].id in exposed_ids for c in group)
|
|
342
|
+
for node, reason, spec in group:
|
|
343
|
+
if has_exposed and node.id not in exposed_ids:
|
|
344
|
+
continue # mirror colliding with a server route — drop
|
|
345
|
+
keep.append((node, reason, spec))
|
|
346
|
+
return keep
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _resolve_client_candidates(
|
|
350
|
+
g: LadybugGraph,
|
|
351
|
+
identifier: str,
|
|
352
|
+
*,
|
|
353
|
+
microservice: str = "",
|
|
354
|
+
module: str = "",
|
|
355
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
356
|
+
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
357
|
+
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
358
|
+
scope, scope_params = _scope_clause("c", microservice, module)
|
|
359
|
+
|
|
360
|
+
rows = g._rows( # noqa: SLF001
|
|
361
|
+
f"MATCH (c:Client) WHERE c.id = $id{scope} RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
362
|
+
{"id": identifier, "lim": lim, **scope_params},
|
|
363
|
+
)
|
|
364
|
+
for row in rows:
|
|
365
|
+
out.append((_node_ref_from_row("client", row), "exact_id", len(identifier)))
|
|
366
|
+
|
|
367
|
+
if " " in identifier:
|
|
368
|
+
target, path_prefix = identifier.split(" ", 1)
|
|
369
|
+
target = target.strip()
|
|
370
|
+
path_prefix = path_prefix.strip()
|
|
371
|
+
if target and path_prefix:
|
|
372
|
+
rows = g._rows( # noqa: SLF001
|
|
373
|
+
f"MATCH (c:Client) WHERE c.target_service = $target "
|
|
374
|
+
f"AND (c.path STARTS WITH $path OR c.path_template STARTS WITH $path){scope} "
|
|
375
|
+
f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
376
|
+
{"target": target, "path": path_prefix, "lim": lim, **scope_params},
|
|
377
|
+
)
|
|
378
|
+
for row in rows:
|
|
379
|
+
spec = len(path_prefix)
|
|
380
|
+
out.append((_node_ref_from_row("client", row), "client_target_path", spec))
|
|
381
|
+
elif not identifier.startswith("/"):
|
|
382
|
+
rows = g._rows( # noqa: SLF001
|
|
383
|
+
f"MATCH (c:Client) WHERE c.target_service = $target{scope} "
|
|
384
|
+
f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
385
|
+
{"target": identifier, "lim": lim, **scope_params},
|
|
386
|
+
)
|
|
387
|
+
for row in rows:
|
|
388
|
+
out.append((_node_ref_from_row("client", row), "client_target", len(identifier)))
|
|
389
|
+
|
|
390
|
+
# Client-by-name/FQN: reach a Client root via its declaring Symbol (the
|
|
391
|
+
# method that declares the client). Only SUFFIX/NAME matches are used — a
|
|
392
|
+
# full method FQN identifier (e.g. 'pkg.Cls#method(Arg)') is intentionally
|
|
393
|
+
# left to resolve to the method Symbol (exact_fqn) rather than ALSO matching
|
|
394
|
+
# the Client declared by that same method, which would surface a spurious
|
|
395
|
+
# "ambiguous" result. A bare class name (no '#') does not suffix-match a
|
|
396
|
+
# Client's member_fqn (which carries '#method(args)'), so it resolves to the
|
|
397
|
+
# type Symbol; a bare method name ('joinOperator') matches Client(s) via the
|
|
398
|
+
# declaring symbol name and surfaces honest ambiguity across clients.
|
|
399
|
+
if " " not in identifier and not identifier.startswith("/"):
|
|
400
|
+
sym_scope, sym_scope_params = _scope_clause("s", microservice, module)
|
|
401
|
+
# Declaring symbol name match (e.g. 'joinOperator').
|
|
402
|
+
rows = g._rows( # noqa: SLF001
|
|
403
|
+
"MATCH (s:Symbol)-[:DECLARES_CLIENT]->(c:Client) "
|
|
404
|
+
f"WHERE s.name = $name{sym_scope} "
|
|
405
|
+
f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
406
|
+
{"name": identifier, "lim": lim, **sym_scope_params},
|
|
407
|
+
)
|
|
408
|
+
for row in rows:
|
|
409
|
+
out.append((_node_ref_from_row("client", row), "client_name", len(identifier)))
|
|
410
|
+
# Declaring symbol FQN / member_fqn SUFFIX match (safe: a full method
|
|
411
|
+
# FQN with args never suffix-matches because stored fqns carry the arg
|
|
412
|
+
# suffix; '.<identifier>' only matches a class-level identifier).
|
|
413
|
+
suffix = "." + identifier
|
|
414
|
+
rows = g._rows( # noqa: SLF001
|
|
415
|
+
"MATCH (s:Symbol)-[:DECLARES_CLIENT]->(c:Client) "
|
|
416
|
+
f"WHERE s.fqn ENDS WITH $suffix OR c.member_fqn ENDS WITH $suffix{sym_scope} "
|
|
417
|
+
f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
418
|
+
{"suffix": suffix, "lim": lim, **sym_scope_params},
|
|
419
|
+
)
|
|
420
|
+
for row in rows:
|
|
421
|
+
fqn = str(row.get("member_fqn") or "")
|
|
422
|
+
out.append((_node_ref_from_row("client", row), "client_fqn", len(fqn) or len(identifier)))
|
|
423
|
+
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _resolve_producer_candidates(
|
|
428
|
+
g: LadybugGraph,
|
|
429
|
+
identifier: str,
|
|
430
|
+
*,
|
|
431
|
+
microservice: str = "",
|
|
432
|
+
module: str = "",
|
|
433
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
434
|
+
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
435
|
+
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
436
|
+
scope, scope_params = _scope_clause("p", microservice, module)
|
|
437
|
+
|
|
438
|
+
rows = g._rows( # noqa: SLF001
|
|
439
|
+
f"MATCH (p:Producer) WHERE p.id = $id{scope} RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
440
|
+
{"id": identifier, "lim": lim, **scope_params},
|
|
441
|
+
)
|
|
442
|
+
for row in rows:
|
|
443
|
+
out.append((_node_ref_from_row("producer", row), "exact_id", len(identifier)))
|
|
444
|
+
|
|
445
|
+
rows = g._rows( # noqa: SLF001
|
|
446
|
+
f"MATCH (p:Producer) WHERE p.topic = $topic{scope} RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
447
|
+
{"topic": identifier, "lim": lim, **scope_params},
|
|
448
|
+
)
|
|
449
|
+
for row in rows:
|
|
450
|
+
out.append((_node_ref_from_row("producer", row), "producer_topic", len(identifier)))
|
|
451
|
+
|
|
452
|
+
if not identifier.startswith("/"):
|
|
453
|
+
rows = g._rows( # noqa: SLF001
|
|
454
|
+
f"MATCH (p:Producer) WHERE p.topic STARTS WITH $topic{scope} "
|
|
455
|
+
f"RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
456
|
+
{"topic": identifier, "lim": lim, **scope_params},
|
|
457
|
+
)
|
|
458
|
+
for row in rows:
|
|
459
|
+
out.append((_node_ref_from_row("producer", row), "producer_topic_prefix", len(identifier)))
|
|
460
|
+
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _resolve_dedupe_candidates(
|
|
465
|
+
raw: list[tuple[NodeRef, ResolveReason, int]],
|
|
466
|
+
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
467
|
+
best: dict[str, tuple[NodeRef, ResolveReason, int]] = {}
|
|
468
|
+
for node, reason, specificity in raw:
|
|
469
|
+
prev = best.get(node.id)
|
|
470
|
+
if prev is None:
|
|
471
|
+
best[node.id] = (node, reason, specificity)
|
|
472
|
+
continue
|
|
473
|
+
prev_pri = _RESOLVE_REASON_PRIORITY[prev[1]]
|
|
474
|
+
new_pri = _RESOLVE_REASON_PRIORITY[reason]
|
|
475
|
+
if new_pri < prev_pri or (new_pri == prev_pri and specificity > prev[2]):
|
|
476
|
+
best[node.id] = (node, reason, specificity)
|
|
477
|
+
return list(best.values())
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _resolve_rank_candidates(
|
|
481
|
+
deduped: list[tuple[NodeRef, ResolveReason, int]],
|
|
482
|
+
) -> list[ResolveCandidate]:
|
|
483
|
+
ordered = sorted(
|
|
484
|
+
deduped,
|
|
485
|
+
key=lambda item: (_RESOLVE_REASON_PRIORITY[item[1]], -item[2], item[0].id),
|
|
486
|
+
)
|
|
487
|
+
total = len(ordered)
|
|
488
|
+
return [
|
|
489
|
+
ResolveCandidate(
|
|
490
|
+
node=node,
|
|
491
|
+
reason=reason,
|
|
492
|
+
score=(1.0 - (idx / total)) if total else 0.0,
|
|
493
|
+
)
|
|
494
|
+
for idx, (node, reason, _spec) in enumerate(ordered)
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _resolve_assert_invariants(out: ResolveOutput) -> None:
|
|
499
|
+
if not out.success:
|
|
500
|
+
assert out.status == "none"
|
|
501
|
+
assert out.node is None
|
|
502
|
+
assert not out.candidates
|
|
503
|
+
assert out.message
|
|
504
|
+
return
|
|
505
|
+
if out.status == "one":
|
|
506
|
+
assert out.node is not None
|
|
507
|
+
assert not out.candidates
|
|
508
|
+
elif out.status == "many":
|
|
509
|
+
assert out.node is None
|
|
510
|
+
assert len(out.candidates) >= 2
|
|
511
|
+
elif out.status == "none":
|
|
512
|
+
assert out.node is None
|
|
513
|
+
assert not out.candidates
|
|
514
|
+
assert out.message
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _resolve_seeds_for_hints(identifier: str) -> tuple[str | None, str | None]:
|
|
518
|
+
path_prefix_seed: str | None = None
|
|
519
|
+
method_path = _resolve_parse_route_method_path(identifier)
|
|
520
|
+
if method_path is not None:
|
|
521
|
+
path_prefix_seed = method_path[1]
|
|
522
|
+
else:
|
|
523
|
+
ms_route = _resolve_parse_microservice_route(identifier)
|
|
524
|
+
if ms_route is not None:
|
|
525
|
+
path_prefix_seed = ms_route[2]
|
|
526
|
+
elif identifier.startswith("/"):
|
|
527
|
+
path_prefix_seed = identifier
|
|
528
|
+
|
|
529
|
+
target_service_seed: str | None = None
|
|
530
|
+
if " " in identifier:
|
|
531
|
+
target, _path_prefix = identifier.split(" ", 1)
|
|
532
|
+
target = target.strip()
|
|
533
|
+
if target:
|
|
534
|
+
target_service_seed = target
|
|
535
|
+
elif not identifier.startswith("/"):
|
|
536
|
+
target_service_seed = identifier
|
|
537
|
+
|
|
538
|
+
return path_prefix_seed, target_service_seed
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _resolve_finalize_success(
|
|
542
|
+
trimmed: str,
|
|
543
|
+
hint_kind: Literal["symbol", "route", "client", "producer"] | None,
|
|
544
|
+
matches: list[ResolveCandidate],
|
|
545
|
+
) -> ResolveOutput:
|
|
546
|
+
if not matches:
|
|
547
|
+
out = ResolveOutput(
|
|
548
|
+
success=True,
|
|
549
|
+
status="none",
|
|
550
|
+
message=(
|
|
551
|
+
"No matches for identifier; use search(query=...) for ranked fuzzy lookup."
|
|
552
|
+
),
|
|
553
|
+
resolved_identifier=trimmed,
|
|
554
|
+
)
|
|
555
|
+
elif len(matches) == 1:
|
|
556
|
+
out = ResolveOutput(
|
|
557
|
+
success=True,
|
|
558
|
+
status="one",
|
|
559
|
+
node=matches[0].node,
|
|
560
|
+
resolved_identifier=trimmed,
|
|
561
|
+
)
|
|
562
|
+
else:
|
|
563
|
+
out = ResolveOutput(
|
|
564
|
+
success=True,
|
|
565
|
+
status="many",
|
|
566
|
+
candidates=matches,
|
|
567
|
+
resolved_identifier=trimmed,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed)
|
|
571
|
+
hint_payload = {
|
|
572
|
+
"status": out.status,
|
|
573
|
+
"resolved_identifier": trimmed,
|
|
574
|
+
"candidates": out.candidates,
|
|
575
|
+
"hint_kind": hint_kind,
|
|
576
|
+
"path_prefix_seed": path_prefix_seed,
|
|
577
|
+
"target_service_seed": target_service_seed,
|
|
578
|
+
}
|
|
579
|
+
raw_struct, raw_advisories = _hints_or_skip("resolve", hint_payload)
|
|
580
|
+
out = out.model_copy(update={
|
|
581
|
+
"advisories": raw_advisories,
|
|
582
|
+
"hints_structured": _to_structured_hints(raw_struct),
|
|
583
|
+
})
|
|
584
|
+
_resolve_assert_invariants(out)
|
|
585
|
+
return out
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def resolve_v2(
|
|
589
|
+
identifier: str,
|
|
590
|
+
hint_kind: Literal["symbol", "route", "client", "producer"] | None = None,
|
|
591
|
+
graph: LadybugGraph | None = None,
|
|
592
|
+
*,
|
|
593
|
+
microservice: str = "",
|
|
594
|
+
module: str = "",
|
|
595
|
+
) -> ResolveOutput:
|
|
596
|
+
try:
|
|
597
|
+
trimmed, err = _resolve_validate_identifier(identifier)
|
|
598
|
+
if err is not None:
|
|
599
|
+
out = ResolveOutput(
|
|
600
|
+
success=False,
|
|
601
|
+
status="none",
|
|
602
|
+
message=err,
|
|
603
|
+
advisories=[],
|
|
604
|
+
resolved_identifier=None,
|
|
605
|
+
)
|
|
606
|
+
_resolve_assert_invariants(out)
|
|
607
|
+
return out
|
|
608
|
+
|
|
609
|
+
assert trimmed is not None
|
|
610
|
+
if "*" in trimmed or "?" in trimmed:
|
|
611
|
+
out = ResolveOutput(
|
|
612
|
+
success=False,
|
|
613
|
+
status="none",
|
|
614
|
+
message=(
|
|
615
|
+
"Wildcards (* and ?) are not supported in resolve; "
|
|
616
|
+
"use search(query=...) for ranked text search."
|
|
617
|
+
),
|
|
618
|
+
advisories=[],
|
|
619
|
+
resolved_identifier=trimmed,
|
|
620
|
+
)
|
|
621
|
+
_resolve_assert_invariants(out)
|
|
622
|
+
return out
|
|
623
|
+
|
|
624
|
+
g = graph or LadybugGraph.get()
|
|
625
|
+
raw: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
626
|
+
for kind in _resolve_kinds_to_search(hint_kind):
|
|
627
|
+
if kind == "symbol":
|
|
628
|
+
raw.extend(_resolve_symbol_candidates(g, trimmed, microservice=microservice, module=module))
|
|
629
|
+
elif kind == "route":
|
|
630
|
+
raw.extend(_resolve_route_candidates(g, trimmed, microservice=microservice, module=module))
|
|
631
|
+
elif kind == "client":
|
|
632
|
+
raw.extend(_resolve_client_candidates(g, trimmed, microservice=microservice, module=module))
|
|
633
|
+
else:
|
|
634
|
+
raw.extend(_resolve_producer_candidates(g, trimmed, microservice=microservice, module=module))
|
|
635
|
+
|
|
636
|
+
deduped = _resolve_dedupe_candidates(raw)
|
|
637
|
+
ranked = _resolve_rank_candidates(deduped)
|
|
638
|
+
capped = ranked[:_RESOLVE_CANDIDATE_CAP]
|
|
639
|
+
return _resolve_finalize_success(trimmed, hint_kind, capped)
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
out = ResolveOutput(
|
|
642
|
+
success=False,
|
|
643
|
+
status="none",
|
|
644
|
+
message=str(exc),
|
|
645
|
+
advisories=[],
|
|
646
|
+
resolved_identifier=None,
|
|
647
|
+
)
|
|
648
|
+
_resolve_assert_invariants(out)
|
|
649
|
+
return out
|
search_lancedb.py
CHANGED
|
@@ -534,6 +534,15 @@ def _search_one_table(
|
|
|
534
534
|
for r in rows:
|
|
535
535
|
r["_kind"] = kind
|
|
536
536
|
r["_hybrid"] = False
|
|
537
|
+
# Populate `_score` from `_distance` so the SearchHit.score reflects
|
|
538
|
+
# relevance. The hybrid branch sets `_score` from `_relevance_score`
|
|
539
|
+
# above; without this, non-hybrid (default) search left `_score` unset
|
|
540
|
+
# and mcp_v2._row_to_search_hit fell back to 0.0 for EVERY hit —
|
|
541
|
+
# ranking still worked (the sort key uses `_distance` directly) but the
|
|
542
|
+
# exposed score was always 0.0, making results look unranked.
|
|
543
|
+
d = r.get("_distance")
|
|
544
|
+
if d is not None:
|
|
545
|
+
r["_score"] = l2_distance_to_score(float(d))
|
|
537
546
|
r["start"] = coerce_position_field(r.get("start"))
|
|
538
547
|
r["end"] = coerce_position_field(r.get("end"))
|
|
539
548
|
return rows
|
|
@@ -853,7 +862,7 @@ def run_search(
|
|
|
853
862
|
capability=capability, capability_in=capability_in,
|
|
854
863
|
) if "java" in table_keys else []
|
|
855
864
|
|
|
856
|
-
skip_role_weight = bool(role or role_in)
|
|
865
|
+
skip_role_weight = bool(role or role_in or exclude_roles)
|
|
857
866
|
query_toks = _query_tokens(query)
|
|
858
867
|
|
|
859
868
|
if len(table_keys) == 1:
|