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,435 @@
|
|
|
1
|
+
"""SQLite FTS5 keyword search index over SchemaTable and Entity nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pretensor.core.registry import GraphRegistry, RegistryEntry
|
|
11
|
+
from pretensor.core.store import KuzuStore
|
|
12
|
+
from pretensor.search.base import BaseSearchIndex, SearchResult
|
|
13
|
+
|
|
14
|
+
__all__ = ["KeywordSearchIndex", "SearchIndex", "SearchResult"]
|
|
15
|
+
|
|
16
|
+
_INDEX_SCHEMA_VERSION = "2"
|
|
17
|
+
_IDENTIFIER_TOKEN_SPLIT_RE = re.compile(r"[^a-zA-Z0-9]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _graph_path_for_entry(entry: RegistryEntry) -> Path:
|
|
21
|
+
return Path(entry.unified_graph_path or entry.graph_path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _camel_to_space(value: str) -> str:
|
|
25
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _dedupe_non_empty(values: list[str]) -> list[str]:
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
out: list[str] = []
|
|
31
|
+
for raw in values:
|
|
32
|
+
text = str(raw).strip()
|
|
33
|
+
if not text:
|
|
34
|
+
continue
|
|
35
|
+
key = text.lower()
|
|
36
|
+
if key in seen:
|
|
37
|
+
continue
|
|
38
|
+
seen.add(key)
|
|
39
|
+
out.append(text)
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _identifier_tokens(value: str) -> list[str]:
|
|
44
|
+
pieces = [value, _camel_to_space(value)]
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for piece in pieces:
|
|
47
|
+
for token in _IDENTIFIER_TOKEN_SPLIT_RE.split(piece.lower()):
|
|
48
|
+
if token:
|
|
49
|
+
out.append(token)
|
|
50
|
+
return _dedupe_non_empty(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _identifier_terms(schema_name: str, table_name: str) -> str:
|
|
54
|
+
fq_name = f"{schema_name}.{table_name}"
|
|
55
|
+
ordered = _dedupe_non_empty(
|
|
56
|
+
[
|
|
57
|
+
fq_name,
|
|
58
|
+
schema_name,
|
|
59
|
+
table_name,
|
|
60
|
+
*_identifier_tokens(schema_name),
|
|
61
|
+
*_identifier_tokens(table_name),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
return " ".join(ordered)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _trim_for_tie_breaker(raw: str, *, max_tokens: int = 16) -> str:
|
|
68
|
+
tokens = raw.split()
|
|
69
|
+
if not tokens:
|
|
70
|
+
return ""
|
|
71
|
+
return " ".join(tokens[:max_tokens])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sanitize_fts_query(raw: str) -> str:
|
|
75
|
+
"""Build a safe FTS5 MATCH expression from free text (token OR token)."""
|
|
76
|
+
cleaned = "".join(c if c.isalnum() or c.isspace() else " " for c in raw)
|
|
77
|
+
tokens = [t for t in cleaned.lower().split() if t]
|
|
78
|
+
if not tokens:
|
|
79
|
+
return '""'
|
|
80
|
+
return " OR ".join(tokens)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _open_sqlite(path: Path) -> sqlite3.Connection:
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
conn = sqlite3.connect(str(path))
|
|
86
|
+
conn.row_factory = sqlite3.Row
|
|
87
|
+
return conn
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class KeywordSearchIndex(BaseSearchIndex):
|
|
91
|
+
"""BM25-ranked keyword search backed by SQLite FTS5."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, index_path: Path) -> None:
|
|
94
|
+
self._path = index_path
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def path(self) -> Path:
|
|
98
|
+
return self._path
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def default_path(graph_dir: Path) -> Path:
|
|
102
|
+
"""Default FTS index location under the graph state directory."""
|
|
103
|
+
return graph_dir / "search_metadata.fts.sqlite"
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def needs_rebuild(cls, registry: GraphRegistry, index_path: Path) -> bool:
|
|
107
|
+
"""True when the index is missing or older than any registered Kuzu file."""
|
|
108
|
+
if not index_path.exists():
|
|
109
|
+
return True
|
|
110
|
+
if not cls._has_current_schema(index_path):
|
|
111
|
+
return True
|
|
112
|
+
entries = registry.list_entries()
|
|
113
|
+
if not entries:
|
|
114
|
+
return False
|
|
115
|
+
index_mtime = index_path.stat().st_mtime
|
|
116
|
+
for entry in entries:
|
|
117
|
+
gp = _graph_path_for_entry(entry)
|
|
118
|
+
if gp.exists() and gp.stat().st_mtime > index_mtime:
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def _has_current_schema(cls, index_path: Path) -> bool:
|
|
124
|
+
conn: sqlite3.Connection | None = None
|
|
125
|
+
try:
|
|
126
|
+
conn = sqlite3.connect(str(index_path))
|
|
127
|
+
row = conn.execute(
|
|
128
|
+
"SELECT value FROM meta WHERE key = ?",
|
|
129
|
+
("schema_version",),
|
|
130
|
+
).fetchone()
|
|
131
|
+
return bool(row) and str(row[0]) == _INDEX_SCHEMA_VERSION
|
|
132
|
+
except sqlite3.Error:
|
|
133
|
+
return False
|
|
134
|
+
finally:
|
|
135
|
+
if conn is not None:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def build(cls, registry: GraphRegistry, index_path: Path) -> KeywordSearchIndex:
|
|
140
|
+
"""Rebuild the FTS index from all graphs in the registry."""
|
|
141
|
+
index = cls(index_path)
|
|
142
|
+
conn = _open_sqlite(index_path)
|
|
143
|
+
try:
|
|
144
|
+
conn.executescript(
|
|
145
|
+
"""
|
|
146
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
147
|
+
DROP TABLE IF EXISTS graph_fts;
|
|
148
|
+
CREATE VIRTUAL TABLE graph_fts USING fts5(
|
|
149
|
+
node_type UNINDEXED,
|
|
150
|
+
name,
|
|
151
|
+
description,
|
|
152
|
+
table_text,
|
|
153
|
+
cluster_context,
|
|
154
|
+
database_name UNINDEXED,
|
|
155
|
+
connection_name UNINDEXED,
|
|
156
|
+
tokenize = 'porter unicode61'
|
|
157
|
+
);
|
|
158
|
+
"""
|
|
159
|
+
)
|
|
160
|
+
for entry in registry.list_entries():
|
|
161
|
+
gp = _graph_path_for_entry(entry)
|
|
162
|
+
if not gp.exists():
|
|
163
|
+
continue
|
|
164
|
+
store = KuzuStore(gp)
|
|
165
|
+
try:
|
|
166
|
+
index._ingest_connection(store, entry, conn)
|
|
167
|
+
finally:
|
|
168
|
+
store.close()
|
|
169
|
+
conn.execute(
|
|
170
|
+
"INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
|
|
171
|
+
("schema_version", _INDEX_SCHEMA_VERSION),
|
|
172
|
+
)
|
|
173
|
+
conn.commit()
|
|
174
|
+
finally:
|
|
175
|
+
conn.close()
|
|
176
|
+
return index
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def load_or_build(
|
|
180
|
+
cls, registry: GraphRegistry, index_path: Path
|
|
181
|
+
) -> KeywordSearchIndex:
|
|
182
|
+
"""Return a search index, rebuilding automatically when graphs are newer."""
|
|
183
|
+
if cls.needs_rebuild(registry, index_path):
|
|
184
|
+
return cls.build(registry, index_path)
|
|
185
|
+
return cls(index_path)
|
|
186
|
+
|
|
187
|
+
def _ingest_connection(
|
|
188
|
+
self,
|
|
189
|
+
store: KuzuStore,
|
|
190
|
+
entry: RegistryEntry,
|
|
191
|
+
conn: sqlite3.Connection,
|
|
192
|
+
) -> None:
|
|
193
|
+
table_signals_rows = store.query_all_rows(
|
|
194
|
+
"""
|
|
195
|
+
MATCH (t:SchemaTable {connection_name: $cn})-[:HAS_COLUMN]->(c:SchemaColumn)
|
|
196
|
+
RETURN t.schema_name, t.table_name, c.column_name, c.most_common_values_json
|
|
197
|
+
""",
|
|
198
|
+
{"cn": entry.connection_name},
|
|
199
|
+
)
|
|
200
|
+
column_names_by_table: dict[tuple[str, str], list[str]] = {}
|
|
201
|
+
mcv_by_table: dict[tuple[str, str], list[str]] = {}
|
|
202
|
+
for sn, tn, column_name, mcv_json in table_signals_rows:
|
|
203
|
+
key = (str(sn), str(tn))
|
|
204
|
+
column_text = str(column_name).strip()
|
|
205
|
+
if column_text:
|
|
206
|
+
column_names_by_table.setdefault(key, []).append(column_text)
|
|
207
|
+
if not mcv_json:
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
parsed = json.loads(str(mcv_json))
|
|
211
|
+
except json.JSONDecodeError:
|
|
212
|
+
continue
|
|
213
|
+
if not isinstance(parsed, list):
|
|
214
|
+
continue
|
|
215
|
+
for v in parsed:
|
|
216
|
+
s = str(v).strip()
|
|
217
|
+
if s:
|
|
218
|
+
mcv_by_table.setdefault(key, []).append(s)
|
|
219
|
+
|
|
220
|
+
entity_text_by_table: dict[tuple[str, str], list[str]] = {}
|
|
221
|
+
entity_rows = store.query_all_rows(
|
|
222
|
+
"""
|
|
223
|
+
MATCH (e:Entity {connection_name: $cn})-[:REPRESENTS]->(t:SchemaTable {connection_name: $cn})
|
|
224
|
+
RETURN t.schema_name, t.table_name, e.name, e.description
|
|
225
|
+
""",
|
|
226
|
+
{"cn": entry.connection_name},
|
|
227
|
+
)
|
|
228
|
+
for sn, tn, entity_name, entity_desc in entity_rows:
|
|
229
|
+
key = (str(sn), str(tn))
|
|
230
|
+
if entity_name:
|
|
231
|
+
entity_text_by_table.setdefault(key, []).append(str(entity_name))
|
|
232
|
+
if entity_desc and str(entity_desc).strip():
|
|
233
|
+
entity_text_by_table.setdefault(key, []).append(str(entity_desc).strip())
|
|
234
|
+
|
|
235
|
+
cluster_text_by_table: dict[tuple[str, str], list[str]] = {}
|
|
236
|
+
cluster_rows = store.query_all_rows(
|
|
237
|
+
"""
|
|
238
|
+
MATCH (t:SchemaTable {connection_name: $cn})-[:IN_CLUSTER]->(c:Cluster)
|
|
239
|
+
RETURN t.schema_name, t.table_name, c.label, c.description
|
|
240
|
+
""",
|
|
241
|
+
{"cn": entry.connection_name},
|
|
242
|
+
)
|
|
243
|
+
for sn, tn, cluster_label, cluster_desc in cluster_rows:
|
|
244
|
+
key = (str(sn), str(tn))
|
|
245
|
+
if cluster_label:
|
|
246
|
+
cluster_text_by_table.setdefault(key, []).append(str(cluster_label))
|
|
247
|
+
if cluster_desc and str(cluster_desc).strip():
|
|
248
|
+
trimmed = _trim_for_tie_breaker(str(cluster_desc).strip())
|
|
249
|
+
if trimmed:
|
|
250
|
+
cluster_text_by_table.setdefault(key, []).append(trimmed)
|
|
251
|
+
|
|
252
|
+
rows = store.query_all_rows(
|
|
253
|
+
"""
|
|
254
|
+
MATCH (t:SchemaTable {connection_name: $cn})
|
|
255
|
+
RETURN t.schema_name, t.table_name, t.comment, t.description,
|
|
256
|
+
t.entity_type, t.database,
|
|
257
|
+
COALESCE(t.tags, CAST([] AS STRING[])) AS tags
|
|
258
|
+
""",
|
|
259
|
+
{"cn": entry.connection_name},
|
|
260
|
+
)
|
|
261
|
+
for (
|
|
262
|
+
schema_name,
|
|
263
|
+
table_name,
|
|
264
|
+
comment,
|
|
265
|
+
desc_col,
|
|
266
|
+
entity_type,
|
|
267
|
+
database,
|
|
268
|
+
tags_arr,
|
|
269
|
+
) in rows:
|
|
270
|
+
key = (str(schema_name), str(table_name))
|
|
271
|
+
parts: list[str] = []
|
|
272
|
+
if comment:
|
|
273
|
+
parts.append(str(comment))
|
|
274
|
+
if desc_col and str(desc_col).strip():
|
|
275
|
+
parts.append(f"description: {str(desc_col).strip()}")
|
|
276
|
+
if isinstance(tags_arr, (list, tuple)) and tags_arr:
|
|
277
|
+
parts.append(
|
|
278
|
+
"tags: " + " ".join(str(x) for x in tags_arr if str(x).strip())
|
|
279
|
+
)
|
|
280
|
+
if entity_type:
|
|
281
|
+
parts.append(f"entity_type: {entity_type}")
|
|
282
|
+
mcv_extra = _dedupe_non_empty(mcv_by_table.get(key, []))
|
|
283
|
+
if mcv_extra:
|
|
284
|
+
parts.append("common_values: " + " ".join(mcv_extra))
|
|
285
|
+
desc = " ".join(parts)
|
|
286
|
+
|
|
287
|
+
table_text_parts: list[str] = []
|
|
288
|
+
table_text_parts.append(
|
|
289
|
+
"identifier: "
|
|
290
|
+
+ _identifier_terms(str(schema_name), str(table_name))
|
|
291
|
+
)
|
|
292
|
+
column_terms = _dedupe_non_empty(column_names_by_table.get(key, []))
|
|
293
|
+
if column_terms:
|
|
294
|
+
column_tokens: list[str] = []
|
|
295
|
+
for col in column_terms:
|
|
296
|
+
column_tokens.extend(_identifier_tokens(col))
|
|
297
|
+
table_text_parts.append(
|
|
298
|
+
"columns: "
|
|
299
|
+
+ " ".join(_dedupe_non_empty([*column_terms, *column_tokens]))
|
|
300
|
+
)
|
|
301
|
+
entity_terms = _dedupe_non_empty(entity_text_by_table.get(key, []))
|
|
302
|
+
if entity_terms:
|
|
303
|
+
table_text_parts.append("entity: " + " ".join(entity_terms))
|
|
304
|
+
table_text = " ".join(table_text_parts)
|
|
305
|
+
|
|
306
|
+
cluster_terms = _dedupe_non_empty(cluster_text_by_table.get(key, []))
|
|
307
|
+
cluster_context = ""
|
|
308
|
+
if cluster_terms:
|
|
309
|
+
cluster_context = "cluster: " + " ".join(cluster_terms)
|
|
310
|
+
|
|
311
|
+
fq = f"{schema_name}.{table_name}"
|
|
312
|
+
conn.execute(
|
|
313
|
+
"""
|
|
314
|
+
INSERT INTO graph_fts(
|
|
315
|
+
node_type, name, description, table_text, cluster_context,
|
|
316
|
+
database_name, connection_name
|
|
317
|
+
)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
319
|
+
""",
|
|
320
|
+
(
|
|
321
|
+
"SchemaTable",
|
|
322
|
+
fq,
|
|
323
|
+
desc,
|
|
324
|
+
table_text,
|
|
325
|
+
cluster_context,
|
|
326
|
+
str(database or entry.database),
|
|
327
|
+
entry.connection_name,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
erows = store.query_all_rows(
|
|
332
|
+
"""
|
|
333
|
+
MATCH (e:Entity {connection_name: $cn})
|
|
334
|
+
RETURN e.name, e.description, e.database
|
|
335
|
+
""",
|
|
336
|
+
{"cn": entry.connection_name},
|
|
337
|
+
)
|
|
338
|
+
for name, description, database in erows:
|
|
339
|
+
conn.execute(
|
|
340
|
+
"""
|
|
341
|
+
INSERT INTO graph_fts(
|
|
342
|
+
node_type, name, description, table_text, cluster_context,
|
|
343
|
+
database_name, connection_name
|
|
344
|
+
)
|
|
345
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
346
|
+
""",
|
|
347
|
+
(
|
|
348
|
+
"Entity",
|
|
349
|
+
str(name),
|
|
350
|
+
str(description or ""),
|
|
351
|
+
"",
|
|
352
|
+
"",
|
|
353
|
+
str(database or entry.database),
|
|
354
|
+
entry.connection_name,
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def search(
|
|
359
|
+
self,
|
|
360
|
+
q: str,
|
|
361
|
+
*,
|
|
362
|
+
db: str | None = None,
|
|
363
|
+
limit: int = 10,
|
|
364
|
+
) -> list[SearchResult]:
|
|
365
|
+
"""Run BM25 search; optional ``db`` filters by connection or logical database name."""
|
|
366
|
+
if not self._path.exists():
|
|
367
|
+
return []
|
|
368
|
+
conn = sqlite3.connect(str(self._path))
|
|
369
|
+
try:
|
|
370
|
+
match_expr = _sanitize_fts_query(q)
|
|
371
|
+
sql = """
|
|
372
|
+
SELECT node_type, name, description, database_name, connection_name,
|
|
373
|
+
bm25(graph_fts, 0.0, 1.0, 1.0, 1.4, 0.2, 0.0, 0.0) AS score
|
|
374
|
+
FROM graph_fts
|
|
375
|
+
WHERE graph_fts MATCH ?
|
|
376
|
+
"""
|
|
377
|
+
params: list[object] = [match_expr]
|
|
378
|
+
if db is not None:
|
|
379
|
+
sql += " AND (connection_name = ? OR database_name = ?)"
|
|
380
|
+
params.extend([db, db])
|
|
381
|
+
# bm25() is lower for better matches in SQLite FTS5
|
|
382
|
+
sql += " ORDER BY score ASC LIMIT ?"
|
|
383
|
+
params.append(limit)
|
|
384
|
+
cur = conn.execute(sql, params)
|
|
385
|
+
out: list[SearchResult] = []
|
|
386
|
+
for row in cur.fetchall():
|
|
387
|
+
out.append(
|
|
388
|
+
SearchResult(
|
|
389
|
+
node_type=str(row[0]),
|
|
390
|
+
name=str(row[1]),
|
|
391
|
+
database_name=str(row[3]),
|
|
392
|
+
connection_name=str(row[4]),
|
|
393
|
+
description=str(row[2] or ""),
|
|
394
|
+
score=float(row[5]),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
return out
|
|
398
|
+
finally:
|
|
399
|
+
conn.close()
|
|
400
|
+
|
|
401
|
+
def similar(
|
|
402
|
+
self,
|
|
403
|
+
name: str,
|
|
404
|
+
*,
|
|
405
|
+
db: str | None = None,
|
|
406
|
+
limit: int = 10,
|
|
407
|
+
) -> list[SearchResult]:
|
|
408
|
+
"""Return nodes related to *name* via keyword proximity in the FTS index.
|
|
409
|
+
|
|
410
|
+
Uses the node's own name as the search query so that nodes sharing
|
|
411
|
+
significant tokens (schema prefix, entity type, common words) rank
|
|
412
|
+
highly. Results matching *name* exactly are excluded.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
name: Fully-qualified node name, e.g. ``schema.table``.
|
|
416
|
+
db: Optional connection or logical database name filter.
|
|
417
|
+
limit: Maximum number of results to return.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Related nodes ranked by BM25 proximity.
|
|
421
|
+
"""
|
|
422
|
+
candidates = self.search(name, db=db, limit=limit + 1)
|
|
423
|
+
return [r for r in candidates if r.name != name][:limit]
|
|
424
|
+
|
|
425
|
+
def index_graph(self, registry: GraphRegistry) -> None:
|
|
426
|
+
"""Rebuild the FTS index from all graphs in *registry*.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
registry: Loaded registry describing available graph files.
|
|
430
|
+
"""
|
|
431
|
+
self.build(registry, self._path)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# Backward-compatible alias — existing imports of ``SearchIndex`` keep working.
|
|
435
|
+
SearchIndex = KeywordSearchIndex
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Semantic layer extension point for Pretensor.
|
|
2
|
+
|
|
3
|
+
OSS ships ``NullSemanticLayer`` (all methods return None/empty).
|
|
4
|
+
Downstream implementations override ``SemanticLayer`` to populate the
|
|
5
|
+
reserved Kuzu node types (``Metric``, ``Dimension``, ``BusinessRule``)
|
|
6
|
+
and their associated edge types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pretensor.semantic.base import NullSemanticLayer, SemanticLayer
|
|
10
|
+
from pretensor.semantic.compiler import (
|
|
11
|
+
CompiledMetric,
|
|
12
|
+
MetricCompileError,
|
|
13
|
+
MetricSqlCompiler,
|
|
14
|
+
)
|
|
15
|
+
from pretensor.semantic.yaml_layer import YamlSemanticLayer
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CompiledMetric",
|
|
19
|
+
"MetricCompileError",
|
|
20
|
+
"MetricSqlCompiler",
|
|
21
|
+
"NullSemanticLayer",
|
|
22
|
+
"SemanticLayer",
|
|
23
|
+
"YamlSemanticLayer",
|
|
24
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""SemanticLayer interface and NullSemanticLayer stub.
|
|
2
|
+
|
|
3
|
+
The ``SemanticLayer`` ABC defines the extension point for semantic-layer
|
|
4
|
+
implementations. ``NullSemanticLayer`` is the default no-op — all methods
|
|
5
|
+
return ``None`` or empty collections so graph operations and MCP tool
|
|
6
|
+
responses compile and run without any semantic-layer implementation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["SemanticLayer", "NullSemanticLayer"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SemanticLayer(ABC):
|
|
18
|
+
"""Abstract interface for the Pretensor semantic layer.
|
|
19
|
+
|
|
20
|
+
Downstream implementations populate this with business-level knowledge
|
|
21
|
+
(metrics, dimensions, business rules). OSS callers always receive
|
|
22
|
+
``NullSemanticLayer``, which returns ``None`` or empty collections
|
|
23
|
+
for every method.
|
|
24
|
+
|
|
25
|
+
All methods are intentionally narrow: they accept identifiers (strings
|
|
26
|
+
or simple primitives) and return plain Python types so callers never
|
|
27
|
+
depend on implementation-specific model classes.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_entity(self, entity_id: str) -> dict[str, Any] | None:
|
|
32
|
+
"""Return business entity metadata by ID.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
entity_id: Canonical entity identifier (e.g. ``"customers"``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Mapping of entity attributes, or ``None`` if not found.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_metric(self, metric_name: str) -> dict[str, Any] | None:
|
|
43
|
+
"""Return a named metric definition.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
metric_name: Unique metric name (e.g. ``"monthly_revenue"``).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Mapping with metric metadata and SQL template, or ``None``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
|
|
54
|
+
"""Return dimension definitions associated with an entity.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
entity_id: Canonical entity identifier.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of dimension attribute mappings; empty list if none exist.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
|
|
65
|
+
"""Return business rules that apply to an entity.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
entity_id: Canonical entity identifier.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of business rule mappings; empty list if none exist.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
|
|
76
|
+
"""Validate a SQL query against the semantic layer.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
sql: SQL statement to validate.
|
|
80
|
+
connection_name: Target connection for dialect and schema context.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Mapping with at minimum ``{"valid": bool, "errors": list[str]}``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
|
|
88
|
+
"""Return semantic objects impacted by changes to a table.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
table_node_id: Kuzu node ID of the affected ``SchemaTable``.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of impacted semantic object descriptors (metrics, rules,
|
|
95
|
+
dimensions); empty list if none exist.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class NullSemanticLayer(SemanticLayer):
|
|
100
|
+
"""No-op semantic layer for OSS deployments.
|
|
101
|
+
|
|
102
|
+
Every method returns ``None`` or an empty collection. This lets the
|
|
103
|
+
graph core and MCP tools reference a ``SemanticLayer`` without
|
|
104
|
+
requiring external credentials or implementations.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def get_entity(self, entity_id: str) -> dict[str, Any] | None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_metric(self, metric_name: str) -> dict[str, Any] | None:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def get_dimensions(self, entity_id: str) -> list[dict[str, Any]]:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
def get_rules(self, entity_id: str) -> list[dict[str, Any]]:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
def validate_query(self, sql: str, *, connection_name: str) -> dict[str, Any]:
|
|
120
|
+
return {"valid": True, "errors": []}
|
|
121
|
+
|
|
122
|
+
def impact_semantic(self, table_node_id: str) -> list[dict[str, Any]]:
|
|
123
|
+
return []
|