java-codebase-rag 0.6.7__py3-none-any.whl → 0.9.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 +149 -25
- java_codebase_rag/config.py +128 -9
- java_codebase_rag/install_data/agents/explorer-rag-cli.md +148 -0
- java_codebase_rag/install_data/agents/explorer-rag-enhanced.md +78 -232
- java_codebase_rag/install_data/skills/explore-codebase/SKILL.md +49 -88
- java_codebase_rag/install_data/skills/explore-codebase-cli/SKILL.md +183 -0
- java_codebase_rag/installer.py +720 -107
- java_codebase_rag/jrag.py +4405 -0
- java_codebase_rag/jrag_envelope.py +1085 -0
- java_codebase_rag/jrag_hints.py +204 -0
- java_codebase_rag/jrag_render.py +697 -0
- java_codebase_rag/lance_optimize.py +18 -0
- java_codebase_rag/pipeline.py +34 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/METADATA +137 -94
- java_codebase_rag-0.9.0.dist-info/RECORD +43 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/WHEEL +1 -1
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/entry_points.txt +1 -0
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.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 +276 -632
- resolve_service.py +649 -0
- search_lancedb.py +159 -4
- server.py +31 -12
- java_codebase_rag-0.6.7.dist-info/RECORD +0 -34
- {java_codebase_rag-0.6.7.dist-info → java_codebase_rag-0.9.0.dist-info}/licenses/LICENSE +0 -0
mcp_v2.py
CHANGED
|
@@ -4,9 +4,9 @@ Strict frame contract
|
|
|
4
4
|
---------------------
|
|
5
5
|
NodeFilter is a typed predicate bag: each populated field maps to one stored graph
|
|
6
6
|
attribute for the selected kind; inapplicable fields fail loud with a teaching message.
|
|
7
|
-
The ``search`` tool's ``query`` parameter is the ranked-text carve-out;
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
The ``search`` tool's ``query`` parameter is the ranked-text carve-out; the substring
|
|
8
|
+
fields (``fqn_contains``, ``path_contains``, ``target_path_contains``, ``topic_contains``)
|
|
9
|
+
match literally (Cypher ``CONTAINS``) — no wildcard/metacharacter handling.
|
|
10
10
|
|
|
11
11
|
Revisit trigger (``propose/completed/MCP-FILTER-FRAME-PROPOSE.md`` section 3.4.6)
|
|
12
12
|
--------------------------------------------------------------
|
|
@@ -22,29 +22,59 @@ import os
|
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
import threading
|
|
25
|
-
from typing import Annotated, Any, Literal, get_args
|
|
25
|
+
from typing import Annotated, Any, Literal, TYPE_CHECKING, get_args
|
|
26
26
|
|
|
27
27
|
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, model_validator, validate_call
|
|
28
|
-
from sentence_transformers import SentenceTransformer
|
|
29
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
# Eager import would pull torch at module load. The vector stack is optional (graph-only
|
|
31
|
+
# installs ship without torch/lancedb); it is imported lazily in _get_sentence_transformer.
|
|
32
|
+
from sentence_transformers import SentenceTransformer
|
|
33
|
+
|
|
34
|
+
from graph_types import (
|
|
35
|
+
NodeRef,
|
|
36
|
+
StructuredHint,
|
|
37
|
+
_hints_or_skip,
|
|
38
|
+
_node_ref_from_row,
|
|
39
|
+
_resolve_node_kind,
|
|
40
|
+
_to_structured_hints,
|
|
41
|
+
set_hints_enabled,
|
|
42
|
+
)
|
|
30
43
|
from index_common import SBERT_MODEL
|
|
31
44
|
from java_codebase_rag.config import resolved_sbert_model_for_process_env
|
|
32
|
-
from java_ontology import EDGE_SCHEMA
|
|
45
|
+
from java_ontology import EDGE_SCHEMA
|
|
33
46
|
from ladybug_queries import LadybugGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES
|
|
34
|
-
from mcp_hints import
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
|
|
48
|
+
|
|
49
|
+
# The vector stack (lancedb/torch, reached via search_lancedb) is optional — it is absent on
|
|
50
|
+
# graph-only installs (macOS Intel). Import eagerly when available so ``run_search``/``TABLES``
|
|
51
|
+
# exist as module attributes (tests monkeypatch ``mcp_v2.run_search``; callers use ``TABLES``);
|
|
52
|
+
# fall back to sentinels on ImportError so importing this module never fails and ``search_v2``
|
|
53
|
+
# can return a clean "vector search unavailable" envelope instead of crashing.
|
|
54
|
+
try:
|
|
55
|
+
from search_lancedb import TABLES, run_search
|
|
56
|
+
except ImportError: # graph-only install: no torch/lancedb
|
|
57
|
+
TABLES = {}
|
|
58
|
+
run_search = None
|
|
59
|
+
__all__ = [
|
|
60
|
+
"search_v2",
|
|
61
|
+
"find_v2",
|
|
62
|
+
"describe_v2",
|
|
63
|
+
"neighbors_v2",
|
|
64
|
+
"resolve_v2",
|
|
65
|
+
"SearchOutput",
|
|
66
|
+
"FindOutput",
|
|
67
|
+
"DescribeOutput",
|
|
68
|
+
"NeighborsOutput",
|
|
69
|
+
"ResolveOutput",
|
|
70
|
+
"ResolveCandidate",
|
|
71
|
+
"ResolveStatus",
|
|
72
|
+
"NodeRef",
|
|
73
|
+
"NodeFilter",
|
|
74
|
+
"EdgeFilter",
|
|
75
|
+
"StructuredHint",
|
|
76
|
+
"set_hints_enabled",
|
|
77
|
+
]
|
|
48
78
|
|
|
49
79
|
DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"]
|
|
50
80
|
|
|
@@ -119,11 +149,18 @@ _fail_loud_lock = threading.Lock()
|
|
|
119
149
|
|
|
120
150
|
|
|
121
151
|
def _log_fail_loud(category: str) -> None:
|
|
122
|
-
"""Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3).
|
|
152
|
+
"""Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3).
|
|
153
|
+
|
|
154
|
+
The stderr line is gated on ``JAVA_CODEBASE_RAG_FAIL_LOUD`` (default ``"1"`` =
|
|
155
|
+
emit) so the MCP server keeps its operator diagnostic while the agent-facing
|
|
156
|
+
``jrag`` CLI (which surfaces the same failure as a clean status:error
|
|
157
|
+
envelope) can run it with the diagnostic silenced.
|
|
158
|
+
"""
|
|
123
159
|
with _fail_loud_lock:
|
|
124
160
|
_fail_loud_counts[category] = _fail_loud_counts.get(category, 0) + 1
|
|
125
161
|
n = _fail_loud_counts[category]
|
|
126
|
-
|
|
162
|
+
if os.environ.get("JAVA_CODEBASE_RAG_FAIL_LOUD", "1") != "0":
|
|
163
|
+
print(f"[filter-frame] fail-loud category={category} count={n}", file=sys.stderr, flush=True)
|
|
127
164
|
|
|
128
165
|
|
|
129
166
|
def filter_frame_counters() -> dict[str, int]:
|
|
@@ -134,6 +171,8 @@ def filter_frame_counters() -> dict[str, int]:
|
|
|
134
171
|
|
|
135
172
|
def _get_sentence_transformer(model_name: str, device: str | None) -> SentenceTransformer:
|
|
136
173
|
global _st_model
|
|
174
|
+
from sentence_transformers import SentenceTransformer
|
|
175
|
+
|
|
137
176
|
with _st_lock:
|
|
138
177
|
if _st_model is None:
|
|
139
178
|
_st_model = SentenceTransformer(
|
|
@@ -154,26 +193,26 @@ class NodeFilter(BaseModel):
|
|
|
154
193
|
exclude_roles: list[Role] | None = None
|
|
155
194
|
annotation: str | None = None
|
|
156
195
|
capability: str | None = None
|
|
157
|
-
|
|
196
|
+
fqn_contains: str | None = None
|
|
158
197
|
symbol_kind: DeclarationSymbolKind | None = None
|
|
159
198
|
symbol_kinds: list[DeclarationSymbolKind] | None = None
|
|
160
199
|
http_method: str | None = Field(
|
|
161
200
|
default=None,
|
|
162
201
|
description="HTTP verb (commonly GET/POST/PUT/DELETE/PATCH; user route annotations may yield others).",
|
|
163
202
|
)
|
|
164
|
-
|
|
203
|
+
path_contains: str | None = None
|
|
165
204
|
framework: Framework | None = None
|
|
166
205
|
client_kind: ClientKind | None = Field(
|
|
167
206
|
default=None,
|
|
168
207
|
description="Outbound HTTP client kind: feign_method, rest_template, or web_client.",
|
|
169
208
|
)
|
|
170
209
|
target_service: str | None = None
|
|
171
|
-
|
|
210
|
+
target_path_contains: str | None = None
|
|
172
211
|
producer_kind: ProducerKind | None = Field(
|
|
173
212
|
default=None,
|
|
174
213
|
description="Outbound async producer kind: kafka_send or stream_bridge_send.",
|
|
175
214
|
)
|
|
176
|
-
|
|
215
|
+
topic_contains: str | None = None
|
|
177
216
|
|
|
178
217
|
|
|
179
218
|
class EdgeFilter(BaseModel):
|
|
@@ -213,12 +252,8 @@ _NODEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(NodeFilter.model_fields.keys())
|
|
|
213
252
|
_EDGEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(EdgeFilter.model_fields.keys())
|
|
214
253
|
|
|
215
254
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
tool: Literal["search", "find", "describe", "neighbors", "resolve"]
|
|
219
|
-
args: dict[str, Any]
|
|
220
|
-
actionable: bool = True
|
|
221
|
-
reason: str = ""
|
|
255
|
+
# StructuredHint is now defined in graph_types.py and imported above
|
|
256
|
+
|
|
222
257
|
|
|
223
258
|
# Populated EdgeFilter field -> EDGE_SCHEMA attribute name used in Cypher pushdown.
|
|
224
259
|
_EDGEFILTER_FIELD_TO_ATTR: dict[str, str] = {
|
|
@@ -240,7 +275,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
|
|
|
240
275
|
"exclude_roles",
|
|
241
276
|
"annotation",
|
|
242
277
|
"capability",
|
|
243
|
-
"
|
|
278
|
+
"fqn_contains",
|
|
244
279
|
"symbol_kind",
|
|
245
280
|
"symbol_kinds",
|
|
246
281
|
),
|
|
@@ -248,7 +283,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
|
|
|
248
283
|
"microservice",
|
|
249
284
|
"module",
|
|
250
285
|
"http_method",
|
|
251
|
-
"
|
|
286
|
+
"path_contains",
|
|
252
287
|
"framework",
|
|
253
288
|
),
|
|
254
289
|
"client": (
|
|
@@ -257,7 +292,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
|
|
|
257
292
|
"source_layer",
|
|
258
293
|
"client_kind",
|
|
259
294
|
"target_service",
|
|
260
|
-
"
|
|
295
|
+
"target_path_contains",
|
|
261
296
|
"http_method",
|
|
262
297
|
),
|
|
263
298
|
"producer": (
|
|
@@ -265,7 +300,7 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
|
|
|
265
300
|
"module",
|
|
266
301
|
"source_layer",
|
|
267
302
|
"producer_kind",
|
|
268
|
-
"
|
|
303
|
+
"topic_contains",
|
|
269
304
|
),
|
|
270
305
|
}
|
|
271
306
|
|
|
@@ -308,20 +343,6 @@ def _nodefilter_applicability_error(
|
|
|
308
343
|
)
|
|
309
344
|
|
|
310
345
|
|
|
311
|
-
def _validate_no_wildcards(nf: NodeFilter) -> str | None:
|
|
312
|
-
"""Reject ``*`` / ``?`` in prefix-match fields; wildcards belong in ``search(query=…)``."""
|
|
313
|
-
for field_name in ("fqn_prefix", "path_prefix", "target_path_prefix"):
|
|
314
|
-
val = getattr(nf, field_name)
|
|
315
|
-
if val is None:
|
|
316
|
-
continue
|
|
317
|
-
if "*" in val or "?" in val:
|
|
318
|
-
return (
|
|
319
|
-
f"Wildcards (* and ?) are not supported in structured filter field `{field_name}`; "
|
|
320
|
-
"use search(query=...) for ranked text match instead."
|
|
321
|
-
)
|
|
322
|
-
return None
|
|
323
|
-
|
|
324
|
-
|
|
325
346
|
def _filter_validation_error_message(exc: ValidationError) -> str:
|
|
326
347
|
items: list[str] = []
|
|
327
348
|
for err in exc.errors():
|
|
@@ -385,8 +406,7 @@ def _edgefilter_applicability_error(edge_types: list[str], ef: EdgeFilter) -> st
|
|
|
385
406
|
return None
|
|
386
407
|
|
|
387
408
|
|
|
388
|
-
|
|
389
|
-
return [StructuredHint(label=h.label, tool=h.tool, args=h.args, actionable=h.actionable, reason=h.reason) for h in raw]
|
|
409
|
+
# _to_structured_hints is now defined in graph_types.py and imported above
|
|
390
410
|
|
|
391
411
|
|
|
392
412
|
def _coerce_edge_filter(
|
|
@@ -444,16 +464,13 @@ class SearchHit(BaseModel):
|
|
|
444
464
|
microservice: str | None = None
|
|
445
465
|
module: str | None = None
|
|
446
466
|
role: str | None = None
|
|
467
|
+
filename: str | None = None
|
|
468
|
+
start_line: int | None = None
|
|
469
|
+
score_components: dict[str, float] | None = None
|
|
470
|
+
chunks: int | None = None
|
|
447
471
|
|
|
448
472
|
|
|
449
|
-
|
|
450
|
-
id: str
|
|
451
|
-
kind: Literal["symbol", "route", "client", "producer", "unresolved_call_site"]
|
|
452
|
-
fqn: str
|
|
453
|
-
symbol_kind: str | None = None
|
|
454
|
-
microservice: str | None = None
|
|
455
|
-
module: str | None = None
|
|
456
|
-
role: str | None = None
|
|
473
|
+
# NodeRef is now defined in graph_types.py and imported above
|
|
457
474
|
|
|
458
475
|
|
|
459
476
|
class NodeRecord(BaseModel):
|
|
@@ -517,6 +534,11 @@ class FindOutput(BaseModel):
|
|
|
517
534
|
default=None,
|
|
518
535
|
description="Echoed from the request — the page offset the server applied. None on success=False.",
|
|
519
536
|
)
|
|
537
|
+
has_more_results: bool | None = Field(
|
|
538
|
+
default=None,
|
|
539
|
+
description="True when additional pages remain beyond offset+limit (more matches exist). "
|
|
540
|
+
"None when unset (e.g. success=False).",
|
|
541
|
+
)
|
|
520
542
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
521
543
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
522
544
|
|
|
@@ -537,114 +559,21 @@ class NeighborsOutput(BaseModel):
|
|
|
537
559
|
default_factory=list,
|
|
538
560
|
description="Echo of neighbors(edge_types=...) from the request; empty when success=False.",
|
|
539
561
|
)
|
|
562
|
+
has_more_results: bool | None = Field(
|
|
563
|
+
default=None,
|
|
564
|
+
description="True when additional pages remain beyond offset+limit. None when unset or "
|
|
565
|
+
"when the single-origin CALLS path paginated in SQL (use unfiltered_calls_count / "
|
|
566
|
+
"calls_row_count there).",
|
|
567
|
+
)
|
|
540
568
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
541
569
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
542
570
|
|
|
543
571
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
_RESOLVE_CANDIDATE_CAP = 10
|
|
547
|
-
|
|
548
|
-
_RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = {
|
|
549
|
-
"exact_id": 0,
|
|
550
|
-
"exact_fqn": 1,
|
|
551
|
-
"route_method_path": 1,
|
|
552
|
-
"client_target_path": 1,
|
|
553
|
-
"producer_topic_prefix": 1,
|
|
554
|
-
"fqn_suffix": 2,
|
|
555
|
-
"route_template": 2,
|
|
556
|
-
"short_name": 3,
|
|
557
|
-
"client_target": 3,
|
|
558
|
-
"producer_topic": 3,
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
_SYMBOL_RESOLVE_RETURN = (
|
|
562
|
-
"s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
|
|
563
|
-
"s.module AS module, s.role AS role, s.kind AS symbol_kind"
|
|
564
|
-
)
|
|
565
|
-
|
|
566
|
-
_ROUTE_RESOLVE_RETURN = (
|
|
567
|
-
"r.id AS id, r.kind AS kind, r.framework AS framework, r.method AS method, "
|
|
568
|
-
"r.path AS path, r.path_template AS path_template, r.path_regex AS path_regex, "
|
|
569
|
-
"r.topic AS topic, r.broker AS broker, r.feign_name AS feign_name, r.feign_url AS feign_url, "
|
|
570
|
-
"r.microservice AS microservice, r.module AS module, r.filename AS filename, "
|
|
571
|
-
"r.start_line AS start_line, r.end_line AS end_line, r.resolved AS resolved"
|
|
572
|
-
)
|
|
572
|
+
# Re-exported from resolve_service.py (imported at end of module to avoid circular import)
|
|
573
|
+
# resolve_v2, ResolveOutput, ResolveCandidate, ResolveStatus are imported below
|
|
573
574
|
|
|
574
|
-
_CLIENT_RESOLVE_RETURN = (
|
|
575
|
-
"c.id AS id, c.client_kind AS client_kind, c.target_service AS target_service, "
|
|
576
|
-
"c.method AS method, c.path AS path, c.path_template AS path_template, "
|
|
577
|
-
"c.path_regex AS path_regex, c.member_fqn AS member_fqn, c.member_id AS member_id, "
|
|
578
|
-
"c.microservice AS microservice, c.module AS module, c.filename AS filename, "
|
|
579
|
-
"c.start_line AS start_line, c.end_line AS end_line, c.resolved AS resolved, "
|
|
580
|
-
"c.source_layer AS source_layer"
|
|
581
|
-
)
|
|
582
575
|
|
|
583
|
-
|
|
584
|
-
"p.id AS id, p.producer_kind AS producer_kind, p.topic AS topic, p.broker AS broker, "
|
|
585
|
-
"p.direction AS direction, p.member_fqn AS member_fqn, p.member_id AS member_id, "
|
|
586
|
-
"p.microservice AS microservice, p.module AS module, p.filename AS filename, "
|
|
587
|
-
"p.start_line AS start_line, p.end_line AS end_line, p.resolved AS resolved, "
|
|
588
|
-
"p.source_layer AS source_layer"
|
|
589
|
-
)
|
|
590
|
-
|
|
591
|
-
_RESOLVE_PRE_DEDUP_LIMIT = 50
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
class ResolveCandidate(BaseModel):
|
|
595
|
-
model_config = ConfigDict(extra="forbid")
|
|
596
|
-
|
|
597
|
-
node: NodeRef
|
|
598
|
-
score: float
|
|
599
|
-
reason: ResolveReason
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
class ResolveOutput(BaseModel):
|
|
603
|
-
model_config = ConfigDict(extra="forbid")
|
|
604
|
-
|
|
605
|
-
success: bool
|
|
606
|
-
status: ResolveStatus
|
|
607
|
-
node: NodeRef | None = None
|
|
608
|
-
candidates: list[ResolveCandidate] = Field(default_factory=list)
|
|
609
|
-
message: str | None = None
|
|
610
|
-
resolved_identifier: str | None = None
|
|
611
|
-
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
612
|
-
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def _node_kind_from_id(
|
|
616
|
-
id_str: str,
|
|
617
|
-
) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
|
|
618
|
-
if id_str.startswith("ucs:"):
|
|
619
|
-
return "unresolved_call_site"
|
|
620
|
-
if id_str.startswith("sym:"):
|
|
621
|
-
return "symbol"
|
|
622
|
-
if id_str.startswith("route:") or id_str.startswith("r:"):
|
|
623
|
-
return "route"
|
|
624
|
-
if id_str.startswith("client:") or id_str.startswith("c:"):
|
|
625
|
-
return "client"
|
|
626
|
-
if id_str.startswith("producer:") or id_str.startswith("p:"):
|
|
627
|
-
return "producer"
|
|
628
|
-
raise ValueError(f"Unknown id prefix for `{id_str}`")
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
def _resolve_node_kind(
|
|
632
|
-
graph: LadybugGraph,
|
|
633
|
-
node_id: str,
|
|
634
|
-
) -> Literal["symbol", "route", "client", "producer", "unresolved_call_site"]:
|
|
635
|
-
try:
|
|
636
|
-
return _node_kind_from_id(node_id)
|
|
637
|
-
except ValueError:
|
|
638
|
-
pass
|
|
639
|
-
if graph._rows("MATCH (n:Symbol) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
|
|
640
|
-
return "symbol"
|
|
641
|
-
if graph._rows("MATCH (n:Route) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
|
|
642
|
-
return "route"
|
|
643
|
-
if graph._rows("MATCH (n:Client) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
|
|
644
|
-
return "client"
|
|
645
|
-
if graph._rows("MATCH (n:Producer) WHERE n.id = $id RETURN n.id AS id LIMIT 1", {"id": node_id}): # noqa: SLF001
|
|
646
|
-
return "producer"
|
|
647
|
-
raise ValueError(f"Unknown id prefix for `{node_id}`")
|
|
576
|
+
# _node_kind_from_id and _resolve_node_kind are now defined in graph_types.py and imported above
|
|
648
577
|
|
|
649
578
|
|
|
650
579
|
def _chunk_id_from_row(row: dict[str, Any]) -> str:
|
|
@@ -656,8 +585,20 @@ def _chunk_id_from_row(row: dict[str, Any]) -> str:
|
|
|
656
585
|
return f"{filename}:{sb}:{eb}"
|
|
657
586
|
|
|
658
587
|
|
|
659
|
-
def _row_to_search_hit(row: dict[str, Any]) -> SearchHit:
|
|
588
|
+
def _row_to_search_hit(row: dict[str, Any], explain: bool = False) -> SearchHit:
|
|
660
589
|
score = float(row.get("_rrf_score") or row.get("_score") or 0.0)
|
|
590
|
+
filename = str(row.get("filename") or "") or None
|
|
591
|
+
start_line: int | None = None
|
|
592
|
+
start = row.get("start")
|
|
593
|
+
if isinstance(start, dict):
|
|
594
|
+
ln = start.get("line")
|
|
595
|
+
if ln is not None:
|
|
596
|
+
try:
|
|
597
|
+
start_line = int(ln)
|
|
598
|
+
except (TypeError, ValueError):
|
|
599
|
+
start_line = None
|
|
600
|
+
chunks = row.get("_chunks_collapsed")
|
|
601
|
+
chunks_int = int(chunks) if chunks is not None and int(chunks) >= 2 else None
|
|
661
602
|
return SearchHit(
|
|
662
603
|
chunk_id=_chunk_id_from_row(row),
|
|
663
604
|
symbol_id=_chunk_to_symbol_id(row),
|
|
@@ -667,6 +608,10 @@ def _row_to_search_hit(row: dict[str, Any]) -> SearchHit:
|
|
|
667
608
|
microservice=str(row.get("microservice")) if row.get("microservice") else None,
|
|
668
609
|
module=str(row.get("module")) if row.get("module") else None,
|
|
669
610
|
role=str(row.get("role")) if row.get("role") else None,
|
|
611
|
+
filename=filename,
|
|
612
|
+
start_line=start_line,
|
|
613
|
+
score_components=row.get("_score_components") if explain else None,
|
|
614
|
+
chunks=chunks_int,
|
|
670
615
|
)
|
|
671
616
|
|
|
672
617
|
|
|
@@ -710,9 +655,9 @@ def _symbol_where_from_filter(f: NodeFilter) -> tuple[str, dict[str, Any]]:
|
|
|
710
655
|
if f.capability:
|
|
711
656
|
preds.append("$capability IN s.capabilities")
|
|
712
657
|
params["capability"] = f.capability
|
|
713
|
-
if f.
|
|
714
|
-
preds.append("s.fqn
|
|
715
|
-
params["
|
|
658
|
+
if f.fqn_contains:
|
|
659
|
+
preds.append("s.fqn CONTAINS $fqn_contains")
|
|
660
|
+
params["fqn_contains"] = f.fqn_contains
|
|
716
661
|
if f.symbol_kind:
|
|
717
662
|
preds.append("s.kind = $symbol_kind")
|
|
718
663
|
params["symbol_kind"] = f.symbol_kind
|
|
@@ -723,38 +668,7 @@ def _symbol_where_from_filter(f: NodeFilter) -> tuple[str, dict[str, Any]]:
|
|
|
723
668
|
return where, params
|
|
724
669
|
|
|
725
670
|
|
|
726
|
-
|
|
727
|
-
symbol_kind: str | None = None
|
|
728
|
-
if kind == "symbol":
|
|
729
|
-
fqn = str(row.get("fqn") or "")
|
|
730
|
-
role = str(row.get("role") or "") or None
|
|
731
|
-
symbol_kind_val = str(row.get("symbol_kind") or row.get("kind") or "").strip()
|
|
732
|
-
symbol_kind = symbol_kind_val or None
|
|
733
|
-
elif kind == "route":
|
|
734
|
-
method = str(row.get("method") or "")
|
|
735
|
-
path = str(row.get("path_template") or row.get("path") or "")
|
|
736
|
-
fqn = f"{method} {path}".strip()
|
|
737
|
-
role = None
|
|
738
|
-
elif kind == "client":
|
|
739
|
-
method = str(row.get("method") or "")
|
|
740
|
-
target = str(row.get("target_service") or "")
|
|
741
|
-
path = str(row.get("path_template") or row.get("path") or "")
|
|
742
|
-
fqn = f"{target} {method} {path}".strip()
|
|
743
|
-
role = None
|
|
744
|
-
else:
|
|
745
|
-
topic = str(row.get("topic") or "")
|
|
746
|
-
broker = str(row.get("broker") or "")
|
|
747
|
-
fqn = f"{topic} {broker}".strip()
|
|
748
|
-
role = None
|
|
749
|
-
return NodeRef(
|
|
750
|
-
id=str(row.get("id") or ""),
|
|
751
|
-
kind=kind,
|
|
752
|
-
fqn=fqn,
|
|
753
|
-
symbol_kind=symbol_kind,
|
|
754
|
-
microservice=str(row.get("microservice") or "") or None,
|
|
755
|
-
module=str(row.get("module") or "") or None,
|
|
756
|
-
role=role,
|
|
757
|
-
)
|
|
671
|
+
# _node_ref_from_row is now defined in graph_types.py and imported above
|
|
758
672
|
|
|
759
673
|
|
|
760
674
|
def _load_node_record(
|
|
@@ -868,7 +782,7 @@ def _node_matches_filter(
|
|
|
868
782
|
return False
|
|
869
783
|
if f.capability and f.capability not in list(row.get("capabilities") or []):
|
|
870
784
|
return False
|
|
871
|
-
if f.
|
|
785
|
+
if f.fqn_contains and f.fqn_contains not in fqn_val:
|
|
872
786
|
return False
|
|
873
787
|
if f.symbol_kind and symbol_kind_val != f.symbol_kind:
|
|
874
788
|
return False
|
|
@@ -877,9 +791,9 @@ def _node_matches_filter(
|
|
|
877
791
|
elif kind == "route":
|
|
878
792
|
if f.http_method and str(row.get("method") or "") != f.http_method:
|
|
879
793
|
return False
|
|
880
|
-
if f.
|
|
794
|
+
if f.path_contains:
|
|
881
795
|
path = str(row.get("path") or "")
|
|
882
|
-
if not path
|
|
796
|
+
if f.path_contains not in path:
|
|
883
797
|
return False
|
|
884
798
|
if f.framework and str(row.get("framework") or "") != f.framework:
|
|
885
799
|
return False
|
|
@@ -888,18 +802,18 @@ def _node_matches_filter(
|
|
|
888
802
|
return False
|
|
889
803
|
if f.target_service and str(row.get("target_service") or "") != f.target_service:
|
|
890
804
|
return False
|
|
891
|
-
if f.
|
|
805
|
+
if f.target_path_contains:
|
|
892
806
|
path = str(row.get("path") or "")
|
|
893
|
-
if not path
|
|
807
|
+
if f.target_path_contains not in path:
|
|
894
808
|
return False
|
|
895
809
|
if f.http_method and str(row.get("method") or "") != f.http_method:
|
|
896
810
|
return False
|
|
897
811
|
else:
|
|
898
812
|
if f.producer_kind and str(row.get("producer_kind") or "") != f.producer_kind:
|
|
899
813
|
return False
|
|
900
|
-
if f.
|
|
814
|
+
if f.topic_contains:
|
|
901
815
|
topic = str(row.get("topic") or "")
|
|
902
|
-
if not topic
|
|
816
|
+
if f.topic_contains not in topic:
|
|
903
817
|
return False
|
|
904
818
|
return True
|
|
905
819
|
|
|
@@ -912,7 +826,9 @@ def search_v2(
|
|
|
912
826
|
offset: int = 0,
|
|
913
827
|
path_contains: str | None = None,
|
|
914
828
|
filter: NodeFilter | dict[str, Any] | str | None = None,
|
|
829
|
+
explain: bool = False,
|
|
915
830
|
graph: LadybugGraph | None = None,
|
|
831
|
+
dedup: bool = True,
|
|
916
832
|
) -> SearchOutput:
|
|
917
833
|
try:
|
|
918
834
|
raw_filter = _coerce_filter(filter)
|
|
@@ -934,9 +850,27 @@ def search_v2(
|
|
|
934
850
|
if nf and (err := _nodefilter_applicability_error("symbol", nf)):
|
|
935
851
|
_log_fail_loud("applicability")
|
|
936
852
|
return SearchOutput(success=False, message=err, advisories=[], limit=None, offset=None)
|
|
937
|
-
if
|
|
938
|
-
|
|
939
|
-
|
|
853
|
+
if run_search is None:
|
|
854
|
+
# Graph-only install (no torch/lancedb): the vector stack is absent. Return a
|
|
855
|
+
# clean failure rather than crashing so the server keeps serving graph tools.
|
|
856
|
+
return SearchOutput(
|
|
857
|
+
success=False,
|
|
858
|
+
message="Vector search unavailable: graph-only mode (vector stack not installed).",
|
|
859
|
+
advisories=[],
|
|
860
|
+
limit=None,
|
|
861
|
+
offset=None,
|
|
862
|
+
)
|
|
863
|
+
# hybrid + table='all' is unsupported (hybrid fuses vector+FTS on ONE
|
|
864
|
+
# table); fail fast with a clean envelope BEFORE loading the embedding
|
|
865
|
+
# model. run_search also guards this — this is the user-facing fast path.
|
|
866
|
+
if hybrid and table == "all":
|
|
867
|
+
return SearchOutput(
|
|
868
|
+
success=False,
|
|
869
|
+
message="hybrid search requires a single table; use java, sql, or yaml (not all)",
|
|
870
|
+
advisories=[],
|
|
871
|
+
limit=None,
|
|
872
|
+
offset=None,
|
|
873
|
+
)
|
|
940
874
|
model_name = resolved_sbert_model_for_process_env(SBERT_MODEL)
|
|
941
875
|
device = os.environ.get("SBERT_DEVICE") or None
|
|
942
876
|
model = _get_sentence_transformer(model_name, device)
|
|
@@ -947,18 +881,66 @@ def search_v2(
|
|
|
947
881
|
if not uri.startswith(("s3://", "gs://", "az://")) and uri_path.exists():
|
|
948
882
|
uri = str(uri_path.resolve())
|
|
949
883
|
table_keys = list(TABLES) if table == "all" else [table]
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
884
|
+
|
|
885
|
+
# Graceful fallback: if hybrid=True and FTS index is missing (old index),
|
|
886
|
+
# retry with hybrid=False and return vector-only results with an advisory.
|
|
887
|
+
advisories: list[str] = []
|
|
888
|
+
try:
|
|
889
|
+
rows = run_search(
|
|
890
|
+
query,
|
|
891
|
+
uri=uri,
|
|
892
|
+
table_keys=table_keys,
|
|
893
|
+
hybrid=hybrid,
|
|
894
|
+
limit=limit,
|
|
895
|
+
offset=offset,
|
|
896
|
+
path_substring=path_contains,
|
|
897
|
+
model_name=model_name,
|
|
898
|
+
device=device,
|
|
899
|
+
model=model,
|
|
900
|
+
# Push the NodeFilter structural predicates into the LanceDB query so
|
|
901
|
+
# they apply BEFORE pagination (issue #353) — previously they were only
|
|
902
|
+
# a post-filter on the already-paginated page, which could shrink or
|
|
903
|
+
# empty filtered pages even when many matches existed deeper in the
|
|
904
|
+
# ranking. _node_matches_filter below still re-checks every row (it
|
|
905
|
+
# covers the non-pushdownable fields and is the contract guarantee).
|
|
906
|
+
role=nf.role if nf else None,
|
|
907
|
+
module=nf.module if nf else None,
|
|
908
|
+
microservice=nf.microservice if nf else None,
|
|
909
|
+
capability=nf.capability if nf else None,
|
|
910
|
+
exclude_roles=nf.exclude_roles if nf else None,
|
|
911
|
+
dedup_by_fqn=dedup,
|
|
912
|
+
)
|
|
913
|
+
except Exception as exc:
|
|
914
|
+
# Check if this is a missing-FTS error (old index built before PR-SEARCH-3)
|
|
915
|
+
exc_text = str(exc).lower()
|
|
916
|
+
is_fts_missing = "full text search" in exc_text or "inverted index" in exc_text
|
|
917
|
+
if hybrid and is_fts_missing:
|
|
918
|
+
# Retry with vector-only search
|
|
919
|
+
rows = run_search(
|
|
920
|
+
query,
|
|
921
|
+
uri=uri,
|
|
922
|
+
table_keys=table_keys,
|
|
923
|
+
hybrid=False, # Fallback to vector-only
|
|
924
|
+
limit=limit,
|
|
925
|
+
offset=offset,
|
|
926
|
+
path_substring=path_contains,
|
|
927
|
+
model_name=model_name,
|
|
928
|
+
device=device,
|
|
929
|
+
model=model,
|
|
930
|
+
role=nf.role if nf else None,
|
|
931
|
+
module=nf.module if nf else None,
|
|
932
|
+
microservice=nf.microservice if nf else None,
|
|
933
|
+
capability=nf.capability if nf else None,
|
|
934
|
+
exclude_roles=nf.exclude_roles if nf else None,
|
|
935
|
+
dedup_by_fqn=dedup,
|
|
936
|
+
)
|
|
937
|
+
advisories.append(
|
|
938
|
+
f"hybrid unavailable on table '{table}' (FTS index missing on this index built before "
|
|
939
|
+
f"PR-SEARCH-3); fell back to vector-only — reindex to enable hybrid"
|
|
940
|
+
)
|
|
941
|
+
else:
|
|
942
|
+
# Non-FTS error: surface as structured failure
|
|
943
|
+
raise
|
|
962
944
|
hits: list[SearchHit] = []
|
|
963
945
|
for row in rows:
|
|
964
946
|
if path_contains and path_contains not in str(row.get("filename") or ""):
|
|
@@ -967,7 +949,7 @@ def search_v2(
|
|
|
967
949
|
row_kind = "symbol"
|
|
968
950
|
if not _node_matches_filter(row_kind, row, nf):
|
|
969
951
|
continue
|
|
970
|
-
hits.append(_row_to_search_hit(row))
|
|
952
|
+
hits.append(_row_to_search_hit(row, explain=explain))
|
|
971
953
|
hint_payload = {
|
|
972
954
|
"success": True,
|
|
973
955
|
"results": [h.model_dump() for h in hits],
|
|
@@ -980,7 +962,7 @@ def search_v2(
|
|
|
980
962
|
results=hits,
|
|
981
963
|
limit=limit,
|
|
982
964
|
offset=offset,
|
|
983
|
-
advisories=raw_advisories,
|
|
965
|
+
advisories=advisories + raw_advisories, # Merge fallback + hints advisories
|
|
984
966
|
hints_structured=_to_structured_hints(raw_struct),
|
|
985
967
|
)
|
|
986
968
|
except Exception as exc:
|
|
@@ -1013,9 +995,6 @@ def find_v2(
|
|
|
1013
995
|
if err := _nodefilter_applicability_error(kind, nf):
|
|
1014
996
|
_log_fail_loud("applicability")
|
|
1015
997
|
return FindOutput(success=False, message=err, advisories=[], limit=None, offset=None)
|
|
1016
|
-
if err := _validate_no_wildcards(nf):
|
|
1017
|
-
_log_fail_loud("wildcard")
|
|
1018
|
-
return FindOutput(success=False, message=err, advisories=[], limit=None, offset=None)
|
|
1019
998
|
fetch_cap = int(limit) + int(offset) + 1
|
|
1020
999
|
if kind == "symbol":
|
|
1021
1000
|
where, params = _symbol_where_from_filter(nf)
|
|
@@ -1029,7 +1008,7 @@ def find_v2(
|
|
|
1029
1008
|
rows = g.list_routes(
|
|
1030
1009
|
microservice=nf.microservice,
|
|
1031
1010
|
framework=nf.framework,
|
|
1032
|
-
|
|
1011
|
+
path_contains=nf.path_contains,
|
|
1033
1012
|
method=nf.http_method,
|
|
1034
1013
|
limit=max(500, fetch_cap),
|
|
1035
1014
|
)
|
|
@@ -1039,7 +1018,7 @@ def find_v2(
|
|
|
1039
1018
|
microservice=nf.microservice,
|
|
1040
1019
|
client_kind=nf.client_kind,
|
|
1041
1020
|
target_service=nf.target_service,
|
|
1042
|
-
|
|
1021
|
+
path_contains=nf.target_path_contains,
|
|
1043
1022
|
method=nf.http_method,
|
|
1044
1023
|
limit=max(500, fetch_cap),
|
|
1045
1024
|
)
|
|
@@ -1048,7 +1027,7 @@ def find_v2(
|
|
|
1048
1027
|
rows = g.list_producers(
|
|
1049
1028
|
microservice=nf.microservice,
|
|
1050
1029
|
producer_kind=nf.producer_kind,
|
|
1051
|
-
|
|
1030
|
+
topic_contains=nf.topic_contains,
|
|
1052
1031
|
limit=max(500, fetch_cap),
|
|
1053
1032
|
)
|
|
1054
1033
|
rows = [r for r in rows if _node_matches_filter("producer", r, nf)]
|
|
@@ -1059,7 +1038,12 @@ def find_v2(
|
|
|
1059
1038
|
hint_payload: dict[str, Any] = {
|
|
1060
1039
|
"success": True,
|
|
1061
1040
|
"kind": kind,
|
|
1062
|
-
|
|
1041
|
+
# exclude_none: this dict feeds generate_hints (which reads fields
|
|
1042
|
+
# defensively via .get), not the tool result (FindOutput below holds the
|
|
1043
|
+
# pydantic objects). Drop null fields -- including the NodeRef.name field
|
|
1044
|
+
# that is None for every structured ref -- to match filter_dump above and
|
|
1045
|
+
# avoid spurious "name": null noise in the hint input.
|
|
1046
|
+
"results": [r.model_dump(exclude_none=True) for r in refs],
|
|
1063
1047
|
"limit": limit,
|
|
1064
1048
|
"offset": offset,
|
|
1065
1049
|
"filter": filter_dump,
|
|
@@ -1071,6 +1055,7 @@ def find_v2(
|
|
|
1071
1055
|
results=refs,
|
|
1072
1056
|
limit=limit,
|
|
1073
1057
|
offset=offset,
|
|
1058
|
+
has_more_results=has_more_results,
|
|
1074
1059
|
advisories=raw_advisories,
|
|
1075
1060
|
hints_structured=_to_structured_hints(raw_struct),
|
|
1076
1061
|
)
|
|
@@ -1159,379 +1144,28 @@ def describe_v2(
|
|
|
1159
1144
|
return DescribeOutput(success=False, message=str(exc), advisories=[])
|
|
1160
1145
|
|
|
1161
1146
|
|
|
1162
|
-
def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]:
|
|
1163
|
-
trimmed = raw.strip()
|
|
1164
|
-
if not trimmed:
|
|
1165
|
-
detail = "empty string" if raw == "" else "whitespace only"
|
|
1166
|
-
return None, f"Invalid identifier: {detail}"
|
|
1167
|
-
return trimmed, None
|
|
1168
|
-
|
|
1169
1147
|
|
|
1170
|
-
def _resolve_kinds_to_search(
|
|
1171
|
-
hint_kind: Literal["symbol", "route", "client", "producer"] | None,
|
|
1172
|
-
) -> list[Literal["symbol", "route", "client", "producer"]]:
|
|
1173
|
-
if hint_kind is None:
|
|
1174
|
-
return ["symbol", "route", "client", "producer"]
|
|
1175
|
-
return [hint_kind]
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
def _resolve_parse_route_method_path(identifier: str) -> tuple[str, str] | None:
|
|
1179
|
-
parts = identifier.split(None, 1)
|
|
1180
|
-
if len(parts) != 2:
|
|
1181
|
-
return None
|
|
1182
|
-
method, path = parts[0].upper(), parts[1].strip()
|
|
1183
|
-
if not method.isalpha() or not path.startswith("/"):
|
|
1184
|
-
return None
|
|
1185
|
-
return method, path
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
def _resolve_parse_microservice_route(identifier: str) -> tuple[str, str, str] | None:
|
|
1189
|
-
parts = identifier.split(None, 2)
|
|
1190
|
-
if len(parts) != 3:
|
|
1191
|
-
return None
|
|
1192
|
-
microservice, method, path = parts[0], parts[1].upper(), parts[2].strip()
|
|
1193
|
-
if not method.isalpha() or not path.startswith("/"):
|
|
1194
|
-
return None
|
|
1195
|
-
return microservice, method, path
|
|
1196
1148
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
)
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
)
|
|
1216
|
-
|
|
1217
|
-
out.append((_node_ref_from_row("symbol", row), "exact_fqn", len(identifier)))
|
|
1218
|
-
|
|
1219
|
-
suffix = f".{identifier}"
|
|
1220
|
-
rows = g._rows( # noqa: SLF001
|
|
1221
|
-
f"MATCH (s:Symbol) WHERE s.fqn = $ident OR s.fqn ENDS WITH $suffix "
|
|
1222
|
-
f"RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
1223
|
-
{"ident": identifier, "suffix": suffix, "lim": lim},
|
|
1224
|
-
)
|
|
1225
|
-
for row in rows:
|
|
1226
|
-
fqn = str(row.get("fqn") or "")
|
|
1227
|
-
spec = len(fqn)
|
|
1228
|
-
out.append((_node_ref_from_row("symbol", row), "fqn_suffix", spec))
|
|
1229
|
-
|
|
1230
|
-
rows = g._rows( # noqa: SLF001
|
|
1231
|
-
f"MATCH (s:Symbol) WHERE s.name = $name RETURN {_SYMBOL_RESOLVE_RETURN} LIMIT $lim",
|
|
1232
|
-
{"name": identifier, "lim": lim},
|
|
1233
|
-
)
|
|
1234
|
-
for row in rows:
|
|
1235
|
-
out.append((_node_ref_from_row("symbol", row), "short_name", len(identifier)))
|
|
1236
|
-
|
|
1237
|
-
return out
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
def _resolve_route_candidates(
|
|
1241
|
-
g: LadybugGraph,
|
|
1242
|
-
identifier: str,
|
|
1243
|
-
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
1244
|
-
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
1245
|
-
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
1246
|
-
|
|
1247
|
-
rows = g._rows( # noqa: SLF001
|
|
1248
|
-
f"MATCH (r:Route) WHERE r.id = $id RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
1249
|
-
{"id": identifier, "lim": lim},
|
|
1250
|
-
)
|
|
1251
|
-
for row in rows:
|
|
1252
|
-
out.append((_node_ref_from_row("route", row), "exact_id", len(identifier)))
|
|
1253
|
-
|
|
1254
|
-
ms_route = _resolve_parse_microservice_route(identifier)
|
|
1255
|
-
if ms_route is not None:
|
|
1256
|
-
microservice, method, path = ms_route
|
|
1257
|
-
rows = g._rows( # noqa: SLF001
|
|
1258
|
-
f"MATCH (r:Route) WHERE r.microservice = $ms AND r.method = $method "
|
|
1259
|
-
f"AND (r.path = $path OR r.path_template = $path) "
|
|
1260
|
-
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
1261
|
-
{"ms": microservice, "method": method, "path": path, "lim": lim},
|
|
1262
|
-
)
|
|
1263
|
-
for row in rows:
|
|
1264
|
-
spec = len(path)
|
|
1265
|
-
out.append((_node_ref_from_row("route", row), "route_method_path", spec))
|
|
1266
|
-
|
|
1267
|
-
method_path = _resolve_parse_route_method_path(identifier)
|
|
1268
|
-
if method_path is not None:
|
|
1269
|
-
method, path = method_path
|
|
1270
|
-
rows = g._rows( # noqa: SLF001
|
|
1271
|
-
f"MATCH (r:Route) WHERE r.method = $method "
|
|
1272
|
-
f"AND (r.path = $path OR r.path_template = $path) "
|
|
1273
|
-
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
1274
|
-
{"method": method, "path": path, "lim": lim},
|
|
1275
|
-
)
|
|
1276
|
-
for row in rows:
|
|
1277
|
-
out.append((_node_ref_from_row("route", row), "route_method_path", len(path)))
|
|
1278
|
-
|
|
1279
|
-
if identifier.startswith("/"):
|
|
1280
|
-
rows = g._rows( # noqa: SLF001
|
|
1281
|
-
f"MATCH (r:Route) WHERE r.path = $path OR r.path_template = $path "
|
|
1282
|
-
f"RETURN {_ROUTE_RESOLVE_RETURN} LIMIT $lim",
|
|
1283
|
-
{"path": identifier, "lim": lim},
|
|
1284
|
-
)
|
|
1285
|
-
for row in rows:
|
|
1286
|
-
path_val = str(row.get("path_template") or row.get("path") or "")
|
|
1287
|
-
out.append((_node_ref_from_row("route", row), "route_template", len(path_val)))
|
|
1288
|
-
|
|
1289
|
-
return out
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
def _resolve_client_candidates(
|
|
1293
|
-
g: LadybugGraph,
|
|
1294
|
-
identifier: str,
|
|
1295
|
-
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
1296
|
-
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
1297
|
-
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
1298
|
-
|
|
1299
|
-
rows = g._rows( # noqa: SLF001
|
|
1300
|
-
f"MATCH (c:Client) WHERE c.id = $id RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
1301
|
-
{"id": identifier, "lim": lim},
|
|
1302
|
-
)
|
|
1303
|
-
for row in rows:
|
|
1304
|
-
out.append((_node_ref_from_row("client", row), "exact_id", len(identifier)))
|
|
1305
|
-
|
|
1306
|
-
if " " in identifier:
|
|
1307
|
-
target, path_prefix = identifier.split(" ", 1)
|
|
1308
|
-
target = target.strip()
|
|
1309
|
-
path_prefix = path_prefix.strip()
|
|
1310
|
-
if target and path_prefix:
|
|
1311
|
-
rows = g._rows( # noqa: SLF001
|
|
1312
|
-
f"MATCH (c:Client) WHERE c.target_service = $target "
|
|
1313
|
-
f"AND (c.path STARTS WITH $path OR c.path_template STARTS WITH $path) "
|
|
1314
|
-
f"RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
1315
|
-
{"target": target, "path": path_prefix, "lim": lim},
|
|
1316
|
-
)
|
|
1317
|
-
for row in rows:
|
|
1318
|
-
spec = len(path_prefix)
|
|
1319
|
-
out.append((_node_ref_from_row("client", row), "client_target_path", spec))
|
|
1320
|
-
elif not identifier.startswith("/"):
|
|
1321
|
-
rows = g._rows( # noqa: SLF001
|
|
1322
|
-
f"MATCH (c:Client) WHERE c.target_service = $target RETURN {_CLIENT_RESOLVE_RETURN} LIMIT $lim",
|
|
1323
|
-
{"target": identifier, "lim": lim},
|
|
1324
|
-
)
|
|
1325
|
-
for row in rows:
|
|
1326
|
-
out.append((_node_ref_from_row("client", row), "client_target", len(identifier)))
|
|
1327
|
-
|
|
1328
|
-
return out
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
def _resolve_producer_candidates(
|
|
1332
|
-
g: LadybugGraph,
|
|
1333
|
-
identifier: str,
|
|
1334
|
-
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
1335
|
-
out: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
1336
|
-
lim = _RESOLVE_PRE_DEDUP_LIMIT
|
|
1337
|
-
|
|
1338
|
-
rows = g._rows( # noqa: SLF001
|
|
1339
|
-
f"MATCH (p:Producer) WHERE p.id = $id RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
1340
|
-
{"id": identifier, "lim": lim},
|
|
1341
|
-
)
|
|
1342
|
-
for row in rows:
|
|
1343
|
-
out.append((_node_ref_from_row("producer", row), "exact_id", len(identifier)))
|
|
1344
|
-
|
|
1345
|
-
rows = g._rows( # noqa: SLF001
|
|
1346
|
-
f"MATCH (p:Producer) WHERE p.topic = $topic RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
1347
|
-
{"topic": identifier, "lim": lim},
|
|
1348
|
-
)
|
|
1349
|
-
for row in rows:
|
|
1350
|
-
out.append((_node_ref_from_row("producer", row), "producer_topic", len(identifier)))
|
|
1351
|
-
|
|
1352
|
-
if not identifier.startswith("/"):
|
|
1353
|
-
rows = g._rows( # noqa: SLF001
|
|
1354
|
-
f"MATCH (p:Producer) WHERE p.topic STARTS WITH $topic RETURN {_PRODUCER_RESOLVE_RETURN} LIMIT $lim",
|
|
1355
|
-
{"topic": identifier, "lim": lim},
|
|
1356
|
-
)
|
|
1357
|
-
for row in rows:
|
|
1358
|
-
out.append((_node_ref_from_row("producer", row), "producer_topic_prefix", len(identifier)))
|
|
1359
|
-
|
|
1360
|
-
return out
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
def _resolve_dedupe_candidates(
|
|
1364
|
-
raw: list[tuple[NodeRef, ResolveReason, int]],
|
|
1365
|
-
) -> list[tuple[NodeRef, ResolveReason, int]]:
|
|
1366
|
-
best: dict[str, tuple[NodeRef, ResolveReason, int]] = {}
|
|
1367
|
-
for node, reason, specificity in raw:
|
|
1368
|
-
prev = best.get(node.id)
|
|
1369
|
-
if prev is None:
|
|
1370
|
-
best[node.id] = (node, reason, specificity)
|
|
1371
|
-
continue
|
|
1372
|
-
prev_pri = _RESOLVE_REASON_PRIORITY[prev[1]]
|
|
1373
|
-
new_pri = _RESOLVE_REASON_PRIORITY[reason]
|
|
1374
|
-
if new_pri < prev_pri or (new_pri == prev_pri and specificity > prev[2]):
|
|
1375
|
-
best[node.id] = (node, reason, specificity)
|
|
1376
|
-
return list(best.values())
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
def _resolve_rank_candidates(
|
|
1380
|
-
deduped: list[tuple[NodeRef, ResolveReason, int]],
|
|
1381
|
-
) -> list[ResolveCandidate]:
|
|
1382
|
-
ordered = sorted(
|
|
1383
|
-
deduped,
|
|
1384
|
-
key=lambda item: (_RESOLVE_REASON_PRIORITY[item[1]], -item[2], item[0].id),
|
|
1385
|
-
)
|
|
1386
|
-
total = len(ordered)
|
|
1387
|
-
return [
|
|
1388
|
-
ResolveCandidate(
|
|
1389
|
-
node=node,
|
|
1390
|
-
reason=reason,
|
|
1391
|
-
score=(1.0 - (idx / total)) if total else 0.0,
|
|
1392
|
-
)
|
|
1393
|
-
for idx, (node, reason, _spec) in enumerate(ordered)
|
|
1394
|
-
]
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
def _resolve_assert_invariants(out: ResolveOutput) -> None:
|
|
1398
|
-
if not out.success:
|
|
1399
|
-
assert out.status == "none"
|
|
1400
|
-
assert out.node is None
|
|
1401
|
-
assert not out.candidates
|
|
1402
|
-
assert out.message
|
|
1403
|
-
return
|
|
1404
|
-
if out.status == "one":
|
|
1405
|
-
assert out.node is not None
|
|
1406
|
-
assert not out.candidates
|
|
1407
|
-
elif out.status == "many":
|
|
1408
|
-
assert out.node is None
|
|
1409
|
-
assert len(out.candidates) >= 2
|
|
1410
|
-
elif out.status == "none":
|
|
1411
|
-
assert out.node is None
|
|
1412
|
-
assert not out.candidates
|
|
1413
|
-
assert out.message
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
def _resolve_seeds_for_hints(identifier: str) -> tuple[str | None, str | None]:
|
|
1417
|
-
path_prefix_seed: str | None = None
|
|
1418
|
-
method_path = _resolve_parse_route_method_path(identifier)
|
|
1419
|
-
if method_path is not None:
|
|
1420
|
-
path_prefix_seed = method_path[1]
|
|
1421
|
-
else:
|
|
1422
|
-
ms_route = _resolve_parse_microservice_route(identifier)
|
|
1423
|
-
if ms_route is not None:
|
|
1424
|
-
path_prefix_seed = ms_route[2]
|
|
1425
|
-
elif identifier.startswith("/"):
|
|
1426
|
-
path_prefix_seed = identifier
|
|
1427
|
-
|
|
1428
|
-
target_service_seed: str | None = None
|
|
1429
|
-
if " " in identifier:
|
|
1430
|
-
target, _path_prefix = identifier.split(" ", 1)
|
|
1431
|
-
target = target.strip()
|
|
1432
|
-
if target:
|
|
1433
|
-
target_service_seed = target
|
|
1434
|
-
elif not identifier.startswith("/"):
|
|
1435
|
-
target_service_seed = identifier
|
|
1436
|
-
|
|
1437
|
-
return path_prefix_seed, target_service_seed
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
def _resolve_finalize_success(
|
|
1441
|
-
trimmed: str,
|
|
1442
|
-
hint_kind: Literal["symbol", "route", "client", "producer"] | None,
|
|
1443
|
-
matches: list[ResolveCandidate],
|
|
1444
|
-
) -> ResolveOutput:
|
|
1445
|
-
if not matches:
|
|
1446
|
-
out = ResolveOutput(
|
|
1447
|
-
success=True,
|
|
1448
|
-
status="none",
|
|
1449
|
-
message=(
|
|
1450
|
-
"No matches for identifier; use search(query=...) for ranked fuzzy lookup."
|
|
1451
|
-
),
|
|
1452
|
-
resolved_identifier=trimmed,
|
|
1453
|
-
)
|
|
1454
|
-
elif len(matches) == 1:
|
|
1455
|
-
out = ResolveOutput(
|
|
1456
|
-
success=True,
|
|
1457
|
-
status="one",
|
|
1458
|
-
node=matches[0].node,
|
|
1459
|
-
resolved_identifier=trimmed,
|
|
1460
|
-
)
|
|
1461
|
-
else:
|
|
1462
|
-
out = ResolveOutput(
|
|
1463
|
-
success=True,
|
|
1464
|
-
status="many",
|
|
1465
|
-
candidates=matches,
|
|
1466
|
-
resolved_identifier=trimmed,
|
|
1467
|
-
)
|
|
1468
|
-
|
|
1469
|
-
path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed)
|
|
1470
|
-
hint_payload = {
|
|
1471
|
-
"status": out.status,
|
|
1472
|
-
"resolved_identifier": trimmed,
|
|
1473
|
-
"candidates": out.candidates,
|
|
1474
|
-
"hint_kind": hint_kind,
|
|
1475
|
-
"path_prefix_seed": path_prefix_seed,
|
|
1476
|
-
"target_service_seed": target_service_seed,
|
|
1477
|
-
}
|
|
1478
|
-
raw_struct, raw_advisories = _hints_or_skip("resolve", hint_payload)
|
|
1479
|
-
out = out.model_copy(update={
|
|
1480
|
-
"advisories": raw_advisories,
|
|
1481
|
-
"hints_structured": _to_structured_hints(raw_struct),
|
|
1482
|
-
})
|
|
1483
|
-
_resolve_assert_invariants(out)
|
|
1484
|
-
return out
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
def resolve_v2(
|
|
1488
|
-
identifier: str,
|
|
1489
|
-
hint_kind: Literal["symbol", "route", "client", "producer"] | None = None,
|
|
1490
|
-
graph: LadybugGraph | None = None,
|
|
1491
|
-
) -> ResolveOutput:
|
|
1492
|
-
try:
|
|
1493
|
-
trimmed, err = _resolve_validate_identifier(identifier)
|
|
1494
|
-
if err is not None:
|
|
1495
|
-
out = ResolveOutput(
|
|
1496
|
-
success=False,
|
|
1497
|
-
status="none",
|
|
1498
|
-
message=err,
|
|
1499
|
-
advisories=[],
|
|
1500
|
-
resolved_identifier=None,
|
|
1501
|
-
)
|
|
1502
|
-
_resolve_assert_invariants(out)
|
|
1503
|
-
return out
|
|
1504
|
-
|
|
1505
|
-
assert trimmed is not None
|
|
1506
|
-
if "*" in trimmed or "?" in trimmed:
|
|
1507
|
-
return _resolve_finalize_success(trimmed, hint_kind, [])
|
|
1508
|
-
|
|
1509
|
-
g = graph or LadybugGraph.get()
|
|
1510
|
-
raw: list[tuple[NodeRef, ResolveReason, int]] = []
|
|
1511
|
-
for kind in _resolve_kinds_to_search(hint_kind):
|
|
1512
|
-
if kind == "symbol":
|
|
1513
|
-
raw.extend(_resolve_symbol_candidates(g, trimmed))
|
|
1514
|
-
elif kind == "route":
|
|
1515
|
-
raw.extend(_resolve_route_candidates(g, trimmed))
|
|
1516
|
-
elif kind == "client":
|
|
1517
|
-
raw.extend(_resolve_client_candidates(g, trimmed))
|
|
1518
|
-
else:
|
|
1519
|
-
raw.extend(_resolve_producer_candidates(g, trimmed))
|
|
1520
|
-
|
|
1521
|
-
deduped = _resolve_dedupe_candidates(raw)
|
|
1522
|
-
ranked = _resolve_rank_candidates(deduped)
|
|
1523
|
-
capped = ranked[:_RESOLVE_CANDIDATE_CAP]
|
|
1524
|
-
return _resolve_finalize_success(trimmed, hint_kind, capped)
|
|
1525
|
-
except Exception as exc:
|
|
1526
|
-
out = ResolveOutput(
|
|
1527
|
-
success=False,
|
|
1528
|
-
status="none",
|
|
1529
|
-
message=str(exc),
|
|
1530
|
-
advisories=[],
|
|
1531
|
-
resolved_identifier=None,
|
|
1532
|
-
)
|
|
1533
|
-
_resolve_assert_invariants(out)
|
|
1534
|
-
return out
|
|
1149
|
+
# Per-edge-type attribute columns selected by the generic (flat-label) neighbors
|
|
1150
|
+
# query (issue #356). RETURNing a fixed superset of columns regardless of which
|
|
1151
|
+
# edge type matched is the typed-union RETURN anti-pattern: a stricter binder
|
|
1152
|
+
# (e.g. Kùzu) errors when a RETURNed column does not exist on the matched type.
|
|
1153
|
+
# Selecting columns per edge type keeps the query portable; _neighbor_edge_attrs
|
|
1154
|
+
# still drops None/"" so each edge exposes only the attrs that exist for its type.
|
|
1155
|
+
# Aligned with the REL TABLE schemas in build_ast_graph.py.
|
|
1156
|
+
_FLAT_EDGE_ATTR_COLUMNS: dict[str, tuple[str, ...]] = {
|
|
1157
|
+
"CALLS": ("confidence", "strategy", "source", "call_site_line", "call_site_byte", "arg_count", "resolved"),
|
|
1158
|
+
"HTTP_CALLS": ("confidence", "strategy", "match"),
|
|
1159
|
+
"ASYNC_CALLS": ("confidence", "strategy", "match"),
|
|
1160
|
+
"EXPOSES": ("confidence", "strategy"),
|
|
1161
|
+
"DECLARES_CLIENT": ("confidence", "strategy"),
|
|
1162
|
+
"DECLARES_PRODUCER": ("confidence", "strategy"),
|
|
1163
|
+
"INJECTS": ("mechanism", "annotation", "field_or_param", "resolved"),
|
|
1164
|
+
"EXTENDS": ("resolved",),
|
|
1165
|
+
"IMPLEMENTS": ("resolved",),
|
|
1166
|
+
"DECLARES": (),
|
|
1167
|
+
"OVERRIDES": (),
|
|
1168
|
+
}
|
|
1535
1169
|
|
|
1536
1170
|
|
|
1537
1171
|
def _neighbor_edge_attrs(row: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -1813,9 +1447,6 @@ def neighbors_v2(
|
|
|
1813
1447
|
message=err,
|
|
1814
1448
|
requested_edge_types=requested_edge_types,
|
|
1815
1449
|
)
|
|
1816
|
-
if nf and (err := _validate_no_wildcards(nf)):
|
|
1817
|
-
_log_fail_loud("wildcard")
|
|
1818
|
-
return NeighborsOutput(success=False, message=err, requested_edge_types=[])
|
|
1819
1450
|
if composed_keys and direction != "out":
|
|
1820
1451
|
return NeighborsOutput(
|
|
1821
1452
|
success=False,
|
|
@@ -1896,33 +1527,25 @@ def neighbors_v2(
|
|
|
1896
1527
|
results.extend(origin_edges)
|
|
1897
1528
|
continue
|
|
1898
1529
|
if flat_labels:
|
|
1899
|
-
#
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
"e.
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
f"{label_predicate} "
|
|
1919
|
-
"RETURN b.id AS other_id, label(e) AS edge_type, e.confidence AS confidence, "
|
|
1920
|
-
"e.strategy AS strategy, e.match AS match, e.mechanism AS mechanism, "
|
|
1921
|
-
"e.annotation AS annotation, e.field_or_param AS field_or_param, "
|
|
1922
|
-
"e.source AS source, e.call_site_line AS call_site_line, "
|
|
1923
|
-
"e.call_site_byte AS call_site_byte, e.arg_count AS arg_count, "
|
|
1924
|
-
"e.resolved AS resolved",
|
|
1925
|
-
q_params,
|
|
1530
|
+
# Select attribute columns per edge type (issue #356). A single
|
|
1531
|
+
# multi-label query RETURNing a fixed column superset references
|
|
1532
|
+
# columns that don't exist on every matched type — the typed-union
|
|
1533
|
+
# RETURN anti-pattern, which errors on stricter binders (e.g. Kùzu).
|
|
1534
|
+
# Run one single-label query per type, RETURNing only that type's
|
|
1535
|
+
# columns, and merge the rows. `label(e) = $label` scalar equality
|
|
1536
|
+
# (not `label(e) IN [...]`) per the AGENTS.md Cypher note.
|
|
1537
|
+
rows: list[dict[str, Any]] = []
|
|
1538
|
+
match_clause = "MATCH (a)-[e]->(b)" if direction == "out" else "MATCH (a)<-[e]-(b)"
|
|
1539
|
+
for label in flat_labels:
|
|
1540
|
+
cols = _FLAT_EDGE_ATTR_COLUMNS.get(label, ())
|
|
1541
|
+
select = "b.id AS other_id, label(e) AS edge_type"
|
|
1542
|
+
if cols:
|
|
1543
|
+
select += ", " + ", ".join(f"e.{c} AS {c}" for c in cols)
|
|
1544
|
+
rows.extend(
|
|
1545
|
+
g._rows( # noqa: SLF001
|
|
1546
|
+
f"{match_clause} WHERE a.id = $id AND label(e) = $label RETURN {select}",
|
|
1547
|
+
{"id": origin_id, "label": label},
|
|
1548
|
+
)
|
|
1926
1549
|
)
|
|
1927
1550
|
for row in rows:
|
|
1928
1551
|
other_id = str(row.get("other_id") or "")
|
|
@@ -1979,14 +1602,25 @@ def neighbors_v2(
|
|
|
1979
1602
|
)
|
|
1980
1603
|
if use_calls_path and len(origins) > 1:
|
|
1981
1604
|
sliced = results[offset : offset + limit]
|
|
1605
|
+
neighbors_has_more = len(results) > offset + limit
|
|
1606
|
+
elif use_calls_path:
|
|
1607
|
+
# Single-origin CALLS path. When paginate_in_sql is True the SQL did
|
|
1608
|
+
# the OFFSET/LIMIT and the row/unfiltered counts carry the has-more
|
|
1609
|
+
# signal, so this field stays None (unknown). When paginate_in_sql is
|
|
1610
|
+
# False (a node_filter is set, include_unresolved, or dedup_calls) we
|
|
1611
|
+
# loaded the FULL matching set with no pushdown, so the client already
|
|
1612
|
+
# has every edge -> False (not None), so a paging client need not probe.
|
|
1613
|
+
sliced = results
|
|
1614
|
+
neighbors_has_more = None if paginate_in_sql else False
|
|
1982
1615
|
else:
|
|
1983
|
-
sliced = results
|
|
1616
|
+
sliced = results[offset : offset + limit]
|
|
1617
|
+
neighbors_has_more = len(results) > offset + limit
|
|
1984
1618
|
first_origin = origins[0]
|
|
1985
1619
|
origin_kind = _resolve_node_kind(g, first_origin)
|
|
1986
1620
|
subject_record = _load_node_record(g, first_origin, origin_kind)
|
|
1987
1621
|
neigh_payload = {
|
|
1988
1622
|
"success": True,
|
|
1989
|
-
"results": [e.model_dump() for e in sliced],
|
|
1623
|
+
"results": [e.model_dump(exclude_none=True) for e in sliced],
|
|
1990
1624
|
"requested_edge_types": requested_edge_types,
|
|
1991
1625
|
"requested_direction": direction,
|
|
1992
1626
|
"offset": offset,
|
|
@@ -2006,6 +1640,7 @@ def neighbors_v2(
|
|
|
2006
1640
|
success=True,
|
|
2007
1641
|
results=sliced,
|
|
2008
1642
|
requested_edge_types=requested_edge_types,
|
|
1643
|
+
has_more_results=neighbors_has_more,
|
|
2009
1644
|
advisories=raw_advisories,
|
|
2010
1645
|
hints_structured=_to_structured_hints(raw_struct),
|
|
2011
1646
|
)
|
|
@@ -2013,3 +1648,12 @@ def neighbors_v2(
|
|
|
2013
1648
|
raise
|
|
2014
1649
|
except Exception as exc:
|
|
2015
1650
|
return NeighborsOutput(success=False, message=str(exc), advisories=[], requested_edge_types=[])
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
# Re-export resolve symbols from resolve_service.py (imported here to avoid circular import)
|
|
1654
|
+
from resolve_service import ( # noqa: E402
|
|
1655
|
+
ResolveCandidate,
|
|
1656
|
+
ResolveOutput,
|
|
1657
|
+
ResolveStatus,
|
|
1658
|
+
resolve_v2,
|
|
1659
|
+
)
|