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,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def explain_result(question: str, sql: str, rows: list[dict]) -> str:
|
|
5
|
+
if not rows:
|
|
6
|
+
return f"The validated query returned no rows for: {question}"
|
|
7
|
+
columns = ", ".join(rows[0].keys())
|
|
8
|
+
return (
|
|
9
|
+
f"The validated query answers the question using columns {columns}. "
|
|
10
|
+
f"It returned {len(rows)} row(s) after QueryPilot safety checks."
|
|
11
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from querypilot.connectors.base import BaseConnector
|
|
6
|
+
from querypilot.core.types import QueryResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def execute(connector: BaseConnector, sql: str) -> QueryResult:
|
|
10
|
+
start = time.perf_counter()
|
|
11
|
+
rows, row_count = connector.execute_readonly(sql)
|
|
12
|
+
elapsed_ms = int((time.perf_counter() - start) * 1000)
|
|
13
|
+
return QueryResult(sql=sql, rows=rows, row_count=row_count, execution_time_ms=elapsed_ms)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from querypilot.generation.llm import AnthropicSQLGenerator, OpenAISQLGenerator
|
|
2
|
+
from querypilot.generation.sql_generator import DemoSQLGenerator, SQLGenerator
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AnthropicSQLGenerator",
|
|
6
|
+
"DemoSQLGenerator",
|
|
7
|
+
"OpenAISQLGenerator",
|
|
8
|
+
"SQLGenerator",
|
|
9
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from querypilot.core.types import DatabaseSchema, GeneratedSQL, ValidationResult
|
|
8
|
+
from querypilot.generation.prompt_builder import build_sql_generation_prompt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SQL_JSON_SCHEMA: dict[str, Any] = {
|
|
12
|
+
"name": "querypilot_sql",
|
|
13
|
+
"schema": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"sql": {"type": "string"},
|
|
17
|
+
"explanation": {"type": "string"},
|
|
18
|
+
},
|
|
19
|
+
"required": ["sql", "explanation"],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
},
|
|
22
|
+
"strict": True,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenAISQLGenerator:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
client: Any | None = None,
|
|
30
|
+
model: str = "gpt-5.1",
|
|
31
|
+
max_output_tokens: int = 800,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.client = client or _default_openai_client()
|
|
34
|
+
self.model = model
|
|
35
|
+
self.max_output_tokens = max_output_tokens
|
|
36
|
+
|
|
37
|
+
def generate(self, question: str, schema: DatabaseSchema, max_rows: int) -> GeneratedSQL:
|
|
38
|
+
prompt = build_sql_generation_prompt(question, schema, max_rows)
|
|
39
|
+
return self._create(question, prompt.instructions, prompt.user_prompt)
|
|
40
|
+
|
|
41
|
+
def repair(
|
|
42
|
+
self,
|
|
43
|
+
question: str,
|
|
44
|
+
schema: DatabaseSchema,
|
|
45
|
+
max_rows: int,
|
|
46
|
+
previous_sql: str,
|
|
47
|
+
validation: ValidationResult,
|
|
48
|
+
) -> GeneratedSQL:
|
|
49
|
+
prompt = build_sql_generation_prompt(
|
|
50
|
+
question,
|
|
51
|
+
schema,
|
|
52
|
+
max_rows,
|
|
53
|
+
validation_errors=validation.errors,
|
|
54
|
+
previous_sql=previous_sql,
|
|
55
|
+
)
|
|
56
|
+
return self._create(question, prompt.instructions, prompt.user_prompt)
|
|
57
|
+
|
|
58
|
+
def _create(self, question: str, instructions: str, user_prompt: str) -> GeneratedSQL:
|
|
59
|
+
response = self.client.responses.create(
|
|
60
|
+
model=self.model,
|
|
61
|
+
instructions=instructions,
|
|
62
|
+
input=user_prompt,
|
|
63
|
+
max_output_tokens=self.max_output_tokens,
|
|
64
|
+
text={"format": {"type": "json_schema", **SQL_JSON_SCHEMA}},
|
|
65
|
+
)
|
|
66
|
+
return _generated_from_text(question, _extract_openai_text(response))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AnthropicSQLGenerator:
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
client: Any | None = None,
|
|
73
|
+
model: str = "claude-sonnet-4-20250514",
|
|
74
|
+
max_tokens: int = 800,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.client = client or _default_anthropic_client()
|
|
77
|
+
self.model = model
|
|
78
|
+
self.max_tokens = max_tokens
|
|
79
|
+
|
|
80
|
+
def generate(self, question: str, schema: DatabaseSchema, max_rows: int) -> GeneratedSQL:
|
|
81
|
+
prompt = build_sql_generation_prompt(question, schema, max_rows)
|
|
82
|
+
return self._create(question, prompt.instructions, prompt.user_prompt)
|
|
83
|
+
|
|
84
|
+
def repair(
|
|
85
|
+
self,
|
|
86
|
+
question: str,
|
|
87
|
+
schema: DatabaseSchema,
|
|
88
|
+
max_rows: int,
|
|
89
|
+
previous_sql: str,
|
|
90
|
+
validation: ValidationResult,
|
|
91
|
+
) -> GeneratedSQL:
|
|
92
|
+
prompt = build_sql_generation_prompt(
|
|
93
|
+
question,
|
|
94
|
+
schema,
|
|
95
|
+
max_rows,
|
|
96
|
+
validation_errors=validation.errors,
|
|
97
|
+
previous_sql=previous_sql,
|
|
98
|
+
)
|
|
99
|
+
return self._create(question, prompt.instructions, prompt.user_prompt)
|
|
100
|
+
|
|
101
|
+
def _create(self, question: str, system_prompt: str, user_prompt: str) -> GeneratedSQL:
|
|
102
|
+
message = self.client.messages.create(
|
|
103
|
+
model=self.model,
|
|
104
|
+
max_tokens=self.max_tokens,
|
|
105
|
+
system=system_prompt,
|
|
106
|
+
messages=[{"role": "user", "content": user_prompt}],
|
|
107
|
+
)
|
|
108
|
+
return _generated_from_text(question, _extract_anthropic_text(message))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _generated_from_text(question: str, text: str) -> GeneratedSQL:
|
|
112
|
+
try:
|
|
113
|
+
payload = json.loads(_strip_code_fence(text))
|
|
114
|
+
except json.JSONDecodeError as exc:
|
|
115
|
+
return GeneratedSQL(
|
|
116
|
+
question=question,
|
|
117
|
+
sql=None,
|
|
118
|
+
errors=[f"LLM response was not valid JSON: {exc}"],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
sql = payload.get("sql")
|
|
122
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
123
|
+
return GeneratedSQL(question=question, sql=None, errors=["LLM response did not include SQL."])
|
|
124
|
+
explanation = payload.get("explanation")
|
|
125
|
+
return GeneratedSQL(
|
|
126
|
+
question=question,
|
|
127
|
+
sql=sql.strip(),
|
|
128
|
+
explanation=explanation if isinstance(explanation, str) else None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _strip_code_fence(text: str) -> str:
|
|
133
|
+
stripped = text.strip()
|
|
134
|
+
match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL)
|
|
135
|
+
return match.group(1).strip() if match else stripped
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _extract_openai_text(response: Any) -> str:
|
|
139
|
+
output_text = getattr(response, "output_text", None)
|
|
140
|
+
if isinstance(output_text, str):
|
|
141
|
+
return output_text
|
|
142
|
+
output = getattr(response, "output", None)
|
|
143
|
+
if isinstance(output, list):
|
|
144
|
+
for item in output:
|
|
145
|
+
for content in getattr(item, "content", []) or []:
|
|
146
|
+
text = getattr(content, "text", None)
|
|
147
|
+
if isinstance(text, str):
|
|
148
|
+
return text
|
|
149
|
+
return str(response)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_anthropic_text(message: Any) -> str:
|
|
153
|
+
content = getattr(message, "content", None)
|
|
154
|
+
if isinstance(content, list):
|
|
155
|
+
chunks: list[str] = []
|
|
156
|
+
for block in content:
|
|
157
|
+
text = getattr(block, "text", None)
|
|
158
|
+
if isinstance(text, str):
|
|
159
|
+
chunks.append(text)
|
|
160
|
+
elif isinstance(block, dict) and isinstance(block.get("text"), str):
|
|
161
|
+
chunks.append(block["text"])
|
|
162
|
+
if chunks:
|
|
163
|
+
return "".join(chunks)
|
|
164
|
+
return str(message)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _default_openai_client() -> Any:
|
|
168
|
+
try:
|
|
169
|
+
from openai import OpenAI
|
|
170
|
+
except ImportError as exc:
|
|
171
|
+
raise ImportError("Install querypilot[openai] to use OpenAISQLGenerator.") from exc
|
|
172
|
+
return OpenAI()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _default_anthropic_client() -> Any:
|
|
176
|
+
try:
|
|
177
|
+
import anthropic
|
|
178
|
+
except ImportError as exc:
|
|
179
|
+
raise ImportError("Install querypilot[anthropic] to use AnthropicSQLGenerator.") from exc
|
|
180
|
+
return anthropic.Anthropic()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from querypilot.core.types import DatabaseSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SQLGenerationPrompt(BaseModel):
|
|
9
|
+
instructions: str
|
|
10
|
+
user_prompt: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_sql_generation_prompt(
|
|
14
|
+
question: str,
|
|
15
|
+
schema: DatabaseSchema,
|
|
16
|
+
max_rows: int,
|
|
17
|
+
validation_errors: list[str] | None = None,
|
|
18
|
+
previous_sql: str | None = None,
|
|
19
|
+
) -> SQLGenerationPrompt:
|
|
20
|
+
instructions = (
|
|
21
|
+
"You generate safe SQL for QueryPilot. Return only JSON with keys "
|
|
22
|
+
"`sql` and `explanation`. Generate SELECT-only SQL. Use only tables "
|
|
23
|
+
"and columns present in the provided schema. Do not use mutations, DDL, "
|
|
24
|
+
"multiple statements, comments, or vendor-specific unsafe commands. "
|
|
25
|
+
f"The maximum row limit is {max_rows}; include a LIMIT no larger than that "
|
|
26
|
+
"unless the query is an aggregate that returns one row."
|
|
27
|
+
)
|
|
28
|
+
if validation_errors:
|
|
29
|
+
instructions += " Repair the SQL using the validation errors."
|
|
30
|
+
|
|
31
|
+
parts = [
|
|
32
|
+
f"Question: {question}",
|
|
33
|
+
"",
|
|
34
|
+
"Schema:",
|
|
35
|
+
build_schema_context(schema),
|
|
36
|
+
]
|
|
37
|
+
if previous_sql:
|
|
38
|
+
parts.extend(["", f"Previous SQL: {previous_sql}"])
|
|
39
|
+
if validation_errors:
|
|
40
|
+
parts.extend(["", "Validation errors:", "\n".join(f"- {error}" for error in validation_errors)])
|
|
41
|
+
return SQLGenerationPrompt(instructions=instructions, user_prompt="\n".join(parts))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_schema_context(schema: DatabaseSchema) -> str:
|
|
45
|
+
lines: list[str] = []
|
|
46
|
+
for table in schema.tables:
|
|
47
|
+
columns = ", ".join(f"{column.name} {column.type}" for column in table.columns)
|
|
48
|
+
lines.append(f"{table.name}({columns})")
|
|
49
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from querypilot.core.types import DatabaseSchema, GeneratedSQL
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLGenerator(Protocol):
|
|
10
|
+
def generate(self, question: str, schema: DatabaseSchema, max_rows: int) -> GeneratedSQL:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DemoSQLGenerator:
|
|
15
|
+
def generate(self, question: str, schema: DatabaseSchema, max_rows: int) -> GeneratedSQL:
|
|
16
|
+
normalized = question.lower()
|
|
17
|
+
explicit_limit = _extract_limit(normalized)
|
|
18
|
+
limit = min(explicit_limit or max_rows, max_rows)
|
|
19
|
+
|
|
20
|
+
if "count" in normalized and schema.tables:
|
|
21
|
+
table = _best_table(normalized, schema) or schema.tables[0]
|
|
22
|
+
return GeneratedSQL(
|
|
23
|
+
question=question,
|
|
24
|
+
sql=f"SELECT COUNT(*) AS count FROM {table.name} LIMIT {limit}",
|
|
25
|
+
explanation=f"Counts rows in {table.name}.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
customer_table = schema.get_table("customers")
|
|
29
|
+
if customer_table and any(term in normalized for term in ["customer", "customers"]):
|
|
30
|
+
if "revenue" in normalized and customer_table.get_column("revenue"):
|
|
31
|
+
return GeneratedSQL(
|
|
32
|
+
question=question,
|
|
33
|
+
sql=(
|
|
34
|
+
"SELECT customer_name, revenue FROM customers "
|
|
35
|
+
f"ORDER BY revenue DESC LIMIT {limit}"
|
|
36
|
+
),
|
|
37
|
+
explanation="Ranks customers by revenue.",
|
|
38
|
+
)
|
|
39
|
+
if "arr" in normalized and customer_table.get_column("arr"):
|
|
40
|
+
return GeneratedSQL(
|
|
41
|
+
question=question,
|
|
42
|
+
sql=f"SELECT customer_name, arr FROM customers ORDER BY arr DESC LIMIT {limit}",
|
|
43
|
+
explanation="Ranks customers by ARR.",
|
|
44
|
+
)
|
|
45
|
+
return GeneratedSQL(
|
|
46
|
+
question=question,
|
|
47
|
+
sql=f"SELECT * FROM customers LIMIT {limit}",
|
|
48
|
+
explanation="Returns customer records.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
table = _best_table(normalized, schema)
|
|
52
|
+
if table and any(term in normalized for term in ["show", "list", "top"]):
|
|
53
|
+
return GeneratedSQL(
|
|
54
|
+
question=question,
|
|
55
|
+
sql=f"SELECT * FROM {table.name} LIMIT {limit}",
|
|
56
|
+
explanation=f"Returns rows from {table.name}.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return GeneratedSQL(
|
|
60
|
+
question=question,
|
|
61
|
+
sql=None,
|
|
62
|
+
explanation=None,
|
|
63
|
+
errors=[
|
|
64
|
+
"Offline generator could not map this question to a safe SQL template. "
|
|
65
|
+
"Configure a SQLGenerator provider for broader natural-language support."
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _extract_limit(question: str) -> int | None:
|
|
71
|
+
match = re.search(r"\btop\s+(\d+)\b|\blimit\s+(\d+)\b", question)
|
|
72
|
+
if not match:
|
|
73
|
+
return None
|
|
74
|
+
value = match.group(1) or match.group(2)
|
|
75
|
+
return int(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _best_table(question: str, schema: DatabaseSchema):
|
|
79
|
+
for table in schema.tables:
|
|
80
|
+
if table.name.lower() in question or table.name.lower().rstrip("s") in question:
|
|
81
|
+
return table
|
|
82
|
+
return None
|
querypilot/mcp/server.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from querypilot import QueryPilot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_mcp_server(
|
|
9
|
+
querypilot: QueryPilot,
|
|
10
|
+
*,
|
|
11
|
+
fastmcp_cls: type | None = None,
|
|
12
|
+
):
|
|
13
|
+
fastmcp = fastmcp_cls or _load_fastmcp()
|
|
14
|
+
server = fastmcp("QueryPilot", stateless_http=True, json_response=True)
|
|
15
|
+
|
|
16
|
+
@server.tool()
|
|
17
|
+
def ask_database(question: str) -> dict[str, Any]:
|
|
18
|
+
"""Ask a database question through QueryPilot's safe SQL flow."""
|
|
19
|
+
return _safe_call(lambda: querypilot.ask(question).model_dump())
|
|
20
|
+
|
|
21
|
+
@server.tool()
|
|
22
|
+
def search_schema(query: str) -> list[dict[str, Any]] | dict[str, str]:
|
|
23
|
+
"""Search tables and columns relevant to a question."""
|
|
24
|
+
return _safe_call(lambda: [match.model_dump() for match in querypilot.search_schema(query)])
|
|
25
|
+
|
|
26
|
+
@server.tool()
|
|
27
|
+
def validate_sql(sql: str) -> dict[str, Any]:
|
|
28
|
+
"""Validate SQL and return policy metadata."""
|
|
29
|
+
return _safe_call(lambda: querypilot.validate_sql(sql).model_dump())
|
|
30
|
+
|
|
31
|
+
@server.tool()
|
|
32
|
+
def execute_sql(sql: str) -> dict[str, Any]:
|
|
33
|
+
"""Validate and execute read-only SQL safely."""
|
|
34
|
+
return _safe_call(lambda: querypilot.execute_sql(sql).model_dump())
|
|
35
|
+
|
|
36
|
+
return server
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_call(callback: Callable[[], Any]) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
return callback()
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
return {"error": str(exc)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_fastmcp():
|
|
47
|
+
try:
|
|
48
|
+
from mcp.server.fastmcp import FastMCP
|
|
49
|
+
except ImportError as exc:
|
|
50
|
+
raise ImportError("Install querypilot[mcp] to run the MCP server.") from exc
|
|
51
|
+
return FastMCP
|
querypilot/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from querypilot.core.types import DatabaseSchema, SchemaMatch
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def search_schema(schema: DatabaseSchema, query: str) -> list[SchemaMatch]:
|
|
9
|
+
tokens = set(re.findall(r"[a-z0-9_]+", query.lower()))
|
|
10
|
+
matches: list[SchemaMatch] = []
|
|
11
|
+
for table in schema.tables:
|
|
12
|
+
table_tokens = _name_tokens(table.name)
|
|
13
|
+
table_score = len(tokens & table_tokens) * 3
|
|
14
|
+
column_hits: list[str] = []
|
|
15
|
+
for column in table.columns:
|
|
16
|
+
column_score = len(tokens & _name_tokens(column.name))
|
|
17
|
+
if column_score:
|
|
18
|
+
column_hits.append(column.name)
|
|
19
|
+
table_score += column_score
|
|
20
|
+
if table_score:
|
|
21
|
+
matches.append(
|
|
22
|
+
SchemaMatch(
|
|
23
|
+
table=table.name,
|
|
24
|
+
column=", ".join(column_hits) if column_hits else None,
|
|
25
|
+
score=table_score,
|
|
26
|
+
reason="Matched table or column name",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
return sorted(matches, key=lambda match: (-match.score, match.table))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _name_tokens(name: str) -> set[str]:
|
|
33
|
+
return set(re.findall(r"[a-z0-9]+", name.lower()))
|
querypilot/server/app.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, HTTPException
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from querypilot import QueryPilot
|
|
9
|
+
from querypilot.access import AccessPolicy
|
|
10
|
+
from querypilot.audit import AuditMetadata
|
|
11
|
+
from querypilot.core.config import SafetyPolicy
|
|
12
|
+
from querypilot.evals.pipeline import run_case
|
|
13
|
+
from querypilot.evals.suite import BenchmarkCase, ComparisonConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class QuestionRequest(BaseModel):
|
|
17
|
+
question: str
|
|
18
|
+
metadata: AuditMetadata | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SQLRequest(BaseModel):
|
|
22
|
+
sql: str
|
|
23
|
+
metadata: AuditMetadata | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SchemaSearchRequest(BaseModel):
|
|
27
|
+
query: str
|
|
28
|
+
metadata: AuditMetadata | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EvalRunRequest(BaseModel):
|
|
32
|
+
cases: list[BenchmarkCase] = Field(default_factory=list)
|
|
33
|
+
comparison: ComparisonConfig | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_app(
|
|
37
|
+
querypilot: QueryPilot | None = None,
|
|
38
|
+
*,
|
|
39
|
+
database_url: str | None = None,
|
|
40
|
+
dialect: str = "sqlite",
|
|
41
|
+
readonly: bool = True,
|
|
42
|
+
max_rows: int = 100,
|
|
43
|
+
timeout_seconds: int = 10,
|
|
44
|
+
max_generation_attempts: int = 2,
|
|
45
|
+
allowed_tables: list[str] | None = None,
|
|
46
|
+
blocked_tables: list[str] | None = None,
|
|
47
|
+
safety_policy: SafetyPolicy | None = None,
|
|
48
|
+
access_policy: AccessPolicy | None = None,
|
|
49
|
+
) -> FastAPI:
|
|
50
|
+
qp = querypilot or _connect_querypilot(
|
|
51
|
+
database_url=database_url,
|
|
52
|
+
dialect=dialect,
|
|
53
|
+
readonly=readonly,
|
|
54
|
+
max_rows=max_rows,
|
|
55
|
+
timeout_seconds=timeout_seconds,
|
|
56
|
+
max_generation_attempts=max_generation_attempts,
|
|
57
|
+
allowed_tables=allowed_tables,
|
|
58
|
+
blocked_tables=blocked_tables,
|
|
59
|
+
safety_policy=safety_policy,
|
|
60
|
+
access_policy=access_policy,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
app = FastAPI(
|
|
64
|
+
title="QueryPilot",
|
|
65
|
+
description="Safe SQL tool layer for AI agents.",
|
|
66
|
+
version="0.1.0",
|
|
67
|
+
)
|
|
68
|
+
app.state.querypilot = qp
|
|
69
|
+
|
|
70
|
+
@app.get("/health")
|
|
71
|
+
def health() -> dict[str, str]:
|
|
72
|
+
return {"status": "ok"}
|
|
73
|
+
|
|
74
|
+
@app.get("/schema")
|
|
75
|
+
def get_schema() -> dict[str, Any]:
|
|
76
|
+
return qp.get_schema().model_dump()
|
|
77
|
+
|
|
78
|
+
@app.post("/search-schema")
|
|
79
|
+
def search_schema(request: SchemaSearchRequest) -> list[dict[str, Any]]:
|
|
80
|
+
scoped_qp = qp.with_audit_metadata(request.metadata)
|
|
81
|
+
return [match.model_dump() for match in scoped_qp.search_schema(request.query)]
|
|
82
|
+
|
|
83
|
+
@app.post("/ask")
|
|
84
|
+
def ask(request: QuestionRequest) -> dict[str, Any]:
|
|
85
|
+
scoped_qp = qp.with_audit_metadata(request.metadata)
|
|
86
|
+
try:
|
|
87
|
+
return scoped_qp.ask(request.question).model_dump()
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
@app.post("/generate-sql")
|
|
92
|
+
def generate_sql(request: QuestionRequest) -> dict[str, Any]:
|
|
93
|
+
scoped_qp = qp.with_audit_metadata(request.metadata)
|
|
94
|
+
return scoped_qp.generate_sql(request.question).model_dump()
|
|
95
|
+
|
|
96
|
+
@app.post("/validate-sql")
|
|
97
|
+
def validate_sql(request: SQLRequest) -> dict[str, Any]:
|
|
98
|
+
scoped_qp = qp.with_audit_metadata(request.metadata)
|
|
99
|
+
return scoped_qp.validate_sql(request.sql).model_dump()
|
|
100
|
+
|
|
101
|
+
@app.post("/execute-sql")
|
|
102
|
+
def execute_sql(request: SQLRequest) -> dict[str, Any]:
|
|
103
|
+
scoped_qp = qp.with_audit_metadata(request.metadata)
|
|
104
|
+
try:
|
|
105
|
+
return scoped_qp.execute_sql(request.sql).model_dump()
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
108
|
+
|
|
109
|
+
@app.post("/evals/run")
|
|
110
|
+
def run_evals(request: EvalRunRequest) -> dict[str, Any]:
|
|
111
|
+
comparison = request.comparison or ComparisonConfig()
|
|
112
|
+
results = [
|
|
113
|
+
run_case(case, lambda _: qp, comparison=comparison).model_dump(mode="json")
|
|
114
|
+
for case in request.cases
|
|
115
|
+
]
|
|
116
|
+
passed = sum(1 for r in results if r["passed"])
|
|
117
|
+
return {
|
|
118
|
+
"total": len(results),
|
|
119
|
+
"passed": passed,
|
|
120
|
+
"failed": len(results) - passed,
|
|
121
|
+
"case_results": results,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@app.get("/audit/recent")
|
|
125
|
+
def recent_audit(limit: int = 100) -> list[dict[str, Any]]:
|
|
126
|
+
return [record.model_dump() for record in qp.get_audit_records(limit)]
|
|
127
|
+
|
|
128
|
+
return app
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _connect_querypilot(
|
|
132
|
+
*,
|
|
133
|
+
database_url: str | None,
|
|
134
|
+
dialect: str,
|
|
135
|
+
readonly: bool,
|
|
136
|
+
max_rows: int,
|
|
137
|
+
timeout_seconds: int,
|
|
138
|
+
max_generation_attempts: int,
|
|
139
|
+
allowed_tables: list[str] | None,
|
|
140
|
+
blocked_tables: list[str] | None,
|
|
141
|
+
safety_policy: SafetyPolicy | None,
|
|
142
|
+
access_policy: AccessPolicy | None,
|
|
143
|
+
) -> QueryPilot:
|
|
144
|
+
if database_url is None:
|
|
145
|
+
raise ValueError("database_url is required when querypilot is not provided.")
|
|
146
|
+
return QueryPilot.connect(
|
|
147
|
+
database_url=database_url,
|
|
148
|
+
dialect=dialect,
|
|
149
|
+
readonly=readonly,
|
|
150
|
+
max_rows=max_rows,
|
|
151
|
+
timeout_seconds=timeout_seconds,
|
|
152
|
+
max_generation_attempts=max_generation_attempts,
|
|
153
|
+
allowed_tables=allowed_tables,
|
|
154
|
+
blocked_tables=blocked_tables,
|
|
155
|
+
safety_policy=safety_policy,
|
|
156
|
+
access_policy=access_policy,
|
|
157
|
+
)
|