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,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from querypilot.evals.suite_runner import SuiteReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CaseRegression(BaseModel):
|
|
12
|
+
id: str
|
|
13
|
+
was_passing: bool
|
|
14
|
+
now_passing: bool
|
|
15
|
+
previous_failure_category: str | None = None
|
|
16
|
+
current_failure_category: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CheckSummary(BaseModel):
|
|
20
|
+
pass_rate: float
|
|
21
|
+
safety_pass_rate: float
|
|
22
|
+
correctness_rate: float
|
|
23
|
+
p95_latency_ms: int
|
|
24
|
+
estimated_cost_usd: float
|
|
25
|
+
total_cases: int
|
|
26
|
+
passed: int
|
|
27
|
+
failed: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CheckOutcome(BaseModel):
|
|
31
|
+
ok: bool
|
|
32
|
+
reasons: list[str] = Field(default_factory=list)
|
|
33
|
+
current: CheckSummary
|
|
34
|
+
baseline: CheckSummary | None = None
|
|
35
|
+
regressed_cases: list[CaseRegression] = Field(default_factory=list)
|
|
36
|
+
latency_p95_delta_ms: int | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_report(
|
|
40
|
+
report: SuiteReport,
|
|
41
|
+
*,
|
|
42
|
+
baseline: SuiteReport | None = None,
|
|
43
|
+
threshold: float | None = None,
|
|
44
|
+
max_p95_ms: int | None = None,
|
|
45
|
+
require_safety_pass_rate: float | None = None,
|
|
46
|
+
require_correctness_rate: float | None = None,
|
|
47
|
+
) -> CheckOutcome:
|
|
48
|
+
reasons: list[str] = []
|
|
49
|
+
|
|
50
|
+
if threshold is not None and report.pass_rate < threshold:
|
|
51
|
+
reasons.append(
|
|
52
|
+
f"pass_rate {report.pass_rate:.3f} below threshold {threshold:.3f}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
require_safety_pass_rate is not None
|
|
57
|
+
and report.safety_pass_rate < require_safety_pass_rate
|
|
58
|
+
):
|
|
59
|
+
reasons.append(
|
|
60
|
+
f"safety_pass_rate {report.safety_pass_rate:.3f} below required "
|
|
61
|
+
f"{require_safety_pass_rate:.3f}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
require_correctness_rate is not None
|
|
66
|
+
and report.correctness_rate < require_correctness_rate
|
|
67
|
+
):
|
|
68
|
+
reasons.append(
|
|
69
|
+
f"correctness_rate {report.correctness_rate:.3f} below required "
|
|
70
|
+
f"{require_correctness_rate:.3f}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if max_p95_ms is not None and report.p95_latency_ms > max_p95_ms:
|
|
74
|
+
reasons.append(
|
|
75
|
+
f"p95_latency_ms {report.p95_latency_ms} exceeds {max_p95_ms}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for violation in report.threshold_violations:
|
|
79
|
+
if violation not in reasons:
|
|
80
|
+
reasons.append(violation)
|
|
81
|
+
|
|
82
|
+
regressed: list[CaseRegression] = []
|
|
83
|
+
latency_delta: int | None = None
|
|
84
|
+
baseline_summary: CheckSummary | None = None
|
|
85
|
+
|
|
86
|
+
if baseline is not None:
|
|
87
|
+
baseline_summary = _summary(baseline)
|
|
88
|
+
latency_delta = report.p95_latency_ms - baseline.p95_latency_ms
|
|
89
|
+
|
|
90
|
+
if report.pass_rate < baseline.pass_rate:
|
|
91
|
+
reasons.append(
|
|
92
|
+
f"pass_rate dropped from baseline {baseline.pass_rate:.3f} to "
|
|
93
|
+
f"{report.pass_rate:.3f}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if report.safety_pass_rate < baseline.safety_pass_rate:
|
|
97
|
+
reasons.append(
|
|
98
|
+
f"safety_pass_rate dropped from baseline "
|
|
99
|
+
f"{baseline.safety_pass_rate:.3f} to {report.safety_pass_rate:.3f}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if report.correctness_rate < baseline.correctness_rate:
|
|
103
|
+
reasons.append(
|
|
104
|
+
f"correctness_rate dropped from baseline "
|
|
105
|
+
f"{baseline.correctness_rate:.3f} to {report.correctness_rate:.3f}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
regressed = _diff_cases(baseline=baseline, current=report)
|
|
109
|
+
|
|
110
|
+
return CheckOutcome(
|
|
111
|
+
ok=not reasons and not regressed,
|
|
112
|
+
reasons=reasons,
|
|
113
|
+
current=_summary(report),
|
|
114
|
+
baseline=baseline_summary,
|
|
115
|
+
regressed_cases=regressed,
|
|
116
|
+
latency_p95_delta_ms=latency_delta,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_report(path: str | Path) -> SuiteReport:
|
|
121
|
+
target = Path(path)
|
|
122
|
+
if not target.is_file():
|
|
123
|
+
raise FileNotFoundError(f"Report file not found: {target}")
|
|
124
|
+
return SuiteReport.model_validate_json(target.read_text(encoding="utf-8"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def format_outcome(outcome: CheckOutcome) -> str:
|
|
128
|
+
if outcome.ok:
|
|
129
|
+
return f"OK\n pass_rate {outcome.current.pass_rate:.3f}, p95 {outcome.current.p95_latency_ms} ms"
|
|
130
|
+
|
|
131
|
+
lines = ["Regression detected.", ""]
|
|
132
|
+
if outcome.baseline is not None:
|
|
133
|
+
lines.extend(
|
|
134
|
+
[
|
|
135
|
+
"Pass rate:",
|
|
136
|
+
f" baseline: {outcome.baseline.pass_rate * 100:.0f}%",
|
|
137
|
+
f" current: {outcome.current.pass_rate * 100:.0f}%",
|
|
138
|
+
"",
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if outcome.regressed_cases:
|
|
143
|
+
lines.append("Failed cases (regression vs. baseline):")
|
|
144
|
+
for case in outcome.regressed_cases:
|
|
145
|
+
verb = "was passing" if case.was_passing else "was failing"
|
|
146
|
+
now = "now passing" if case.now_passing else (
|
|
147
|
+
f"now {case.current_failure_category}" if case.current_failure_category
|
|
148
|
+
else "now failing"
|
|
149
|
+
)
|
|
150
|
+
lines.append(f" - {case.id} ({verb} -> {now})")
|
|
151
|
+
lines.append("")
|
|
152
|
+
|
|
153
|
+
if outcome.latency_p95_delta_ms is not None and outcome.baseline is not None:
|
|
154
|
+
delta = outcome.latency_p95_delta_ms
|
|
155
|
+
lines.extend(
|
|
156
|
+
[
|
|
157
|
+
"Latency:",
|
|
158
|
+
f" baseline p95: {outcome.baseline.p95_latency_ms} ms",
|
|
159
|
+
f" current p95: {outcome.current.p95_latency_ms} ms ({delta:+d} ms)",
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if outcome.reasons:
|
|
165
|
+
lines.append("Reasons:")
|
|
166
|
+
for r in outcome.reasons:
|
|
167
|
+
lines.append(f" - {r}")
|
|
168
|
+
|
|
169
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def write_outcome(outcome: CheckOutcome, path: str | Path) -> Path:
|
|
173
|
+
target = Path(path)
|
|
174
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
target.write_text(
|
|
176
|
+
json.dumps(outcome.model_dump(mode="json"), indent=2) + "\n",
|
|
177
|
+
encoding="utf-8",
|
|
178
|
+
)
|
|
179
|
+
return target
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _summary(report: SuiteReport) -> CheckSummary:
|
|
183
|
+
return CheckSummary(
|
|
184
|
+
pass_rate=report.pass_rate,
|
|
185
|
+
safety_pass_rate=report.safety_pass_rate,
|
|
186
|
+
correctness_rate=report.correctness_rate,
|
|
187
|
+
p95_latency_ms=report.p95_latency_ms,
|
|
188
|
+
estimated_cost_usd=report.estimated_cost_usd,
|
|
189
|
+
total_cases=report.total_cases,
|
|
190
|
+
passed=report.passed,
|
|
191
|
+
failed=report.failed,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _diff_cases(*, baseline: SuiteReport, current: SuiteReport) -> list[CaseRegression]:
|
|
196
|
+
baseline_by_id = {c.id: c for c in baseline.case_results}
|
|
197
|
+
current_by_id = {c.id: c for c in current.case_results}
|
|
198
|
+
|
|
199
|
+
out: list[CaseRegression] = []
|
|
200
|
+
for case_id, current_case in current_by_id.items():
|
|
201
|
+
baseline_case = baseline_by_id.get(case_id)
|
|
202
|
+
if baseline_case is None:
|
|
203
|
+
continue
|
|
204
|
+
if baseline_case.passed and not current_case.passed:
|
|
205
|
+
out.append(
|
|
206
|
+
CaseRegression(
|
|
207
|
+
id=case_id,
|
|
208
|
+
was_passing=True,
|
|
209
|
+
now_passing=False,
|
|
210
|
+
previous_failure_category=(
|
|
211
|
+
baseline_case.failure_category.value
|
|
212
|
+
if baseline_case.failure_category
|
|
213
|
+
else None
|
|
214
|
+
),
|
|
215
|
+
current_failure_category=(
|
|
216
|
+
current_case.failure_category.value
|
|
217
|
+
if current_case.failure_category
|
|
218
|
+
else None
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return out
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from sqlglot import parse_one
|
|
11
|
+
from sqlglot.errors import ParseError
|
|
12
|
+
|
|
13
|
+
from querypilot.evals.suite import ComparisonConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _NaNSentinel:
|
|
17
|
+
_instance = None
|
|
18
|
+
|
|
19
|
+
def __new__(cls) -> "_NaNSentinel":
|
|
20
|
+
if cls._instance is None:
|
|
21
|
+
cls._instance = super().__new__(cls)
|
|
22
|
+
return cls._instance
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return "<NaN>"
|
|
26
|
+
|
|
27
|
+
def __reduce__(self):
|
|
28
|
+
return (_NaNSentinel, ())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
NAN = _NaNSentinel()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValueMismatch(BaseModel):
|
|
35
|
+
row_index: int
|
|
36
|
+
column: str
|
|
37
|
+
gold: Any
|
|
38
|
+
candidate: Any
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RowsetMatch(BaseModel):
|
|
42
|
+
matched: bool
|
|
43
|
+
order_sensitive: bool
|
|
44
|
+
column_mismatch: list[str] = Field(default_factory=list)
|
|
45
|
+
missing_rows: list[dict] = Field(default_factory=list)
|
|
46
|
+
extra_rows: list[dict] = Field(default_factory=list)
|
|
47
|
+
mismatched_values: list[ValueMismatch] = Field(default_factory=list)
|
|
48
|
+
normalized_gold_rows: list[dict] = Field(default_factory=list)
|
|
49
|
+
normalized_candidate_rows: list[dict] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def has_order_by(sql: str) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
tree = parse_one(sql)
|
|
55
|
+
except ParseError:
|
|
56
|
+
return False
|
|
57
|
+
if tree is None:
|
|
58
|
+
return False
|
|
59
|
+
return tree.args.get("order") is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compare_rows(
|
|
63
|
+
gold_rows: list[dict],
|
|
64
|
+
candidate_rows: list[dict],
|
|
65
|
+
gold_sql: str,
|
|
66
|
+
config: ComparisonConfig | None = None,
|
|
67
|
+
) -> RowsetMatch:
|
|
68
|
+
config = config or ComparisonConfig()
|
|
69
|
+
|
|
70
|
+
order_sensitive = has_order_by(gold_sql) or not config.ignore_row_order
|
|
71
|
+
|
|
72
|
+
column_mismatch = _column_mismatch(gold_rows, candidate_rows, config)
|
|
73
|
+
if column_mismatch:
|
|
74
|
+
return RowsetMatch(
|
|
75
|
+
matched=False,
|
|
76
|
+
order_sensitive=order_sensitive,
|
|
77
|
+
column_mismatch=column_mismatch,
|
|
78
|
+
normalized_gold_rows=gold_rows,
|
|
79
|
+
normalized_candidate_rows=candidate_rows,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
norm_gold = [_normalize_row(row, config) for row in gold_rows]
|
|
83
|
+
norm_candidate = [_normalize_row(row, config) for row in candidate_rows]
|
|
84
|
+
|
|
85
|
+
if order_sensitive:
|
|
86
|
+
return _compare_ordered(norm_gold, norm_candidate, tolerance=config.float_tolerance)
|
|
87
|
+
return _compare_bag(norm_gold, norm_candidate, tolerance=config.float_tolerance)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _column_mismatch(
|
|
91
|
+
gold_rows: list[dict], candidate_rows: list[dict], config: ComparisonConfig
|
|
92
|
+
) -> list[str]:
|
|
93
|
+
if not gold_rows or not candidate_rows:
|
|
94
|
+
return []
|
|
95
|
+
gold_cols = list(gold_rows[0].keys())
|
|
96
|
+
candidate_cols = list(candidate_rows[0].keys())
|
|
97
|
+
|
|
98
|
+
if config.ignore_column_order:
|
|
99
|
+
gold_set = set(gold_cols)
|
|
100
|
+
candidate_set = set(candidate_cols)
|
|
101
|
+
if gold_set == candidate_set:
|
|
102
|
+
return []
|
|
103
|
+
diffs: list[str] = []
|
|
104
|
+
for col in sorted(gold_set - candidate_set):
|
|
105
|
+
diffs.append(f"missing in candidate: {col}")
|
|
106
|
+
for col in sorted(candidate_set - gold_set):
|
|
107
|
+
diffs.append(f"unexpected in candidate: {col}")
|
|
108
|
+
return diffs
|
|
109
|
+
|
|
110
|
+
if gold_cols == candidate_cols:
|
|
111
|
+
return []
|
|
112
|
+
return [f"column order mismatch: gold={gold_cols} candidate={candidate_cols}"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_row(row: dict, config: ComparisonConfig) -> dict:
|
|
116
|
+
return {key: _normalize_value(value, config) for key, value in row.items()}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_value(value: Any, config: ComparisonConfig) -> Any:
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
if isinstance(value, bool):
|
|
123
|
+
return value
|
|
124
|
+
if isinstance(value, Decimal):
|
|
125
|
+
value = float(value)
|
|
126
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
127
|
+
return value
|
|
128
|
+
if isinstance(value, float):
|
|
129
|
+
if math.isnan(value):
|
|
130
|
+
return NAN
|
|
131
|
+
return value
|
|
132
|
+
if isinstance(value, datetime):
|
|
133
|
+
return value.isoformat() if config.normalize_datetimes else value
|
|
134
|
+
if isinstance(value, date):
|
|
135
|
+
return value.isoformat() if config.normalize_datetimes else value
|
|
136
|
+
if isinstance(value, time):
|
|
137
|
+
return value.isoformat() if config.normalize_datetimes else value
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
if config.case_insensitive_strings:
|
|
140
|
+
return value.casefold()
|
|
141
|
+
return value
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _compare_ordered(
|
|
146
|
+
gold: list[dict], candidate: list[dict], *, tolerance: float
|
|
147
|
+
) -> RowsetMatch:
|
|
148
|
+
mismatched: list[ValueMismatch] = []
|
|
149
|
+
missing: list[dict] = []
|
|
150
|
+
extra: list[dict] = []
|
|
151
|
+
|
|
152
|
+
common = min(len(gold), len(candidate))
|
|
153
|
+
for index in range(common):
|
|
154
|
+
for column, gold_value in gold[index].items():
|
|
155
|
+
candidate_value = candidate[index].get(column)
|
|
156
|
+
if not _values_equal(gold_value, candidate_value, tolerance):
|
|
157
|
+
mismatched.append(
|
|
158
|
+
ValueMismatch(
|
|
159
|
+
row_index=index,
|
|
160
|
+
column=column,
|
|
161
|
+
gold=gold_value,
|
|
162
|
+
candidate=candidate_value,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if len(gold) > len(candidate):
|
|
167
|
+
missing = gold[common:]
|
|
168
|
+
elif len(candidate) > len(gold):
|
|
169
|
+
extra = candidate[common:]
|
|
170
|
+
|
|
171
|
+
matched = not mismatched and not missing and not extra
|
|
172
|
+
return RowsetMatch(
|
|
173
|
+
matched=matched,
|
|
174
|
+
order_sensitive=True,
|
|
175
|
+
missing_rows=missing,
|
|
176
|
+
extra_rows=extra,
|
|
177
|
+
mismatched_values=mismatched,
|
|
178
|
+
normalized_gold_rows=gold,
|
|
179
|
+
normalized_candidate_rows=candidate,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _compare_bag(
|
|
184
|
+
gold: list[dict], candidate: list[dict], *, tolerance: float
|
|
185
|
+
) -> RowsetMatch:
|
|
186
|
+
if tolerance > 0:
|
|
187
|
+
missing_rows, extra_rows = _greedy_match(gold, candidate, tolerance)
|
|
188
|
+
else:
|
|
189
|
+
missing_rows, extra_rows = _counter_diff(gold, candidate)
|
|
190
|
+
|
|
191
|
+
matched = not missing_rows and not extra_rows
|
|
192
|
+
return RowsetMatch(
|
|
193
|
+
matched=matched,
|
|
194
|
+
order_sensitive=False,
|
|
195
|
+
missing_rows=missing_rows,
|
|
196
|
+
extra_rows=extra_rows,
|
|
197
|
+
normalized_gold_rows=gold,
|
|
198
|
+
normalized_candidate_rows=candidate,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _counter_diff(
|
|
203
|
+
gold: list[dict], candidate: list[dict]
|
|
204
|
+
) -> tuple[list[dict], list[dict]]:
|
|
205
|
+
gold_counter = Counter(_row_key(row) for row in gold)
|
|
206
|
+
candidate_counter = Counter(_row_key(row) for row in candidate)
|
|
207
|
+
|
|
208
|
+
missing_keys = gold_counter - candidate_counter
|
|
209
|
+
extra_keys = candidate_counter - gold_counter
|
|
210
|
+
|
|
211
|
+
return _rows_for_keys(gold, missing_keys), _rows_for_keys(candidate, extra_keys)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _greedy_match(
|
|
215
|
+
gold: list[dict], candidate: list[dict], tolerance: float
|
|
216
|
+
) -> tuple[list[dict], list[dict]]:
|
|
217
|
+
available = list(range(len(candidate)))
|
|
218
|
+
missing: list[dict] = []
|
|
219
|
+
matched_candidate: set[int] = set()
|
|
220
|
+
|
|
221
|
+
for gold_row in gold:
|
|
222
|
+
match_index = None
|
|
223
|
+
for i in available:
|
|
224
|
+
if _rows_equal(gold_row, candidate[i], tolerance):
|
|
225
|
+
match_index = i
|
|
226
|
+
break
|
|
227
|
+
if match_index is None:
|
|
228
|
+
missing.append(gold_row)
|
|
229
|
+
else:
|
|
230
|
+
available.remove(match_index)
|
|
231
|
+
matched_candidate.add(match_index)
|
|
232
|
+
|
|
233
|
+
extra = [candidate[i] for i in range(len(candidate)) if i not in matched_candidate]
|
|
234
|
+
return missing, extra
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _rows_equal(gold: dict, candidate: dict, tolerance: float) -> bool:
|
|
238
|
+
if set(gold.keys()) != set(candidate.keys()):
|
|
239
|
+
return False
|
|
240
|
+
for column, gold_value in gold.items():
|
|
241
|
+
if not _values_equal(gold_value, candidate.get(column), tolerance):
|
|
242
|
+
return False
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _row_key(row: dict) -> tuple:
|
|
247
|
+
return tuple(sorted((key, _hashable(value)) for key, value in row.items()))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _hashable(value: Any) -> Any:
|
|
251
|
+
if isinstance(value, (list, tuple)):
|
|
252
|
+
return tuple(_hashable(item) for item in value)
|
|
253
|
+
if isinstance(value, dict):
|
|
254
|
+
return tuple(sorted((k, _hashable(v)) for k, v in value.items()))
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _rows_for_keys(rows: list[dict], wanted: Counter) -> list[dict]:
|
|
259
|
+
remaining = Counter(wanted)
|
|
260
|
+
out: list[dict] = []
|
|
261
|
+
for row in rows:
|
|
262
|
+
key = _row_key(row)
|
|
263
|
+
if remaining.get(key, 0) > 0:
|
|
264
|
+
out.append(row)
|
|
265
|
+
remaining[key] -= 1
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _values_equal(a: Any, b: Any, tolerance: float = 0.0) -> bool:
|
|
270
|
+
if isinstance(a, _NaNSentinel) and isinstance(b, _NaNSentinel):
|
|
271
|
+
return True
|
|
272
|
+
if isinstance(a, _NaNSentinel) or isinstance(b, _NaNSentinel):
|
|
273
|
+
return False
|
|
274
|
+
if a is None and b is None:
|
|
275
|
+
return True
|
|
276
|
+
if a is None or b is None:
|
|
277
|
+
return False
|
|
278
|
+
if (
|
|
279
|
+
isinstance(a, float)
|
|
280
|
+
and isinstance(b, float)
|
|
281
|
+
and not isinstance(a, bool)
|
|
282
|
+
and not isinstance(b, bool)
|
|
283
|
+
and tolerance > 0
|
|
284
|
+
):
|
|
285
|
+
return abs(a - b) <= tolerance
|
|
286
|
+
return a == b
|
querypilot/evals/cost.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# (input_per_1k_usd, output_per_1k_usd). Missing models leave estimated_usd unset.
|
|
9
|
+
MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
10
|
+
# OpenAI Responses API (illustrative; refresh as pricing changes)
|
|
11
|
+
"gpt-5.1": (0.01, 0.03),
|
|
12
|
+
"gpt-4o": (0.005, 0.015),
|
|
13
|
+
"gpt-4o-mini": (0.00015, 0.0006),
|
|
14
|
+
# Anthropic Messages API
|
|
15
|
+
"claude-opus-4-7": (0.015, 0.075),
|
|
16
|
+
"claude-sonnet-4-6": (0.003, 0.015),
|
|
17
|
+
"claude-sonnet-4-20250514": (0.003, 0.015),
|
|
18
|
+
"claude-haiku-4-5-20251001": (0.00025, 0.00125),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenUsage(BaseModel):
|
|
23
|
+
prompt_tokens: int
|
|
24
|
+
completion_tokens: int
|
|
25
|
+
total_tokens: int
|
|
26
|
+
model: str | None = None
|
|
27
|
+
estimated_usd: float | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CostTracker(Protocol):
|
|
31
|
+
def wrap(self, generator: Any) -> Any:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def restore(self) -> None:
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def last_usage(self) -> TokenUsage | None:
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NullCostTracker:
|
|
45
|
+
def wrap(self, generator: Any) -> Any:
|
|
46
|
+
return generator
|
|
47
|
+
|
|
48
|
+
def restore(self) -> None:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def last_usage(self) -> TokenUsage | None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def reset(self) -> None:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _ResponseCapture:
|
|
59
|
+
def __init__(self, inner: Any, sink: list) -> None:
|
|
60
|
+
self._inner = inner
|
|
61
|
+
self._sink = sink
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def __querypilot_inner__(self) -> Any:
|
|
65
|
+
return self._inner
|
|
66
|
+
|
|
67
|
+
def __getattr__(self, name: str) -> Any:
|
|
68
|
+
return getattr(self._inner, name)
|
|
69
|
+
|
|
70
|
+
def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
71
|
+
response = self._inner.create(*args, **kwargs)
|
|
72
|
+
self._sink.append(response)
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unwrap(namespace: Any) -> Any:
|
|
77
|
+
while isinstance(namespace, _ResponseCapture):
|
|
78
|
+
namespace = namespace.__querypilot_inner__
|
|
79
|
+
return namespace
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OpenAICostTracker:
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
self._captured: list[Any] = []
|
|
85
|
+
self._model: str | None = None
|
|
86
|
+
self._wrapped: tuple[Any, Any] | None = None # (client, original_responses)
|
|
87
|
+
|
|
88
|
+
def wrap(self, generator: Any) -> Any:
|
|
89
|
+
self._model = getattr(generator, "model", None)
|
|
90
|
+
client = getattr(generator, "client", None)
|
|
91
|
+
if client is None or not hasattr(client, "responses"):
|
|
92
|
+
return generator
|
|
93
|
+
original = _unwrap(client.responses)
|
|
94
|
+
if isinstance(client.responses, _ResponseCapture):
|
|
95
|
+
return generator # already wrapped
|
|
96
|
+
client.responses = _ResponseCapture(original, self._captured)
|
|
97
|
+
self._wrapped = (client, original)
|
|
98
|
+
return generator
|
|
99
|
+
|
|
100
|
+
def restore(self) -> None:
|
|
101
|
+
if self._wrapped is None:
|
|
102
|
+
return
|
|
103
|
+
client, original = self._wrapped
|
|
104
|
+
client.responses = original
|
|
105
|
+
self._wrapped = None
|
|
106
|
+
|
|
107
|
+
def last_usage(self) -> TokenUsage | None:
|
|
108
|
+
if not self._captured:
|
|
109
|
+
return None
|
|
110
|
+
response = self._captured[-1]
|
|
111
|
+
usage = getattr(response, "usage", None)
|
|
112
|
+
if usage is None:
|
|
113
|
+
return None
|
|
114
|
+
prompt = _coerce_int(getattr(usage, "input_tokens", None))
|
|
115
|
+
completion = _coerce_int(getattr(usage, "output_tokens", None))
|
|
116
|
+
if prompt is None and completion is None:
|
|
117
|
+
return None
|
|
118
|
+
prompt = prompt or 0
|
|
119
|
+
completion = completion or 0
|
|
120
|
+
return TokenUsage(
|
|
121
|
+
prompt_tokens=prompt,
|
|
122
|
+
completion_tokens=completion,
|
|
123
|
+
total_tokens=prompt + completion,
|
|
124
|
+
model=self._model,
|
|
125
|
+
estimated_usd=estimate_usd(self._model, prompt, completion),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def reset(self) -> None:
|
|
129
|
+
self._captured.clear()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AnthropicCostTracker:
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
self._captured: list[Any] = []
|
|
135
|
+
self._model: str | None = None
|
|
136
|
+
self._wrapped: tuple[Any, Any] | None = None # (client, original_messages)
|
|
137
|
+
|
|
138
|
+
def wrap(self, generator: Any) -> Any:
|
|
139
|
+
self._model = getattr(generator, "model", None)
|
|
140
|
+
client = getattr(generator, "client", None)
|
|
141
|
+
if client is None or not hasattr(client, "messages"):
|
|
142
|
+
return generator
|
|
143
|
+
original = _unwrap(client.messages)
|
|
144
|
+
if isinstance(client.messages, _ResponseCapture):
|
|
145
|
+
return generator # already wrapped
|
|
146
|
+
client.messages = _ResponseCapture(original, self._captured)
|
|
147
|
+
self._wrapped = (client, original)
|
|
148
|
+
return generator
|
|
149
|
+
|
|
150
|
+
def restore(self) -> None:
|
|
151
|
+
if self._wrapped is None:
|
|
152
|
+
return
|
|
153
|
+
client, original = self._wrapped
|
|
154
|
+
client.messages = original
|
|
155
|
+
self._wrapped = None
|
|
156
|
+
|
|
157
|
+
def last_usage(self) -> TokenUsage | None:
|
|
158
|
+
if not self._captured:
|
|
159
|
+
return None
|
|
160
|
+
message = self._captured[-1]
|
|
161
|
+
usage = getattr(message, "usage", None)
|
|
162
|
+
if usage is None:
|
|
163
|
+
return None
|
|
164
|
+
prompt = _coerce_int(getattr(usage, "input_tokens", None))
|
|
165
|
+
completion = _coerce_int(getattr(usage, "output_tokens", None))
|
|
166
|
+
if prompt is None and completion is None:
|
|
167
|
+
return None
|
|
168
|
+
prompt = prompt or 0
|
|
169
|
+
completion = completion or 0
|
|
170
|
+
return TokenUsage(
|
|
171
|
+
prompt_tokens=prompt,
|
|
172
|
+
completion_tokens=completion,
|
|
173
|
+
total_tokens=prompt + completion,
|
|
174
|
+
model=self._model,
|
|
175
|
+
estimated_usd=estimate_usd(self._model, prompt, completion),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def reset(self) -> None:
|
|
179
|
+
self._captured.clear()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def estimate_usd(model: str | None, prompt_tokens: int, completion_tokens: int) -> float | None:
|
|
183
|
+
if model is None:
|
|
184
|
+
return None
|
|
185
|
+
pricing = MODEL_PRICING.get(model)
|
|
186
|
+
if pricing is None:
|
|
187
|
+
return None
|
|
188
|
+
input_per_1k, output_per_1k = pricing
|
|
189
|
+
return round(
|
|
190
|
+
(prompt_tokens / 1000.0) * input_per_1k
|
|
191
|
+
+ (completion_tokens / 1000.0) * output_per_1k,
|
|
192
|
+
6,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _coerce_int(value: Any) -> int | None:
|
|
197
|
+
if value is None:
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
return int(value)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
return None
|