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,383 @@
|
|
|
1
|
+
"""Weighted multi-signal table classification for schema intelligence.
|
|
2
|
+
|
|
3
|
+
Scores naming patterns, FK fan-out/fan-in, column composition, row cardinality,
|
|
4
|
+
and optional usage counters. Returns a single :class:`TableClassification` with
|
|
5
|
+
role, confidence in ``[0, 1]``, and human-readable signals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"TABLE_ROLES",
|
|
19
|
+
"TableClassification",
|
|
20
|
+
"TableClassifier",
|
|
21
|
+
"TableClassifierInput",
|
|
22
|
+
"TableRole",
|
|
23
|
+
"is_entity_extraction_candidate",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
TABLE_ROLES = (
|
|
27
|
+
"system",
|
|
28
|
+
"staging",
|
|
29
|
+
"audit",
|
|
30
|
+
"junction",
|
|
31
|
+
"fact",
|
|
32
|
+
"dimension",
|
|
33
|
+
"bridge",
|
|
34
|
+
"aggregate",
|
|
35
|
+
"snapshot_scd",
|
|
36
|
+
"entity_candidate",
|
|
37
|
+
"unknown",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
TableRole = Literal[
|
|
41
|
+
"system",
|
|
42
|
+
"staging",
|
|
43
|
+
"audit",
|
|
44
|
+
"junction",
|
|
45
|
+
"fact",
|
|
46
|
+
"dimension",
|
|
47
|
+
"bridge",
|
|
48
|
+
"aggregate",
|
|
49
|
+
"snapshot_scd",
|
|
50
|
+
"entity_candidate",
|
|
51
|
+
"unknown",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class TableClassification:
|
|
57
|
+
"""Result of :meth:`TableClassifier.classify`."""
|
|
58
|
+
|
|
59
|
+
role: TableRole
|
|
60
|
+
confidence: float
|
|
61
|
+
signals: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TableClassifierInput(BaseModel):
|
|
65
|
+
"""Table shape used by :class:`TableClassifier`."""
|
|
66
|
+
|
|
67
|
+
name: str = Field(description="Physical table name (no schema).")
|
|
68
|
+
schema_name: str = Field(default="public", description="Schema qualifier.")
|
|
69
|
+
columns: list[str] = Field(
|
|
70
|
+
default_factory=list, description="Column names in declaration order."
|
|
71
|
+
)
|
|
72
|
+
row_count: int | None = Field(default=None, description="Optional row estimate.")
|
|
73
|
+
fk_out_degree: int = Field(
|
|
74
|
+
default=0, ge=0, description="Count of outgoing FK_REFERENCES from this table."
|
|
75
|
+
)
|
|
76
|
+
fk_in_degree: int = Field(
|
|
77
|
+
default=0, ge=0, description="Count of incoming FK_REFERENCES to this table."
|
|
78
|
+
)
|
|
79
|
+
seq_scan_count: int | None = Field(
|
|
80
|
+
default=None, description="Optional sequential scan count (usage signal)."
|
|
81
|
+
)
|
|
82
|
+
idx_scan_count: int | None = Field(
|
|
83
|
+
default=None, description="Optional index scan count (usage signal)."
|
|
84
|
+
)
|
|
85
|
+
insert_count: int | None = Field(default=None)
|
|
86
|
+
update_count: int | None = Field(default=None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_SNAKE_WORD = re.compile(r"^[a-z][a-z0-9]*$")
|
|
90
|
+
_ID_COLUMN = re.compile(r"^id$", re.IGNORECASE)
|
|
91
|
+
_SURROGATE_KEY = re.compile(r"^[a-z][a-z0-9_]*_id$", re.IGNORECASE)
|
|
92
|
+
_FK_SUFFIX = re.compile(r"_id$", re.IGNORECASE)
|
|
93
|
+
_DATE_TOKEN = re.compile(
|
|
94
|
+
r"(^|_)(date|time|timestamp|day|month|year)($|_)", re.IGNORECASE
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TableClassifier:
|
|
99
|
+
"""Classify a table using weighted heuristics (no graph-wide context)."""
|
|
100
|
+
|
|
101
|
+
def classify(
|
|
102
|
+
self,
|
|
103
|
+
table: TableClassifierInput,
|
|
104
|
+
*,
|
|
105
|
+
embedding_vote: dict[str, float] | None = None,
|
|
106
|
+
role_weight: float = 0.0,
|
|
107
|
+
) -> TableClassification:
|
|
108
|
+
"""Return the highest-scoring role with confidence and contributing signals.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
table: Per-table input (name, columns, structural counts).
|
|
112
|
+
embedding_vote: Optional per-role cosine score from
|
|
113
|
+
:func:`pretensor.intelligence.role_exemplars.embedding_role_vote`.
|
|
114
|
+
When provided AND ``role_weight > 0``, each role's heuristic
|
|
115
|
+
score is incremented by ``role_weight * vote[role]`` before
|
|
116
|
+
the ``max()`` pick. Default ``None`` is byte-identical to
|
|
117
|
+
pre-embedding behavior.
|
|
118
|
+
role_weight: Blend weight for the embedding vote. ``0.0`` (default)
|
|
119
|
+
discards the vote entirely → null path. Heuristic scores
|
|
120
|
+
typically range 0.5–2.5; ``role_weight=0.25`` keeps the
|
|
121
|
+
embedding contribution as a tiebreaker.
|
|
122
|
+
"""
|
|
123
|
+
name = table.name.strip()
|
|
124
|
+
lower = name.lower()
|
|
125
|
+
cols = table.columns
|
|
126
|
+
weights = self._score_all(table, name, lower, cols)
|
|
127
|
+
if embedding_vote is not None and role_weight > 0.0:
|
|
128
|
+
for role, vote in embedding_vote.items():
|
|
129
|
+
if role in weights:
|
|
130
|
+
weights[role] += role_weight * float(vote)
|
|
131
|
+
best_role, raw = max(weights.items(), key=lambda kv: kv[1])
|
|
132
|
+
second = sorted(weights.values(), reverse=True)
|
|
133
|
+
runner = second[1] if len(second) > 1 else 0.0
|
|
134
|
+
margin = raw - runner
|
|
135
|
+
confidence = self._confidence_from_score(raw, margin)
|
|
136
|
+
signals = self._signals_for_winner(table, lower, cols, best_role)
|
|
137
|
+
return TableClassification(
|
|
138
|
+
role=best_role, confidence=confidence, signals=tuple(signals)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _confidence_from_score(self, raw: float, margin: float) -> float:
|
|
142
|
+
"""Map unbounded score and margin to ``[0, 1]``."""
|
|
143
|
+
base = 1.0 - math.exp(-raw / 1.4)
|
|
144
|
+
boost = min(0.35, margin * 0.45)
|
|
145
|
+
return max(0.0, min(1.0, base + boost))
|
|
146
|
+
|
|
147
|
+
def _score_all(
|
|
148
|
+
self,
|
|
149
|
+
table: TableClassifierInput,
|
|
150
|
+
name: str,
|
|
151
|
+
lower: str,
|
|
152
|
+
cols: list[str],
|
|
153
|
+
) -> dict[TableRole, float]:
|
|
154
|
+
out: dict[TableRole, float] = {r: 0.0 for r in TABLE_ROLES}
|
|
155
|
+
out["system"] = self._score_system(lower)
|
|
156
|
+
out["staging"] = self._score_staging(lower)
|
|
157
|
+
out["audit"] = self._score_audit(lower)
|
|
158
|
+
j_score, _ = self._junction_signals(name, cols)
|
|
159
|
+
out["junction"] = j_score
|
|
160
|
+
struct = self._score_structure(table, cols)
|
|
161
|
+
for role, v in struct.items():
|
|
162
|
+
out[role] += v
|
|
163
|
+
out["dimension"] += self._score_dimension_name(lower)
|
|
164
|
+
out["fact"] += self._score_fact_name(lower)
|
|
165
|
+
out["aggregate"] += self._score_aggregate_name(lower)
|
|
166
|
+
out["snapshot_scd"] += self._score_snapshot_name(lower)
|
|
167
|
+
out["bridge"] += self._score_bridge_name(lower)
|
|
168
|
+
out["entity_candidate"] += self._score_entity_fallback(cols)
|
|
169
|
+
if max(out.values()) < 0.15:
|
|
170
|
+
out["unknown"] = 0.25
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
def _score_system(self, lower_name: str) -> float:
|
|
174
|
+
if lower_name in ("migrations", "schema_migrations"):
|
|
175
|
+
return 2.5
|
|
176
|
+
if lower_name.startswith("ar_internal_"):
|
|
177
|
+
return 2.5
|
|
178
|
+
if lower_name.startswith("pg_"):
|
|
179
|
+
return 2.5
|
|
180
|
+
return 0.0
|
|
181
|
+
|
|
182
|
+
def _score_staging(self, lower_name: str) -> float:
|
|
183
|
+
if (
|
|
184
|
+
lower_name.startswith("stg_")
|
|
185
|
+
or lower_name.startswith("raw_")
|
|
186
|
+
or lower_name.startswith("tmp_")
|
|
187
|
+
or lower_name.startswith("staging_")
|
|
188
|
+
):
|
|
189
|
+
return 2.2
|
|
190
|
+
return 0.0
|
|
191
|
+
|
|
192
|
+
def _score_audit(self, lower_name: str) -> float:
|
|
193
|
+
if lower_name.endswith(("_audit", "_log", "_history", "_changelog")):
|
|
194
|
+
return 2.0
|
|
195
|
+
return 0.0
|
|
196
|
+
|
|
197
|
+
def _junction_signals(
|
|
198
|
+
self, name: str, columns: list[str]
|
|
199
|
+
) -> tuple[float, list[str]]:
|
|
200
|
+
"""Score junction pattern; dim_/fact_ names are excluded."""
|
|
201
|
+
sigs: list[str] = []
|
|
202
|
+
lower = name.lower()
|
|
203
|
+
if lower.startswith("dim_") or lower.startswith("fact_"):
|
|
204
|
+
return 0.0, sigs
|
|
205
|
+
parts = name.split("_")
|
|
206
|
+
if len(parts) != 2:
|
|
207
|
+
return 0.0, sigs
|
|
208
|
+
a, b = parts
|
|
209
|
+
if not _SNAKE_WORD.match(a) or not _SNAKE_WORD.match(b):
|
|
210
|
+
return 0.0, sigs
|
|
211
|
+
if any(_ID_COLUMN.match(c.strip()) for c in columns):
|
|
212
|
+
return 0.0, sigs
|
|
213
|
+
n = len(columns)
|
|
214
|
+
if 2 <= n <= 3:
|
|
215
|
+
sigs.append("two-part snake name with 2–3 columns (junction pattern)")
|
|
216
|
+
return 1.8, sigs
|
|
217
|
+
return 0.0, sigs
|
|
218
|
+
|
|
219
|
+
def _score_structure(
|
|
220
|
+
self, table: TableClassifierInput, cols: list[str]
|
|
221
|
+
) -> dict[TableRole, float]:
|
|
222
|
+
fk_out = table.fk_out_degree
|
|
223
|
+
fk_in = table.fk_in_degree
|
|
224
|
+
ncols = len(cols)
|
|
225
|
+
fk_cols = sum(1 for c in cols if _FK_SUFFIX.search(c.strip() or ""))
|
|
226
|
+
date_cols = sum(1 for c in cols if _DATE_TOKEN.search(c.strip() or ""))
|
|
227
|
+
row_count = table.row_count
|
|
228
|
+
|
|
229
|
+
scores: dict[TableRole, float] = {
|
|
230
|
+
"fact": 0.0,
|
|
231
|
+
"staging": 0.0,
|
|
232
|
+
"dimension": 0.0,
|
|
233
|
+
"bridge": 0.0,
|
|
234
|
+
"aggregate": 0.0,
|
|
235
|
+
"snapshot_scd": 0.0,
|
|
236
|
+
"entity_candidate": 0.0,
|
|
237
|
+
}
|
|
238
|
+
if fk_out >= 3:
|
|
239
|
+
scores["fact"] += 0.55 + min(0.45, (fk_out - 3) * 0.12)
|
|
240
|
+
elif fk_out == 2:
|
|
241
|
+
scores["fact"] += 0.25
|
|
242
|
+
if fk_in >= 2 and fk_out <= 1:
|
|
243
|
+
scores["dimension"] += 0.4 + min(0.35, (fk_in - 2) * 0.12)
|
|
244
|
+
if fk_out >= 2 and fk_in >= 2:
|
|
245
|
+
scores["bridge"] += 0.85 + min(0.4, (min(fk_out, fk_in) - 2) * 0.1)
|
|
246
|
+
if fk_cols >= 3 and fk_out >= 2:
|
|
247
|
+
scores["fact"] += 0.15
|
|
248
|
+
if ncols >= 2 and fk_cols <= 1 and fk_out <= 1:
|
|
249
|
+
scores["dimension"] += 0.2
|
|
250
|
+
if date_cols >= 2 and fk_out >= 1:
|
|
251
|
+
scores["snapshot_scd"] += 0.5
|
|
252
|
+
if row_count is not None and row_count > 500_000 and fk_out >= 2:
|
|
253
|
+
scores["fact"] += 0.2
|
|
254
|
+
if row_count is not None and row_count < 50_000 and fk_in >= 1 and fk_out <= 1:
|
|
255
|
+
scores["dimension"] += 0.12
|
|
256
|
+
if "total" in " ".join(c.lower() for c in cols) or any(
|
|
257
|
+
c.lower().startswith(("sum_", "avg_", "count_")) for c in cols
|
|
258
|
+
):
|
|
259
|
+
scores["aggregate"] += 0.55
|
|
260
|
+
scores["entity_candidate"] += 0.08 * min(ncols, 8) / 8.0
|
|
261
|
+
self._apply_usage_scores(table, scores)
|
|
262
|
+
self._apply_seq_idx_ratio(table, scores)
|
|
263
|
+
return scores
|
|
264
|
+
|
|
265
|
+
def _apply_usage_scores(
|
|
266
|
+
self, table: TableClassifierInput, scores: dict[TableRole, float]
|
|
267
|
+
) -> None:
|
|
268
|
+
seq = table.seq_scan_count
|
|
269
|
+
ins = table.insert_count
|
|
270
|
+
upd = table.update_count
|
|
271
|
+
if seq is None and ins is None and upd is None:
|
|
272
|
+
return
|
|
273
|
+
writes = (ins or 0) + (upd or 0)
|
|
274
|
+
reads = seq or 0
|
|
275
|
+
if writes > 500 and reads > 0 and writes > reads * 0.15:
|
|
276
|
+
scores["fact"] += min(0.35, math.log1p(writes) / 25.0)
|
|
277
|
+
|
|
278
|
+
def _apply_seq_idx_ratio(
|
|
279
|
+
self, table: TableClassifierInput, scores: dict[TableRole, float]
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Boost fact/staging when sequential scans dominate on a large table."""
|
|
282
|
+
seq = table.seq_scan_count
|
|
283
|
+
idx = table.idx_scan_count
|
|
284
|
+
if seq is None and idx is None:
|
|
285
|
+
return
|
|
286
|
+
s = seq or 0
|
|
287
|
+
i = idx or 0
|
|
288
|
+
total = s + i
|
|
289
|
+
if total <= 0:
|
|
290
|
+
return
|
|
291
|
+
rc = table.row_count
|
|
292
|
+
if rc is None or rc <= 10_000:
|
|
293
|
+
return
|
|
294
|
+
if s / total > 0.9:
|
|
295
|
+
scores["fact"] += 0.15
|
|
296
|
+
scores["staging"] += 0.15
|
|
297
|
+
|
|
298
|
+
def _score_dimension_name(self, lower: str) -> float:
|
|
299
|
+
if lower.startswith("dim_") or lower.endswith("_dim") or "_dim_" in lower:
|
|
300
|
+
return 1.1
|
|
301
|
+
return 0.0
|
|
302
|
+
|
|
303
|
+
def _score_fact_name(self, lower: str) -> float:
|
|
304
|
+
if lower.startswith("fact_") or lower.startswith("fct_"):
|
|
305
|
+
return 1.1
|
|
306
|
+
if lower.endswith("_fact") or "_fact_" in lower:
|
|
307
|
+
return 0.85
|
|
308
|
+
return 0.0
|
|
309
|
+
|
|
310
|
+
def _score_aggregate_name(self, lower: str) -> float:
|
|
311
|
+
if lower.startswith(("agg_", "aggregate_", "summary_", "rollup_")):
|
|
312
|
+
return 1.2
|
|
313
|
+
return 0.0
|
|
314
|
+
|
|
315
|
+
def _score_snapshot_name(self, lower: str) -> float:
|
|
316
|
+
if "snapshot" in lower or lower.startswith("snap_") or "_scd" in lower:
|
|
317
|
+
return 1.0
|
|
318
|
+
return 0.0
|
|
319
|
+
|
|
320
|
+
def _score_bridge_name(self, lower: str) -> float:
|
|
321
|
+
if (
|
|
322
|
+
lower.startswith("bridge_")
|
|
323
|
+
or "_bridge_" in lower
|
|
324
|
+
or lower.endswith("_bridge")
|
|
325
|
+
):
|
|
326
|
+
return 1.0
|
|
327
|
+
return 0.0
|
|
328
|
+
|
|
329
|
+
def _score_entity_fallback(self, cols: list[str]) -> float:
|
|
330
|
+
for c in cols:
|
|
331
|
+
s = c.strip()
|
|
332
|
+
if _ID_COLUMN.match(s) or _SURROGATE_KEY.match(s):
|
|
333
|
+
return 0.35
|
|
334
|
+
return 0.05
|
|
335
|
+
|
|
336
|
+
def _signals_for_winner(
|
|
337
|
+
self,
|
|
338
|
+
table: TableClassifierInput,
|
|
339
|
+
lower: str,
|
|
340
|
+
cols: list[str],
|
|
341
|
+
role: TableRole,
|
|
342
|
+
) -> list[str]:
|
|
343
|
+
sigs: list[str] = []
|
|
344
|
+
if table.fk_out_degree:
|
|
345
|
+
sigs.append(f"FK out-degree {table.fk_out_degree}")
|
|
346
|
+
if table.fk_in_degree:
|
|
347
|
+
sigs.append(f"FK in-degree {table.fk_in_degree}")
|
|
348
|
+
if table.row_count is not None:
|
|
349
|
+
sigs.append(f"row_count {table.row_count}")
|
|
350
|
+
seq = table.seq_scan_count
|
|
351
|
+
idx = table.idx_scan_count
|
|
352
|
+
rc_sig = table.row_count
|
|
353
|
+
if (
|
|
354
|
+
role in ("fact", "staging")
|
|
355
|
+
and (seq is not None or idx is not None)
|
|
356
|
+
and rc_sig is not None
|
|
357
|
+
and rc_sig > 10_000
|
|
358
|
+
):
|
|
359
|
+
s, i = seq or 0, idx or 0
|
|
360
|
+
tot = s + i
|
|
361
|
+
if tot > 0 and s / tot > 0.9:
|
|
362
|
+
sigs.append("high seq vs idx scan ratio (large table)")
|
|
363
|
+
j_score, j_sigs = self._junction_signals(table.name.strip(), cols)
|
|
364
|
+
if role == "junction" and j_sigs:
|
|
365
|
+
sigs.extend(j_sigs)
|
|
366
|
+
if role == "dimension" and (lower.startswith("dim_") or "_dim" in lower):
|
|
367
|
+
sigs.append("dimension name prefix/pattern")
|
|
368
|
+
if role == "fact" and (lower.startswith("fact_") or lower.startswith("fct_")):
|
|
369
|
+
sigs.append("fact name prefix")
|
|
370
|
+
if role == "aggregate":
|
|
371
|
+
sigs.append("aggregate naming or measure-like columns")
|
|
372
|
+
if role == "snapshot_scd":
|
|
373
|
+
sigs.append("snapshot / SCD naming or multiple date columns")
|
|
374
|
+
if role == "bridge":
|
|
375
|
+
sigs.append("high FK fan-in and fan-out (bridge pattern)")
|
|
376
|
+
if role == "unknown":
|
|
377
|
+
sigs.append("weak signal mix")
|
|
378
|
+
return sigs[:8] if sigs else ["heuristic winner"]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def is_entity_extraction_candidate(classification: TableClassification) -> bool:
|
|
382
|
+
"""Whether a table should be offered to LLM entity grouping."""
|
|
383
|
+
return classification.role in ("entity_candidate", "dimension", "fact")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""LLM entity extraction Protocol and null client (extension point)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from pretensor.connectors.models import SchemaSnapshot
|
|
11
|
+
from pretensor.observability import run_timed_async
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ExtractedEntity",
|
|
15
|
+
"LLMEntityExtractor",
|
|
16
|
+
"LlmEntityClient",
|
|
17
|
+
"NullLlmEntityClient",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExtractedEntity(BaseModel):
|
|
24
|
+
"""One business entity grouping physical tables."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
description: str = ""
|
|
28
|
+
tables: list[str] = Field(
|
|
29
|
+
default_factory=list,
|
|
30
|
+
description="Table refs: schema.name or bare name.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LlmEntityClient(Protocol):
|
|
35
|
+
"""Async client returning YAML entity extraction (implement per provider)."""
|
|
36
|
+
|
|
37
|
+
async def extract_entities(self, *, prompt: str) -> str:
|
|
38
|
+
"""Return YAML body (list of entity dicts)."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NullLlmEntityClient:
|
|
43
|
+
"""No-op client: returns an empty YAML list."""
|
|
44
|
+
|
|
45
|
+
async def extract_entities(self, *, prompt: str) -> str:
|
|
46
|
+
async def _empty_yaml() -> str:
|
|
47
|
+
return "[]"
|
|
48
|
+
|
|
49
|
+
return await run_timed_async(
|
|
50
|
+
logger,
|
|
51
|
+
event="llm.extract_entities",
|
|
52
|
+
callback=_empty_yaml,
|
|
53
|
+
provider="null",
|
|
54
|
+
prompt_chars=len(prompt),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LLMEntityExtractor:
|
|
59
|
+
"""Runs entity extraction using a :class:`LlmEntityClient`."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, client: LlmEntityClient | None = None) -> None:
|
|
62
|
+
self._client = client or NullLlmEntityClient()
|
|
63
|
+
|
|
64
|
+
async def extract(self, snapshot: SchemaSnapshot) -> list[ExtractedEntity]:
|
|
65
|
+
"""Return extracted entities (empty in OSS; plugins wire a real client)."""
|
|
66
|
+
return []
|
pretensor/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Root exception hierarchy for the pretensor package.
|
|
2
|
+
|
|
3
|
+
All internal errors raised by pretensor modules are subclasses of
|
|
4
|
+
:class:`PretensorError`. Callers that want to catch any pretensor-specific
|
|
5
|
+
failure without enumerating every subclass can use a single ``except
|
|
6
|
+
PretensorError`` clause. Subclasses may additionally inherit from a Python
|
|
7
|
+
builtin (e.g. ``ValueError``, ``KeyError``) so that existing ``except
|
|
8
|
+
<Builtin>`` sites keep working without any changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = ["PretensorError", "ConnectorError"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PretensorError(Exception):
|
|
17
|
+
"""Base class for all exceptions raised by the pretensor library.
|
|
18
|
+
|
|
19
|
+
Catching this class is sufficient to handle any error that originates
|
|
20
|
+
inside pretensor code (as opposed to errors propagated from third-party
|
|
21
|
+
libraries such as SQLAlchemy or Fernet, which are left unwrapped unless
|
|
22
|
+
pretensor re-raises them explicitly).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectorError(PretensorError):
|
|
27
|
+
"""Raised when a database connector operation fails.
|
|
28
|
+
|
|
29
|
+
This is the single shared base for all connector-level errors across
|
|
30
|
+
every supported database backend (PostgreSQL, MySQL, Snowflake, BigQuery,
|
|
31
|
+
etc.). Both ``pretensor.connectors.postgres`` and
|
|
32
|
+
``pretensor.connectors.mysql`` re-export this class under the name
|
|
33
|
+
``ConnectorError`` so that code importing from either module continues
|
|
34
|
+
to work unchanged while now sharing the same exception identity.
|
|
35
|
+
"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Graph-layer data models (distinct from pipeline schema models)."""
|
|
2
|
+
|
|
3
|
+
from pretensor.graph_models.consumer import ConsumesEdge, ExternalConsumerNode
|
|
4
|
+
from pretensor.graph_models.edge import GraphEdge, LineageEdge
|
|
5
|
+
from pretensor.graph_models.entity import EntityNode
|
|
6
|
+
from pretensor.graph_models.node import GraphNode
|
|
7
|
+
from pretensor.graph_models.relationship import RelationshipCandidate
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConsumesEdge",
|
|
11
|
+
"EntityNode",
|
|
12
|
+
"ExternalConsumerNode",
|
|
13
|
+
"GraphEdge",
|
|
14
|
+
"GraphNode",
|
|
15
|
+
"LineageEdge",
|
|
16
|
+
"RelationshipCandidate",
|
|
17
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Graph models for external code consumers of physical tables.
|
|
2
|
+
|
|
3
|
+
``ExternalConsumerNode`` is a code location (service + repo-relative file + a
|
|
4
|
+
normalized SQL fingerprint) that issues a SQL statement; ``ConsumesEdge`` links
|
|
5
|
+
it to the ``SchemaTable`` it reads or writes. Raw SQL text is never carried on
|
|
6
|
+
either model — only the ``sql_fingerprint`` and resolved endpoints.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from pretensor.graph_models.base import GraphModel
|
|
14
|
+
|
|
15
|
+
__all__ = ["ExternalConsumerNode", "ConsumesEdge"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ExternalConsumerNode(GraphModel):
|
|
19
|
+
"""A code location that consumes one or more physical tables via SQL."""
|
|
20
|
+
|
|
21
|
+
node_id: str = Field(
|
|
22
|
+
description="Stable primary key in the Kuzu ExternalConsumer node table."
|
|
23
|
+
)
|
|
24
|
+
connection_name: str = Field(
|
|
25
|
+
description="Pretensor connection whose tables this consumer resolves against."
|
|
26
|
+
)
|
|
27
|
+
service_name: str = Field(
|
|
28
|
+
description="Consumer-service label (defaults to the scanned repo basename)."
|
|
29
|
+
)
|
|
30
|
+
file_path: str = Field(
|
|
31
|
+
description="Repo-relative path of the source file (never absolute)."
|
|
32
|
+
)
|
|
33
|
+
language: str = Field(description="Source language, e.g. 'python'.")
|
|
34
|
+
symbol: str = Field(
|
|
35
|
+
description="Enclosing identifier: assigned variable or called method."
|
|
36
|
+
)
|
|
37
|
+
kind: str = Field(
|
|
38
|
+
description="Extraction context: 'assignment', 'call', 'fstring', or 'sql_file'."
|
|
39
|
+
)
|
|
40
|
+
line_start: int
|
|
41
|
+
line_end: int
|
|
42
|
+
sql_fingerprint: str = Field(
|
|
43
|
+
description="sha256[:16] of the normalized SQL. NOT the raw SQL text."
|
|
44
|
+
)
|
|
45
|
+
confidence: float = 1.0
|
|
46
|
+
dialect_used: str = Field(
|
|
47
|
+
default="",
|
|
48
|
+
description="sqlglot dialect that parsed the statement (empty if none).",
|
|
49
|
+
)
|
|
50
|
+
scan_run_id: str = Field(
|
|
51
|
+
description="Idempotency key for the scan that wrote this node."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConsumesEdge(GraphModel):
|
|
56
|
+
"""Directed edge from an ExternalConsumer to a table it reads or writes."""
|
|
57
|
+
|
|
58
|
+
edge_id: str = Field(
|
|
59
|
+
description="Stable primary key in the Kuzu CONSUMES rel table."
|
|
60
|
+
)
|
|
61
|
+
source_node_id: str = Field(description="ExternalConsumer node id (FROM).")
|
|
62
|
+
target_node_id: str = Field(description="SchemaTable node id (TO).")
|
|
63
|
+
op: str = Field(description="Access kind: 'read' or 'write'.")
|
|
64
|
+
source: str = Field(
|
|
65
|
+
default="analyze",
|
|
66
|
+
description="Provenance label for the edge (code-scanner = 'analyze').",
|
|
67
|
+
)
|
|
68
|
+
confidence: float = 1.0
|
|
69
|
+
scan_run_id: str = Field(
|
|
70
|
+
description="Idempotency key for the scan that wrote this edge."
|
|
71
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Graph edge payloads for FK relationships and table-level lineage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from pretensor.graph_models.base import GraphModel
|
|
8
|
+
|
|
9
|
+
__all__ = ["GraphEdge", "LineageEdge"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GraphEdge(GraphModel):
|
|
13
|
+
"""A directed foreign-key edge between two table nodes."""
|
|
14
|
+
|
|
15
|
+
edge_id: str = Field(
|
|
16
|
+
description="Stable primary key in the Kuzu FK edge rel table."
|
|
17
|
+
)
|
|
18
|
+
source_node_id: str
|
|
19
|
+
target_node_id: str
|
|
20
|
+
source_column: str
|
|
21
|
+
target_column: str
|
|
22
|
+
constraint_name: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LineageEdge(GraphModel):
|
|
26
|
+
"""Directed lineage from a source table to a derived or dependent table."""
|
|
27
|
+
|
|
28
|
+
edge_id: str = Field(description="Stable primary key in the Kuzu LINEAGE rel table.")
|
|
29
|
+
source_node_id: str
|
|
30
|
+
target_node_id: str
|
|
31
|
+
source: str = Field(
|
|
32
|
+
description="Provenance label (e.g. connector + object name), not SQL text."
|
|
33
|
+
)
|
|
34
|
+
lineage_type: str
|
|
35
|
+
confidence: float = 1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Business entity node payload for Kuzu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from pretensor.graph_models.base import GraphModel
|
|
8
|
+
|
|
9
|
+
__all__ = ["EntityNode"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EntityNode(GraphModel):
|
|
13
|
+
"""A named business entity linked to one or more ``SchemaTable`` nodes."""
|
|
14
|
+
|
|
15
|
+
node_id: str = Field(
|
|
16
|
+
description="Stable primary key in the Kuzu Entity node table."
|
|
17
|
+
)
|
|
18
|
+
connection_name: str
|
|
19
|
+
database: str
|
|
20
|
+
name: str
|
|
21
|
+
description: str = ""
|