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,150 @@
|
|
|
1
|
+
"""MCP ``validate_sql`` tool payload — sqlglot + graph-backed SQL validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pretensor.mcp.tool_registry import McpTool
|
|
10
|
+
from pretensor.validation.query_validator import QueryValidator
|
|
11
|
+
|
|
12
|
+
from ..service_registry import (
|
|
13
|
+
load_registry,
|
|
14
|
+
open_store_for_entry,
|
|
15
|
+
release_store,
|
|
16
|
+
resolve_registry_entry,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["create_tool", "validate_sql_payload"]
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_DEFAULT_DIALECT = "postgres"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_sql_payload(
|
|
27
|
+
graph_dir: Path,
|
|
28
|
+
*,
|
|
29
|
+
sql: str,
|
|
30
|
+
database: str,
|
|
31
|
+
dialect: str = _DEFAULT_DIALECT,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Build JSON-serializable payload for ``validate_sql``.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
graph_dir: Root directory of the Pretensor graph store.
|
|
37
|
+
sql: SQL statement to validate.
|
|
38
|
+
database: ``connection_name`` or logical ``database`` key.
|
|
39
|
+
dialect: sqlglot dialect (default ``postgres``).
|
|
40
|
+
"""
|
|
41
|
+
if not sql.strip():
|
|
42
|
+
return {"error": "Missing or empty `sql`"}
|
|
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
|
+
store = open_store_for_entry(entry)
|
|
53
|
+
except Exception:
|
|
54
|
+
# Keep the on-disk graph path and engine error server-side; the client
|
|
55
|
+
# sees a stable, connection-named message only.
|
|
56
|
+
logger.exception(
|
|
57
|
+
"validate_sql: failed to open graph for connection %r",
|
|
58
|
+
entry.connection_name,
|
|
59
|
+
)
|
|
60
|
+
return {
|
|
61
|
+
"error": (
|
|
62
|
+
f"Could not open the graph for database {database!r}. "
|
|
63
|
+
"The graph may be missing or corrupt — re-run indexing."
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
validator = QueryValidator(
|
|
68
|
+
store,
|
|
69
|
+
connection_name=entry.connection_name,
|
|
70
|
+
database_key=entry.database,
|
|
71
|
+
dialect=dialect,
|
|
72
|
+
)
|
|
73
|
+
result = validator.validate(sql)
|
|
74
|
+
finally:
|
|
75
|
+
release_store(store)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"valid": result.valid,
|
|
79
|
+
"dialect": dialect,
|
|
80
|
+
"syntax_errors": result.syntax_errors,
|
|
81
|
+
"missing_tables": result.missing_tables,
|
|
82
|
+
"missing_columns": result.missing_columns,
|
|
83
|
+
"invalid_joins": [
|
|
84
|
+
{
|
|
85
|
+
"message": j.message,
|
|
86
|
+
"left_table": j.left_table,
|
|
87
|
+
"right_table": j.right_table,
|
|
88
|
+
}
|
|
89
|
+
for j in result.invalid_joins
|
|
90
|
+
],
|
|
91
|
+
"suggestions": result.suggestions,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_tool(graph_dir: Path) -> McpTool:
|
|
96
|
+
from ._timed import timed_tool
|
|
97
|
+
|
|
98
|
+
async def _handle(args: dict) -> dict:
|
|
99
|
+
sql_s = str(args.get("sql", ""))
|
|
100
|
+
db_t = str(args.get("database", "")).strip()
|
|
101
|
+
dialect_raw = args.get("dialect")
|
|
102
|
+
dialect_s = str(dialect_raw).strip() if dialect_raw is not None else "postgres"
|
|
103
|
+
if not dialect_s:
|
|
104
|
+
dialect_s = "postgres"
|
|
105
|
+
if not sql_s.strip():
|
|
106
|
+
return {"error": "Missing `sql`"}
|
|
107
|
+
if not db_t:
|
|
108
|
+
return {"error": "Missing `database`"}
|
|
109
|
+
with timed_tool("validate_sql", graph_dir, database=db_t, dialect=dialect_s):
|
|
110
|
+
return validate_sql_payload(
|
|
111
|
+
graph_dir, sql=sql_s, database=db_t, dialect=dialect_s
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return McpTool(
|
|
115
|
+
name="validate_sql",
|
|
116
|
+
description=(
|
|
117
|
+
"Validate a SQL statement against the indexed graph before executing it. "
|
|
118
|
+
"Catches: unknown tables, unknown columns, invalid joins (pairs not in "
|
|
119
|
+
"FK_REFERENCES or INFERRED_JOIN edges), sqlglot parse errors. Returns "
|
|
120
|
+
"fuzzy suggestions for misspelled identifiers. Read-only.\n\n"
|
|
121
|
+
"Examples:\n"
|
|
122
|
+
" sql='SELECT * FROM custmer' -> missing_tables=['custmer'], "
|
|
123
|
+
"suggestions=['customer']\n"
|
|
124
|
+
" sql='SELECT c.foo FROM customer c' -> "
|
|
125
|
+
"missing_columns=[{'table':'customer','column':'foo'}]\n"
|
|
126
|
+
" sql='SELECT * FROM orders o JOIN customer c ON o.x = c.y' -> "
|
|
127
|
+
"invalid_joins=[...] when no FK or inferred edge exists between columns"
|
|
128
|
+
),
|
|
129
|
+
input_schema={
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"sql": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "SQL statement to validate",
|
|
135
|
+
},
|
|
136
|
+
"database": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"description": "Connection name or logical database",
|
|
139
|
+
},
|
|
140
|
+
"dialect": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"default": "postgres",
|
|
143
|
+
"description": "sqlglot dialect (default 'postgres')",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
"required": ["sql", "database"],
|
|
147
|
+
"additionalProperties": False,
|
|
148
|
+
},
|
|
149
|
+
handler=_handle,
|
|
150
|
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Process-wide logging configuration and timing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Awaitable, Callable, Iterator, Literal, TypeVar
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"LogFormat",
|
|
14
|
+
"LogLevel",
|
|
15
|
+
"configure_logging",
|
|
16
|
+
"log_timed_operation",
|
|
17
|
+
"run_timed_async",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
LogLevel = Literal["debug", "info", "warning", "error"]
|
|
21
|
+
LogFormat = Literal["text", "json"]
|
|
22
|
+
|
|
23
|
+
_LOG_LEVELS: dict[LogLevel, int] = {
|
|
24
|
+
"debug": logging.DEBUG,
|
|
25
|
+
"info": logging.INFO,
|
|
26
|
+
"warning": logging.WARNING,
|
|
27
|
+
"error": logging.ERROR,
|
|
28
|
+
}
|
|
29
|
+
_PRETENSOR_HANDLER_ATTR = "_pretensor_logging_handler"
|
|
30
|
+
_T = TypeVar("_T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _JsonFormatter(logging.Formatter):
|
|
34
|
+
"""Render log records as one JSON object per line."""
|
|
35
|
+
|
|
36
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
37
|
+
payload: dict[str, Any] = {
|
|
38
|
+
"timestamp": datetime.fromtimestamp(
|
|
39
|
+
record.created, tz=timezone.utc
|
|
40
|
+
).isoformat(),
|
|
41
|
+
"level": record.levelname.lower(),
|
|
42
|
+
"logger": record.name,
|
|
43
|
+
"event": getattr(record, "event", None),
|
|
44
|
+
"status": getattr(record, "status", None),
|
|
45
|
+
"message": record.getMessage(),
|
|
46
|
+
}
|
|
47
|
+
if hasattr(record, "duration_ms"):
|
|
48
|
+
payload["duration_ms"] = getattr(record, "duration_ms")
|
|
49
|
+
for key, value in record.__dict__.items():
|
|
50
|
+
if key.startswith("_"):
|
|
51
|
+
continue
|
|
52
|
+
if key in payload:
|
|
53
|
+
continue
|
|
54
|
+
if key in ("args", "msg", "exc_info", "exc_text", "stack_info"):
|
|
55
|
+
continue
|
|
56
|
+
if key in (
|
|
57
|
+
"name",
|
|
58
|
+
"levelname",
|
|
59
|
+
"levelno",
|
|
60
|
+
"pathname",
|
|
61
|
+
"filename",
|
|
62
|
+
"module",
|
|
63
|
+
"lineno",
|
|
64
|
+
"funcName",
|
|
65
|
+
"created",
|
|
66
|
+
"msecs",
|
|
67
|
+
"relativeCreated",
|
|
68
|
+
"thread",
|
|
69
|
+
"threadName",
|
|
70
|
+
"processName",
|
|
71
|
+
"process",
|
|
72
|
+
"taskName",
|
|
73
|
+
"asctime",
|
|
74
|
+
):
|
|
75
|
+
continue
|
|
76
|
+
payload[key] = value
|
|
77
|
+
if record.exc_info:
|
|
78
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
79
|
+
return _json_dumps(payload)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _json_dumps(payload: dict[str, Any]) -> str:
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def configure_logging(
|
|
89
|
+
*,
|
|
90
|
+
level: LogLevel = "info",
|
|
91
|
+
log_format: LogFormat = "text",
|
|
92
|
+
log_file: Path | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Configure root logging handlers for CLI and MCP code paths."""
|
|
95
|
+
root = logging.getLogger()
|
|
96
|
+
target_level = _LOG_LEVELS[level]
|
|
97
|
+
root.setLevel(target_level)
|
|
98
|
+
logging.getLogger("pretensor").setLevel(logging.NOTSET)
|
|
99
|
+
_remove_pretensor_handlers(root)
|
|
100
|
+
|
|
101
|
+
formatter: logging.Formatter
|
|
102
|
+
if log_format == "json":
|
|
103
|
+
formatter = _JsonFormatter()
|
|
104
|
+
else:
|
|
105
|
+
formatter = logging.Formatter(
|
|
106
|
+
"%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
107
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
stream_handler = logging.StreamHandler()
|
|
111
|
+
stream_handler.setFormatter(formatter)
|
|
112
|
+
stream_handler.addFilter(_build_handler_filter(target_level))
|
|
113
|
+
setattr(stream_handler, _PRETENSOR_HANDLER_ATTR, True)
|
|
114
|
+
root.addHandler(stream_handler)
|
|
115
|
+
|
|
116
|
+
if log_file is not None:
|
|
117
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
119
|
+
file_handler.setFormatter(formatter)
|
|
120
|
+
file_handler.addFilter(_build_handler_filter(target_level))
|
|
121
|
+
setattr(file_handler, _PRETENSOR_HANDLER_ATTR, True)
|
|
122
|
+
root.addHandler(file_handler)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _remove_pretensor_handlers(root: logging.Logger) -> None:
|
|
126
|
+
for handler in list(root.handlers):
|
|
127
|
+
if not getattr(handler, _PRETENSOR_HANDLER_ATTR, False):
|
|
128
|
+
continue
|
|
129
|
+
root.removeHandler(handler)
|
|
130
|
+
handler.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_handler_filter(min_level: int) -> Callable[[logging.LogRecord], bool]:
|
|
134
|
+
def _handler_filter(record: logging.LogRecord) -> bool:
|
|
135
|
+
if record.name.startswith("pretensor"):
|
|
136
|
+
if record.levelno < min_level:
|
|
137
|
+
return False
|
|
138
|
+
if (
|
|
139
|
+
min_level == logging.INFO
|
|
140
|
+
and record.levelno == logging.INFO
|
|
141
|
+
and not hasattr(record, "event")
|
|
142
|
+
):
|
|
143
|
+
return False
|
|
144
|
+
return True
|
|
145
|
+
return record.levelno >= logging.WARNING
|
|
146
|
+
|
|
147
|
+
return _handler_filter
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@contextmanager
|
|
151
|
+
def log_timed_operation(
|
|
152
|
+
logger: logging.Logger,
|
|
153
|
+
*,
|
|
154
|
+
event: str,
|
|
155
|
+
level: int = logging.INFO,
|
|
156
|
+
**fields: Any,
|
|
157
|
+
) -> Iterator[None]:
|
|
158
|
+
"""Log operation latency and status with structured fields."""
|
|
159
|
+
started = time.perf_counter()
|
|
160
|
+
try:
|
|
161
|
+
yield
|
|
162
|
+
except Exception:
|
|
163
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
164
|
+
logger.log(
|
|
165
|
+
level,
|
|
166
|
+
"%s failed in %.2fms",
|
|
167
|
+
event,
|
|
168
|
+
duration_ms,
|
|
169
|
+
extra={
|
|
170
|
+
"event": event,
|
|
171
|
+
"status": "error",
|
|
172
|
+
"duration_ms": duration_ms,
|
|
173
|
+
**fields,
|
|
174
|
+
},
|
|
175
|
+
exc_info=True,
|
|
176
|
+
)
|
|
177
|
+
raise
|
|
178
|
+
duration_ms = (time.perf_counter() - started) * 1000
|
|
179
|
+
logger.log(
|
|
180
|
+
level,
|
|
181
|
+
"%s completed in %.2fms",
|
|
182
|
+
event,
|
|
183
|
+
duration_ms,
|
|
184
|
+
extra={
|
|
185
|
+
"event": event,
|
|
186
|
+
"status": "ok",
|
|
187
|
+
"duration_ms": duration_ms,
|
|
188
|
+
**fields,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def run_timed_async(
|
|
194
|
+
logger: logging.Logger,
|
|
195
|
+
*,
|
|
196
|
+
event: str,
|
|
197
|
+
callback: Callable[[], Awaitable[_T]],
|
|
198
|
+
level: int = logging.INFO,
|
|
199
|
+
**fields: Any,
|
|
200
|
+
) -> _T:
|
|
201
|
+
"""Await ``callback`` and emit a structured timing log."""
|
|
202
|
+
with log_timed_operation(logger, event=event, level=level, **fields):
|
|
203
|
+
return await callback()
|
pretensor/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Pretensor quickstart database
|
|
2
|
+
|
|
3
|
+
A throwaway Postgres instance pre-loaded with the Pagila sample schema (DVD
|
|
4
|
+
rental store), sized for `pretensor quickstart`. Listens on **localhost:55432**.
|
|
5
|
+
|
|
6
|
+
## Use it
|
|
7
|
+
|
|
8
|
+
The recommended path is `pretensor quickstart`, which starts the container,
|
|
9
|
+
indexes the DB into `./.pretensor/`, and prints an MCP config snippet.
|
|
10
|
+
|
|
11
|
+
To run the container directly from a clone of the repo:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
docker compose -f src/pretensor/quickstart/docker-compose.yml up -d
|
|
15
|
+
# DSN: postgresql://postgres:postgres@localhost:55432/pagila
|
|
16
|
+
docker compose -f src/pretensor/quickstart/docker-compose.yml down -v
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Contents
|
|
20
|
+
|
|
21
|
+
- 8 tables across `public` and `staff` schemas
|
|
22
|
+
- ~35 seed rows total — enough for `query`, `context`, and `traverse` to
|
|
23
|
+
return non-empty results
|
|
24
|
+
- FK chain: `payment → rental → inventory → film` plus `rental → customer`
|
|
25
|
+
|
|
26
|
+
The schema and seed live next to this README as `pagila_ddl.sql` and
|
|
27
|
+
`pagila_data.sql` — a curated subset of upstream Pagila. They ship inside
|
|
28
|
+
the wheel so `pretensor quickstart` works after a plain `pip install`
|
|
29
|
+
without needing a clone of the repo.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Quickstart assets shipped with the wheel.
|
|
2
|
+
|
|
3
|
+
Holds the docker-compose file and pagila SQL fixtures consumed by
|
|
4
|
+
``pretensor quickstart``. Resolved at runtime via ``importlib.resources`` so
|
|
5
|
+
they work the same in editable installs and in pip-installed wheels.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
services:
|
|
2
|
+
pretensor-quickstart-db:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
container_name: pretensor-quickstart-db
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_USER: postgres
|
|
7
|
+
POSTGRES_PASSWORD: postgres
|
|
8
|
+
POSTGRES_DB: pagila
|
|
9
|
+
ports:
|
|
10
|
+
- "55432:5432"
|
|
11
|
+
volumes:
|
|
12
|
+
- ./pagila_ddl.sql:/docker-entrypoint-initdb.d/01_pagila_ddl.sql:ro
|
|
13
|
+
- ./pagila_data.sql:/docker-entrypoint-initdb.d/02_pagila_data.sql:ro
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d pagila"]
|
|
16
|
+
interval: 2s
|
|
17
|
+
timeout: 3s
|
|
18
|
+
retries: 30
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Minimal Pagila seed for the hello-world quickstart.
|
|
2
|
+
-- Curated subset: enough rows for `query` / `context` / `traverse` to return
|
|
3
|
+
-- non-empty results, while staying small enough to commit to the repo.
|
|
4
|
+
|
|
5
|
+
INSERT INTO public.film (title, description, release_year, rental_rate, length, rating) VALUES
|
|
6
|
+
('Academy Dinosaur', 'A Boring Saga of a Dinosaur', 2006, 0.99, 86, 'PG'),
|
|
7
|
+
('Ace Goldfinger', 'An Astounding Epistle in Hong Kong', 2006, 4.99, 48, 'G'),
|
|
8
|
+
('Adaptation Holes', 'A Astounding Reflection of a Lumberjack', 2006, 2.99, 50, 'NC-17'),
|
|
9
|
+
('Affair Prejudice', 'A Fanciful Documentary of a Frisbee', 2006, 2.99, 117, 'G'),
|
|
10
|
+
('African Egg', 'A Fast-Paced Documentary of a Pastry Chef', 2006, 2.99, 130, 'G');
|
|
11
|
+
|
|
12
|
+
INSERT INTO public.actor (first_name, last_name, birth_date) VALUES
|
|
13
|
+
('Penelope', 'Guiness', '1970-04-12'),
|
|
14
|
+
('Nick', 'Wahlberg', '1971-01-30'),
|
|
15
|
+
('Ed', 'Chase', '1955-07-08'),
|
|
16
|
+
('Jennifer', 'Davis', '1980-09-15'),
|
|
17
|
+
('Johnny', 'Lollobrigida', '1948-11-22');
|
|
18
|
+
|
|
19
|
+
INSERT INTO public.film_actor (actor_id, film_id, role) VALUES
|
|
20
|
+
(1, 1, 'lead'),
|
|
21
|
+
(2, 1, 'support'),
|
|
22
|
+
(3, 2, 'lead'),
|
|
23
|
+
(4, 3, 'lead'),
|
|
24
|
+
(5, 4, 'support');
|
|
25
|
+
|
|
26
|
+
INSERT INTO public.inventory (film_id, store_id, condition, acquired_at) VALUES
|
|
27
|
+
(1, 1, 'good', '2024-01-15'),
|
|
28
|
+
(1, 1, 'good', '2024-01-15'),
|
|
29
|
+
(2, 1, 'fair', '2024-02-01'),
|
|
30
|
+
(3, 2, 'good', '2024-02-10'),
|
|
31
|
+
(4, 1, 'good', '2024-03-05');
|
|
32
|
+
|
|
33
|
+
INSERT INTO public.customer (first_name, last_name, email) VALUES
|
|
34
|
+
('Mary', 'Smith', 'mary.smith@example.com'),
|
|
35
|
+
('Patricia','Johnson', 'patricia.j@example.com'),
|
|
36
|
+
('Linda', 'Williams','linda.w@example.com');
|
|
37
|
+
|
|
38
|
+
INSERT INTO public.rental (inventory_id, customer_id, rental_date, return_date) VALUES
|
|
39
|
+
(1, 1, '2024-05-24 22:53:30+00', '2024-05-26 22:04:30+00'),
|
|
40
|
+
(2, 2, '2024-05-24 22:54:33+00', '2024-05-28 19:40:33+00'),
|
|
41
|
+
(3, 1, '2024-05-25 11:30:37+00', '2024-06-01 14:25:37+00'),
|
|
42
|
+
(4, 3, '2024-05-26 09:15:00+00', '2024-05-30 12:00:00+00'),
|
|
43
|
+
(5, 2, '2024-05-27 13:42:11+00', NULL);
|
|
44
|
+
|
|
45
|
+
INSERT INTO public.payment (rental_id, customer_id, amount, method) VALUES
|
|
46
|
+
(1, 1, 2.99, 'cash'),
|
|
47
|
+
(2, 2, 4.99, 'card'),
|
|
48
|
+
(3, 1, 0.99, 'card'),
|
|
49
|
+
(4, 3, 2.99, 'cash'),
|
|
50
|
+
(5, 2, 4.99, 'card');
|
|
51
|
+
|
|
52
|
+
INSERT INTO staff.staff (first_name, last_name, email, store_id, username) VALUES
|
|
53
|
+
('Mike', 'Hillyer', 'mike@example.com', 1, 'mike'),
|
|
54
|
+
('Jon', 'Stephens', 'jon@example.com', 2, 'jon');
|
|
55
|
+
|
|
56
|
+
-- Reset sequences so subsequent inserts (if any) start past seeded ids.
|
|
57
|
+
SELECT setval(pg_get_serial_sequence('public.film', 'film_id'), (SELECT MAX(film_id) FROM public.film));
|
|
58
|
+
SELECT setval(pg_get_serial_sequence('public.actor', 'actor_id'), (SELECT MAX(actor_id) FROM public.actor));
|
|
59
|
+
SELECT setval(pg_get_serial_sequence('public.inventory', 'inventory_id'), (SELECT MAX(inventory_id) FROM public.inventory));
|
|
60
|
+
SELECT setval(pg_get_serial_sequence('public.customer', 'customer_id'), (SELECT MAX(customer_id) FROM public.customer));
|
|
61
|
+
SELECT setval(pg_get_serial_sequence('public.rental', 'rental_id'), (SELECT MAX(rental_id) FROM public.rental));
|
|
62
|
+
SELECT setval(pg_get_serial_sequence('public.payment', 'payment_id'), (SELECT MAX(payment_id) FROM public.payment));
|
|
63
|
+
SELECT setval(pg_get_serial_sequence('staff.staff', 'staff_id'), (SELECT MAX(staff_id) FROM staff.staff));
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
-- Minimal Pagila-derived schema for E2E testing
|
|
2
|
+
-- 2 schemas: public, staff
|
|
3
|
+
-- 7 tables in public: film, actor, film_actor, inventory, customer, rental, payment
|
|
4
|
+
-- 1 table in staff: staff
|
|
5
|
+
-- FK chains: inventory→film, rental→inventory, rental→customer, payment→rental
|
|
6
|
+
|
|
7
|
+
CREATE SCHEMA IF NOT EXISTS staff;
|
|
8
|
+
|
|
9
|
+
-- ============================================================
|
|
10
|
+
-- public schema
|
|
11
|
+
-- ============================================================
|
|
12
|
+
|
|
13
|
+
CREATE TABLE public.film (
|
|
14
|
+
film_id SERIAL PRIMARY KEY,
|
|
15
|
+
title VARCHAR(255) NOT NULL,
|
|
16
|
+
description TEXT,
|
|
17
|
+
release_year SMALLINT,
|
|
18
|
+
rental_rate NUMERIC(4,2) NOT NULL DEFAULT 4.99,
|
|
19
|
+
length SMALLINT,
|
|
20
|
+
rating VARCHAR(10) DEFAULT 'G',
|
|
21
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE public.actor (
|
|
25
|
+
actor_id SERIAL PRIMARY KEY,
|
|
26
|
+
first_name VARCHAR(45) NOT NULL,
|
|
27
|
+
last_name VARCHAR(45) NOT NULL,
|
|
28
|
+
birth_date DATE,
|
|
29
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
30
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE public.film_actor (
|
|
34
|
+
actor_id INT NOT NULL REFERENCES public.actor(actor_id),
|
|
35
|
+
film_id INT NOT NULL REFERENCES public.film(film_id),
|
|
36
|
+
role VARCHAR(50),
|
|
37
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
38
|
+
PRIMARY KEY (actor_id, film_id)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE public.inventory (
|
|
42
|
+
inventory_id SERIAL PRIMARY KEY,
|
|
43
|
+
film_id INT NOT NULL REFERENCES public.film(film_id),
|
|
44
|
+
store_id SMALLINT NOT NULL DEFAULT 1,
|
|
45
|
+
condition VARCHAR(20) NOT NULL DEFAULT 'good',
|
|
46
|
+
acquired_at DATE,
|
|
47
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE public.customer (
|
|
51
|
+
customer_id SERIAL PRIMARY KEY,
|
|
52
|
+
first_name VARCHAR(45) NOT NULL,
|
|
53
|
+
last_name VARCHAR(45) NOT NULL,
|
|
54
|
+
email VARCHAR(100),
|
|
55
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
56
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
57
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE public.rental (
|
|
61
|
+
rental_id SERIAL PRIMARY KEY,
|
|
62
|
+
inventory_id INT NOT NULL REFERENCES public.inventory(inventory_id),
|
|
63
|
+
customer_id INT NOT NULL REFERENCES public.customer(customer_id),
|
|
64
|
+
rental_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
65
|
+
return_date TIMESTAMPTZ,
|
|
66
|
+
staff_id SMALLINT NOT NULL DEFAULT 1,
|
|
67
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE public.payment (
|
|
71
|
+
payment_id SERIAL PRIMARY KEY,
|
|
72
|
+
rental_id INT NOT NULL REFERENCES public.rental(rental_id),
|
|
73
|
+
customer_id INT NOT NULL REFERENCES public.customer(customer_id),
|
|
74
|
+
amount NUMERIC(5,2) NOT NULL,
|
|
75
|
+
payment_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
76
|
+
method VARCHAR(20) NOT NULL DEFAULT 'cash'
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- ============================================================
|
|
80
|
+
-- staff schema (multi-schema coverage)
|
|
81
|
+
-- ============================================================
|
|
82
|
+
|
|
83
|
+
CREATE TABLE staff.staff (
|
|
84
|
+
staff_id SERIAL PRIMARY KEY,
|
|
85
|
+
first_name VARCHAR(45) NOT NULL,
|
|
86
|
+
last_name VARCHAR(45) NOT NULL,
|
|
87
|
+
email VARCHAR(100),
|
|
88
|
+
store_id SMALLINT NOT NULL DEFAULT 1,
|
|
89
|
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
90
|
+
username VARCHAR(50) NOT NULL,
|
|
91
|
+
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
92
|
+
);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Keyword search over graph metadata (FTS5)."""
|
|
2
|
+
|
|
3
|
+
from pretensor.search.base import BaseSearchIndex, SearchResult
|
|
4
|
+
from pretensor.search.index import KeywordSearchIndex, SearchIndex
|
|
5
|
+
|
|
6
|
+
__all__ = ["BaseSearchIndex", "KeywordSearchIndex", "SearchIndex", "SearchResult"]
|
pretensor/search/base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Abstract base for search index implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from pretensor.core.registry import GraphRegistry
|
|
9
|
+
|
|
10
|
+
__all__ = ["BaseSearchIndex", "SearchResult"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class SearchResult:
|
|
15
|
+
"""One ranked hit from a search index."""
|
|
16
|
+
|
|
17
|
+
node_type: str
|
|
18
|
+
name: str
|
|
19
|
+
database_name: str
|
|
20
|
+
connection_name: str
|
|
21
|
+
description: str
|
|
22
|
+
score: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseSearchIndex(ABC):
|
|
26
|
+
"""Abstract search index over graph metadata.
|
|
27
|
+
|
|
28
|
+
Concrete implementations may use keyword (BM25), vector (embeddings),
|
|
29
|
+
or hybrid strategies. OSS ships ``KeywordSearchIndex``; plugins can register
|
|
30
|
+
a ``HybridSearchIndex`` subclass.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def search(
|
|
35
|
+
self,
|
|
36
|
+
q: str,
|
|
37
|
+
*,
|
|
38
|
+
db: str | None = None,
|
|
39
|
+
limit: int = 10,
|
|
40
|
+
) -> list[SearchResult]:
|
|
41
|
+
"""Search indexed metadata.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
q: Free-text query string.
|
|
45
|
+
db: Optional connection or logical database name filter.
|
|
46
|
+
limit: Maximum number of results to return.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Ranked list of search results.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def similar(
|
|
54
|
+
self,
|
|
55
|
+
name: str,
|
|
56
|
+
*,
|
|
57
|
+
db: str | None = None,
|
|
58
|
+
limit: int = 10,
|
|
59
|
+
) -> list[SearchResult]:
|
|
60
|
+
"""Return nodes related to the named node.
|
|
61
|
+
|
|
62
|
+
OSS implementations use graph proximity (FK / join edges). Other
|
|
63
|
+
implementations may additionally use vector similarity.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Fully-qualified node name, e.g. ``schema.table``.
|
|
67
|
+
db: Optional connection or logical database name filter.
|
|
68
|
+
limit: Maximum number of results to return.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Related nodes ranked by proximity or similarity.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def index_graph(self, registry: GraphRegistry) -> None:
|
|
76
|
+
"""Build or rebuild the search index from all graphs in *registry*.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
registry: Loaded registry describing available graph files.
|
|
80
|
+
"""
|