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.
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
- try:
889
- rows = run_search(
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
- uri=uri,
892
- table_keys=table_keys,
893
- hybrid=hybrid,
950
+ table=table,
894
951
  limit=limit,
895
952
  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,
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
- 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
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=False, # Fallback to vector-only
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
- 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
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
- return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'")
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
- return DescribeOutput(success=False, message=f"No node found for `{node_id}`", advisories=[])
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(trimmed, hint_kind, capped)
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,