java-codebase-rag 0.9.4__py3-none-any.whl → 0.9.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. java_codebase_rag/absence/__init__.py +0 -0
  2. java_codebase_rag/absence/absence_diagnosis.py +700 -0
  3. java_codebase_rag/absence/absence_types.py +124 -0
  4. java_codebase_rag/absence/absence_vocab.py +455 -0
  5. java_codebase_rag/analysis/__init__.py +0 -0
  6. pr_analysis.py → java_codebase_rag/analysis/pr_analysis.py +1 -1
  7. resolve_service.py → java_codebase_rag/analysis/resolve_service.py +73 -6
  8. java_codebase_rag/ast/__init__.py +0 -0
  9. ast_java.py → java_codebase_rag/ast/ast_java.py +5 -5
  10. java_codebase_rag/cli.py +13 -18
  11. java_codebase_rag/config.py +116 -0
  12. java_codebase_rag/graph/__init__.py +0 -0
  13. build_ast_graph.py → java_codebase_rag/graph/build_ast_graph.py +89 -11
  14. graph_enrich.py → java_codebase_rag/graph/graph_enrich.py +248 -3
  15. graph_types.py → java_codebase_rag/graph/graph_types.py +6 -2
  16. java_ontology.py → java_codebase_rag/graph/java_ontology.py +1 -1
  17. ladybug_queries.py → java_codebase_rag/graph/ladybug_queries.py +6 -6
  18. java_codebase_rag/index/__init__.py +0 -0
  19. java_index_flow_lancedb.py → java_codebase_rag/index/java_index_flow_lancedb.py +30 -10
  20. java_codebase_rag/install_data/__init__.py +0 -0
  21. java_codebase_rag/jrag.py +71 -16
  22. java_codebase_rag/jrag_envelope.py +13 -4
  23. java_codebase_rag/jrag_hints.py +1 -1
  24. java_codebase_rag/jrag_render.py +67 -3
  25. java_codebase_rag/mcp/__init__.py +0 -0
  26. mcp_hints.py → java_codebase_rag/mcp/mcp_hints.py +1 -1
  27. mcp_v2.py → java_codebase_rag/mcp/mcp_v2.py +280 -81
  28. server.py → java_codebase_rag/mcp/server.py +138 -54
  29. java_codebase_rag/pipeline.py +26 -7
  30. java_codebase_rag/search/__init__.py +0 -0
  31. search_lancedb.py → java_codebase_rag/search/search_lancedb.py +53 -314
  32. java_codebase_rag/search/search_lexical.py +329 -0
  33. java_codebase_rag/search/search_scoring.py +338 -0
  34. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/METADATA +2 -2
  35. java_codebase_rag-0.9.6.dist-info/RECORD +57 -0
  36. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/entry_points.txt +1 -1
  37. java_codebase_rag-0.9.6.dist-info/top_level.txt +1 -0
  38. java_codebase_rag-0.9.4.dist-info/RECORD +0 -44
  39. java_codebase_rag-0.9.4.dist-info/top_level.txt +0 -19
  40. /brownfield_events.py → /java_codebase_rag/ast/brownfield_events.py +0 -0
  41. /chunk_heuristics.py → /java_codebase_rag/ast/chunk_heuristics.py +0 -0
  42. /path_filtering.py → /java_codebase_rag/graph/path_filtering.py +0 -0
  43. /java_index_v1_common.py → /java_codebase_rag/index/java_index_v1_common.py +0 -0
  44. /index_common.py → /java_codebase_rag/search/index_common.py +0 -0
  45. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/WHEEL +0 -0
  46. {java_codebase_rag-0.9.4.dist-info → java_codebase_rag-0.9.6.dist-info}/licenses/LICENSE +0 -0
@@ -31,7 +31,10 @@ 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 graph_types import (
34
+ from java_codebase_rag.absence.absence_types import AbsenceDiagnosis
35
+ from java_codebase_rag.absence.absence_diagnosis import diagnose
36
+ from java_codebase_rag.absence.absence_vocab import get_vocabulary_index
37
+ from java_codebase_rag.graph.graph_types import (
35
38
  NodeRef,
36
39
  StructuredHint,
37
40
  _hints_or_skip,
@@ -40,11 +43,11 @@ from graph_types import (
40
43
  _to_structured_hints,
41
44
  set_hints_enabled,
42
45
  )
43
- from index_common import SBERT_MODEL
46
+ from java_codebase_rag.search.index_common import SBERT_MODEL
44
47
  from java_codebase_rag.config import resolved_sbert_model_for_process_env
45
- from java_ontology import EDGE_SCHEMA
46
- from ladybug_queries import LadybugGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES
47
- from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
48
+ from java_codebase_rag.graph.java_ontology import EDGE_SCHEMA
49
+ from java_codebase_rag.graph.ladybug_queries import LadybugGraph, OVERRIDE_AXIS_COMPOSED_EDGE_TYPES
50
+ from java_codebase_rag.mcp.mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
48
51
 
49
52
  # The vector stack (lancedb/torch, reached via search_lancedb) is optional — it is absent on
50
53
  # graph-only installs (macOS Intel). Import eagerly when available so ``run_search``/``TABLES``
@@ -52,7 +55,7 @@ from mcp_hints import MCP_HINTS_STRUCTURED_FIELD_DESCRIPTION
52
55
  # fall back to sentinels on ImportError so importing this module never fails and ``search_v2``
53
56
  # can return a clean "vector search unavailable" envelope instead of crashing.
54
57
  try:
55
- from search_lancedb import TABLES, run_search
58
+ from java_codebase_rag.search.search_lancedb import TABLES, run_search
56
59
  except ImportError: # graph-only install: no torch/lancedb
57
60
  TABLES = {}
58
61
  run_search = None
@@ -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 java_codebase_rag.search.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)
@@ -1533,7 +1711,7 @@ def neighbors_v2(
1533
1711
  # RETURN anti-pattern, which errors on stricter binders (e.g. Kùzu).
1534
1712
  # Run one single-label query per type, RETURNing only that type's
1535
1713
  # columns, and merge the rows. `label(e) = $label` scalar equality
1536
- # (not `label(e) IN [...]`) per the AGENTS.md Cypher note.
1714
+ # (not `label(e) IN [...]`) per the CLAUDE.md Cypher note.
1537
1715
  rows: list[dict[str, Any]] = []
1538
1716
  match_clause = "MATCH (a)-[e]->(b)" if direction == "out" else "MATCH (a)<-[e]-(b)"
1539
1717
  for label in flat_labels:
@@ -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
@@ -1651,7 +1850,7 @@ def neighbors_v2(
1651
1850
 
1652
1851
 
1653
1852
  # Re-export resolve symbols from resolve_service.py (imported here to avoid circular import)
1654
- from resolve_service import ( # noqa: E402
1853
+ from java_codebase_rag.analysis.resolve_service import ( # noqa: E402
1655
1854
  ResolveCandidate,
1656
1855
  ResolveOutput,
1657
1856
  ResolveStatus,