java-codebase-rag 0.9.4__py3-none-any.whl → 0.9.5__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.
- absence_diagnosis.py +700 -0
- absence_types.py +124 -0
- absence_vocab.py +455 -0
- ast_java.py +1 -1
- build_ast_graph.py +85 -7
- graph_enrich.py +245 -0
- graph_types.py +4 -0
- java_codebase_rag/cli.py +1 -6
- java_codebase_rag/config.py +116 -0
- java_codebase_rag/jrag.py +56 -1
- java_codebase_rag/jrag_envelope.py +10 -1
- java_codebase_rag/jrag_render.py +67 -3
- java_codebase_rag/pipeline.py +10 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/METADATA +2 -2
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/RECORD +27 -22
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/top_level.txt +5 -0
- java_index_flow_lancedb.py +27 -7
- ladybug_queries.py +4 -4
- mcp_v2.py +272 -73
- resolve_service.py +69 -2
- search_lancedb.py +50 -311
- search_lexical.py +329 -0
- search_scoring.py +338 -0
- server.py +101 -34
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/WHEEL +0 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/entry_points.txt +0 -0
- {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.5.dist-info}/licenses/LICENSE +0 -0
mcp_v2.py
CHANGED
|
@@ -31,6 +31,9 @@ if TYPE_CHECKING:
|
|
|
31
31
|
# installs ship without torch/lancedb); it is imported lazily in _get_sentence_transformer.
|
|
32
32
|
from sentence_transformers import SentenceTransformer
|
|
33
33
|
|
|
34
|
+
from absence_types import AbsenceDiagnosis
|
|
35
|
+
from absence_diagnosis import diagnose
|
|
36
|
+
from absence_vocab import get_vocabulary_index
|
|
34
37
|
from graph_types import (
|
|
35
38
|
NodeRef,
|
|
36
39
|
StructuredHint,
|
|
@@ -74,6 +77,7 @@ __all__ = [
|
|
|
74
77
|
"EdgeFilter",
|
|
75
78
|
"StructuredHint",
|
|
76
79
|
"set_hints_enabled",
|
|
80
|
+
"set_absence_config",
|
|
77
81
|
]
|
|
78
82
|
|
|
79
83
|
DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"]
|
|
@@ -147,6 +151,35 @@ _METHOD_SYMBOL_KINDS_FOR_OVERRIDE_ROLLUP = frozenset({"method"})
|
|
|
147
151
|
_fail_loud_counts: dict[str, int] = {}
|
|
148
152
|
_fail_loud_lock = threading.Lock()
|
|
149
153
|
|
|
154
|
+
# Module-level holder for absence diagnosis config (set by server.py)
|
|
155
|
+
_absence_config: Any = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def set_absence_config(cfg: Any) -> None:
|
|
159
|
+
"""Set the global absence diagnosis config for MCP tools.
|
|
160
|
+
|
|
161
|
+
Mirrors set_hints_enabled: called from server.py to make cfg reachable
|
|
162
|
+
in tool functions without threading it through every signature.
|
|
163
|
+
"""
|
|
164
|
+
global _absence_config
|
|
165
|
+
_absence_config = cfg
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _get_absence_config() -> Any:
|
|
169
|
+
"""Get the absence diagnosis config, with safe fallback.
|
|
170
|
+
|
|
171
|
+
Returns the module-level config if set; otherwise falls back to a
|
|
172
|
+
default config (for direct tool calls in tests without server init).
|
|
173
|
+
"""
|
|
174
|
+
if _absence_config is not None:
|
|
175
|
+
return _absence_config
|
|
176
|
+
|
|
177
|
+
# Fallback: resolve from cwd for direct test calls
|
|
178
|
+
from java_codebase_rag.config import resolve_operator_config
|
|
179
|
+
from pathlib import Path
|
|
180
|
+
|
|
181
|
+
return resolve_operator_config(source_root=Path.cwd())
|
|
182
|
+
|
|
150
183
|
|
|
151
184
|
def _log_fail_loud(category: str) -> None:
|
|
152
185
|
"""Increment process-local fail-loud counter and emit one stderr line (PR-FRAME-3).
|
|
@@ -191,6 +224,8 @@ class NodeFilter(BaseModel):
|
|
|
191
224
|
source_layer: SourceLayer | None = None
|
|
192
225
|
role: Role | None = None
|
|
193
226
|
exclude_roles: list[Role] | None = None
|
|
227
|
+
generated_only: bool = False
|
|
228
|
+
exclude_generated: bool = False
|
|
194
229
|
annotation: str | None = None
|
|
195
230
|
capability: str | None = None
|
|
196
231
|
fqn_contains: str | None = None
|
|
@@ -273,6 +308,8 @@ _NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client", "produc
|
|
|
273
308
|
"module",
|
|
274
309
|
"role",
|
|
275
310
|
"exclude_roles",
|
|
311
|
+
"generated_only",
|
|
312
|
+
"exclude_generated",
|
|
276
313
|
"annotation",
|
|
277
314
|
"capability",
|
|
278
315
|
"fqn_contains",
|
|
@@ -317,6 +354,9 @@ def _populated_nodefilter_fields(nf: NodeFilter) -> set[str]:
|
|
|
317
354
|
continue
|
|
318
355
|
if isinstance(value, list) and not value:
|
|
319
356
|
continue
|
|
357
|
+
if isinstance(value, bool) and not value:
|
|
358
|
+
# default-False NodeFilter fields (generated_only, exclude_generated) must not count as "populated"
|
|
359
|
+
continue
|
|
320
360
|
populated.add(field_name)
|
|
321
361
|
return populated
|
|
322
362
|
|
|
@@ -364,6 +404,8 @@ def _populated_edgefilter_fields(ef: EdgeFilter) -> set[str]:
|
|
|
364
404
|
continue
|
|
365
405
|
if isinstance(value, list) and not value:
|
|
366
406
|
continue
|
|
407
|
+
if isinstance(value, bool) and not value:
|
|
408
|
+
continue
|
|
367
409
|
populated.add(field_name)
|
|
368
410
|
return populated
|
|
369
411
|
|
|
@@ -464,6 +506,8 @@ class SearchHit(BaseModel):
|
|
|
464
506
|
microservice: str | None = None
|
|
465
507
|
module: str | None = None
|
|
466
508
|
role: str | None = None
|
|
509
|
+
generated: bool | None = None
|
|
510
|
+
generated_by: str | None = None
|
|
467
511
|
filename: str | None = None
|
|
468
512
|
start_line: int | None = None
|
|
469
513
|
score_components: dict[str, float] | None = None
|
|
@@ -520,6 +564,11 @@ class SearchOutput(BaseModel):
|
|
|
520
564
|
)
|
|
521
565
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
522
566
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
567
|
+
lexical_mode: bool = Field(
|
|
568
|
+
default=False,
|
|
569
|
+
description="True when results come from the graph-only lexical (keyword) backend instead of semantic/vector search.",
|
|
570
|
+
)
|
|
571
|
+
absence: AbsenceDiagnosis | None = None
|
|
523
572
|
|
|
524
573
|
|
|
525
574
|
class FindOutput(BaseModel):
|
|
@@ -541,6 +590,7 @@ class FindOutput(BaseModel):
|
|
|
541
590
|
)
|
|
542
591
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
543
592
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
593
|
+
absence: AbsenceDiagnosis | None = None
|
|
544
594
|
|
|
545
595
|
|
|
546
596
|
class DescribeOutput(BaseModel):
|
|
@@ -549,6 +599,7 @@ class DescribeOutput(BaseModel):
|
|
|
549
599
|
message: str | None = None
|
|
550
600
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
551
601
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
602
|
+
absence: AbsenceDiagnosis | None = None
|
|
552
603
|
|
|
553
604
|
|
|
554
605
|
class NeighborsOutput(BaseModel):
|
|
@@ -567,6 +618,7 @@ class NeighborsOutput(BaseModel):
|
|
|
567
618
|
)
|
|
568
619
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
569
620
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
621
|
+
absence: AbsenceDiagnosis | None = None
|
|
570
622
|
|
|
571
623
|
|
|
572
624
|
# Re-exported from resolve_service.py (imported at end of module to avoid circular import)
|
|
@@ -608,6 +660,8 @@ def _row_to_search_hit(row: dict[str, Any], explain: bool = False) -> SearchHit:
|
|
|
608
660
|
microservice=str(row.get("microservice")) if row.get("microservice") else None,
|
|
609
661
|
module=str(row.get("module")) if row.get("module") else None,
|
|
610
662
|
role=str(row.get("role")) if row.get("role") else None,
|
|
663
|
+
generated=bool(row.get("generated")) if row.get("generated") is not None else None,
|
|
664
|
+
generated_by=str(row.get("generated_by")) if row.get("generated_by") else None,
|
|
611
665
|
filename=filename,
|
|
612
666
|
start_line=start_line,
|
|
613
667
|
score_components=row.get("_score_components") if explain else None,
|
|
@@ -649,6 +703,10 @@ def _symbol_where_from_filter(f: NodeFilter) -> tuple[str, dict[str, Any]]:
|
|
|
649
703
|
if f.exclude_roles:
|
|
650
704
|
preds.append("NOT s.role IN $exclude_roles")
|
|
651
705
|
params["exclude_roles"] = list(f.exclude_roles)
|
|
706
|
+
if f.generated_only:
|
|
707
|
+
preds.append("s.generated = true")
|
|
708
|
+
if f.exclude_generated:
|
|
709
|
+
preds.append("(s.generated IS NULL OR s.generated = false)")
|
|
652
710
|
if f.annotation:
|
|
653
711
|
preds.append("list_contains(s.annotations, $annotation)")
|
|
654
712
|
params["annotation"] = f.annotation
|
|
@@ -681,7 +739,7 @@ def _load_node_record(
|
|
|
681
739
|
"n.start_line AS start_line, n.end_line AS end_line, n.start_byte AS start_byte, "
|
|
682
740
|
"n.end_byte AS end_byte, n.modifiers AS modifiers, n.annotations AS annotations, "
|
|
683
741
|
"n.capabilities AS capabilities, n.role AS role, n.signature AS signature, "
|
|
684
|
-
"n.parent_id AS parent_id, n.resolved AS resolved"
|
|
742
|
+
"n.parent_id AS parent_id, n.resolved AS resolved, n.generated AS generated, n.generated_by AS generated_by"
|
|
685
743
|
)
|
|
686
744
|
label = "Symbol"
|
|
687
745
|
elif kind == "route":
|
|
@@ -778,6 +836,11 @@ def _node_matches_filter(
|
|
|
778
836
|
return False
|
|
779
837
|
if f.exclude_roles and role in set(f.exclude_roles):
|
|
780
838
|
return False
|
|
839
|
+
generated = row.get("generated")
|
|
840
|
+
if f.generated_only and not generated:
|
|
841
|
+
return False
|
|
842
|
+
if f.exclude_generated and generated:
|
|
843
|
+
return False
|
|
781
844
|
if f.annotation and f.annotation not in list(row.get("annotations") or []):
|
|
782
845
|
return False
|
|
783
846
|
if f.capability and f.capability not in list(row.get("capabilities") or []):
|
|
@@ -850,97 +913,135 @@ def search_v2(
|
|
|
850
913
|
if nf and (err := _nodefilter_applicability_error("symbol", nf)):
|
|
851
914
|
_log_fail_loud("applicability")
|
|
852
915
|
return SearchOutput(success=False, message=err, advisories=[], limit=None, offset=None)
|
|
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
|
-
)
|
|
874
|
-
model_name = resolved_sbert_model_for_process_env(SBERT_MODEL)
|
|
875
|
-
device = os.environ.get("SBERT_DEVICE") or None
|
|
876
|
-
model = _get_sentence_transformer(model_name, device)
|
|
877
|
-
uri = os.environ.get("JAVA_CODEBASE_RAG_INDEX_DIR", "").strip() or str(
|
|
878
|
-
(Path.cwd() / ".java-codebase-rag").resolve()
|
|
879
|
-
)
|
|
880
|
-
uri_path = Path(uri)
|
|
881
|
-
if not uri.startswith(("s3://", "gs://", "az://")) and uri_path.exists():
|
|
882
|
-
uri = str(uri_path.resolve())
|
|
883
|
-
table_keys = list(TABLES) if table == "all" else [table]
|
|
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
916
|
advisories: list[str] = []
|
|
888
|
-
|
|
889
|
-
|
|
917
|
+
lexical_mode = run_search is None
|
|
918
|
+
if lexical_mode:
|
|
919
|
+
# Graph-only install (macOS Intel: no torch/lancedb). Fall back to lexical
|
|
920
|
+
# (keyword) search over the symbol graph that graph-only mode already builds.
|
|
921
|
+
# run_lexical_search returns rows in the same shape as run_search, so the
|
|
922
|
+
# shared row->hit loop below works unchanged. It raises (message contains
|
|
923
|
+
# "lexical search unavailable") when no graph exists — caught by the outer
|
|
924
|
+
# try -> success=False. It returns [] for sql/yaml (advisory below) and for
|
|
925
|
+
# empty-but-valid results.
|
|
926
|
+
try:
|
|
927
|
+
from search_lexical import run_lexical_search
|
|
928
|
+
except ImportError: # pragma: no cover - search_lexical has no heavy deps
|
|
929
|
+
run_lexical_search = None # type: ignore[assignment]
|
|
930
|
+
if run_lexical_search is None:
|
|
931
|
+
return SearchOutput(
|
|
932
|
+
success=False,
|
|
933
|
+
message="search unavailable: graph-only mode and lexical backend not importable.",
|
|
934
|
+
advisories=[],
|
|
935
|
+
limit=None,
|
|
936
|
+
offset=None,
|
|
937
|
+
)
|
|
938
|
+
advisories.append(
|
|
939
|
+
"lexical (graph-only) mode — keyword ranking only; "
|
|
940
|
+
"semantic/vector search requires Apple Silicon, Linux, or Windows"
|
|
941
|
+
)
|
|
942
|
+
if table in ("sql", "yaml", "all"):
|
|
943
|
+
advisories.append(
|
|
944
|
+
"sql/yaml tables are not indexed in graph-only mode; only Java symbols were searched"
|
|
945
|
+
)
|
|
946
|
+
if hybrid:
|
|
947
|
+
advisories.append("hybrid is ignored in graph-only lexical mode")
|
|
948
|
+
rows = run_lexical_search(
|
|
890
949
|
query,
|
|
891
|
-
|
|
892
|
-
table_keys=table_keys,
|
|
893
|
-
hybrid=hybrid,
|
|
950
|
+
table=table,
|
|
894
951
|
limit=limit,
|
|
895
952
|
offset=offset,
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
953
|
+
path_contains=path_contains,
|
|
954
|
+
filter=nf,
|
|
955
|
+
explain=explain,
|
|
956
|
+
dedup=dedup,
|
|
957
|
+
advisories=advisories,
|
|
958
|
+
graph=graph,
|
|
959
|
+
)
|
|
960
|
+
else:
|
|
961
|
+
# hybrid + table='all' is unsupported (hybrid fuses vector+FTS on ONE
|
|
962
|
+
# table); fail fast with a clean envelope BEFORE loading the embedding
|
|
963
|
+
# model. run_search also guards this — this is the user-facing fast path.
|
|
964
|
+
if hybrid and table == "all":
|
|
965
|
+
return SearchOutput(
|
|
966
|
+
success=False,
|
|
967
|
+
message="hybrid search requires a single table; use java, sql, or yaml (not all)",
|
|
968
|
+
advisories=[],
|
|
969
|
+
limit=None,
|
|
970
|
+
offset=None,
|
|
971
|
+
)
|
|
972
|
+
model_name = resolved_sbert_model_for_process_env(SBERT_MODEL)
|
|
973
|
+
device = os.environ.get("SBERT_DEVICE") or None
|
|
974
|
+
model = _get_sentence_transformer(model_name, device)
|
|
975
|
+
uri = os.environ.get("JAVA_CODEBASE_RAG_INDEX_DIR", "").strip() or str(
|
|
976
|
+
(Path.cwd() / ".java-codebase-rag").resolve()
|
|
912
977
|
)
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
978
|
+
uri_path = Path(uri)
|
|
979
|
+
if not uri.startswith(("s3://", "gs://", "az://")) and uri_path.exists():
|
|
980
|
+
uri = str(uri_path.resolve())
|
|
981
|
+
table_keys = list(TABLES) if table == "all" else [table]
|
|
982
|
+
|
|
983
|
+
# Graceful fallback: if hybrid=True and FTS index is missing (old index),
|
|
984
|
+
# retry with hybrid=False and return vector-only results with an advisory.
|
|
985
|
+
try:
|
|
919
986
|
rows = run_search(
|
|
920
987
|
query,
|
|
921
988
|
uri=uri,
|
|
922
989
|
table_keys=table_keys,
|
|
923
|
-
hybrid=
|
|
990
|
+
hybrid=hybrid,
|
|
924
991
|
limit=limit,
|
|
925
992
|
offset=offset,
|
|
926
993
|
path_substring=path_contains,
|
|
927
994
|
model_name=model_name,
|
|
928
995
|
device=device,
|
|
929
996
|
model=model,
|
|
997
|
+
# Push the NodeFilter structural predicates into the LanceDB query so
|
|
998
|
+
# they apply BEFORE pagination (issue #353) — previously they were only
|
|
999
|
+
# a post-filter on the already-paginated page, which could shrink or
|
|
1000
|
+
# empty filtered pages even when many matches existed deeper in the
|
|
1001
|
+
# ranking. _node_matches_filter below still re-checks every row (it
|
|
1002
|
+
# covers the non-pushdownable fields and is the contract guarantee).
|
|
930
1003
|
role=nf.role if nf else None,
|
|
931
1004
|
module=nf.module if nf else None,
|
|
932
1005
|
microservice=nf.microservice if nf else None,
|
|
933
1006
|
capability=nf.capability if nf else None,
|
|
934
1007
|
exclude_roles=nf.exclude_roles if nf else None,
|
|
1008
|
+
exclude_generated=nf.exclude_generated if nf else None,
|
|
1009
|
+
generated_only=nf.generated_only if nf else None,
|
|
935
1010
|
dedup_by_fqn=dedup,
|
|
936
1011
|
)
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1012
|
+
except Exception as exc:
|
|
1013
|
+
# Check if this is a missing-FTS error (old index built before PR-SEARCH-3)
|
|
1014
|
+
exc_text = str(exc).lower()
|
|
1015
|
+
is_fts_missing = "full text search" in exc_text or "inverted index" in exc_text
|
|
1016
|
+
if hybrid and is_fts_missing:
|
|
1017
|
+
# Retry with vector-only search
|
|
1018
|
+
rows = run_search(
|
|
1019
|
+
query,
|
|
1020
|
+
uri=uri,
|
|
1021
|
+
table_keys=table_keys,
|
|
1022
|
+
hybrid=False, # Fallback to vector-only
|
|
1023
|
+
limit=limit,
|
|
1024
|
+
offset=offset,
|
|
1025
|
+
path_substring=path_contains,
|
|
1026
|
+
model_name=model_name,
|
|
1027
|
+
device=device,
|
|
1028
|
+
model=model,
|
|
1029
|
+
role=nf.role if nf else None,
|
|
1030
|
+
module=nf.module if nf else None,
|
|
1031
|
+
microservice=nf.microservice if nf else None,
|
|
1032
|
+
capability=nf.capability if nf else None,
|
|
1033
|
+
exclude_roles=nf.exclude_roles if nf else None,
|
|
1034
|
+
exclude_generated=nf.exclude_generated if nf else None,
|
|
1035
|
+
generated_only=nf.generated_only if nf else None,
|
|
1036
|
+
dedup_by_fqn=dedup,
|
|
1037
|
+
)
|
|
1038
|
+
advisories.append(
|
|
1039
|
+
f"hybrid unavailable on table '{table}' (FTS index missing on this index built before "
|
|
1040
|
+
f"PR-SEARCH-3); fell back to vector-only — reindex to enable hybrid"
|
|
1041
|
+
)
|
|
1042
|
+
else:
|
|
1043
|
+
# Non-FTS error: surface as structured failure
|
|
1044
|
+
raise
|
|
944
1045
|
hits: list[SearchHit] = []
|
|
945
1046
|
for row in rows:
|
|
946
1047
|
if path_contains and path_contains not in str(row.get("filename") or ""):
|
|
@@ -950,6 +1051,25 @@ def search_v2(
|
|
|
950
1051
|
if not _node_matches_filter(row_kind, row, nf):
|
|
951
1052
|
continue
|
|
952
1053
|
hits.append(_row_to_search_hit(row, explain=explain))
|
|
1054
|
+
|
|
1055
|
+
# Absence diagnosis for empty results
|
|
1056
|
+
cfg = _get_absence_config()
|
|
1057
|
+
diag: AbsenceDiagnosis | None = None
|
|
1058
|
+
if not hits:
|
|
1059
|
+
g = graph or LadybugGraph.get()
|
|
1060
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
1061
|
+
diag = diagnose(
|
|
1062
|
+
tool="search",
|
|
1063
|
+
query=query,
|
|
1064
|
+
filt=None,
|
|
1065
|
+
filter_kind=None,
|
|
1066
|
+
root_node=None,
|
|
1067
|
+
scope={},
|
|
1068
|
+
vocab=vocab,
|
|
1069
|
+
graph=g,
|
|
1070
|
+
cfg=cfg,
|
|
1071
|
+
)
|
|
1072
|
+
|
|
953
1073
|
hint_payload = {
|
|
954
1074
|
"success": True,
|
|
955
1075
|
"results": [h.model_dump() for h in hits],
|
|
@@ -964,6 +1084,8 @@ def search_v2(
|
|
|
964
1084
|
offset=offset,
|
|
965
1085
|
advisories=advisories + raw_advisories, # Merge fallback + hints advisories
|
|
966
1086
|
hints_structured=_to_structured_hints(raw_struct),
|
|
1087
|
+
lexical_mode=lexical_mode,
|
|
1088
|
+
absence=diag,
|
|
967
1089
|
)
|
|
968
1090
|
except Exception as exc:
|
|
969
1091
|
return SearchOutput(success=False, message=str(exc), advisories=[], limit=None, offset=None)
|
|
@@ -1001,7 +1123,7 @@ def find_v2(
|
|
|
1001
1123
|
params["lim"] = fetch_cap
|
|
1002
1124
|
rows = g._rows( # noqa: SLF001
|
|
1003
1125
|
f"MATCH (s:Symbol) {where} RETURN s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
|
|
1004
|
-
"s.module AS module, s.role AS role, s.kind AS symbol_kind ORDER BY s.fqn LIMIT $lim",
|
|
1126
|
+
"s.module AS module, s.role AS role, s.kind AS symbol_kind, s.generated AS generated, s.generated_by AS generated_by ORDER BY s.fqn LIMIT $lim",
|
|
1005
1127
|
params,
|
|
1006
1128
|
)
|
|
1007
1129
|
elif kind == "route":
|
|
@@ -1035,6 +1157,24 @@ def find_v2(
|
|
|
1035
1157
|
rows = rows[offset : offset + limit]
|
|
1036
1158
|
refs = [_node_ref_from_row(kind, r) for r in rows]
|
|
1037
1159
|
filter_dump = nf.model_dump(exclude_none=True)
|
|
1160
|
+
|
|
1161
|
+
# Absence diagnosis for empty results
|
|
1162
|
+
cfg = _get_absence_config()
|
|
1163
|
+
diag: AbsenceDiagnosis | None = None
|
|
1164
|
+
if not refs:
|
|
1165
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
1166
|
+
diag = diagnose(
|
|
1167
|
+
tool="find",
|
|
1168
|
+
query=None,
|
|
1169
|
+
filt=filter_dump,
|
|
1170
|
+
filter_kind=kind,
|
|
1171
|
+
root_node=None,
|
|
1172
|
+
scope={},
|
|
1173
|
+
vocab=vocab,
|
|
1174
|
+
graph=g,
|
|
1175
|
+
cfg=cfg,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1038
1178
|
hint_payload: dict[str, Any] = {
|
|
1039
1179
|
"success": True,
|
|
1040
1180
|
"kind": kind,
|
|
@@ -1058,6 +1198,7 @@ def find_v2(
|
|
|
1058
1198
|
has_more_results=has_more_results,
|
|
1059
1199
|
advisories=raw_advisories,
|
|
1060
1200
|
hints_structured=_to_structured_hints(raw_struct),
|
|
1201
|
+
absence=diag,
|
|
1061
1202
|
)
|
|
1062
1203
|
except Exception as exc:
|
|
1063
1204
|
return FindOutput(success=False, message=str(exc), advisories=[], limit=None, offset=None)
|
|
@@ -1094,7 +1235,25 @@ def describe_v2(
|
|
|
1094
1235
|
{"fqn": fqn_val},
|
|
1095
1236
|
)
|
|
1096
1237
|
if not rows:
|
|
1097
|
-
|
|
1238
|
+
# FQN not found: run diagnosis with query=fqn
|
|
1239
|
+
cfg = _get_absence_config()
|
|
1240
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
1241
|
+
diag = diagnose(
|
|
1242
|
+
tool="describe",
|
|
1243
|
+
query=fqn_val,
|
|
1244
|
+
filt=None,
|
|
1245
|
+
filter_kind=None,
|
|
1246
|
+
root_node=None,
|
|
1247
|
+
scope={},
|
|
1248
|
+
vocab=vocab,
|
|
1249
|
+
graph=g,
|
|
1250
|
+
cfg=cfg,
|
|
1251
|
+
)
|
|
1252
|
+
return DescribeOutput(
|
|
1253
|
+
success=False,
|
|
1254
|
+
message=f"No Symbol found for fqn='{fqn_val}'",
|
|
1255
|
+
absence=diag,
|
|
1256
|
+
)
|
|
1098
1257
|
node_id = str(rows[0]["id"] or "")
|
|
1099
1258
|
if len(rows) > 1:
|
|
1100
1259
|
hint_message = (
|
|
@@ -1107,7 +1266,26 @@ def describe_v2(
|
|
|
1107
1266
|
return DescribeOutput(success=False, message=_DESCRIBE_UCS_ID_MESSAGE, advisories=[])
|
|
1108
1267
|
row = _load_node_record(g, node_id, kind)
|
|
1109
1268
|
if row is None:
|
|
1110
|
-
|
|
1269
|
+
# Node ID not found: run diagnosis with query=None (minimal refine)
|
|
1270
|
+
cfg = _get_absence_config()
|
|
1271
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
1272
|
+
diag = diagnose(
|
|
1273
|
+
tool="describe",
|
|
1274
|
+
query=None,
|
|
1275
|
+
filt=None,
|
|
1276
|
+
filter_kind=None,
|
|
1277
|
+
root_node=None,
|
|
1278
|
+
scope={},
|
|
1279
|
+
vocab=vocab,
|
|
1280
|
+
graph=g,
|
|
1281
|
+
cfg=cfg,
|
|
1282
|
+
)
|
|
1283
|
+
return DescribeOutput(
|
|
1284
|
+
success=False,
|
|
1285
|
+
message=f"No node found for `{node_id}`",
|
|
1286
|
+
advisories=[],
|
|
1287
|
+
absence=diag,
|
|
1288
|
+
)
|
|
1111
1289
|
ref = _node_ref_from_row(kind, row)
|
|
1112
1290
|
edge_summary = _edge_summary_for_node(g, node_id, kind=kind, row=row)
|
|
1113
1291
|
data = dict(row)
|
|
@@ -1618,6 +1796,26 @@ def neighbors_v2(
|
|
|
1618
1796
|
first_origin = origins[0]
|
|
1619
1797
|
origin_kind = _resolve_node_kind(g, first_origin)
|
|
1620
1798
|
subject_record = _load_node_record(g, first_origin, origin_kind)
|
|
1799
|
+
|
|
1800
|
+
# Absence diagnosis for empty results
|
|
1801
|
+
cfg = _get_absence_config()
|
|
1802
|
+
diag: AbsenceDiagnosis | None = None
|
|
1803
|
+
if not sliced:
|
|
1804
|
+
# Build root_node from first_origin + subject_record
|
|
1805
|
+
root_node = _node_ref_from_row(origin_kind, subject_record) if subject_record else None
|
|
1806
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
1807
|
+
diag = diagnose(
|
|
1808
|
+
tool="neighbors",
|
|
1809
|
+
query=None,
|
|
1810
|
+
filt=None,
|
|
1811
|
+
filter_kind=None,
|
|
1812
|
+
root_node=root_node,
|
|
1813
|
+
scope={},
|
|
1814
|
+
vocab=vocab,
|
|
1815
|
+
graph=g,
|
|
1816
|
+
cfg=cfg,
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1621
1819
|
neigh_payload = {
|
|
1622
1820
|
"success": True,
|
|
1623
1821
|
"results": [e.model_dump(exclude_none=True) for e in sliced],
|
|
@@ -1643,6 +1841,7 @@ def neighbors_v2(
|
|
|
1643
1841
|
has_more_results=neighbors_has_more,
|
|
1644
1842
|
advisories=raw_advisories,
|
|
1645
1843
|
hints_structured=_to_structured_hints(raw_struct),
|
|
1844
|
+
absence=diag,
|
|
1646
1845
|
)
|
|
1647
1846
|
except ValidationError:
|
|
1648
1847
|
raise
|
resolve_service.py
CHANGED
|
@@ -6,10 +6,13 @@ by the CLI layer. Provides resolve_v2(identifier, hint_kind, graph) -> ResolveOu
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from typing import Literal
|
|
9
|
+
from typing import Any, Literal
|
|
10
10
|
|
|
11
11
|
from pydantic import BaseModel, ConfigDict, Field
|
|
12
12
|
|
|
13
|
+
from absence_types import AbsenceDiagnosis
|
|
14
|
+
from absence_diagnosis import diagnose
|
|
15
|
+
from absence_vocab import get_vocabulary_index
|
|
13
16
|
from graph_types import (
|
|
14
17
|
NodeRef,
|
|
15
18
|
StructuredHint,
|
|
@@ -28,6 +31,7 @@ __all__ = [
|
|
|
28
31
|
"ResolveCandidate",
|
|
29
32
|
"ResolveStatus",
|
|
30
33
|
"set_hints_enabled",
|
|
34
|
+
"set_absence_config",
|
|
31
35
|
]
|
|
32
36
|
|
|
33
37
|
|
|
@@ -35,6 +39,35 @@ ResolveStatus = Literal["one", "many", "none"]
|
|
|
35
39
|
|
|
36
40
|
_RESOLVE_CANDIDATE_CAP = 10
|
|
37
41
|
|
|
42
|
+
# Module-level holder for absence diagnosis config (set by server.py)
|
|
43
|
+
_absence_config: Any = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_absence_config(cfg: Any) -> None:
|
|
47
|
+
"""Set the global absence diagnosis config for resolve service.
|
|
48
|
+
|
|
49
|
+
Mirrors set_hints_enabled: called from server.py to make cfg reachable
|
|
50
|
+
in resolve functions without threading it through every signature.
|
|
51
|
+
"""
|
|
52
|
+
global _absence_config
|
|
53
|
+
_absence_config = cfg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_absence_config() -> Any:
|
|
57
|
+
"""Get the absence diagnosis config, with safe fallback.
|
|
58
|
+
|
|
59
|
+
Returns the module-level config if set; otherwise falls back to a
|
|
60
|
+
default config (for direct tool calls in tests without server init).
|
|
61
|
+
"""
|
|
62
|
+
if _absence_config is not None:
|
|
63
|
+
return _absence_config
|
|
64
|
+
|
|
65
|
+
# Fallback: resolve from cwd for direct test calls
|
|
66
|
+
from java_codebase_rag.config import resolve_operator_config
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
return resolve_operator_config(source_root=Path.cwd())
|
|
70
|
+
|
|
38
71
|
_RESOLVE_REASON_PRIORITY: dict[ResolveReason, int] = {
|
|
39
72
|
"exact_id": 0,
|
|
40
73
|
"exact_fqn": 1,
|
|
@@ -129,6 +162,7 @@ class ResolveOutput(BaseModel):
|
|
|
129
162
|
resolved_identifier: str | None = None
|
|
130
163
|
advisories: list[str] = Field(default_factory=list, description="Pure informational text with no tool call suggestion")
|
|
131
164
|
hints_structured: list[StructuredHint] = Field(default_factory=list, description=MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION)
|
|
165
|
+
absence: AbsenceDiagnosis | None = None
|
|
132
166
|
|
|
133
167
|
|
|
134
168
|
def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]:
|
|
@@ -566,6 +600,9 @@ def _resolve_finalize_success(
|
|
|
566
600
|
trimmed: str,
|
|
567
601
|
hint_kind: Literal["symbol", "route", "client", "producer"] | None,
|
|
568
602
|
matches: list[ResolveCandidate],
|
|
603
|
+
graph: LadybugGraph | None = None,
|
|
604
|
+
microservice: str = "",
|
|
605
|
+
module: str = "",
|
|
569
606
|
) -> ResolveOutput:
|
|
570
607
|
if not matches:
|
|
571
608
|
out = ResolveOutput(
|
|
@@ -591,6 +628,28 @@ def _resolve_finalize_success(
|
|
|
591
628
|
resolved_identifier=trimmed,
|
|
592
629
|
)
|
|
593
630
|
|
|
631
|
+
# Absence diagnosis for empty results (status="none")
|
|
632
|
+
cfg = _get_absence_config()
|
|
633
|
+
diag: AbsenceDiagnosis | None = None
|
|
634
|
+
if not matches:
|
|
635
|
+
g = graph
|
|
636
|
+
vocab = get_vocabulary_index(g, cfg)
|
|
637
|
+
# Map hint_kind to filter_kind (symbol -> "symbol", etc.)
|
|
638
|
+
filter_kind = hint_kind if hint_kind in ("symbol", "route", "client", "producer") else None
|
|
639
|
+
# Build scope from microservice/module params
|
|
640
|
+
scope = {"microservice": microservice or "", "module": module or ""}
|
|
641
|
+
diag = diagnose(
|
|
642
|
+
tool="resolve",
|
|
643
|
+
query=trimmed,
|
|
644
|
+
filt=None,
|
|
645
|
+
filter_kind=filter_kind,
|
|
646
|
+
root_node=None,
|
|
647
|
+
scope=scope,
|
|
648
|
+
vocab=vocab,
|
|
649
|
+
graph=g,
|
|
650
|
+
cfg=cfg,
|
|
651
|
+
)
|
|
652
|
+
|
|
594
653
|
path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed)
|
|
595
654
|
hint_payload = {
|
|
596
655
|
"status": out.status,
|
|
@@ -604,6 +663,7 @@ def _resolve_finalize_success(
|
|
|
604
663
|
out = out.model_copy(update={
|
|
605
664
|
"advisories": raw_advisories,
|
|
606
665
|
"hints_structured": _to_structured_hints(raw_struct),
|
|
666
|
+
"absence": diag,
|
|
607
667
|
})
|
|
608
668
|
_resolve_assert_invariants(out)
|
|
609
669
|
return out
|
|
@@ -660,7 +720,14 @@ def resolve_v2(
|
|
|
660
720
|
deduped = _resolve_dedupe_candidates(raw)
|
|
661
721
|
ranked = _resolve_rank_candidates(deduped)
|
|
662
722
|
capped = ranked[:_RESOLVE_CANDIDATE_CAP]
|
|
663
|
-
return _resolve_finalize_success(
|
|
723
|
+
return _resolve_finalize_success(
|
|
724
|
+
trimmed,
|
|
725
|
+
hint_kind,
|
|
726
|
+
capped,
|
|
727
|
+
graph=g,
|
|
728
|
+
microservice=microservice,
|
|
729
|
+
module=module,
|
|
730
|
+
)
|
|
664
731
|
except Exception as exc:
|
|
665
732
|
out = ResolveOutput(
|
|
666
733
|
success=False,
|