downshift 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.
- downshift/__init__.py +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +930 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/diff.py +387 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/export.py +412 -0
- downshift/gitref.py +65 -0
- downshift/llm.py +171 -0
- downshift/py.typed +0 -0
- downshift/report.py +380 -0
- downshift/resolve.py +439 -0
- downshift/runner.py +312 -0
- downshift/scanner.py +334 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dist-info/METADATA +218 -0
- downshift-0.1.0.dist-info/RECORD +24 -0
- downshift-0.1.0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dist-info/licenses/LICENSE +21 -0
downshift/evalgen.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Generate eval sets with any configured model (the no-Bob path).
|
|
2
|
+
|
|
3
|
+
`generate_eval_set` asks a model for test cases for one call site, then keeps
|
|
4
|
+
only the cases that pass the same checks as `downshift check-evals`. Cases are
|
|
5
|
+
renumbered, so the model never has to get ids right. The CLI command
|
|
6
|
+
`evalgen` is a thin wrapper that writes one `<slug>.jsonl` per call site.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from downshift.evals import (
|
|
19
|
+
EvalCase,
|
|
20
|
+
EvalSet,
|
|
21
|
+
allowed_values,
|
|
22
|
+
expression_placeholders,
|
|
23
|
+
graded_fields,
|
|
24
|
+
site_placeholders,
|
|
25
|
+
validate_eval_set,
|
|
26
|
+
)
|
|
27
|
+
from downshift.llm import LLMClient
|
|
28
|
+
from downshift.schema import CallSite
|
|
29
|
+
|
|
30
|
+
_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$")
|
|
31
|
+
|
|
32
|
+
SYSTEM_PROMPT = (
|
|
33
|
+
"You write test cases for one LLM feature in a software product. "
|
|
34
|
+
"Every expected answer must be certainly correct. Reply with JSON only."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EvalGenSkip(Exception):
|
|
39
|
+
"""Raised when a call site cannot get generated evals; the message says why."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GenerationResult:
|
|
44
|
+
site_id: str
|
|
45
|
+
grading: str
|
|
46
|
+
cases: list[EvalCase] = field(default_factory=list)
|
|
47
|
+
dropped: list[str] = field(default_factory=list)
|
|
48
|
+
attempts: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def grading_for(site: CallSite) -> str:
|
|
52
|
+
"""The audit's grading, or a safe default for an unaudited call site."""
|
|
53
|
+
if site.grading:
|
|
54
|
+
return site.grading
|
|
55
|
+
return "json_fields" if site.output_format == "json" else "judge"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def generate_eval_set(
|
|
59
|
+
client: LLMClient,
|
|
60
|
+
model: str,
|
|
61
|
+
site: CallSite,
|
|
62
|
+
*,
|
|
63
|
+
count: int = 20,
|
|
64
|
+
shared: dict[str, str] | None = None,
|
|
65
|
+
max_attempts: int = 3,
|
|
66
|
+
temperature: float = 0.7,
|
|
67
|
+
) -> GenerationResult:
|
|
68
|
+
"""Ask `model` for cases until `count` valid ones are kept or attempts run out."""
|
|
69
|
+
_check_site(site)
|
|
70
|
+
shared = {k: v for k, v in (shared or {}).items() if k in site_placeholders(site)}
|
|
71
|
+
grading = grading_for(site)
|
|
72
|
+
result = GenerationResult(site_id=site.id, grading=grading)
|
|
73
|
+
seen_inputs: set[str] = set()
|
|
74
|
+
|
|
75
|
+
while len(result.cases) < count and result.attempts < max_attempts:
|
|
76
|
+
result.attempts += 1
|
|
77
|
+
wanted = count - len(result.cases)
|
|
78
|
+
messages = build_messages(site, grading, wanted, shared)
|
|
79
|
+
completion = client.complete(model, messages, temperature=temperature, json_mode=True)
|
|
80
|
+
try:
|
|
81
|
+
raw_cases = parse_cases(completion.text)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
result.dropped.append(f"attempt {result.attempts}: {exc}")
|
|
84
|
+
continue
|
|
85
|
+
for raw in raw_cases:
|
|
86
|
+
if len(result.cases) >= count:
|
|
87
|
+
break
|
|
88
|
+
case, problem = _to_case(raw, site, grading, shared, len(result.cases) + 1)
|
|
89
|
+
if case is None:
|
|
90
|
+
result.dropped.append(problem)
|
|
91
|
+
continue
|
|
92
|
+
key = json.dumps(case.inputs, sort_keys=True, ensure_ascii=False)
|
|
93
|
+
if key in seen_inputs:
|
|
94
|
+
result.dropped.append(f"duplicate inputs: {case.notes}")
|
|
95
|
+
continue
|
|
96
|
+
seen_inputs.add(key)
|
|
97
|
+
result.cases.append(case)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_messages(
|
|
102
|
+
site: CallSite, grading: str, count: int, shared: dict[str, str]
|
|
103
|
+
) -> list[dict[str, str]]:
|
|
104
|
+
inputs = [name for name in site_placeholders(site) if name not in shared]
|
|
105
|
+
prompt_lines = [f"[{m.role}]\n{m.content}" for m in site.messages or []]
|
|
106
|
+
parts = [
|
|
107
|
+
f"Feature: {site.purpose or site.function}",
|
|
108
|
+
f"Output contract: {site.output_contract or 'not documented'}",
|
|
109
|
+
"Prompt template sent to the model ({name} is filled per case):",
|
|
110
|
+
"\n\n".join(prompt_lines),
|
|
111
|
+
]
|
|
112
|
+
if shared:
|
|
113
|
+
parts.append("Fixed inputs, the same for every case (do not repeat them in cases):")
|
|
114
|
+
parts.extend(f"--- {k} ---\n{v}" for k, v in shared.items())
|
|
115
|
+
parts.append(f"Write {count} diverse, realistic test cases.")
|
|
116
|
+
parts.append(
|
|
117
|
+
f"Each case has keys: inputs (an object with exactly these keys: "
|
|
118
|
+
f"{', '.join(inputs)}), expected, notes (what the case tests)."
|
|
119
|
+
)
|
|
120
|
+
parts.append(_expected_rule(site, grading))
|
|
121
|
+
parts.append(
|
|
122
|
+
"Include edge cases: very short text, other languages, typos, "
|
|
123
|
+
"missing details. Skip any case whose answer could be argued."
|
|
124
|
+
)
|
|
125
|
+
parts.append('Reply with: {"cases": [ ... ]}')
|
|
126
|
+
return [
|
|
127
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
128
|
+
{"role": "user", "content": "\n\n".join(parts)},
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_cases(text: str) -> list[Any]:
|
|
133
|
+
"""Pull the case list out of a model reply. Raises ValueError if there is none."""
|
|
134
|
+
cleaned = _FENCE.sub("", text.strip())
|
|
135
|
+
try:
|
|
136
|
+
data = json.loads(cleaned)
|
|
137
|
+
except json.JSONDecodeError as exc:
|
|
138
|
+
raise ValueError(f"reply is not valid JSON ({exc.msg})") from exc
|
|
139
|
+
if isinstance(data, dict):
|
|
140
|
+
data = data.get("cases")
|
|
141
|
+
if not isinstance(data, list):
|
|
142
|
+
raise ValueError('reply has no "cases" list')
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def write_eval_set(
|
|
147
|
+
path: Path, cases: list[EvalCase], shared_files: dict[str, Path] | None = None
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Write cases as JSONL, with a `shared` first line pointing at files if given."""
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
lines = []
|
|
152
|
+
if shared_files:
|
|
153
|
+
header = {
|
|
154
|
+
key: {"file": Path(os.path.relpath(target, path.parent)).as_posix()}
|
|
155
|
+
for key, target in shared_files.items()
|
|
156
|
+
}
|
|
157
|
+
lines.append(json.dumps({"shared": header}, ensure_ascii=False))
|
|
158
|
+
for case in cases:
|
|
159
|
+
record = {
|
|
160
|
+
"id": case.id,
|
|
161
|
+
"inputs": case.inputs,
|
|
162
|
+
"expected": case.expected,
|
|
163
|
+
"grading": case.grading,
|
|
164
|
+
"notes": case.notes,
|
|
165
|
+
}
|
|
166
|
+
lines.append(json.dumps(record, ensure_ascii=False))
|
|
167
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- helpers ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_site(site: CallSite) -> None:
|
|
174
|
+
if not site.prompt_resolved:
|
|
175
|
+
raise EvalGenSkip("prompt is not resolved; run the Bob auditor first")
|
|
176
|
+
expressions = expression_placeholders(site)
|
|
177
|
+
if expressions:
|
|
178
|
+
names = ", ".join("{" + e + "}" for e in expressions)
|
|
179
|
+
raise EvalGenSkip(f"prompt has expression placeholders {names}; run the Bob auditor first")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _expected_rule(site: CallSite, grading: str) -> str:
|
|
183
|
+
if grading == "exact":
|
|
184
|
+
labels = allowed_values(site)
|
|
185
|
+
if labels:
|
|
186
|
+
return f"expected: exactly one of {', '.join(labels)} (a string)."
|
|
187
|
+
return "expected: the exact short answer as a string."
|
|
188
|
+
if grading == "json_fields":
|
|
189
|
+
fields = graded_fields(site)
|
|
190
|
+
if fields:
|
|
191
|
+
return f"expected: an object with exactly these keys: {', '.join(fields)}."
|
|
192
|
+
return "expected: the JSON object the feature should return."
|
|
193
|
+
return (
|
|
194
|
+
"expected: a rubric string listing what a correct reply must and must not do, "
|
|
195
|
+
"point by point."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _to_case(
|
|
200
|
+
raw: Any, site: CallSite, grading: str, shared: dict[str, str], number: int
|
|
201
|
+
) -> tuple[EvalCase | None, str]:
|
|
202
|
+
if not isinstance(raw, dict):
|
|
203
|
+
return None, f"not an object: {str(raw)[:60]}"
|
|
204
|
+
notes = raw.get("notes")
|
|
205
|
+
notes = notes if isinstance(notes, str) and notes.strip() else "generated"
|
|
206
|
+
inputs = raw.get("inputs")
|
|
207
|
+
if not isinstance(inputs, dict):
|
|
208
|
+
return None, f"inputs is not an object: {notes}"
|
|
209
|
+
case = EvalCase(
|
|
210
|
+
id=f"{site.function}-{number:02d}",
|
|
211
|
+
inputs=inputs,
|
|
212
|
+
expected=raw.get("expected"),
|
|
213
|
+
grading=grading,
|
|
214
|
+
notes=notes,
|
|
215
|
+
)
|
|
216
|
+
check = validate_eval_set(EvalSet(path=Path("-"), cases=[case], shared=shared), site)
|
|
217
|
+
if check.errors:
|
|
218
|
+
reason = check.errors[0].split(": ", 1)[-1]
|
|
219
|
+
return None, f"{reason}: {notes}"
|
|
220
|
+
return case, ""
|
downshift/evals.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Eval sets: one JSONL file of test cases per call site.
|
|
2
|
+
|
|
3
|
+
File layout (``<slug>.jsonl``, slug from `slug_for`)::
|
|
4
|
+
|
|
5
|
+
{"shared": {"policy": {"file": "../data/refund_policy.md"}}} <- optional first line
|
|
6
|
+
{"id": "c01", "inputs": {...}, "expected": ..., "grading": "exact", "notes": "..."}
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
`shared` inputs are merged under every case's own inputs, so long fixed
|
|
10
|
+
values (a policy document) are written once. A shared value is a JSON value
|
|
11
|
+
or ``{"file": "relative/path"}``, resolved against the eval file's folder.
|
|
12
|
+
|
|
13
|
+
`validate_eval_set` checks a file against its call site: the input keys must
|
|
14
|
+
match the prompt placeholders, and `expected` must fit the grading type.
|
|
15
|
+
The CLI command `check-evals` is a thin wrapper around `check_eval_dir`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from downshift.schema import GRADINGS, CallSite
|
|
28
|
+
|
|
29
|
+
EVAL_SUFFIX = ".jsonl"
|
|
30
|
+
CASE_FIELDS = frozenset({"id", "inputs", "expected", "grading", "notes"})
|
|
31
|
+
MIN_CASES = 20
|
|
32
|
+
|
|
33
|
+
_BRACED = re.compile(r"\{([^{}\n]+)\}")
|
|
34
|
+
_GRADED_FIELDS = re.compile(r"Graded fields?:\s*([^\n]+)", re.IGNORECASE)
|
|
35
|
+
_VALUE_SET = re.compile(r"from the set \{([^{}]+)\}", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EvalError(ValueError):
|
|
39
|
+
"""Raised when an eval file cannot be parsed at all."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- call site helpers --------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def slug_for(site_id: str) -> str:
|
|
46
|
+
"""`supportdesk/triage.py::classify_category` -> `supportdesk.triage__classify_category`."""
|
|
47
|
+
path, _, function = site_id.partition("::")
|
|
48
|
+
module = path[:-3] if path.endswith(".py") else path
|
|
49
|
+
module = module.replace("\\", "/").strip("/").replace("/", ".")
|
|
50
|
+
return f"{module}__{function}" if function else module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def placeholders(template: str) -> list[str]:
|
|
54
|
+
"""Placeholders in a prompt as the scanner renders it, in order, without duplicates.
|
|
55
|
+
|
|
56
|
+
`{name}` and `{ticket['body']}` both count. Braces around text that is not
|
|
57
|
+
a Python expression (literal JSON such as `{"a": 1}`) are ignored.
|
|
58
|
+
"""
|
|
59
|
+
found: list[str] = []
|
|
60
|
+
for match in _BRACED.finditer(template):
|
|
61
|
+
inner = match.group(1).strip()
|
|
62
|
+
if not inner or not _is_expression(inner):
|
|
63
|
+
continue
|
|
64
|
+
if inner not in found:
|
|
65
|
+
found.append(inner)
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def site_placeholders(site: CallSite) -> list[str]:
|
|
70
|
+
found: list[str] = []
|
|
71
|
+
for message in site.messages or []:
|
|
72
|
+
for name in placeholders(message.content):
|
|
73
|
+
if name not in found:
|
|
74
|
+
found.append(name)
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def expression_placeholders(site: CallSite) -> list[str]:
|
|
79
|
+
"""Placeholders that are not plain names; eval inputs cannot fill them."""
|
|
80
|
+
return [name for name in site_placeholders(site) if not name.isidentifier()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def graded_fields(site: CallSite) -> list[str] | None:
|
|
84
|
+
"""Field names after 'Graded fields:' in the output contract, or None if absent."""
|
|
85
|
+
if not site.output_contract:
|
|
86
|
+
return None
|
|
87
|
+
match = _GRADED_FIELDS.search(site.output_contract)
|
|
88
|
+
if not match:
|
|
89
|
+
return None
|
|
90
|
+
names: list[str] = []
|
|
91
|
+
for part in match.group(1).split(","):
|
|
92
|
+
token = part.strip().rstrip(".;").strip("`'\"")
|
|
93
|
+
if not token.isidentifier():
|
|
94
|
+
break
|
|
95
|
+
names.append(token)
|
|
96
|
+
return names or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def allowed_values(site: CallSite) -> list[str] | None:
|
|
100
|
+
"""Labels from 'from the set {a, b, c}' in the output contract, or None if absent."""
|
|
101
|
+
if not site.output_contract:
|
|
102
|
+
return None
|
|
103
|
+
match = _VALUE_SET.search(site.output_contract)
|
|
104
|
+
if not match:
|
|
105
|
+
return None
|
|
106
|
+
values = [v.strip().strip("`'\"") for v in match.group(1).split(",")]
|
|
107
|
+
return [v for v in values if v] or None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_messages(site: CallSite, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
|
111
|
+
"""Fill a call site's prompt with one case's inputs (for Phase 5 runs)."""
|
|
112
|
+
rendered = []
|
|
113
|
+
for message in site.messages or []:
|
|
114
|
+
content = message.content
|
|
115
|
+
for name in placeholders(content):
|
|
116
|
+
if name in inputs:
|
|
117
|
+
content = content.replace("{" + name + "}", _as_text(inputs[name]))
|
|
118
|
+
rendered.append({"role": message.role, "content": content})
|
|
119
|
+
return rendered
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- loading ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class EvalCase:
|
|
127
|
+
id: str
|
|
128
|
+
inputs: dict[str, Any]
|
|
129
|
+
expected: Any
|
|
130
|
+
grading: str
|
|
131
|
+
notes: str | None = None
|
|
132
|
+
line: int = 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class EvalSet:
|
|
137
|
+
path: Path
|
|
138
|
+
cases: list[EvalCase]
|
|
139
|
+
shared: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
problems: list[str] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def slug(self) -> str:
|
|
144
|
+
return self.path.name[: -len(EVAL_SUFFIX)]
|
|
145
|
+
|
|
146
|
+
def inputs_for(self, case: EvalCase) -> dict[str, Any]:
|
|
147
|
+
return {**self.shared, **case.inputs}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_eval_set(path: Path) -> EvalSet:
|
|
151
|
+
"""Parse a JSONL eval file. Bad lines become `problems`; unreadable files raise."""
|
|
152
|
+
try:
|
|
153
|
+
text = path.read_text(encoding="utf-8")
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
raise EvalError(f"eval file not found: {path}") from None
|
|
156
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
157
|
+
raise EvalError(f"{path}: could not read ({exc})") from exc
|
|
158
|
+
|
|
159
|
+
eval_set = EvalSet(path=path, cases=[])
|
|
160
|
+
first_record = True
|
|
161
|
+
for number, raw in enumerate(text.splitlines(), start=1):
|
|
162
|
+
if not raw.strip():
|
|
163
|
+
continue
|
|
164
|
+
where = f"line {number}"
|
|
165
|
+
try:
|
|
166
|
+
record = json.loads(raw)
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
eval_set.problems.append(f"{where}: invalid JSON ({exc.msg})")
|
|
169
|
+
first_record = False
|
|
170
|
+
continue
|
|
171
|
+
if not isinstance(record, dict):
|
|
172
|
+
eval_set.problems.append(f"{where}: expected a JSON object")
|
|
173
|
+
first_record = False
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if "shared" in record:
|
|
177
|
+
if not first_record:
|
|
178
|
+
eval_set.problems.append(f"{where}: 'shared' is only allowed on the first line")
|
|
179
|
+
else:
|
|
180
|
+
eval_set.shared = _load_shared(record, path, where, eval_set.problems)
|
|
181
|
+
first_record = False
|
|
182
|
+
continue
|
|
183
|
+
first_record = False
|
|
184
|
+
|
|
185
|
+
case = _case_from_record(record, number, eval_set.problems)
|
|
186
|
+
if case is not None:
|
|
187
|
+
eval_set.cases.append(case)
|
|
188
|
+
return eval_set
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _load_shared(record: dict[str, Any], path: Path, where: str, problems: list[str]) -> dict:
|
|
192
|
+
extra = sorted(set(record) - {"shared"})
|
|
193
|
+
if extra:
|
|
194
|
+
problems.append(f"{where}: unknown field(s) next to 'shared': {', '.join(extra)}")
|
|
195
|
+
shared = record["shared"]
|
|
196
|
+
if not isinstance(shared, dict):
|
|
197
|
+
problems.append(f"{where}: 'shared' must be an object")
|
|
198
|
+
return {}
|
|
199
|
+
values: dict[str, Any] = {}
|
|
200
|
+
for key, value in shared.items():
|
|
201
|
+
if isinstance(value, dict) and set(value) == {"file"}:
|
|
202
|
+
target = path.parent / str(value["file"])
|
|
203
|
+
try:
|
|
204
|
+
values[key] = target.read_text(encoding="utf-8")
|
|
205
|
+
except OSError:
|
|
206
|
+
problems.append(f"{where}: shared input {key!r}: file not found: {value['file']}")
|
|
207
|
+
else:
|
|
208
|
+
values[key] = value
|
|
209
|
+
return values
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _case_from_record(record: dict[str, Any], number: int, problems: list[str]) -> EvalCase | None:
|
|
213
|
+
where = f"line {number}"
|
|
214
|
+
unknown = sorted(set(record) - CASE_FIELDS)
|
|
215
|
+
if unknown:
|
|
216
|
+
problems.append(f"{where}: unknown field(s) {', '.join(unknown)}")
|
|
217
|
+
missing = [name for name in ("id", "inputs", "expected", "grading") if name not in record]
|
|
218
|
+
if missing:
|
|
219
|
+
problems.append(f"{where}: missing {', '.join(missing)}")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
case_id, inputs, grading = record["id"], record["inputs"], record["grading"]
|
|
223
|
+
notes = record.get("notes")
|
|
224
|
+
ok = True
|
|
225
|
+
if not isinstance(case_id, str) or not case_id.strip():
|
|
226
|
+
problems.append(f"{where}: id must be a non-empty string")
|
|
227
|
+
ok = False
|
|
228
|
+
if not isinstance(inputs, dict):
|
|
229
|
+
problems.append(f"{where}: inputs must be an object")
|
|
230
|
+
ok = False
|
|
231
|
+
if grading not in GRADINGS:
|
|
232
|
+
problems.append(f"{where}: grading must be one of {', '.join(sorted(GRADINGS))}")
|
|
233
|
+
ok = False
|
|
234
|
+
if notes is not None and not isinstance(notes, str):
|
|
235
|
+
problems.append(f"{where}: notes must be a string")
|
|
236
|
+
ok = False
|
|
237
|
+
if not ok:
|
|
238
|
+
return None
|
|
239
|
+
return EvalCase(
|
|
240
|
+
id=case_id,
|
|
241
|
+
inputs=inputs,
|
|
242
|
+
expected=record["expected"],
|
|
243
|
+
grading=grading,
|
|
244
|
+
notes=notes,
|
|
245
|
+
line=number,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- validation ---------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class EvalReport:
|
|
254
|
+
path: Path
|
|
255
|
+
site_id: str | None = None
|
|
256
|
+
cases: int = 0
|
|
257
|
+
grading: str | None = None
|
|
258
|
+
errors: list[str] = field(default_factory=list)
|
|
259
|
+
warnings: list[str] = field(default_factory=list)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def ok(self) -> bool:
|
|
263
|
+
return not self.errors
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_eval_set(eval_set: EvalSet, site: CallSite) -> EvalReport:
|
|
267
|
+
report = EvalReport(
|
|
268
|
+
path=eval_set.path, site_id=site.id, cases=len(eval_set.cases), grading=site.grading
|
|
269
|
+
)
|
|
270
|
+
report.errors.extend(eval_set.problems)
|
|
271
|
+
|
|
272
|
+
names = site_placeholders(site)
|
|
273
|
+
expressions = [name for name in names if not name.isidentifier()]
|
|
274
|
+
if site.messages is None or not site.prompt_resolved:
|
|
275
|
+
report.errors.append("call site prompt is not resolved; re-run the Bob auditor")
|
|
276
|
+
if expressions:
|
|
277
|
+
report.errors.append(
|
|
278
|
+
"prompt has expression placeholders "
|
|
279
|
+
+ ", ".join("{" + e + "}" for e in expressions)
|
|
280
|
+
+ "; rename them to plain names in the audit"
|
|
281
|
+
)
|
|
282
|
+
wanted = set(names) - set(expressions)
|
|
283
|
+
|
|
284
|
+
fields = graded_fields(site)
|
|
285
|
+
labels = allowed_values(site)
|
|
286
|
+
if site.grading == "json_fields" and fields is None:
|
|
287
|
+
report.warnings.append("output_contract has no 'Graded fields:' list")
|
|
288
|
+
|
|
289
|
+
seen: set[str] = set()
|
|
290
|
+
for case in eval_set.cases:
|
|
291
|
+
where = f"line {case.line} ({case.id})"
|
|
292
|
+
if case.id in seen:
|
|
293
|
+
report.errors.append(f"{where}: duplicate id")
|
|
294
|
+
seen.add(case.id)
|
|
295
|
+
|
|
296
|
+
if site.grading is not None and case.grading != site.grading:
|
|
297
|
+
report.errors.append(
|
|
298
|
+
f"{where}: grading {case.grading} does not match call site grading {site.grading}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
given = set(eval_set.inputs_for(case))
|
|
302
|
+
if not expressions:
|
|
303
|
+
missing = sorted(wanted - given)
|
|
304
|
+
extra = sorted(given - wanted)
|
|
305
|
+
if missing:
|
|
306
|
+
report.errors.append(f"{where}: inputs missing {', '.join(missing)}")
|
|
307
|
+
if extra:
|
|
308
|
+
report.errors.append(f"{where}: inputs not in the prompt: {', '.join(extra)}")
|
|
309
|
+
|
|
310
|
+
report.errors.extend(f"{where}: {p}" for p in _check_expected(case, fields, labels))
|
|
311
|
+
|
|
312
|
+
if len(eval_set.cases) < MIN_CASES:
|
|
313
|
+
report.warnings.append(f"only {len(eval_set.cases)} cases (aim for {MIN_CASES} or more)")
|
|
314
|
+
return report
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_expected(case: EvalCase, fields: list[str] | None, labels: list[str] | None) -> list:
|
|
318
|
+
expected = case.expected
|
|
319
|
+
if case.grading == "exact":
|
|
320
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
321
|
+
return ["exact grading needs expected to be a non-empty string"]
|
|
322
|
+
if labels is not None and expected not in labels:
|
|
323
|
+
return [f"expected {expected!r} is not one of {', '.join(labels)}"]
|
|
324
|
+
return []
|
|
325
|
+
if case.grading == "json_fields":
|
|
326
|
+
if not isinstance(expected, dict) or not expected:
|
|
327
|
+
return ["json_fields grading needs expected to be a non-empty object"]
|
|
328
|
+
if fields is None:
|
|
329
|
+
return []
|
|
330
|
+
problems = []
|
|
331
|
+
missing = [name for name in fields if name not in expected]
|
|
332
|
+
extra = sorted(set(expected) - set(fields))
|
|
333
|
+
if missing:
|
|
334
|
+
problems.append(f"expected missing graded field(s) {', '.join(missing)}")
|
|
335
|
+
if extra:
|
|
336
|
+
problems.append(f"expected has fields that are not graded: {', '.join(extra)}")
|
|
337
|
+
return problems
|
|
338
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
339
|
+
return ["judge grading needs expected to be a reference answer or rubric string"]
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def check_eval_dir(directory: Path, sites: list[CallSite]) -> list[EvalReport]:
|
|
344
|
+
"""Validate every `*.jsonl` in a folder against the call sites it is named after.
|
|
345
|
+
|
|
346
|
+
Returns one report per eval file, plus one per call site that has no file.
|
|
347
|
+
"""
|
|
348
|
+
by_slug = {slug_for(site.id): site for site in sites}
|
|
349
|
+
reports: list[EvalReport] = []
|
|
350
|
+
covered: set[str] = set()
|
|
351
|
+
|
|
352
|
+
for path in sorted(directory.glob(f"*{EVAL_SUFFIX}")):
|
|
353
|
+
slug = path.name[: -len(EVAL_SUFFIX)]
|
|
354
|
+
site = by_slug.get(slug)
|
|
355
|
+
if site is None:
|
|
356
|
+
reports.append(
|
|
357
|
+
EvalReport(path=path, errors=[f"no call site with slug {slug!r} in the audit"])
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
360
|
+
covered.add(slug)
|
|
361
|
+
try:
|
|
362
|
+
eval_set = load_eval_set(path)
|
|
363
|
+
except EvalError as exc:
|
|
364
|
+
reports.append(EvalReport(path=path, site_id=site.id, errors=[str(exc)]))
|
|
365
|
+
continue
|
|
366
|
+
reports.append(validate_eval_set(eval_set, site))
|
|
367
|
+
|
|
368
|
+
for slug, site in by_slug.items():
|
|
369
|
+
if slug not in covered:
|
|
370
|
+
reports.append(
|
|
371
|
+
EvalReport(
|
|
372
|
+
path=directory / f"{slug}{EVAL_SUFFIX}",
|
|
373
|
+
site_id=site.id,
|
|
374
|
+
grading=site.grading,
|
|
375
|
+
warnings=["no eval file"],
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return reports
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --- helpers ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_expression(text: str) -> bool:
|
|
385
|
+
if text[0] in "\"'":
|
|
386
|
+
return False
|
|
387
|
+
try:
|
|
388
|
+
node = ast.parse(text, mode="eval").body
|
|
389
|
+
except SyntaxError:
|
|
390
|
+
return False
|
|
391
|
+
return not isinstance(node, ast.Constant)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _as_text(value: Any) -> str:
|
|
395
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|