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.
Files changed (33) hide show
  1. ast_java.py +8 -3
  2. build_ast_graph.py +72 -16
  3. graph_enrich.py +2 -1
  4. graph_types.py +133 -0
  5. java_codebase_rag/_fdlimit.py +10 -2
  6. java_codebase_rag/_stdio.py +32 -0
  7. java_codebase_rag/cli.py +135 -24
  8. java_codebase_rag/config.py +128 -9
  9. java_codebase_rag/install_data/agents/explorer-rag-cli.md +291 -0
  10. java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +8 -8
  11. java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +8 -8
  12. java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +251 -0
  13. java_codebase_rag/installer.py +438 -103
  14. java_codebase_rag/jrag.py +4300 -0
  15. java_codebase_rag/jrag_envelope.py +1085 -0
  16. java_codebase_rag/jrag_hints.py +204 -0
  17. java_codebase_rag/jrag_render.py +688 -0
  18. java_codebase_rag/pipeline.py +20 -0
  19. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/METADATA +137 -94
  20. java_codebase_rag-0.8.0.dist-info/RECORD +43 -0
  21. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/WHEEL +1 -1
  22. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/entry_points.txt +1 -0
  23. {java_codebase_rag-0.6.6.dist-info → java_codebase_rag-0.8.0.dist-info}/top_level.txt +2 -0
  24. java_index_flow_lancedb.py +34 -19
  25. java_ontology.py +12 -0
  26. ladybug_queries.py +233 -52
  27. mcp_hints.py +6 -6
  28. mcp_v2.py +205 -617
  29. resolve_service.py +649 -0
  30. search_lancedb.py +10 -1
  31. server.py +20 -12
  32. java_codebase_rag-0.6.6.dist-info/RECORD +0 -34
  33. {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: