pretensor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,244 @@
1
+ """Shared ranking helpers for the ``query`` (hybrid) and ``semantic_search`` tools.
2
+
3
+ Module-private to the ``pretensor.mcp.tools`` package: centralizes the
4
+ ``KuzuStore.iter_table_embeddings`` + ``cosine_similarity`` scan
5
+ so the hybrid BM25+cosine fusion path in ``query`` and the
6
+ cosine-only ``semantic_search`` tool share one implementation.
7
+
8
+ Also hosts the Reciprocal Rank Fusion helper and its ``RRF_K`` constant.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass
16
+
17
+ from pretensor.core.registry import RegistryEntry
18
+ from pretensor.core.store import TableEmbeddingRow
19
+ from pretensor.intelligence.embeddings import cosine_similarity
20
+ from pretensor.visibility.filter import VisibilityFilter
21
+
22
+ from ..service_registry import open_store_for_entry, release_store
23
+
24
+ __all__ = [
25
+ "RRF_K",
26
+ "CosineHit",
27
+ "cosine_rank_tables",
28
+ "fusion_key",
29
+ "rrf_fuse",
30
+ "score_embedding_rows",
31
+ ]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Reciprocal Rank Fusion constant (Cormack, Clarke & Büttcher, 2009).
36
+ # 60 is the value from the canonical paper; kept as a named constant so a
37
+ # future tweak (e.g. after an L2 benchmark regression) is a one-line change.
38
+ RRF_K: int = 60
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class CosineHit:
43
+ """One cosine-ranked ``SchemaTable`` hit with its fusion key pre-computed."""
44
+
45
+ key: str
46
+ node_id: str
47
+ connection_name: str
48
+ database: str
49
+ schema_name: str
50
+ table_name: str
51
+ description: str
52
+ score: float
53
+ cluster_id: str | None
54
+
55
+
56
+ def fusion_key(
57
+ *,
58
+ connection_name: str,
59
+ database: str,
60
+ schema_name: str,
61
+ table_name: str,
62
+ ) -> str:
63
+ """Stable key shared by BM25 and cosine rankings for RRF fusion.
64
+
65
+ The pipe delimiter cannot collide with legal identifier characters, so
66
+ ``"a|b|c.d"`` maps 1:1 to ``(a, b, c, d)``.
67
+ """
68
+ return f"{connection_name}|{database}|{schema_name}.{table_name}"
69
+
70
+
71
+ def score_embedding_rows(
72
+ rows: Iterable[TableEmbeddingRow],
73
+ qvec: list[float],
74
+ *,
75
+ visibility_filter: VisibilityFilter | None,
76
+ ) -> tuple[list[CosineHit], bool]:
77
+ """Score a stream of ``TableEmbeddingRow`` against ``qvec``.
78
+
79
+ Shared inner loop used by :func:`cosine_rank_tables` (which chains rows
80
+ from freshly-opened stores) and by ``context.similar_tables_for_table``
81
+ (which feeds rows from an already-open ``KuzuStore``). Rows without an
82
+ embedding are skipped; rows filtered by ``visibility_filter`` are
83
+ dropped **after** flipping ``any_candidate_had_vector`` so callers can
84
+ distinguish "no tables carry vectors" from "hidden by visibility".
85
+
86
+ Returns ``(scored, any_candidate_had_vector)`` **unsorted and
87
+ un-truncated** — callers sort + slice to taste.
88
+ """
89
+ # ``KuzuStore.iter_table_embeddings(cluster=None)`` joins through
90
+ # ``IN_CLUSTER`` via OPTIONAL MATCH, which yields one row per cluster
91
+ # a table belongs to. A table in N clusters would therefore show up
92
+ # N times here, double-counting in downstream RRF fusion and clobbering
93
+ # the cosine_by_key dict in ``_maybe_fuse``. Dedup by node_id, keeping
94
+ # the first occurrence — the embedding (and therefore the cosine
95
+ # score) is identical across the duplicate rows; only ``cluster_id``
96
+ # varies and we already preserve the first cluster encountered.
97
+ scored: list[CosineHit] = []
98
+ seen_nids: set[str] = set()
99
+ any_candidate_had_vector = False
100
+
101
+ for row in rows:
102
+ if row.embedding is None:
103
+ continue
104
+ if row.node_id in seen_nids:
105
+ continue
106
+ # Mark the node as seen regardless of whether we end up emitting
107
+ # a hit for it. A subsequent (table, cluster) row for the same
108
+ # ``node_id`` would otherwise bypass the dedup — Kuzu's OPTIONAL
109
+ # MATCH yields one row per cluster the table is in, and we want
110
+ # exactly one decision per table here.
111
+ seen_nids.add(row.node_id)
112
+ any_candidate_had_vector = True
113
+ if visibility_filter is not None and not visibility_filter.is_table_visible(
114
+ row.connection_name, row.schema_name, row.table_name
115
+ ):
116
+ continue
117
+ try:
118
+ score = cosine_similarity(qvec, row.embedding)
119
+ except ValueError as exc:
120
+ logger.warning(
121
+ "score_embedding_rows: dim mismatch for %s: %s",
122
+ row.node_id,
123
+ exc,
124
+ )
125
+ continue
126
+ scored.append(
127
+ CosineHit(
128
+ key=fusion_key(
129
+ connection_name=row.connection_name,
130
+ database=row.database,
131
+ schema_name=row.schema_name,
132
+ table_name=row.table_name,
133
+ ),
134
+ node_id=row.node_id,
135
+ connection_name=row.connection_name,
136
+ database=row.database,
137
+ schema_name=row.schema_name,
138
+ table_name=row.table_name,
139
+ description=row.description,
140
+ score=score,
141
+ cluster_id=row.cluster_id,
142
+ )
143
+ )
144
+
145
+ return scored, any_candidate_had_vector
146
+
147
+
148
+ def any_entry_has_vectors(entries: Iterable[RegistryEntry]) -> bool:
149
+ """Cheap LIMIT-1 probe: does any entry's store carry a table vector?
150
+
151
+ Callers run this BEFORE embedding the query text so a store with no
152
+ vectors never pays the embedding-client cost — on a cold process the
153
+ first ``embed()`` call triggers the ONNX model download + session
154
+ init, which must not happen when the result would be discarded by the
155
+ "no tables carry vectors" fallback anyway.
156
+ """
157
+ for entry in entries:
158
+ try:
159
+ store = open_store_for_entry(entry)
160
+ except RuntimeError as exc:
161
+ logger.warning(
162
+ "any_entry_has_vectors: skipping %s (%s)",
163
+ entry.connection_name,
164
+ exc,
165
+ )
166
+ continue
167
+ try:
168
+ if store.has_any_table_embeddings():
169
+ return True
170
+ finally:
171
+ release_store(store)
172
+ return False
173
+
174
+
175
+ def cosine_rank_tables(
176
+ entries: Iterable[RegistryEntry],
177
+ qvec: list[float],
178
+ *,
179
+ database: str | None,
180
+ cluster: str | None,
181
+ visibility_filter: VisibilityFilter | None,
182
+ k: int,
183
+ ) -> tuple[list[CosineHit], bool]:
184
+ """Top-``k`` ``SchemaTable`` rows by cosine similarity to ``qvec``.
185
+
186
+ Iterates ``KuzuStore.iter_table_embeddings`` for each registry entry,
187
+ applies the ``visibility_filter``, scores rows whose ``embedding`` is
188
+ present, sorts descending by score, and truncates to ``k``.
189
+
190
+ Returns ``(hits, any_candidate_had_vector)``. The boolean preserves the
191
+ "no tables carry vectors" fallback signal without a second store scan —
192
+ callers in ``query`` and ``semantic_search`` use it to route to the
193
+ documented fallback envelope instead of returning an empty result set
194
+ that looks indistinguishable from "scored but nothing matched".
195
+
196
+ Entry selection (including the "unknown database" error path) is left to
197
+ callers since ``semantic_search`` and ``query`` want different behavior
198
+ there (error envelope vs. silent empty fusion).
199
+ """
200
+ scored: list[CosineHit] = []
201
+ any_candidate_had_vector = False
202
+
203
+ for entry in entries:
204
+ try:
205
+ store = open_store_for_entry(entry)
206
+ except RuntimeError as exc:
207
+ logger.warning(
208
+ "cosine_rank_tables: skipping %s (%s)", entry.connection_name, exc
209
+ )
210
+ continue
211
+ try:
212
+ entry_scored, entry_had_vector = score_embedding_rows(
213
+ store.iter_table_embeddings(database=database, cluster=cluster),
214
+ qvec,
215
+ visibility_filter=visibility_filter,
216
+ )
217
+ finally:
218
+ release_store(store)
219
+ scored.extend(entry_scored)
220
+ any_candidate_had_vector = any_candidate_had_vector or entry_had_vector
221
+
222
+ scored.sort(key=lambda h: h.score, reverse=True)
223
+ return scored[:k], any_candidate_had_vector
224
+
225
+
226
+ def rrf_fuse(
227
+ *,
228
+ bm25_keys: list[str],
229
+ cosine_keys: list[str],
230
+ k_constant: int = RRF_K,
231
+ ) -> list[tuple[str, float]]:
232
+ """Reciprocal Rank Fusion of two ranked key lists.
233
+
234
+ Input lists are ordered best-first (rank 1 = index 0). Returns a list of
235
+ ``(key, fused_score)`` ordered by fused score descending. A key appearing
236
+ in only one list contributes only that list's term — missing means rank
237
+ infinity, i.e. zero contribution.
238
+ """
239
+ scores: dict[str, float] = {}
240
+ for rank, key in enumerate(bm25_keys, start=1):
241
+ scores[key] = scores.get(key, 0.0) + 1.0 / (k_constant + rank)
242
+ for rank, key in enumerate(cosine_keys, start=1):
243
+ scores[key] = scores.get(key, 0.0) + 1.0 / (k_constant + rank)
244
+ return sorted(scores.items(), key=lambda item: item[1], reverse=True)
@@ -0,0 +1,26 @@
1
+ """Shared timing context manager for MCP tool handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import AbstractContextManager
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from pretensor.observability import log_timed_operation
11
+
12
+
13
+ def timed_tool(
14
+ tool_name: str, graph_dir: Path, **fields: Any
15
+ ) -> AbstractContextManager[None]:
16
+ # Logger name is intentionally pinned to "pretensor.mcp.server" (not
17
+ # __name__) so these handler-timing records keep emitting under the same
18
+ # logger they used when the handlers lived in server.py — preserving any
19
+ # downstream log routing/filtering. Do not switch to getLogger(__name__).
20
+ return log_timed_operation(
21
+ logging.getLogger("pretensor.mcp.server"),
22
+ event="mcp.tool_handler",
23
+ tool=tool_name,
24
+ graph_dir=str(graph_dir),
25
+ **fields,
26
+ )
@@ -0,0 +1,144 @@
1
+ """MCP ``compile_metric`` tool payload — compile YAML metrics to validated SQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pretensor.introspection.models.semantic import (
9
+ SemanticLayer as SemanticLayerModel,
10
+ )
11
+ from pretensor.mcp.tool_registry import McpTool
12
+ from pretensor.semantic.compiler import MetricCompileError, MetricSqlCompiler
13
+
14
+ from ..service_registry import (
15
+ load_registry,
16
+ open_store_for_entry,
17
+ release_store,
18
+ resolve_registry_entry,
19
+ )
20
+
21
+ __all__ = ["compile_metric_payload", "create_tool"]
22
+
23
+
24
+ def compile_metric_payload(
25
+ graph_dir: Path,
26
+ *,
27
+ semantic_yaml: str,
28
+ metric: str,
29
+ database: str,
30
+ ) -> dict[str, Any]:
31
+ """Build JSON-serializable payload for ``compile_metric``.
32
+
33
+ Args:
34
+ graph_dir: Root directory of the Pretensor graph store.
35
+ semantic_yaml: Full YAML text of the semantic layer (inline, not a path).
36
+ metric: Name of the metric to compile.
37
+ database: ``connection_name`` or logical ``database`` key.
38
+ """
39
+ if not semantic_yaml.strip():
40
+ return {"error": "Missing or empty `semantic_yaml`"}
41
+ if not metric.strip():
42
+ return {"error": "Missing or empty `metric`"}
43
+ if not database.strip():
44
+ return {"error": "Missing or empty `database`"}
45
+
46
+ reg = load_registry(graph_dir)
47
+ entry = resolve_registry_entry(reg, database)
48
+ if entry is None:
49
+ return {"error": f"No registry entry matches database {database!r}"}
50
+
51
+ try:
52
+ layer = SemanticLayerModel.from_yaml(semantic_yaml)
53
+ except Exception as exc:
54
+ return {"error": f"Failed to parse semantic_yaml: {exc}"}
55
+
56
+ store = open_store_for_entry(entry)
57
+ try:
58
+ compiler = MetricSqlCompiler(
59
+ store,
60
+ connection_name=entry.connection_name,
61
+ database_key=entry.database,
62
+ )
63
+ try:
64
+ compiled = compiler.compile(layer, metric)
65
+ except MetricCompileError as exc:
66
+ return {"error": str(exc)}
67
+ finally:
68
+ release_store(store)
69
+
70
+ return {
71
+ "metric": compiled.metric,
72
+ "entity": compiled.entity,
73
+ "sql": compiled.sql,
74
+ "dialect": compiled.dialect,
75
+ "warnings": compiled.warnings,
76
+ "valid": compiled.validation.valid,
77
+ "syntax_errors": compiled.validation.syntax_errors,
78
+ "missing_tables": compiled.validation.missing_tables,
79
+ "missing_columns": compiled.validation.missing_columns,
80
+ "invalid_joins": [
81
+ {
82
+ "message": j.message,
83
+ "left_table": j.left_table,
84
+ "right_table": j.right_table,
85
+ }
86
+ for j in compiled.validation.invalid_joins
87
+ ],
88
+ "suggestions": compiled.validation.suggestions,
89
+ }
90
+
91
+
92
+ def create_tool(graph_dir: Path) -> McpTool:
93
+ from ._timed import timed_tool
94
+
95
+ async def _handle(args: dict) -> dict:
96
+ yaml_s = str(args.get("semantic_yaml", ""))
97
+ metric_s = str(args.get("metric", "")).strip()
98
+ db_t = str(args.get("database", "")).strip()
99
+ if not yaml_s.strip():
100
+ return {"error": "Missing `semantic_yaml`"}
101
+ if not metric_s:
102
+ return {"error": "Missing `metric`"}
103
+ if not db_t:
104
+ return {"error": "Missing `database`"}
105
+ with timed_tool("compile_metric", graph_dir, database=db_t, metric=metric_s):
106
+ return compile_metric_payload(
107
+ graph_dir,
108
+ semantic_yaml=yaml_s,
109
+ metric=metric_s,
110
+ database=db_t,
111
+ )
112
+
113
+ return McpTool(
114
+ name="compile_metric",
115
+ description=(
116
+ "Compile a user-authored YAML metric to validated SQL against the "
117
+ "indexed graph. Accepts the semantic-layer YAML inline and a metric "
118
+ "name; returns SQL plus a validation report (missing tables/columns, "
119
+ "invalid joins, suggestions)."
120
+ ),
121
+ input_schema={
122
+ "type": "object",
123
+ "properties": {
124
+ "semantic_yaml": {
125
+ "type": "string",
126
+ "description": (
127
+ "Full YAML text of the semantic layer "
128
+ "(metric definitions, joins, and base tables)."
129
+ ),
130
+ },
131
+ "metric": {
132
+ "type": "string",
133
+ "description": "Name of the metric to compile",
134
+ },
135
+ "database": {
136
+ "type": "string",
137
+ "description": "Connection name or logical database",
138
+ },
139
+ },
140
+ "required": ["semantic_yaml", "metric", "database"],
141
+ "additionalProperties": False,
142
+ },
143
+ handler=_handle,
144
+ )
@@ -0,0 +1,161 @@
1
+ """MCP ``consumers`` tool: external code consumers of a table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pretensor.mcp.tool_registry import McpTool
10
+ from pretensor.visibility.filter import VisibilityFilter
11
+
12
+ from ..service_context import get_effective_visibility_filter
13
+ from ..service_registry import (
14
+ graph_path_for_entry,
15
+ load_registry,
16
+ open_store_for_entry,
17
+ release_store,
18
+ resolve_registry_entry,
19
+ )
20
+ from .context import resolve_table_node_id
21
+
22
+ __all__ = ["create_tool", "consumers_payload"]
23
+
24
+
25
+ def consumers_payload(
26
+ graph_dir: Path,
27
+ *,
28
+ table: str,
29
+ database: str,
30
+ op: str | None = None,
31
+ min_confidence: float = 0.6,
32
+ visibility_filter: VisibilityFilter | None = None,
33
+ ) -> dict[str, Any]:
34
+ """External code locations that read or write ``table``, with provenance."""
35
+ reg = load_registry(graph_dir)
36
+ entry = resolve_registry_entry(reg, database)
37
+ if entry is None:
38
+ return {"error": "Unknown database connection or name; pass `database`."}
39
+ gp = graph_path_for_entry(entry)
40
+ if not gp.exists():
41
+ return {"error": f"Graph file missing: {gp}"}
42
+
43
+ db_key = str(entry.database)
44
+ vf = visibility_filter or get_effective_visibility_filter()
45
+ store = open_store_for_entry(entry)
46
+ try:
47
+ start_id, err = resolve_table_node_id(
48
+ store, table, db_key, visibility_filter=vf
49
+ )
50
+ if err is not None:
51
+ try:
52
+ return json.loads(err)
53
+ except json.JSONDecodeError:
54
+ return {"error": err}
55
+ if start_id is None:
56
+ return {
57
+ "error": "Could not resolve table; check the table name and database."
58
+ }
59
+
60
+ rows = store.query_all_rows(
61
+ """
62
+ MATCH (c:ExternalConsumer)-[r:CONSUMES]->(t:SchemaTable {node_id: $nid})
63
+ WHERE r.confidence >= $minc
64
+ AND ($op IS NULL OR r.op = $op)
65
+ RETURN c.service_name, c.file_path, c.line_start, c.line_end, c.kind,
66
+ c.language, r.op, r.confidence, c.sql_fingerprint
67
+ ORDER BY r.confidence DESC, c.service_name, c.file_path
68
+ """,
69
+ {"nid": start_id, "minc": min_confidence, "op": op},
70
+ )
71
+
72
+ consumers: list[dict[str, Any]] = []
73
+ reads = writes = 0
74
+ for service, file_path, ls, le, kind, lang, edge_op, conf, fp in rows:
75
+ consumers.append(
76
+ {
77
+ "service": service,
78
+ "file": file_path,
79
+ "line_start": ls,
80
+ "line_end": le,
81
+ "kind": kind,
82
+ "language": lang,
83
+ "op": edge_op,
84
+ "confidence": conf,
85
+ "sql_fingerprint": fp,
86
+ }
87
+ )
88
+ if edge_op == "write":
89
+ writes += 1
90
+ else:
91
+ reads += 1
92
+
93
+ return {
94
+ "table": table,
95
+ "database": database,
96
+ "consumers": consumers,
97
+ "counts": {"read": reads, "write": writes, "total": len(consumers)},
98
+ }
99
+ finally:
100
+ release_store(store)
101
+
102
+
103
+ def create_tool(graph_dir: Path) -> McpTool:
104
+ from ._timed import timed_tool
105
+
106
+ async def _handle(args: dict) -> dict:
107
+ tbl = str(args.get("table", "")).strip()
108
+ db_t = str(args.get("database", "")).strip()
109
+ if not tbl:
110
+ return {"error": "Missing `table`"}
111
+ if not db_t:
112
+ return {"error": "Missing `database`"}
113
+ op = args.get("op")
114
+ op_s = str(op).strip() if op is not None else None
115
+ if op_s == "":
116
+ op_s = None
117
+ min_conf = float(args.get("min_confidence", 0.6))
118
+ with timed_tool("consumers", graph_dir, table=tbl, database=db_t, op=op_s):
119
+ return consumers_payload(
120
+ graph_dir,
121
+ table=tbl,
122
+ database=db_t,
123
+ op=op_s,
124
+ min_confidence=min_conf,
125
+ )
126
+
127
+ return McpTool(
128
+ name="consumers",
129
+ description=(
130
+ "External code locations (services) that read or write a table, from "
131
+ "`pretensor analyze`. Each entry carries service, file, line range, op, "
132
+ "kind, and confidence."
133
+ ),
134
+ input_schema={
135
+ "type": "object",
136
+ "properties": {
137
+ "table": {
138
+ "type": "string",
139
+ "description": "Table name or schema.table",
140
+ },
141
+ "database": {
142
+ "type": "string",
143
+ "description": "Connection name or logical database",
144
+ },
145
+ "op": {
146
+ "type": ["string", "null"],
147
+ "enum": ["read", "write", None],
148
+ "description": "Filter to 'read' or 'write' consumers only",
149
+ },
150
+ "min_confidence": {
151
+ "type": "number",
152
+ "default": 0.6,
153
+ "minimum": 0.0,
154
+ "maximum": 1.0,
155
+ },
156
+ },
157
+ "required": ["table", "database"],
158
+ "additionalProperties": False,
159
+ },
160
+ handler=_handle,
161
+ )