pretensor 0.1.0a0__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 (138) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/cli/__init__.py +1 -0
  3. pretensor/cli/commands/connections/__init__.py +7 -0
  4. pretensor/cli/commands/connections/add_remove.py +122 -0
  5. pretensor/cli/commands/connections/register.py +12 -0
  6. pretensor/cli/commands/export.py +120 -0
  7. pretensor/cli/commands/index.py +551 -0
  8. pretensor/cli/commands/list.py +65 -0
  9. pretensor/cli/commands/quickstart.py +171 -0
  10. pretensor/cli/commands/reindex.py +581 -0
  11. pretensor/cli/commands/semantic.py +190 -0
  12. pretensor/cli/commands/serve.py +129 -0
  13. pretensor/cli/commands/sync_grants.py +149 -0
  14. pretensor/cli/commands/validate.py +156 -0
  15. pretensor/cli/config_file.py +412 -0
  16. pretensor/cli/constants.py +10 -0
  17. pretensor/cli/dbt_enrichment.py +96 -0
  18. pretensor/cli/main.py +81 -0
  19. pretensor/cli/paths.py +43 -0
  20. pretensor/cli/plugin.py +52 -0
  21. pretensor/config.py +145 -0
  22. pretensor/connectors/SKILL.md +16 -0
  23. pretensor/connectors/__init__.py +29 -0
  24. pretensor/connectors/base.py +165 -0
  25. pretensor/connectors/bigquery.py +463 -0
  26. pretensor/connectors/inspect.py +321 -0
  27. pretensor/connectors/lineage_sqlglot.py +91 -0
  28. pretensor/connectors/models.py +130 -0
  29. pretensor/connectors/pg_array_parse.py +53 -0
  30. pretensor/connectors/postgres.py +921 -0
  31. pretensor/connectors/registry.py +51 -0
  32. pretensor/connectors/snapshot.py +244 -0
  33. pretensor/connectors/snowflake.py +840 -0
  34. pretensor/core/__init__.py +1 -0
  35. pretensor/core/builder.py +268 -0
  36. pretensor/core/dsn_crypto.py +40 -0
  37. pretensor/core/ids.py +68 -0
  38. pretensor/core/portable_export.py +253 -0
  39. pretensor/core/registry.py +204 -0
  40. pretensor/core/schema.py +368 -0
  41. pretensor/core/store.py +1343 -0
  42. pretensor/enrichment/__init__.py +1 -0
  43. pretensor/enrichment/dbt/__init__.py +30 -0
  44. pretensor/enrichment/dbt/lineage.py +100 -0
  45. pretensor/enrichment/dbt/manifest.py +298 -0
  46. pretensor/enrichment/dbt/metadata.py +263 -0
  47. pretensor/enrichment/dbt/pipeline.py +77 -0
  48. pretensor/enrichment/dbt/resolution.py +101 -0
  49. pretensor/enrichment/dbt/signals.py +305 -0
  50. pretensor/entities/__init__.py +27 -0
  51. pretensor/entities/builder.py +63 -0
  52. pretensor/entities/classifier.py +359 -0
  53. pretensor/entities/llm_extract.py +66 -0
  54. pretensor/graph_models/__init__.py +14 -0
  55. pretensor/graph_models/base.py +11 -0
  56. pretensor/graph_models/edge.py +35 -0
  57. pretensor/graph_models/entity.py +21 -0
  58. pretensor/graph_models/node.py +75 -0
  59. pretensor/graph_models/relationship.py +27 -0
  60. pretensor/intelligence/__init__.py +33 -0
  61. pretensor/intelligence/cluster_labeler.py +335 -0
  62. pretensor/intelligence/clustering.py +162 -0
  63. pretensor/intelligence/combining.py +32 -0
  64. pretensor/intelligence/discovery.py +114 -0
  65. pretensor/intelligence/embeddings.py +198 -0
  66. pretensor/intelligence/graph_export.py +139 -0
  67. pretensor/intelligence/heuristic.py +540 -0
  68. pretensor/intelligence/join_paths/__init__.py +130 -0
  69. pretensor/intelligence/join_paths/on_demand.py +516 -0
  70. pretensor/intelligence/join_paths/storage.py +70 -0
  71. pretensor/intelligence/llm_infer.py +78 -0
  72. pretensor/intelligence/llm_runtime.py +60 -0
  73. pretensor/intelligence/metric_templates.py +193 -0
  74. pretensor/intelligence/pipeline.py +254 -0
  75. pretensor/intelligence/schema_classification.py +306 -0
  76. pretensor/intelligence/scoring.py +66 -0
  77. pretensor/intelligence/shadow_alias.py +101 -0
  78. pretensor/intelligence/statistical.py +50 -0
  79. pretensor/intelligence/steps.py +161 -0
  80. pretensor/introspection/__init__.py +6 -0
  81. pretensor/introspection/inspector.py +5 -0
  82. pretensor/introspection/models/__init__.py +0 -0
  83. pretensor/introspection/models/base.py +5 -0
  84. pretensor/introspection/models/config.py +236 -0
  85. pretensor/introspection/models/dsn.py +442 -0
  86. pretensor/introspection/models/plan.py +116 -0
  87. pretensor/introspection/models/schema.py +10 -0
  88. pretensor/introspection/models/semantic.py +121 -0
  89. pretensor/introspection/models/validation.py +116 -0
  90. pretensor/introspection/snapshot.py +46 -0
  91. pretensor/mcp/__init__.py +16 -0
  92. pretensor/mcp/config_json.py +24 -0
  93. pretensor/mcp/payload_types.py +248 -0
  94. pretensor/mcp/resources/__init__.py +19 -0
  95. pretensor/mcp/resources/markdown.py +420 -0
  96. pretensor/mcp/server.py +723 -0
  97. pretensor/mcp/service.py +47 -0
  98. pretensor/mcp/service_context.py +126 -0
  99. pretensor/mcp/service_registry.py +242 -0
  100. pretensor/mcp/tool_registry.py +126 -0
  101. pretensor/mcp/tools/__init__.py +1 -0
  102. pretensor/mcp/tools/compile_metric.py +87 -0
  103. pretensor/mcp/tools/context.py +894 -0
  104. pretensor/mcp/tools/cypher.py +333 -0
  105. pretensor/mcp/tools/detect_changes.py +226 -0
  106. pretensor/mcp/tools/impact.py +165 -0
  107. pretensor/mcp/tools/list.py +110 -0
  108. pretensor/mcp/tools/schema.py +128 -0
  109. pretensor/mcp/tools/search.py +108 -0
  110. pretensor/mcp/tools/traverse.py +753 -0
  111. pretensor/mcp/tools/validate_sql.py +74 -0
  112. pretensor/observability.py +203 -0
  113. pretensor/search/__init__.py +6 -0
  114. pretensor/search/base.py +80 -0
  115. pretensor/search/index.py +435 -0
  116. pretensor/semantic/__init__.py +24 -0
  117. pretensor/semantic/base.py +123 -0
  118. pretensor/semantic/compiler.py +346 -0
  119. pretensor/semantic/yaml_layer.py +182 -0
  120. pretensor/skills/__init__.py +5 -0
  121. pretensor/skills/generator.py +235 -0
  122. pretensor/staleness/__init__.py +15 -0
  123. pretensor/staleness/graph_patcher.py +355 -0
  124. pretensor/staleness/impact_analyzer.py +162 -0
  125. pretensor/staleness/snapshot_store.py +38 -0
  126. pretensor/validation/__init__.py +9 -0
  127. pretensor/validation/query_validator.py +436 -0
  128. pretensor/visibility/__init__.py +23 -0
  129. pretensor/visibility/config.py +126 -0
  130. pretensor/visibility/filter.py +143 -0
  131. pretensor/visibility/kuzu_helpers.py +32 -0
  132. pretensor/visibility/runtime.py +36 -0
  133. pretensor/visibility/sync_grants.py +188 -0
  134. pretensor-0.1.0a0.dist-info/METADATA +175 -0
  135. pretensor-0.1.0a0.dist-info/RECORD +138 -0
  136. pretensor-0.1.0a0.dist-info/WHEEL +4 -0
  137. pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
  138. pretensor-0.1.0a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,165 @@
1
+ """MCP ``impact`` tool payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import deque
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
11
+ from pretensor.visibility.filter import VisibilityFilter
12
+
13
+ from ..payload_types import ImpactItemPayload
14
+ from ..service_context import get_effective_visibility_filter
15
+ from ..service_registry import (
16
+ graph_path_for_entry,
17
+ load_registry,
18
+ open_store_for_entry,
19
+ resolve_registry_entry,
20
+ )
21
+ from .context import qualified_from_node_id, resolve_table_node_id
22
+
23
+
24
+ def impact_payload(
25
+ graph_dir: Path,
26
+ *,
27
+ table: str,
28
+ database: str,
29
+ column: str | None = None,
30
+ max_depth: int = 3,
31
+ visibility_filter: VisibilityFilter | None = None,
32
+ ) -> dict[str, Any]:
33
+ """Downstream tables grouped by hop depth (FK + inferred edges)."""
34
+ reg = load_registry(graph_dir)
35
+ entry = resolve_registry_entry(reg, database)
36
+ if entry is None:
37
+ return {
38
+ "error": "Unknown database connection or name; pass `database`.",
39
+ }
40
+ gp = graph_path_for_entry(entry)
41
+ if not gp.exists():
42
+ return {"error": f"Graph file missing: {gp}"}
43
+
44
+ db_key = str(entry.database)
45
+ vf = visibility_filter or get_effective_visibility_filter()
46
+ cn = str(entry.connection_name)
47
+ store = open_store_for_entry(entry)
48
+ try:
49
+ start_id, err = resolve_table_node_id(
50
+ store, table, db_key, visibility_filter=vf
51
+ )
52
+ if err is not None:
53
+ try:
54
+ return json.loads(err)
55
+ except json.JSONDecodeError:
56
+ return {"error": err}
57
+ if start_id is None:
58
+ return {"error": "Could not resolve starting table; check the table name and database."}
59
+
60
+ fk_rows = store.query_all_rows(
61
+ """
62
+ MATCH (a:SchemaTable)-[r:FK_REFERENCES]->(b:SchemaTable)
63
+ WHERE a.database = $db AND b.database = $db
64
+ RETURN a.node_id, b.node_id, r.source_column, r.target_column, 1.0 AS conf
65
+ """,
66
+ {"db": db_key},
67
+ )
68
+ inf_rows = store.query_all_rows(
69
+ """
70
+ MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
71
+ WHERE a.database = $db AND b.database = $db
72
+ RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.confidence
73
+ """,
74
+ {"db": db_key},
75
+ )
76
+ shadow_ids = get_shadow_alias_node_ids(store, db_key)
77
+
78
+ rev: dict[str, list[tuple[str, str, str, float]]] = {}
79
+ for row in fk_rows + inf_rows:
80
+ child, parent, sc, tc, conf = row
81
+ if child is None or parent is None:
82
+ continue
83
+ sa, sb = str(child), str(parent)
84
+ if sa in shadow_ids or sb in shadow_ids:
85
+ continue
86
+ if vf is not None:
87
+ cmeta = store.query_all_rows(
88
+ """
89
+ MATCH (t:SchemaTable {node_id: $id})
90
+ RETURN t.schema_name, t.table_name
91
+ """,
92
+ {"id": sa},
93
+ )
94
+ if cmeta:
95
+ sn, tn = cmeta[0]
96
+ if not vf.is_table_visible(cn, str(sn or ""), str(tn or "")):
97
+ continue
98
+ w = float(conf) if conf is not None else 1.0
99
+ rev.setdefault(sb, []).append((sa, str(sc or ""), str(tc or ""), w))
100
+
101
+ q: deque[tuple[str, int, float, str, str]] = deque()
102
+ for dependent, scol, _tcol, w in rev.get(start_id, []):
103
+ if column is not None and scol != column:
104
+ continue
105
+ dep_short = qualified_from_node_id(store, dependent).split(".")[-1]
106
+ via = f"{dep_short}.{scol}"
107
+ q.append((dependent, 1, w, via, start_id))
108
+
109
+ seen: dict[str, tuple[int, float, str]] = {}
110
+ while q:
111
+ cur, depth, path_min, via, _parent = q.popleft()
112
+ if cur in seen:
113
+ continue
114
+ seen[cur] = (depth, path_min, via)
115
+ if depth >= max_depth:
116
+ continue
117
+ for dependent, scol, _tcol, w in rev.get(cur, []):
118
+ if dependent in seen:
119
+ continue
120
+ new_d = depth + 1
121
+ new_min = min(path_min, w)
122
+ dep_short = qualified_from_node_id(store, dependent).split(".")[-1]
123
+ new_via = f"{via} → {dep_short}.{scol}"
124
+ q.append((dependent, new_d, new_min, new_via, cur))
125
+
126
+ direct: list[ImpactItemPayload] = []
127
+ two_hop: list[ImpactItemPayload] = []
128
+ three_hop: list[ImpactItemPayload] = []
129
+ low_confidence: list[ImpactItemPayload] = []
130
+ for node_id, (depth, conf, via) in seen.items():
131
+ name = qualified_from_node_id(store, node_id)
132
+ item: ImpactItemPayload = {
133
+ "type": "table",
134
+ "name": name,
135
+ "via": via,
136
+ "confidence": conf,
137
+ }
138
+ if conf < 0.5:
139
+ item["hop"] = depth
140
+ low_confidence.append(item)
141
+ continue
142
+ if depth == 1:
143
+ direct.append(item)
144
+ elif depth == 2:
145
+ two_hop.append(item)
146
+ elif depth == 3:
147
+ three_hop.append(item)
148
+
149
+ total = len(seen)
150
+ return {
151
+ "target": {"table": table, "column": column},
152
+ "database": database,
153
+ "impact": {
154
+ "direct": direct,
155
+ "two_hop": two_hop,
156
+ "three_hop": three_hop,
157
+ "low_confidence": low_confidence,
158
+ },
159
+ "total_affected": total,
160
+ }
161
+ finally:
162
+ store.close()
163
+
164
+
165
+ __all__ = ["impact_payload"]
@@ -0,0 +1,110 @@
1
+ """MCP ``list_databases`` tool payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pretensor.config import GraphConfig
9
+ from pretensor.visibility.filter import VisibilityFilter
10
+
11
+ from ..payload_types import (
12
+ DIALECT_TO_DB_TYPE,
13
+ DatabaseListItem,
14
+ iso_format,
15
+ stale_threshold_days,
16
+ staleness_days,
17
+ utc_now,
18
+ )
19
+ from ..service_context import (
20
+ get_effective_graph_config,
21
+ get_effective_visibility_filter,
22
+ )
23
+ from ..service_registry import (
24
+ counts_for_graph,
25
+ graph_path_for_entry,
26
+ load_registry,
27
+ open_store_for_entry,
28
+ )
29
+
30
+
31
+ def list_databases_payload(
32
+ graph_dir: Path,
33
+ *,
34
+ config: GraphConfig | None = None,
35
+ visibility_filter: VisibilityFilter | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Build JSON-serializable payload for ``list_databases``."""
38
+ cfg = get_effective_graph_config(config)
39
+ vf = visibility_filter or get_effective_visibility_filter()
40
+ reg = load_registry(graph_dir)
41
+ items: list[DatabaseListItem] = []
42
+ now = utc_now()
43
+ threshold = stale_threshold_days(cfg)
44
+ for entry in reg.list_entries():
45
+ table_count = 0
46
+ column_count = 0
47
+ row_sum = 0
48
+ schemas: list[str] = []
49
+ has_dbt_manifest: str = "not_attempted"
50
+ has_llm_enrichment: str = "not_attempted"
51
+ has_external_consumers: str = "not_attempted"
52
+ gp = graph_path_for_entry(entry)
53
+ if gp.exists():
54
+ store = open_store_for_entry(entry)
55
+ try:
56
+ counts = counts_for_graph(
57
+ store,
58
+ connection_name=entry.connection_name,
59
+ visibility_filter=vf,
60
+ entry=entry,
61
+ )
62
+ table_count = counts.table_count
63
+ column_count = counts.column_count
64
+ row_sum = counts.row_count
65
+ schemas = counts.schemas
66
+ has_dbt_manifest = counts.has_dbt_manifest
67
+ has_llm_enrichment = counts.has_llm_enrichment
68
+ has_external_consumers = counts.has_external_consumers
69
+ finally:
70
+ store.close()
71
+ days = staleness_days(entry.last_indexed_at, now)
72
+ stale = days > threshold
73
+ capabilities = ["schema", "fk_edges", "inferred_joins", "clustering"]
74
+ if has_dbt_manifest == "present":
75
+ capabilities.append("dbt_lineage")
76
+ capabilities.append("dbt_metrics")
77
+ if has_llm_enrichment == "present":
78
+ capabilities.append("llm_enrichment")
79
+ if has_external_consumers == "present":
80
+ capabilities.append("external_consumers")
81
+ row: DatabaseListItem = {
82
+ "name": entry.connection_name,
83
+ "db_type": DIALECT_TO_DB_TYPE.get(entry.dialect, "postgresql"),
84
+ "table_count": table_count,
85
+ "column_count": column_count,
86
+ "row_count": row_sum,
87
+ "schemas": schemas,
88
+ "has_dbt_manifest": has_dbt_manifest,
89
+ "has_llm_enrichment": has_llm_enrichment,
90
+ "has_external_consumers": has_external_consumers,
91
+ "capabilities": capabilities,
92
+ "last_indexed": iso_format(entry.last_indexed_at),
93
+ "is_stale": stale,
94
+ "staleness_days": days,
95
+ "graph_path": str(graph_path_for_entry(entry)),
96
+ }
97
+ # Only emit ``database`` when it differs from ``name`` — otherwise the
98
+ # duplicate field is noise (e.g. ``{"name": "pagila", "database": "pagila"}``).
99
+ if entry.database and entry.database != entry.connection_name:
100
+ row["database"] = entry.database
101
+ if stale:
102
+ row["stale_warning"] = (
103
+ f"Graph index is {days} days old (threshold {threshold} days). "
104
+ "Consider `pretensor reindex <dsn>` or `pretensor index`."
105
+ )
106
+ items.append(row)
107
+ return {"databases": items}
108
+
109
+
110
+ __all__ = ["list_databases_payload"]
@@ -0,0 +1,128 @@
1
+ """MCP ``schema`` tool: introspect node labels and edge types in the Kuzu graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pretensor.core.schema import CATALOG_EDGE_TYPES, CATALOG_NODE_LABELS
10
+ from pretensor.core.store import KuzuStore
11
+
12
+ from ..service_registry import (
13
+ graph_path_for_entry,
14
+ load_registry,
15
+ resolve_registry_entry,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ __all__ = ["schema_payload"]
21
+
22
+ _NODE_LABEL_DESC = {label: desc for label, desc in CATALOG_NODE_LABELS}
23
+ _EDGE_INFO = {name: (src, dst, desc) for name, src, dst, desc in CATALOG_EDGE_TYPES}
24
+
25
+
26
+ def _list_catalog_tables(store: KuzuStore) -> list[tuple[str, str]]:
27
+ """Return ``[(name, type), ...]`` from ``CALL show_tables()``.
28
+
29
+ ``type`` is ``'NODE'`` or ``'REL'`` per Kuzu.
30
+ """
31
+ raw = store.execute("CALL show_tables() RETURN name, type")
32
+ if isinstance(raw, list):
33
+ return []
34
+ rows = raw.rows_as_dict()
35
+ out: list[tuple[str, str]] = []
36
+ while rows.has_next():
37
+ row: Any = rows.get_next()
38
+ if isinstance(row, dict):
39
+ out.append((str(row.get("name", "")), str(row.get("type", "")).upper()))
40
+ return out
41
+
42
+
43
+ def _table_properties(store: KuzuStore, label: str) -> list[dict[str, str]]:
44
+ """Return ``[{"name": ..., "type": ...}, ...]`` for one node/rel table."""
45
+ try:
46
+ raw = store.execute(f"CALL table_info('{label}') RETURN name, type")
47
+ except Exception:
48
+ return []
49
+ if isinstance(raw, list):
50
+ return []
51
+ rows = raw.rows_as_dict()
52
+ out: list[dict[str, str]] = []
53
+ while rows.has_next():
54
+ row: Any = rows.get_next()
55
+ if isinstance(row, dict):
56
+ out.append(
57
+ {
58
+ "name": str(row.get("name", "")),
59
+ "type": str(row.get("type", "")),
60
+ }
61
+ )
62
+ return out
63
+
64
+
65
+ def schema_payload(
66
+ graph_dir: Path,
67
+ *,
68
+ database: str,
69
+ label: str | None = None,
70
+ ) -> dict[str, Any]:
71
+ """Return the node and edge catalog for the indexed graph of ``database``.
72
+
73
+ Args:
74
+ graph_dir: Graph workspace directory (registry + Kuzu files).
75
+ database: Connection name or logical database name.
76
+ label: If set, return only the matching node label or edge type.
77
+
78
+ Returns:
79
+ ``{"nodes": [...], "edges": [...]}`` on success, or ``{"error": "..."}``.
80
+ """
81
+ db = (database or "").strip()
82
+ if not db:
83
+ return {"error": "Missing `database`"}
84
+
85
+ reg = load_registry(graph_dir)
86
+ entry = resolve_registry_entry(reg, db)
87
+ if entry is None:
88
+ return {"error": f"Unknown database: {db!r}"}
89
+
90
+ graph_path = graph_path_for_entry(entry)
91
+ if not graph_path.exists():
92
+ return {"error": f"Graph file not found for database: {db!r}"}
93
+
94
+ store = KuzuStore(graph_path)
95
+ try:
96
+ store.ensure_schema()
97
+ catalog = _list_catalog_tables(store)
98
+ nodes: list[dict[str, Any]] = []
99
+ edges: list[dict[str, Any]] = []
100
+ for name, kind in catalog:
101
+ if label and name != label:
102
+ continue
103
+ properties = _table_properties(store, name)
104
+ if kind == "NODE":
105
+ nodes.append(
106
+ {
107
+ "label": name,
108
+ "description": _NODE_LABEL_DESC.get(name, ""),
109
+ "properties": properties,
110
+ }
111
+ )
112
+ elif kind == "REL":
113
+ src, dst, desc = _EDGE_INFO.get(name, ("", "", ""))
114
+ edges.append(
115
+ {
116
+ "type": name,
117
+ "from": src,
118
+ "to": dst,
119
+ "description": desc,
120
+ "properties": properties,
121
+ }
122
+ )
123
+ finally:
124
+ store.close()
125
+
126
+ if label and not nodes and not edges:
127
+ return {"error": f"Unknown label: {label!r}"}
128
+ return {"nodes": nodes, "edges": edges}
@@ -0,0 +1,108 @@
1
+ """MCP ``query`` tool (BM25 search) payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ from pretensor.observability import log_timed_operation
10
+ from pretensor.search.base import BaseSearchIndex
11
+ from pretensor.search.index import KeywordSearchIndex
12
+ from pretensor.visibility.filter import VisibilityFilter
13
+
14
+ from ..payload_types import QueryHit, snippet
15
+ from ..service_context import (
16
+ get_effective_search_index_cls,
17
+ get_effective_visibility_filter,
18
+ )
19
+ from ..service_registry import load_registry
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _hit_visible(hit: QueryHit, vf: VisibilityFilter | None) -> bool:
25
+ if vf is None or hit.get("node_type") != "SchemaTable":
26
+ return True
27
+ name = str(hit.get("name", "")).strip()
28
+ if "." not in name:
29
+ return vf.is_table_visible(str(hit.get("connection_name", "")), "", name)
30
+ sn, _, tn = name.partition(".")
31
+ return vf.is_table_visible(str(hit.get("connection_name", "")), sn, tn)
32
+
33
+
34
+ def _load_search_index(
35
+ graph_dir: Path,
36
+ *,
37
+ search_index_cls: type[BaseSearchIndex],
38
+ ) -> BaseSearchIndex:
39
+ """Load/build a search index using class-level hooks when available."""
40
+ reg = load_registry(graph_dir)
41
+ search_index_cls_any = search_index_cls
42
+ default_path = getattr(search_index_cls_any, "default_path", None)
43
+ index_path = (
44
+ Path(cast(Path, default_path(graph_dir)))
45
+ if callable(default_path)
46
+ else KeywordSearchIndex.default_path(graph_dir)
47
+ )
48
+ load_or_build = getattr(search_index_cls_any, "load_or_build", None)
49
+ if callable(load_or_build):
50
+ built = load_or_build(reg, index_path)
51
+ if not isinstance(built, BaseSearchIndex):
52
+ raise TypeError(
53
+ f"{search_index_cls.__name__}.load_or_build must return BaseSearchIndex"
54
+ )
55
+ return built
56
+ index = cast(Any, search_index_cls_any)(index_path)
57
+ if not isinstance(index, BaseSearchIndex):
58
+ raise TypeError(
59
+ f"{search_index_cls.__name__} constructor must return BaseSearchIndex"
60
+ )
61
+ index.index_graph(reg)
62
+ return index
63
+
64
+
65
+ def query_payload(
66
+ graph_dir: Path,
67
+ *,
68
+ q: str,
69
+ db: str | None = None,
70
+ limit: int = 10,
71
+ search_index_cls: type[BaseSearchIndex] | None = None,
72
+ visibility_filter: VisibilityFilter | None = None,
73
+ ) -> dict[str, Any]:
74
+ """BM25 search over indexed metadata."""
75
+ with log_timed_operation(
76
+ logger,
77
+ event="mcp.query_payload",
78
+ query=q,
79
+ db=db,
80
+ limit=limit,
81
+ graph_dir=str(graph_dir),
82
+ ):
83
+ vf = visibility_filter or get_effective_visibility_filter()
84
+ index_cls = get_effective_search_index_cls(search_index_cls)
85
+ idx = _load_search_index(graph_dir, search_index_cls=index_cls)
86
+ raw = idx.search(q, db=db, limit=limit * 4)
87
+ hits: list[QueryHit] = []
88
+ for row in raw:
89
+ if row.node_type != "SchemaTable":
90
+ continue
91
+ item: QueryHit = {
92
+ "node_type": row.node_type,
93
+ "name": row.name,
94
+ "database_name": row.database_name,
95
+ "connection_name": row.connection_name,
96
+ "description": row.description,
97
+ "snippet": snippet(row.description),
98
+ "score": row.score,
99
+ }
100
+ if not _hit_visible(item, vf):
101
+ continue
102
+ hits.append(item)
103
+ if len(hits) >= limit:
104
+ break
105
+ return {"query": q, "db": db, "results": hits}
106
+
107
+
108
+ __all__ = ["query_payload"]