downshift 0.1.0.dev0__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/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)
downshift/llm.py ADDED
@@ -0,0 +1,171 @@
1
+ """LLM client interface used by every Downshift command.
2
+
3
+ All model calls go through LLMClient, so tests can swap in FakeLLMClient
4
+ and run with no network and no models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ ChatMessage = Mapping[str, str]
15
+ Responder = Callable[[str, list[dict[str, str]]], str]
16
+
17
+
18
+ class LLMError(Exception):
19
+ """Raised when a model call fails."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Completion:
24
+ """One model response plus the numbers Downshift needs for cost math."""
25
+
26
+ text: str
27
+ model: str
28
+ prompt_tokens: int
29
+ completion_tokens: int
30
+ latency_s: float
31
+
32
+ @property
33
+ def total_tokens(self) -> int:
34
+ return self.prompt_tokens + self.completion_tokens
35
+
36
+
37
+ @runtime_checkable
38
+ class LLMClient(Protocol):
39
+ """Anything that can run a chat completion."""
40
+
41
+ def complete(
42
+ self,
43
+ model: str,
44
+ messages: Sequence[ChatMessage],
45
+ *,
46
+ temperature: float = 0.0,
47
+ max_tokens: int | None = None,
48
+ json_mode: bool = False,
49
+ ) -> Completion: ...
50
+
51
+
52
+ class OpenAICompatClient:
53
+ """Client for any OpenAI-compatible endpoint (Ollama, vLLM, OpenAI, Groq, ...)."""
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str,
58
+ api_key: str = "not-needed",
59
+ *,
60
+ timeout: float = 120.0,
61
+ client: Any = None,
62
+ ) -> None:
63
+ if client is None:
64
+ from openai import OpenAI
65
+
66
+ client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
67
+ self._client = client
68
+
69
+ def complete(
70
+ self,
71
+ model: str,
72
+ messages: Sequence[ChatMessage],
73
+ *,
74
+ temperature: float = 0.0,
75
+ max_tokens: int | None = None,
76
+ json_mode: bool = False,
77
+ ) -> Completion:
78
+ kwargs: dict[str, Any] = {
79
+ "model": model,
80
+ "messages": [dict(m) for m in messages],
81
+ "temperature": temperature,
82
+ }
83
+ if max_tokens is not None:
84
+ kwargs["max_tokens"] = max_tokens
85
+ if json_mode:
86
+ kwargs["response_format"] = {"type": "json_object"}
87
+
88
+ start = time.perf_counter()
89
+ try:
90
+ response = self._client.chat.completions.create(**kwargs)
91
+ except Exception as exc:
92
+ raise LLMError(f"call to {model!r} failed: {exc}") from exc
93
+ latency = time.perf_counter() - start
94
+
95
+ if not response.choices:
96
+ raise LLMError(f"call to {model!r} returned no choices")
97
+ text = response.choices[0].message.content or ""
98
+ usage = getattr(response, "usage", None)
99
+ return Completion(
100
+ text=text,
101
+ model=model,
102
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
103
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
104
+ latency_s=latency,
105
+ )
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class FakeCall:
110
+ """A call recorded by FakeLLMClient, for assertions in tests."""
111
+
112
+ model: str
113
+ messages: list[dict[str, str]]
114
+ temperature: float
115
+ max_tokens: int | None
116
+ json_mode: bool
117
+
118
+
119
+ def estimate_tokens(text: str) -> int:
120
+ """Deterministic stand-in for a tokenizer: one token per whitespace-separated word."""
121
+ return len(text.split())
122
+
123
+
124
+ class FakeLLMClient:
125
+ """In-memory client for tests. No network, no models.
126
+
127
+ responses can be:
128
+ - a string: every call returns it
129
+ - a mapping of model name to string: unknown models raise LLMError
130
+ - a function (model, messages) -> string
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ responses: str | Mapping[str, str] | Responder = "",
136
+ *,
137
+ latency_s: float = 0.0,
138
+ ) -> None:
139
+ self._responses = responses
140
+ self._latency_s = latency_s
141
+ self.calls: list[FakeCall] = []
142
+
143
+ def complete(
144
+ self,
145
+ model: str,
146
+ messages: Sequence[ChatMessage],
147
+ *,
148
+ temperature: float = 0.0,
149
+ max_tokens: int | None = None,
150
+ json_mode: bool = False,
151
+ ) -> Completion:
152
+ msgs = [dict(m) for m in messages]
153
+ self.calls.append(FakeCall(model, msgs, temperature, max_tokens, json_mode))
154
+ text = self._respond(model, msgs)
155
+ prompt_text = " ".join(m.get("content", "") for m in msgs)
156
+ return Completion(
157
+ text=text,
158
+ model=model,
159
+ prompt_tokens=estimate_tokens(prompt_text),
160
+ completion_tokens=estimate_tokens(text),
161
+ latency_s=self._latency_s,
162
+ )
163
+
164
+ def _respond(self, model: str, msgs: list[dict[str, str]]) -> str:
165
+ if isinstance(self._responses, str):
166
+ return self._responses
167
+ if isinstance(self._responses, Mapping):
168
+ if model not in self._responses:
169
+ raise LLMError(f"FakeLLMClient has no response for model {model!r}")
170
+ return self._responses[model]
171
+ return self._responses(model, msgs)
downshift/py.typed ADDED
File without changes