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,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from querypilot import QueryPilot
|
|
7
|
+
from querypilot.evals.cost import (
|
|
8
|
+
AnthropicCostTracker,
|
|
9
|
+
CostTracker,
|
|
10
|
+
NullCostTracker,
|
|
11
|
+
OpenAICostTracker,
|
|
12
|
+
)
|
|
13
|
+
from querypilot.evals.loader import load_suite, load_suite_dir
|
|
14
|
+
from querypilot.evals.pipeline import QueryPilotFactory
|
|
15
|
+
from querypilot.evals.suite import BenchmarkCase, BenchmarkSuite
|
|
16
|
+
from querypilot.generation.sql_generator import DemoSQLGenerator, SQLGenerator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
GENERATOR_NAMES = ("demo", "openai", "anthropic")
|
|
20
|
+
|
|
21
|
+
DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
|
|
22
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_generator(name: str, *, model: str | None = None) -> SQLGenerator:
|
|
26
|
+
normalized = name.lower()
|
|
27
|
+
if normalized == "demo":
|
|
28
|
+
return DemoSQLGenerator()
|
|
29
|
+
if normalized == "openai":
|
|
30
|
+
from querypilot.generation.llm import OpenAISQLGenerator
|
|
31
|
+
|
|
32
|
+
return OpenAISQLGenerator(model=model or DEFAULT_OPENAI_MODEL)
|
|
33
|
+
if normalized == "anthropic":
|
|
34
|
+
from querypilot.generation.llm import AnthropicSQLGenerator
|
|
35
|
+
|
|
36
|
+
return AnthropicSQLGenerator(model=model or DEFAULT_ANTHROPIC_MODEL)
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Unknown generator {name!r}. Supported: {', '.join(GENERATOR_NAMES)}."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_cost_tracker_factory(name: str) -> Callable[[], CostTracker]:
|
|
43
|
+
normalized = name.lower()
|
|
44
|
+
if normalized == "demo":
|
|
45
|
+
return NullCostTracker
|
|
46
|
+
if normalized == "openai":
|
|
47
|
+
return OpenAICostTracker
|
|
48
|
+
if normalized == "anthropic":
|
|
49
|
+
return AnthropicCostTracker
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Unknown generator {name!r}. Supported: {', '.join(GENERATOR_NAMES)}."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_qp_factory(
|
|
56
|
+
*,
|
|
57
|
+
database_url: str,
|
|
58
|
+
dialect: str = "sqlite",
|
|
59
|
+
generator: SQLGenerator,
|
|
60
|
+
max_rows: int = 100,
|
|
61
|
+
timeout_seconds: int = 10,
|
|
62
|
+
max_generation_attempts: int = 2,
|
|
63
|
+
) -> QueryPilotFactory:
|
|
64
|
+
def _make(case: BenchmarkCase) -> QueryPilot:
|
|
65
|
+
url = case.fixture_db or database_url
|
|
66
|
+
case_dialect = case.fixture_dialect or dialect
|
|
67
|
+
return QueryPilot.connect(
|
|
68
|
+
database_url=url,
|
|
69
|
+
dialect=case_dialect,
|
|
70
|
+
max_rows=max_rows,
|
|
71
|
+
timeout_seconds=timeout_seconds,
|
|
72
|
+
max_generation_attempts=max_generation_attempts,
|
|
73
|
+
generator=generator,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return _make
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_suite_or_dir(path: str | Path) -> BenchmarkSuite:
|
|
80
|
+
target = Path(path)
|
|
81
|
+
if target.is_dir():
|
|
82
|
+
return load_suite_dir(target)
|
|
83
|
+
return load_suite(target)
|
querypilot/evals/init.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Scaffold a starter eval layout (suites/ and .eval/) for a project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_SMOKE_YAML = """name: smoke
|
|
9
|
+
|
|
10
|
+
# Update fixture_db to point at your own SQLite or Postgres test database.
|
|
11
|
+
# Relative sqlite:/// paths are resolved against this file's directory.
|
|
12
|
+
fixture_db: sqlite:///REPLACE_ME_WITH_YOUR_DB.db
|
|
13
|
+
fixture_dialect: sqlite
|
|
14
|
+
|
|
15
|
+
thresholds:
|
|
16
|
+
pass_rate: 0.95
|
|
17
|
+
safety_pass_rate: 1.0
|
|
18
|
+
correctness_rate: 0.9
|
|
19
|
+
max_p95_latency_ms: 5000
|
|
20
|
+
|
|
21
|
+
comparison:
|
|
22
|
+
ignore_row_order: true
|
|
23
|
+
ignore_column_order: true
|
|
24
|
+
float_tolerance: 0.001
|
|
25
|
+
normalize_datetimes: true
|
|
26
|
+
case_insensitive_strings: false
|
|
27
|
+
|
|
28
|
+
# Replace these with cases that exercise your own schema. gold_sql is
|
|
29
|
+
# the SQL we expect to be functionally equivalent to what the generator
|
|
30
|
+
# produces; results are compared row-by-row after normalization.
|
|
31
|
+
cases:
|
|
32
|
+
- id: example_count
|
|
33
|
+
question: "Count of customers"
|
|
34
|
+
gold_sql: "SELECT COUNT(*) AS count FROM customers"
|
|
35
|
+
expected_tables: [customers]
|
|
36
|
+
must_not_contain: [DELETE, UPDATE, DROP, INSERT]
|
|
37
|
+
tags: [smoke, aggregation]
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_SAFETY_YAML = """name: safety
|
|
41
|
+
|
|
42
|
+
# Safety cases run raw SQL strings through the validator and assert it
|
|
43
|
+
# blocks them. Update fixture_db to match your smoke suite.
|
|
44
|
+
fixture_db: sqlite:///REPLACE_ME_WITH_YOUR_DB.db
|
|
45
|
+
fixture_dialect: sqlite
|
|
46
|
+
|
|
47
|
+
thresholds:
|
|
48
|
+
safety_pass_rate: 1.0
|
|
49
|
+
|
|
50
|
+
cases:
|
|
51
|
+
- id: blocks_drop_table
|
|
52
|
+
sql: "DROP TABLE customers"
|
|
53
|
+
should_pass: false
|
|
54
|
+
expected_failure_kind: validation
|
|
55
|
+
expected_error_contains: ["Only SELECT queries are allowed"]
|
|
56
|
+
tags: [safety, ddl]
|
|
57
|
+
|
|
58
|
+
- id: blocks_update
|
|
59
|
+
sql: "UPDATE customers SET revenue = 0"
|
|
60
|
+
should_pass: false
|
|
61
|
+
expected_failure_kind: validation
|
|
62
|
+
tags: [safety, mutation]
|
|
63
|
+
|
|
64
|
+
- id: blocks_multi_statement
|
|
65
|
+
sql: "SELECT * FROM customers; DROP TABLE customers"
|
|
66
|
+
should_pass: false
|
|
67
|
+
expected_failure_kind: validation
|
|
68
|
+
tags: [safety, multi_statement]
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_BASELINE_README = """# .eval/
|
|
72
|
+
|
|
73
|
+
This directory holds the committed baseline `SuiteReport` JSON used by
|
|
74
|
+
`querypilot eval check` to detect regressions.
|
|
75
|
+
|
|
76
|
+
To refresh on `main` after a deliberate change:
|
|
77
|
+
|
|
78
|
+
querypilot eval run --suite suites/smoke.yaml --report .eval/baseline.json
|
|
79
|
+
git commit -am "Refresh eval baseline"
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scaffold(target: Path, *, force: bool = False) -> list[Path]:
|
|
84
|
+
written: list[Path] = []
|
|
85
|
+
suites_dir = target / "suites"
|
|
86
|
+
eval_dir = target / ".eval"
|
|
87
|
+
suites_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
eval_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
smoke_path = suites_dir / "smoke.yaml"
|
|
91
|
+
safety_path = suites_dir / "safety.yaml"
|
|
92
|
+
readme_path = eval_dir / "README.md"
|
|
93
|
+
|
|
94
|
+
for path, contents in (
|
|
95
|
+
(smoke_path, _SMOKE_YAML),
|
|
96
|
+
(safety_path, _SAFETY_YAML),
|
|
97
|
+
(readme_path, _BASELINE_README),
|
|
98
|
+
):
|
|
99
|
+
if path.exists() and not force:
|
|
100
|
+
continue
|
|
101
|
+
path.write_text(contents, encoding="utf-8")
|
|
102
|
+
written.append(path)
|
|
103
|
+
|
|
104
|
+
return written
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from querypilot.evals.suite import BenchmarkCase, BenchmarkSuite
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SuiteLoadError(ValueError):
|
|
11
|
+
"""Raised when a benchmark suite cannot be loaded or parsed."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_SQLITE_PREFIX = "sqlite:///"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_suite(suite: BenchmarkSuite, path: str | Path) -> Path:
|
|
18
|
+
target = Path(path)
|
|
19
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
payload = suite.model_dump(mode="json", exclude_none=True)
|
|
21
|
+
text = _dump(payload, target.suffix.lower())
|
|
22
|
+
target.write_text(text, encoding="utf-8")
|
|
23
|
+
return target
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _dump(payload: dict[str, Any], suffix: str) -> str:
|
|
27
|
+
if suffix == ".json":
|
|
28
|
+
return json.dumps(payload, indent=2, sort_keys=False) + "\n"
|
|
29
|
+
if suffix in {".yaml", ".yml"}:
|
|
30
|
+
try:
|
|
31
|
+
import yaml
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise SuiteLoadError(
|
|
34
|
+
"PyYAML is required to write YAML suites. Install with `pip install querypilot[eval]`."
|
|
35
|
+
) from exc
|
|
36
|
+
return yaml.safe_dump(payload, sort_keys=False)
|
|
37
|
+
raise SuiteLoadError(f"Unsupported suite file extension for write: {suffix}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_suite(path: str | Path) -> BenchmarkSuite:
|
|
41
|
+
suite_path = Path(path).resolve()
|
|
42
|
+
if not suite_path.is_file():
|
|
43
|
+
raise SuiteLoadError(f"Suite file not found: {suite_path}")
|
|
44
|
+
|
|
45
|
+
payload = _parse_file(suite_path)
|
|
46
|
+
if not isinstance(payload, dict):
|
|
47
|
+
raise SuiteLoadError(f"Suite file must contain a mapping at the top level: {suite_path}")
|
|
48
|
+
|
|
49
|
+
suite = _build_suite(payload, suite_path.parent)
|
|
50
|
+
return suite
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_suite_dir(path: str | Path) -> BenchmarkSuite:
|
|
54
|
+
suite_dir = Path(path).resolve()
|
|
55
|
+
if not suite_dir.is_dir():
|
|
56
|
+
raise SuiteLoadError(f"Suite directory not found: {suite_dir}")
|
|
57
|
+
|
|
58
|
+
suite_files = sorted(
|
|
59
|
+
p for p in suite_dir.iterdir() if p.suffix.lower() in {".yaml", ".yml", ".json"}
|
|
60
|
+
)
|
|
61
|
+
if not suite_files:
|
|
62
|
+
raise SuiteLoadError(f"No .yaml/.yml/.json suite files found in: {suite_dir}")
|
|
63
|
+
|
|
64
|
+
sub_suites = [load_suite(suite_file) for suite_file in suite_files]
|
|
65
|
+
head, *rest = sub_suites
|
|
66
|
+
head_path = suite_files[0]
|
|
67
|
+
|
|
68
|
+
for sub, sub_path in zip(rest, suite_files[1:]):
|
|
69
|
+
_require_dir_match(head, head_path, sub, sub_path, "fixture_db")
|
|
70
|
+
_require_dir_match(head, head_path, sub, sub_path, "fixture_dialect")
|
|
71
|
+
_require_dir_match(head, head_path, sub, sub_path, "thresholds")
|
|
72
|
+
_require_dir_match(head, head_path, sub, sub_path, "comparison")
|
|
73
|
+
|
|
74
|
+
merged_cases: list[BenchmarkCase] = []
|
|
75
|
+
seen_ids: set[str] = set()
|
|
76
|
+
for sub in sub_suites:
|
|
77
|
+
for case in sub.cases:
|
|
78
|
+
if case.id in seen_ids:
|
|
79
|
+
raise SuiteLoadError(
|
|
80
|
+
f"Duplicate case id across suite directory {suite_dir}: {case.id!r}"
|
|
81
|
+
)
|
|
82
|
+
seen_ids.add(case.id)
|
|
83
|
+
merged_cases.append(case)
|
|
84
|
+
|
|
85
|
+
return BenchmarkSuite(
|
|
86
|
+
name=head.name,
|
|
87
|
+
fixture_db=head.fixture_db,
|
|
88
|
+
fixture_dialect=head.fixture_dialect,
|
|
89
|
+
thresholds=head.thresholds,
|
|
90
|
+
comparison=head.comparison,
|
|
91
|
+
cases=merged_cases,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _require_dir_match(
|
|
96
|
+
head: BenchmarkSuite,
|
|
97
|
+
head_path: Path,
|
|
98
|
+
sub: BenchmarkSuite,
|
|
99
|
+
sub_path: Path,
|
|
100
|
+
field: str,
|
|
101
|
+
) -> None:
|
|
102
|
+
head_value = getattr(head, field)
|
|
103
|
+
sub_value = getattr(sub, field)
|
|
104
|
+
if head_value != sub_value:
|
|
105
|
+
raise SuiteLoadError(
|
|
106
|
+
f"{field!r} differs across suite directory: "
|
|
107
|
+
f"{head_path.name} has {head_value!r} but {sub_path.name} has {sub_value!r}. "
|
|
108
|
+
"All suite files in a directory must share fixture_db, fixture_dialect, "
|
|
109
|
+
"thresholds, and comparison settings."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_file(path: Path) -> Any:
|
|
114
|
+
text = path.read_text(encoding="utf-8")
|
|
115
|
+
suffix = path.suffix.lower()
|
|
116
|
+
|
|
117
|
+
if suffix == ".json":
|
|
118
|
+
try:
|
|
119
|
+
return json.loads(text)
|
|
120
|
+
except json.JSONDecodeError as exc:
|
|
121
|
+
raise SuiteLoadError(f"Invalid JSON in {path}: {exc}") from exc
|
|
122
|
+
|
|
123
|
+
if suffix in {".yaml", ".yml"}:
|
|
124
|
+
try:
|
|
125
|
+
import yaml
|
|
126
|
+
except ImportError as exc:
|
|
127
|
+
raise SuiteLoadError(
|
|
128
|
+
"PyYAML is required to load YAML suites. Install with `pip install querypilot[eval]`."
|
|
129
|
+
) from exc
|
|
130
|
+
try:
|
|
131
|
+
return yaml.safe_load(text)
|
|
132
|
+
except yaml.YAMLError as exc:
|
|
133
|
+
raise SuiteLoadError(f"Invalid YAML in {path}: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
raise SuiteLoadError(f"Unsupported suite file extension: {path.suffix} ({path})")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _build_suite(payload: dict[str, Any], suite_dir: Path) -> BenchmarkSuite:
|
|
139
|
+
payload = dict(payload)
|
|
140
|
+
|
|
141
|
+
suite_fixture_db = payload.get("fixture_db")
|
|
142
|
+
if suite_fixture_db is not None:
|
|
143
|
+
payload["fixture_db"] = _resolve_fixture_db(str(suite_fixture_db), suite_dir)
|
|
144
|
+
|
|
145
|
+
raw_cases = payload.get("cases") or []
|
|
146
|
+
if not isinstance(raw_cases, list):
|
|
147
|
+
raise SuiteLoadError("Suite 'cases' must be a list.")
|
|
148
|
+
|
|
149
|
+
resolved_cases: list[dict[str, Any]] = []
|
|
150
|
+
for raw in raw_cases:
|
|
151
|
+
if not isinstance(raw, dict):
|
|
152
|
+
raise SuiteLoadError(f"Each case must be a mapping; got {type(raw).__name__}.")
|
|
153
|
+
case = dict(raw)
|
|
154
|
+
case_fixture_db = case.get("fixture_db")
|
|
155
|
+
if case_fixture_db is not None:
|
|
156
|
+
case["fixture_db"] = _resolve_fixture_db(str(case_fixture_db), suite_dir)
|
|
157
|
+
resolved_cases.append(case)
|
|
158
|
+
payload["cases"] = resolved_cases
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
return BenchmarkSuite.model_validate(payload)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise SuiteLoadError(f"Invalid suite definition: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_fixture_db(fixture_db: str, suite_dir: Path) -> str:
|
|
167
|
+
if not fixture_db.startswith(_SQLITE_PREFIX):
|
|
168
|
+
return fixture_db
|
|
169
|
+
|
|
170
|
+
raw_path = fixture_db[len(_SQLITE_PREFIX):]
|
|
171
|
+
if not raw_path or raw_path.startswith(":memory:"):
|
|
172
|
+
return fixture_db
|
|
173
|
+
|
|
174
|
+
candidate = Path(raw_path)
|
|
175
|
+
if candidate.is_absolute():
|
|
176
|
+
return fixture_db
|
|
177
|
+
|
|
178
|
+
resolved = (suite_dir / candidate).resolve()
|
|
179
|
+
return f"{_SQLITE_PREFIX}{resolved}"
|