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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|