querypilot 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.
- querypilot/__init__.py +36 -0
- querypilot/access/__init__.py +3 -0
- querypilot/access/policy.py +32 -0
- querypilot/adapters/__init__.py +1 -0
- querypilot/adapters/anthropic.py +49 -0
- querypilot/adapters/openai.py +53 -0
- querypilot/audit/__init__.py +10 -0
- querypilot/audit/sinks.py +46 -0
- querypilot/audit/types.py +53 -0
- querypilot/cli.py +401 -0
- querypilot/connectors/__init__.py +5 -0
- querypilot/connectors/base.py +24 -0
- querypilot/connectors/postgres.py +48 -0
- querypilot/connectors/sqlite.py +49 -0
- querypilot/core/__init__.py +1 -0
- querypilot/core/client.py +286 -0
- querypilot/core/config.py +29 -0
- querypilot/core/safety_defaults.py +12 -0
- querypilot/core/types.py +89 -0
- querypilot/evals/__init__.py +81 -0
- querypilot/evals/check.py +222 -0
- querypilot/evals/compare.py +286 -0
- querypilot/evals/cost.py +202 -0
- querypilot/evals/factory.py +83 -0
- querypilot/evals/init.py +104 -0
- querypilot/evals/loader.py +179 -0
- querypilot/evals/pipeline.py +484 -0
- querypilot/evals/replay.py +217 -0
- querypilot/evals/report.py +246 -0
- querypilot/evals/suite.py +91 -0
- querypilot/evals/suite_runner.py +361 -0
- querypilot/execution/__init__.py +1 -0
- querypilot/execution/formatter.py +11 -0
- querypilot/execution/runner.py +13 -0
- querypilot/generation/__init__.py +9 -0
- querypilot/generation/llm.py +180 -0
- querypilot/generation/prompt_builder.py +49 -0
- querypilot/generation/sql_generator.py +82 -0
- querypilot/mcp/__init__.py +3 -0
- querypilot/mcp/server.py +51 -0
- querypilot/py.typed +0 -0
- querypilot/schema/__init__.py +3 -0
- querypilot/schema/introspector.py +8 -0
- querypilot/schema/search.py +33 -0
- querypilot/server/__init__.py +3 -0
- querypilot/server/app.py +157 -0
- querypilot/validation/__init__.py +3 -0
- querypilot/validation/policies.py +3 -0
- querypilot/validation/validator.py +592 -0
- querypilot-0.1.0.dist-info/METADATA +601 -0
- querypilot-0.1.0.dist-info/RECORD +54 -0
- querypilot-0.1.0.dist-info/WHEEL +4 -0
- querypilot-0.1.0.dist-info/entry_points.txt +2 -0
- querypilot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from querypilot.core.types import DatabaseSchema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseConnector(ABC):
|
|
10
|
+
def __init__(self, database_url: str, timeout_seconds: int = 10) -> None:
|
|
11
|
+
self.database_url = database_url
|
|
12
|
+
self.timeout_seconds = timeout_seconds
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_schema(self) -> DatabaseSchema:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def execute_readonly(self, sql: str) -> tuple[list[dict], int]:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def rows_to_dicts(keys: Sequence[str], rows: Sequence[Sequence[object]]) -> list[dict]:
|
|
24
|
+
return [dict(zip(keys, row, strict=False)) for row in rows]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine, inspect, text
|
|
4
|
+
from sqlalchemy.engine import Engine
|
|
5
|
+
|
|
6
|
+
from querypilot.connectors.base import BaseConnector
|
|
7
|
+
from querypilot.core.types import ColumnSchema, DatabaseSchema, TableSchema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgresConnector(BaseConnector):
|
|
11
|
+
def __init__(self, database_url: str, timeout_seconds: int = 10) -> None:
|
|
12
|
+
super().__init__(database_url, timeout_seconds)
|
|
13
|
+
self.engine: Engine = create_engine(_normalize_postgres_url(database_url))
|
|
14
|
+
|
|
15
|
+
def get_schema(self) -> DatabaseSchema:
|
|
16
|
+
inspector = inspect(self.engine)
|
|
17
|
+
tables: list[TableSchema] = []
|
|
18
|
+
for table_name in inspector.get_table_names():
|
|
19
|
+
columns = [
|
|
20
|
+
ColumnSchema(
|
|
21
|
+
name=column["name"],
|
|
22
|
+
type=str(column["type"]),
|
|
23
|
+
nullable=bool(column.get("nullable", True)),
|
|
24
|
+
)
|
|
25
|
+
for column in inspector.get_columns(table_name)
|
|
26
|
+
]
|
|
27
|
+
tables.append(TableSchema(name=table_name, columns=columns))
|
|
28
|
+
return DatabaseSchema(dialect="postgres", tables=tables)
|
|
29
|
+
|
|
30
|
+
def execute_readonly(self, sql: str) -> tuple[list[dict], int]:
|
|
31
|
+
with self.engine.connect() as conn:
|
|
32
|
+
readonly_result = conn.execute(text("SET TRANSACTION READ ONLY"))
|
|
33
|
+
readonly_result.close()
|
|
34
|
+
timeout_result = conn.execute(
|
|
35
|
+
text(f"SET LOCAL statement_timeout = {self.timeout_seconds * 1000}")
|
|
36
|
+
)
|
|
37
|
+
timeout_result.close()
|
|
38
|
+
query_result = conn.execute(text(sql))
|
|
39
|
+
rows = [dict(row._mapping) for row in query_result.fetchall()]
|
|
40
|
+
return rows, len(rows)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize_postgres_url(database_url: str) -> str:
|
|
44
|
+
if database_url.startswith("postgresql://"):
|
|
45
|
+
return database_url.replace("postgresql://", "postgresql+psycopg://", 1)
|
|
46
|
+
if database_url.startswith("postgres://"):
|
|
47
|
+
return database_url.replace("postgres://", "postgresql+psycopg://", 1)
|
|
48
|
+
return database_url
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine, inspect, text
|
|
4
|
+
from sqlalchemy.engine import Engine
|
|
5
|
+
|
|
6
|
+
from querypilot.connectors.base import BaseConnector
|
|
7
|
+
from querypilot.core.types import ColumnSchema, DatabaseSchema, TableSchema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SQLiteConnector(BaseConnector):
|
|
11
|
+
def __init__(self, database_url: str, timeout_seconds: int = 10) -> None:
|
|
12
|
+
super().__init__(database_url, timeout_seconds)
|
|
13
|
+
self.engine: Engine = create_engine(database_url)
|
|
14
|
+
|
|
15
|
+
def get_schema(self) -> DatabaseSchema:
|
|
16
|
+
inspector = inspect(self.engine)
|
|
17
|
+
tables: list[TableSchema] = []
|
|
18
|
+
for table_name in inspector.get_table_names():
|
|
19
|
+
columns = [
|
|
20
|
+
ColumnSchema(
|
|
21
|
+
name=column["name"],
|
|
22
|
+
type=str(column["type"]),
|
|
23
|
+
nullable=bool(column.get("nullable", True)),
|
|
24
|
+
)
|
|
25
|
+
for column in inspector.get_columns(table_name)
|
|
26
|
+
]
|
|
27
|
+
tables.append(TableSchema(name=table_name, columns=columns))
|
|
28
|
+
return DatabaseSchema(dialect="sqlite", tables=tables)
|
|
29
|
+
|
|
30
|
+
def execute_readonly(self, sql: str) -> tuple[list[dict], int]:
|
|
31
|
+
with self.engine.connect() as conn:
|
|
32
|
+
conn.connection.set_progress_handler(
|
|
33
|
+
_timeout_handler(self.timeout_seconds),
|
|
34
|
+
1000,
|
|
35
|
+
)
|
|
36
|
+
result = conn.execute(text(sql))
|
|
37
|
+
rows = [dict(row._mapping) for row in result.fetchall()]
|
|
38
|
+
return rows, len(rows)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _timeout_handler(timeout_seconds: int):
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
deadline = time.monotonic() + timeout_seconds
|
|
45
|
+
|
|
46
|
+
def handler() -> int:
|
|
47
|
+
return 1 if time.monotonic() > deadline else 0
|
|
48
|
+
|
|
49
|
+
return handler
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core QueryPilot package."""
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter
|
|
6
|
+
|
|
7
|
+
from querypilot.access import AccessPolicy
|
|
8
|
+
from querypilot.adapters.anthropic import anthropic_tools
|
|
9
|
+
from querypilot.adapters.openai import openai_tools
|
|
10
|
+
from querypilot.audit import AuditMetadata, AuditSink, InMemoryAuditSink, QueryAuditRecord
|
|
11
|
+
from querypilot.connectors.base import BaseConnector
|
|
12
|
+
from querypilot.connectors.postgres import PostgresConnector
|
|
13
|
+
from querypilot.connectors.sqlite import SQLiteConnector
|
|
14
|
+
from querypilot.core.config import QueryPilotConfig, SafetyPolicy
|
|
15
|
+
from querypilot.core.types import (
|
|
16
|
+
DatabaseSchema,
|
|
17
|
+
GeneratedSQL,
|
|
18
|
+
QueryPilotAnswer,
|
|
19
|
+
QueryResult,
|
|
20
|
+
SchemaMatch,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
)
|
|
23
|
+
from querypilot.execution.formatter import explain_result
|
|
24
|
+
from querypilot.execution.runner import execute
|
|
25
|
+
from querypilot.generation.sql_generator import DemoSQLGenerator, SQLGenerator
|
|
26
|
+
from querypilot.schema.search import search_schema as search_schema_impl
|
|
27
|
+
from querypilot.validation.validator import SQLValidator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QueryPilot:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
connector: BaseConnector,
|
|
34
|
+
config: QueryPilotConfig,
|
|
35
|
+
generator: SQLGenerator | None = None,
|
|
36
|
+
audit_sink: AuditSink | None = None,
|
|
37
|
+
audit_metadata: AuditMetadata | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.connector = connector
|
|
40
|
+
self.config = config
|
|
41
|
+
self.generator = generator or DemoSQLGenerator()
|
|
42
|
+
self.validator = SQLValidator(config)
|
|
43
|
+
self.audit_sink = audit_sink or InMemoryAuditSink()
|
|
44
|
+
self.audit_metadata = audit_metadata or AuditMetadata()
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def connect(
|
|
48
|
+
cls,
|
|
49
|
+
database_url: str,
|
|
50
|
+
dialect: str = "sqlite",
|
|
51
|
+
readonly: bool = True,
|
|
52
|
+
max_rows: int = 100,
|
|
53
|
+
timeout_seconds: int = 10,
|
|
54
|
+
max_generation_attempts: int = 2,
|
|
55
|
+
allowed_tables: list[str] | None = None,
|
|
56
|
+
blocked_tables: list[str] | None = None,
|
|
57
|
+
safety_policy: SafetyPolicy | None = None,
|
|
58
|
+
access_policy: AccessPolicy | None = None,
|
|
59
|
+
generator: SQLGenerator | None = None,
|
|
60
|
+
audit_sink: AuditSink | None = None,
|
|
61
|
+
audit_metadata: AuditMetadata | None = None,
|
|
62
|
+
) -> "QueryPilot":
|
|
63
|
+
config = QueryPilotConfig(
|
|
64
|
+
dialect=dialect,
|
|
65
|
+
readonly=readonly,
|
|
66
|
+
max_rows=max_rows,
|
|
67
|
+
timeout_seconds=timeout_seconds,
|
|
68
|
+
max_generation_attempts=max_generation_attempts,
|
|
69
|
+
allowed_tables=allowed_tables,
|
|
70
|
+
blocked_tables=blocked_tables,
|
|
71
|
+
safety_policy=safety_policy or SafetyPolicy(),
|
|
72
|
+
access_policy=access_policy or AccessPolicy(),
|
|
73
|
+
)
|
|
74
|
+
connector = _connector_for(database_url, dialect, timeout_seconds)
|
|
75
|
+
return cls(
|
|
76
|
+
connector=connector,
|
|
77
|
+
config=config,
|
|
78
|
+
generator=generator,
|
|
79
|
+
audit_sink=audit_sink,
|
|
80
|
+
audit_metadata=audit_metadata,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def get_schema(self) -> DatabaseSchema:
|
|
84
|
+
return self.connector.get_schema()
|
|
85
|
+
|
|
86
|
+
def search_schema(self, query: str) -> list[SchemaMatch]:
|
|
87
|
+
matches = search_schema_impl(self.get_schema(), query)
|
|
88
|
+
self._write_audit_record(
|
|
89
|
+
operation="schema_search",
|
|
90
|
+
question=query,
|
|
91
|
+
row_count=len(matches),
|
|
92
|
+
executed=True,
|
|
93
|
+
)
|
|
94
|
+
return matches
|
|
95
|
+
|
|
96
|
+
def generate_sql(self, question: str) -> GeneratedSQL:
|
|
97
|
+
generated = self.generator.generate(question, self.get_schema(), self.config.max_rows)
|
|
98
|
+
self._write_audit_record(
|
|
99
|
+
operation="generate_sql",
|
|
100
|
+
question=question,
|
|
101
|
+
sql=generated.sql,
|
|
102
|
+
error="; ".join(generated.errors) if generated.errors else None,
|
|
103
|
+
executed=generated.sql is not None,
|
|
104
|
+
)
|
|
105
|
+
return generated
|
|
106
|
+
|
|
107
|
+
def validate_sql(self, sql: str) -> ValidationResult:
|
|
108
|
+
validation = self.validator.validate(sql, self.get_schema())
|
|
109
|
+
record = self._write_audit_record(
|
|
110
|
+
operation="validate_sql",
|
|
111
|
+
sql=sql,
|
|
112
|
+
rewritten_sql=validation.rewritten_sql,
|
|
113
|
+
validation=validation,
|
|
114
|
+
valid=validation.valid,
|
|
115
|
+
access_policy=validation.access_policy,
|
|
116
|
+
error="; ".join(validation.errors) if validation.errors else None,
|
|
117
|
+
)
|
|
118
|
+
validation.audit_id = record.audit_id
|
|
119
|
+
return validation
|
|
120
|
+
|
|
121
|
+
def execute_sql(self, sql: str) -> QueryResult:
|
|
122
|
+
validation = self.validator.validate(sql, self.get_schema())
|
|
123
|
+
if not validation.valid or validation.rewritten_sql is None:
|
|
124
|
+
details = "; ".join(validation.errors) or "unknown validation error"
|
|
125
|
+
self._write_audit_record(
|
|
126
|
+
operation="execute_sql",
|
|
127
|
+
sql=sql,
|
|
128
|
+
rewritten_sql=validation.rewritten_sql,
|
|
129
|
+
validation=validation,
|
|
130
|
+
valid=validation.valid,
|
|
131
|
+
executed=False,
|
|
132
|
+
access_policy=validation.access_policy,
|
|
133
|
+
error=details,
|
|
134
|
+
)
|
|
135
|
+
raise ValueError(f"SQL validation failed: {details}")
|
|
136
|
+
result = execute(self.connector, validation.rewritten_sql)
|
|
137
|
+
result.rows = self._apply_masking(result.rows, validation)
|
|
138
|
+
result.access_policy = validation.access_policy
|
|
139
|
+
record = self._write_audit_record(
|
|
140
|
+
operation="execute_sql",
|
|
141
|
+
sql=sql,
|
|
142
|
+
rewritten_sql=result.sql,
|
|
143
|
+
validation=validation,
|
|
144
|
+
valid=True,
|
|
145
|
+
executed=True,
|
|
146
|
+
row_count=result.row_count,
|
|
147
|
+
execution_time_ms=result.execution_time_ms,
|
|
148
|
+
access_policy=validation.access_policy,
|
|
149
|
+
)
|
|
150
|
+
validation.audit_id = record.audit_id
|
|
151
|
+
result.audit_id = record.audit_id
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
def ask(self, question: str) -> QueryPilotAnswer:
|
|
155
|
+
generated = self.generate_sql(question)
|
|
156
|
+
validation: ValidationResult | None = None
|
|
157
|
+
|
|
158
|
+
for attempt in range(self.config.max_generation_attempts):
|
|
159
|
+
if generated.sql is None:
|
|
160
|
+
details = "; ".join(generated.errors) or "no SQL was returned"
|
|
161
|
+
raise ValueError(f"Could not generate SQL: {details}")
|
|
162
|
+
|
|
163
|
+
validation = self.validate_sql(generated.sql)
|
|
164
|
+
if validation.valid and validation.rewritten_sql is not None:
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
can_repair = hasattr(self.generator, "repair")
|
|
168
|
+
if not can_repair or attempt >= self.config.max_generation_attempts - 1:
|
|
169
|
+
details = "; ".join(validation.errors) or "unknown validation error"
|
|
170
|
+
raise ValueError(f"SQL validation failed: {details}")
|
|
171
|
+
|
|
172
|
+
generated = self.generator.repair( # type: ignore[attr-defined]
|
|
173
|
+
question,
|
|
174
|
+
self.get_schema(),
|
|
175
|
+
self.config.max_rows,
|
|
176
|
+
generated.sql,
|
|
177
|
+
validation,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if validation is None or not validation.valid or validation.rewritten_sql is None:
|
|
181
|
+
raise ValueError("SQL validation failed: unknown validation error")
|
|
182
|
+
|
|
183
|
+
result = execute(self.connector, validation.rewritten_sql)
|
|
184
|
+
result.rows = self._apply_masking(result.rows, validation)
|
|
185
|
+
result.access_policy = validation.access_policy
|
|
186
|
+
explanation = generated.explanation or explain_result(question, result.sql, result.rows)
|
|
187
|
+
record = self._write_audit_record(
|
|
188
|
+
operation="ask",
|
|
189
|
+
question=question,
|
|
190
|
+
sql=generated.sql,
|
|
191
|
+
rewritten_sql=result.sql,
|
|
192
|
+
validation=validation,
|
|
193
|
+
valid=True,
|
|
194
|
+
executed=True,
|
|
195
|
+
row_count=result.row_count,
|
|
196
|
+
execution_time_ms=result.execution_time_ms,
|
|
197
|
+
access_policy=validation.access_policy,
|
|
198
|
+
)
|
|
199
|
+
return QueryPilotAnswer(
|
|
200
|
+
audit_id=record.audit_id,
|
|
201
|
+
question=question,
|
|
202
|
+
sql=result.sql,
|
|
203
|
+
rows=result.rows,
|
|
204
|
+
explanation=explanation,
|
|
205
|
+
validation=validation,
|
|
206
|
+
execution_time_ms=result.execution_time_ms,
|
|
207
|
+
access_policy=validation.access_policy,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def as_openai_tools(self) -> list[dict[str, Any]]:
|
|
211
|
+
return openai_tools()
|
|
212
|
+
|
|
213
|
+
def as_anthropic_tools(self) -> list[dict[str, Any]]:
|
|
214
|
+
return anthropic_tools()
|
|
215
|
+
|
|
216
|
+
def handle_anthropic_tool_call(self, tool_name: str, tool_input: dict[str, Any]) -> Any:
|
|
217
|
+
if tool_name == "ask_database":
|
|
218
|
+
return self.ask(str(tool_input["question"])).model_dump()
|
|
219
|
+
if tool_name == "search_schema":
|
|
220
|
+
matches = self.search_schema(str(tool_input["query"]))
|
|
221
|
+
return TypeAdapter(list[SchemaMatch]).dump_python(matches)
|
|
222
|
+
if tool_name == "validate_sql":
|
|
223
|
+
return self.validate_sql(str(tool_input["sql"])).model_dump()
|
|
224
|
+
if tool_name == "execute_sql":
|
|
225
|
+
return self.execute_sql(str(tool_input["sql"])).model_dump()
|
|
226
|
+
raise ValueError(f"Unknown Anthropic tool: {tool_name}")
|
|
227
|
+
|
|
228
|
+
def get_audit_records(self, limit: int = 100) -> list[QueryAuditRecord]:
|
|
229
|
+
return self.audit_sink.recent(limit)
|
|
230
|
+
|
|
231
|
+
def with_audit_metadata(self, metadata: AuditMetadata | None) -> "QueryPilot":
|
|
232
|
+
return QueryPilot(
|
|
233
|
+
connector=self.connector,
|
|
234
|
+
config=self.config,
|
|
235
|
+
generator=self.generator,
|
|
236
|
+
audit_sink=self.audit_sink,
|
|
237
|
+
audit_metadata=metadata or AuditMetadata(),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _write_audit_record(self, operation: str, **kwargs) -> QueryAuditRecord:
|
|
241
|
+
record = QueryAuditRecord.create(
|
|
242
|
+
operation=operation,
|
|
243
|
+
metadata=self.audit_metadata,
|
|
244
|
+
**kwargs,
|
|
245
|
+
)
|
|
246
|
+
self.audit_sink.write(record)
|
|
247
|
+
return record
|
|
248
|
+
|
|
249
|
+
def _apply_masking(self, rows: list[dict], validation: ValidationResult) -> list[dict]:
|
|
250
|
+
if not rows or not self.config.access_policy.masking_rules:
|
|
251
|
+
return rows
|
|
252
|
+
|
|
253
|
+
rules: dict[str, str] = {}
|
|
254
|
+
for table_name in validation.tables:
|
|
255
|
+
for column_name, rule in self.config.access_policy.masking_rules.get(table_name, {}).items():
|
|
256
|
+
rules[column_name] = rule.mode
|
|
257
|
+
|
|
258
|
+
if not rules:
|
|
259
|
+
return rows
|
|
260
|
+
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
key: _mask_value(value, rules[key]) if key in rules else value
|
|
264
|
+
for key, value in row.items()
|
|
265
|
+
}
|
|
266
|
+
for row in rows
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _connector_for(database_url: str, dialect: str, timeout_seconds: int) -> BaseConnector:
|
|
271
|
+
normalized = dialect.lower()
|
|
272
|
+
if normalized == "sqlite":
|
|
273
|
+
return SQLiteConnector(database_url, timeout_seconds=timeout_seconds)
|
|
274
|
+
if normalized in {"postgres", "postgresql"}:
|
|
275
|
+
return PostgresConnector(database_url, timeout_seconds=timeout_seconds)
|
|
276
|
+
raise ValueError(f"Unsupported dialect: {dialect}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _mask_value(value: Any, mode: str) -> Any:
|
|
280
|
+
if mode == "null":
|
|
281
|
+
return None
|
|
282
|
+
if mode == "hash":
|
|
283
|
+
import hashlib
|
|
284
|
+
|
|
285
|
+
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
|
|
286
|
+
return "[REDACTED]"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from querypilot.access import AccessPolicy
|
|
6
|
+
from querypilot.core.safety_defaults import BLOCKED_POSTGRES_FUNCTIONS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SafetyPolicy(BaseModel):
|
|
10
|
+
allow_select_star: bool = True
|
|
11
|
+
reject_cartesian_joins: bool = True
|
|
12
|
+
reject_multi_statement: bool = True
|
|
13
|
+
warn_on_select_star: bool = True
|
|
14
|
+
blocked_functions: list[str] = Field(
|
|
15
|
+
default_factory=lambda: sorted(BLOCKED_POSTGRES_FUNCTIONS)
|
|
16
|
+
)
|
|
17
|
+
allowed_functions: list[str] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QueryPilotConfig(BaseModel):
|
|
21
|
+
dialect: str = "sqlite"
|
|
22
|
+
readonly: bool = True
|
|
23
|
+
max_rows: int = Field(default=100, ge=1)
|
|
24
|
+
timeout_seconds: int = Field(default=10, ge=1)
|
|
25
|
+
max_generation_attempts: int = Field(default=2, ge=1)
|
|
26
|
+
allowed_tables: list[str] | None = None
|
|
27
|
+
blocked_tables: list[str] | None = None
|
|
28
|
+
safety_policy: SafetyPolicy = Field(default_factory=SafetyPolicy)
|
|
29
|
+
access_policy: AccessPolicy = Field(default_factory=AccessPolicy)
|
querypilot/core/types.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ColumnSchema(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
type: str
|
|
9
|
+
nullable: bool = True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TableSchema(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
columns: list[ColumnSchema] = Field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
def get_column(self, name: str) -> ColumnSchema | None:
|
|
17
|
+
lowered = name.lower()
|
|
18
|
+
return next((column for column in self.columns if column.name.lower() == lowered), None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DatabaseSchema(BaseModel):
|
|
22
|
+
dialect: str
|
|
23
|
+
tables: list[TableSchema] = Field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def get_table(self, name: str) -> TableSchema | None:
|
|
26
|
+
normalized = name.split(".")[-1].strip('"').lower()
|
|
27
|
+
return next((table for table in self.tables if table.name.lower() == normalized), None)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def table_names(self) -> set[str]:
|
|
31
|
+
return {table.name.lower() for table in self.tables}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SchemaMatch(BaseModel):
|
|
35
|
+
table: str
|
|
36
|
+
column: str | None = None
|
|
37
|
+
score: int
|
|
38
|
+
reason: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GeneratedSQL(BaseModel):
|
|
42
|
+
question: str
|
|
43
|
+
sql: str | None
|
|
44
|
+
explanation: str | None = None
|
|
45
|
+
errors: list[str] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PolicyCheck(BaseModel):
|
|
49
|
+
name: str
|
|
50
|
+
passed: bool
|
|
51
|
+
message: str
|
|
52
|
+
severity: str = "low"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationResult(BaseModel):
|
|
56
|
+
audit_id: str | None = None
|
|
57
|
+
valid: bool
|
|
58
|
+
readonly: bool
|
|
59
|
+
tables: list[str] = Field(default_factory=list)
|
|
60
|
+
columns: list[str] = Field(default_factory=list)
|
|
61
|
+
limit_applied: bool = False
|
|
62
|
+
rewritten_sql: str | None = None
|
|
63
|
+
risk_level: str = "low"
|
|
64
|
+
blocked_reason: str | None = None
|
|
65
|
+
policy_checks: list[PolicyCheck] = Field(default_factory=list)
|
|
66
|
+
query_fingerprint: str | None = None
|
|
67
|
+
access_policy: dict = Field(default_factory=dict)
|
|
68
|
+
warnings: list[str] = Field(default_factory=list)
|
|
69
|
+
errors: list[str] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QueryResult(BaseModel):
|
|
73
|
+
audit_id: str | None = None
|
|
74
|
+
sql: str
|
|
75
|
+
rows: list[dict] = Field(default_factory=list)
|
|
76
|
+
row_count: int
|
|
77
|
+
execution_time_ms: int
|
|
78
|
+
access_policy: dict = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class QueryPilotAnswer(BaseModel):
|
|
82
|
+
audit_id: str | None = None
|
|
83
|
+
question: str
|
|
84
|
+
sql: str
|
|
85
|
+
rows: list[dict] = Field(default_factory=list)
|
|
86
|
+
explanation: str
|
|
87
|
+
validation: ValidationResult
|
|
88
|
+
execution_time_ms: int
|
|
89
|
+
access_policy: dict = Field(default_factory=dict)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from querypilot.evals.check import (
|
|
2
|
+
CaseRegression,
|
|
3
|
+
CheckOutcome,
|
|
4
|
+
CheckSummary,
|
|
5
|
+
check_report,
|
|
6
|
+
format_outcome,
|
|
7
|
+
load_report,
|
|
8
|
+
write_outcome,
|
|
9
|
+
)
|
|
10
|
+
from querypilot.evals.compare import RowsetMatch, ValueMismatch, compare_rows, has_order_by
|
|
11
|
+
from querypilot.evals.cost import (
|
|
12
|
+
AnthropicCostTracker,
|
|
13
|
+
CostTracker,
|
|
14
|
+
NullCostTracker,
|
|
15
|
+
OpenAICostTracker,
|
|
16
|
+
TokenUsage,
|
|
17
|
+
)
|
|
18
|
+
from querypilot.evals.factory import (
|
|
19
|
+
build_cost_tracker_factory,
|
|
20
|
+
build_generator,
|
|
21
|
+
build_qp_factory,
|
|
22
|
+
load_suite_or_dir,
|
|
23
|
+
)
|
|
24
|
+
from querypilot.evals.loader import SuiteLoadError, load_suite, load_suite_dir, write_suite
|
|
25
|
+
from querypilot.evals.replay import replay_from_jsonl, replay_from_sink
|
|
26
|
+
from querypilot.evals.pipeline import (
|
|
27
|
+
CaseResult,
|
|
28
|
+
FailureCategory,
|
|
29
|
+
StageTimings,
|
|
30
|
+
run_case,
|
|
31
|
+
)
|
|
32
|
+
from querypilot.evals.report import render_terminal, write_json
|
|
33
|
+
from querypilot.evals.suite import (
|
|
34
|
+
BenchmarkCase,
|
|
35
|
+
BenchmarkSuite,
|
|
36
|
+
ComparisonConfig,
|
|
37
|
+
SuiteThresholds,
|
|
38
|
+
)
|
|
39
|
+
from querypilot.evals.suite_runner import SuiteReport, TagRollup, run_suite
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AnthropicCostTracker",
|
|
43
|
+
"BenchmarkCase",
|
|
44
|
+
"BenchmarkSuite",
|
|
45
|
+
"CaseRegression",
|
|
46
|
+
"CaseResult",
|
|
47
|
+
"CheckOutcome",
|
|
48
|
+
"CheckSummary",
|
|
49
|
+
"ComparisonConfig",
|
|
50
|
+
"CostTracker",
|
|
51
|
+
"FailureCategory",
|
|
52
|
+
"NullCostTracker",
|
|
53
|
+
"OpenAICostTracker",
|
|
54
|
+
"RowsetMatch",
|
|
55
|
+
"StageTimings",
|
|
56
|
+
"SuiteLoadError",
|
|
57
|
+
"SuiteReport",
|
|
58
|
+
"SuiteThresholds",
|
|
59
|
+
"TagRollup",
|
|
60
|
+
"TokenUsage",
|
|
61
|
+
"ValueMismatch",
|
|
62
|
+
"build_cost_tracker_factory",
|
|
63
|
+
"build_generator",
|
|
64
|
+
"build_qp_factory",
|
|
65
|
+
"check_report",
|
|
66
|
+
"compare_rows",
|
|
67
|
+
"format_outcome",
|
|
68
|
+
"has_order_by",
|
|
69
|
+
"load_report",
|
|
70
|
+
"load_suite",
|
|
71
|
+
"load_suite_dir",
|
|
72
|
+
"load_suite_or_dir",
|
|
73
|
+
"render_terminal",
|
|
74
|
+
"replay_from_jsonl",
|
|
75
|
+
"replay_from_sink",
|
|
76
|
+
"run_case",
|
|
77
|
+
"run_suite",
|
|
78
|
+
"write_json",
|
|
79
|
+
"write_outcome",
|
|
80
|
+
"write_suite",
|
|
81
|
+
]
|