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/cli.py ADDED
@@ -0,0 +1,755 @@
1
+ """Downshift command line interface."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from downshift import __version__
12
+ from downshift.audit import Comparison, compare_scans, validate_file
13
+ from downshift.config import ConfigError, resolve_config
14
+ from downshift.evalgen import EvalGenSkip, generate_eval_set, write_eval_set
15
+ from downshift.evals import (
16
+ EVAL_SUFFIX,
17
+ EvalError,
18
+ EvalReport,
19
+ EvalSet,
20
+ check_eval_dir,
21
+ load_eval_set,
22
+ site_placeholders,
23
+ slug_for,
24
+ validate_eval_set,
25
+ )
26
+ from downshift.llm import LLMClient, LLMError, OpenAICompatClient
27
+ from downshift.report import ReportError, build_report, render_markdown
28
+ from downshift.runner import ResultRow, Runner, RunSummary, rescore_site, results_path
29
+ from downshift.scanner import scan_path
30
+ from downshift.schema import CallSite, ScanResult, SchemaError
31
+ from downshift.scorer import Judge
32
+
33
+ DEFAULT_OUT = Path(".downshift") / "callsites.json"
34
+
35
+ app = typer.Typer(
36
+ name="downshift",
37
+ help=(
38
+ "Find every LLM call in your repo, prove which ones can use cheaper "
39
+ "models, and show the cost impact of every PR."
40
+ ),
41
+ no_args_is_help=True,
42
+ add_completion=False,
43
+ )
44
+
45
+
46
+ def _version_callback(value: bool) -> None:
47
+ if value:
48
+ typer.echo(f"downshift {__version__}")
49
+ raise typer.Exit()
50
+
51
+
52
+ @app.callback()
53
+ def main_callback(
54
+ version: bool = typer.Option(
55
+ False,
56
+ "--version",
57
+ callback=_version_callback,
58
+ is_eager=True,
59
+ help="Show the version and exit.",
60
+ ),
61
+ ) -> None:
62
+ """Downshift: cut LLM costs per PR."""
63
+
64
+
65
+ def _not_implemented(name: str) -> None:
66
+ typer.echo(f"`downshift {name}` is not implemented yet.", err=True)
67
+ raise typer.Exit(code=1)
68
+
69
+
70
+ def _fail(message: str) -> None:
71
+ typer.echo(f"Error: {message}", err=True)
72
+ raise typer.Exit(code=2)
73
+
74
+
75
+ # --- scan ---------------------------------------------------------------------
76
+
77
+
78
+ @app.command()
79
+ def scan(
80
+ path: Path = typer.Argument(Path("."), help="Repository directory or Python file to scan."),
81
+ config: Path | None = typer.Option(
82
+ None,
83
+ "--config",
84
+ "-c",
85
+ help="Config file. Default: downshift.yaml in PATH, if present.",
86
+ ),
87
+ out: Path | None = typer.Option(
88
+ None,
89
+ "--out",
90
+ "-o",
91
+ help="Where to write callsites.json. Default: PATH/.downshift/callsites.json.",
92
+ ),
93
+ as_json: bool = typer.Option(
94
+ False, "--json", help="Print the JSON to stdout instead of a table."
95
+ ),
96
+ ) -> None:
97
+ """Find every LLM call site in a repository."""
98
+ try:
99
+ cfg = resolve_config(config, path)
100
+ result = scan_path(path, cfg.scan)
101
+ except (ConfigError, FileNotFoundError) as exc:
102
+ _fail(str(exc))
103
+ return
104
+
105
+ if as_json:
106
+ typer.echo(result.to_json(), nl=False)
107
+ if out is not None:
108
+ result.write(out)
109
+ return
110
+
111
+ target = out if out is not None else _default_out(path)
112
+ result.write(target)
113
+ _print_table(result)
114
+
115
+ summary = result.summary()
116
+ total = summary["call_sites"]
117
+ typer.echo(
118
+ f"{total} call sites in {summary['files_scanned']} files, "
119
+ f"models resolved {summary['models_resolved']}/{total}, "
120
+ f"prompts resolved {summary['prompts_resolved']}/{total}"
121
+ )
122
+ for warning in result.warnings:
123
+ typer.echo(f"warning: {warning}", err=True)
124
+ typer.echo(f"Wrote {target}")
125
+
126
+
127
+ def _default_out(path: Path) -> Path:
128
+ base = path if path.is_dir() else path.parent
129
+ return base / DEFAULT_OUT
130
+
131
+
132
+ def _print_table(result: ScanResult) -> None:
133
+ table = Table(title=f"LLM call sites in {result.root}")
134
+ table.add_column("File", no_wrap=True)
135
+ table.add_column("Function", no_wrap=True)
136
+ table.add_column("Line", justify="right")
137
+ table.add_column("Model", no_wrap=True)
138
+ table.add_column("Source")
139
+ table.add_column("Prompt")
140
+ table.add_column("Output")
141
+ for site in result.call_sites:
142
+ table.add_row(
143
+ site.file,
144
+ site.function,
145
+ str(site.line),
146
+ site.model.value or "[red]?[/red]",
147
+ site.model.source,
148
+ "known" if site.prompt_resolved else "[yellow]runtime[/yellow]",
149
+ site.output_format,
150
+ )
151
+ Console().print(table)
152
+
153
+
154
+ # --- validate -----------------------------------------------------------------
155
+
156
+
157
+ @app.command()
158
+ def validate(
159
+ path: Path = typer.Argument(..., help="callsites or audit JSON file to check."),
160
+ strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors."),
161
+ ) -> None:
162
+ """Check a callsites or audit file against the schema."""
163
+ if not path.is_file():
164
+ _fail(f"file not found: {path}")
165
+ return
166
+
167
+ report = validate_file(path)
168
+ result = report.result
169
+ if report.error is not None or result is None:
170
+ typer.echo(f"Invalid: {report.error}", err=True)
171
+ raise typer.Exit(code=1)
172
+
173
+ summary = result.summary()
174
+ total = summary["call_sites"]
175
+ typer.echo(
176
+ f"Valid: {path} ({total} call sites, generated by {result.generated_by}, "
177
+ f"models resolved {summary['models_resolved']}/{total}, "
178
+ f"prompts resolved {summary['prompts_resolved']}/{total})"
179
+ )
180
+ for warning in report.warnings:
181
+ typer.echo(f"warning: {warning}", err=True)
182
+ if strict and report.warnings:
183
+ raise typer.Exit(code=1)
184
+
185
+
186
+ # --- compare ------------------------------------------------------------------
187
+
188
+
189
+ @app.command()
190
+ def compare(
191
+ ast_file: Path = typer.Argument(..., help="Scan output from `downshift scan`."),
192
+ audit_file: Path = typer.Argument(..., help="Audit output from the Bob auditor."),
193
+ as_json: bool = typer.Option(False, "--json", help="Print the comparison as JSON."),
194
+ ) -> None:
195
+ """Compare the ast scan with a Bob audit, side by side."""
196
+ try:
197
+ before = ScanResult.load(ast_file)
198
+ after = ScanResult.load(audit_file)
199
+ except SchemaError as exc:
200
+ _fail(str(exc))
201
+ return
202
+
203
+ comparison = compare_scans(before, after)
204
+ if as_json:
205
+ typer.echo(json.dumps(comparison.to_dict(), indent=2))
206
+ return
207
+
208
+ left, right = before.generated_by, after.generated_by
209
+ if left == right:
210
+ left, right = "before", "after"
211
+ _print_comparison(comparison, left, right)
212
+
213
+ b, a = before.summary(), after.summary()
214
+ typer.echo(
215
+ f"{left}: {b['call_sites']} call sites, {b['models_resolved']} models resolved | "
216
+ f"{right}: {a['call_sites']} call sites, {a['models_resolved']} models resolved"
217
+ )
218
+
219
+
220
+ def _print_comparison(comparison: Comparison, left: str, right: str) -> None:
221
+ console = Console()
222
+ metrics = Table(title="Scan vs audit")
223
+ metrics.add_column("Metric")
224
+ metrics.add_column(left, justify="right")
225
+ metrics.add_column(right, justify="right")
226
+ for metric in comparison.metrics:
227
+ metrics.add_row(metric.name, str(metric.ast), str(metric.audit))
228
+ console.print(metrics)
229
+
230
+ changes = Table(title="Call site changes")
231
+ changes.add_column("Call site", no_wrap=True)
232
+ changes.add_column("Change")
233
+ changes.add_column("Detail")
234
+ for change in comparison.changes:
235
+ changes.add_row(change.id, change.kind, change.detail)
236
+ console.print(changes)
237
+
238
+
239
+ # --- check-evals --------------------------------------------------------------
240
+
241
+
242
+ @app.command("check-evals")
243
+ def check_evals(
244
+ directory: Path = typer.Argument(..., help="Folder of <slug>.jsonl eval files."),
245
+ callsites: Path = typer.Option(
246
+ ..., "--callsites", help="Audit (or callsites) JSON the eval files belong to."
247
+ ),
248
+ strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors."),
249
+ ) -> None:
250
+ """Check eval files against the call sites they test."""
251
+ if not directory.is_dir():
252
+ _fail(f"folder not found: {directory}")
253
+ return
254
+ try:
255
+ result = ScanResult.load(callsites)
256
+ except SchemaError as exc:
257
+ _fail(str(exc))
258
+ return
259
+
260
+ reports = check_eval_dir(directory, result.call_sites)
261
+ _print_eval_reports(reports)
262
+
263
+ errors = sum(len(r.errors) for r in reports)
264
+ warnings = sum(len(r.warnings) for r in reports)
265
+ cases = sum(r.cases for r in reports)
266
+ files = sum(1 for r in reports if r.path.exists())
267
+ for r in reports:
268
+ for message in r.errors:
269
+ typer.echo(f"error: {r.path.name}: {message}", err=True)
270
+ for message in r.warnings:
271
+ typer.echo(f"warning: {r.path.name}: {message}", err=True)
272
+ typer.echo(f"{files} eval files, {cases} cases, {errors} errors, {warnings} warnings")
273
+ if errors or (strict and warnings):
274
+ raise typer.Exit(code=1)
275
+
276
+
277
+ def _print_eval_reports(reports: list[EvalReport]) -> None:
278
+ table = Table(title="Eval sets")
279
+ table.add_column("Call site", overflow="fold")
280
+ table.add_column("Grading")
281
+ table.add_column("Cases", justify="right")
282
+ table.add_column("Status")
283
+ for r in reports:
284
+ if r.errors:
285
+ status = f"[red]{len(r.errors)} errors[/red]"
286
+ elif r.warnings:
287
+ status = f"[yellow]{len(r.warnings)} warnings[/yellow]"
288
+ else:
289
+ status = "[green]ok[/green]"
290
+ table.add_row(r.site_id or "?", r.grading or "-", str(r.cases), status)
291
+ Console().print(table)
292
+
293
+
294
+ # --- evalgen ------------------------------------------------------------------
295
+
296
+
297
+ def _make_client(provider_base_url: str, api_key: str) -> LLMClient:
298
+ return OpenAICompatClient(provider_base_url, api_key)
299
+
300
+
301
+ def _make_judge_client(base_url: str, api_key: str) -> LLMClient:
302
+ """Client for a hosted judge; retries rate limits (429) with backoff."""
303
+ from openai import OpenAI
304
+
305
+ return OpenAICompatClient(
306
+ base_url,
307
+ api_key,
308
+ client=OpenAI(base_url=base_url, api_key=api_key, timeout=120.0, max_retries=8),
309
+ )
310
+
311
+
312
+ def _build_judge(
313
+ client: LLMClient, model: str, base_url: str | None, key_env: str, max_tokens: int
314
+ ) -> Judge:
315
+ if base_url is None:
316
+ return Judge(client, model, max_tokens=max_tokens)
317
+ api_key = os.environ.get(key_env, "")
318
+ if not api_key:
319
+ raise ValueError(f"judge endpoint {base_url} needs an API key in ${key_env}")
320
+ return Judge(_make_judge_client(base_url, api_key), model, max_tokens=max_tokens)
321
+
322
+
323
+ @app.command()
324
+ def evalgen(
325
+ callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
326
+ out: Path = typer.Option(..., "--out", "-o", help="Folder to write <slug>.jsonl files to."),
327
+ site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
328
+ model: str | None = typer.Option(None, "--model", help="Default: models.judge or baseline."),
329
+ count: int = typer.Option(20, "--count", min=1, help="Cases per call site."),
330
+ shared: list[str] = typer.Option(
331
+ [], "--shared", help="KEY=FILE, a fixed input read from a file. Repeatable."
332
+ ),
333
+ config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
334
+ force: bool = typer.Option(False, "--force", help="Overwrite existing eval files."),
335
+ ) -> None:
336
+ """Generate an eval set for each call site with a configured model."""
337
+ try:
338
+ result = ScanResult.load(callsites)
339
+ cfg = resolve_config(config, callsites.parent)
340
+ client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
341
+ except (SchemaError, ConfigError) as exc:
342
+ _fail(str(exc))
343
+ return
344
+
345
+ shared_files: dict[str, Path] = {}
346
+ for item in shared:
347
+ key, sep, file = item.partition("=")
348
+ if not sep or not key or not Path(file).is_file():
349
+ _fail(f"--shared expects KEY=FILE with an existing file, got {item!r}")
350
+ return
351
+ shared_files[key] = Path(file)
352
+ shared_text = {k: p.read_text(encoding="utf-8") for k, p in shared_files.items()}
353
+
354
+ sites = result.call_sites
355
+ if site:
356
+ known = {s.id for s in sites}
357
+ unknown = [s for s in site if s not in known]
358
+ if unknown:
359
+ _fail(f"unknown call site id(s): {', '.join(unknown)}")
360
+ return
361
+ sites = [s for s in sites if s.id in site]
362
+
363
+ use_model = model or cfg.models.judge_model
364
+ failed = False
365
+ for call_site in sites:
366
+ path = out / f"{slug_for(call_site.id)}{EVAL_SUFFIX}"
367
+ if path.exists() and not force:
368
+ typer.echo(f"skip {call_site.id}: {path} exists (use --force)")
369
+ continue
370
+ try:
371
+ gen = generate_eval_set(client, use_model, call_site, count=count, shared=shared_text)
372
+ except EvalGenSkip as exc:
373
+ typer.echo(f"skip {call_site.id}: {exc}")
374
+ continue
375
+ except LLMError as exc:
376
+ typer.echo(f"error {call_site.id}: {exc}", err=True)
377
+ failed = True
378
+ continue
379
+ if not gen.cases:
380
+ typer.echo(
381
+ f"error {call_site.id}: no valid cases after {gen.attempts} attempts", err=True
382
+ )
383
+ failed = True
384
+ continue
385
+ names = set(site_placeholders(call_site))
386
+ used = {k: p for k, p in shared_files.items() if k in names}
387
+ write_eval_set(path, gen.cases, used)
388
+ typer.echo(
389
+ f"wrote {path} ({len(gen.cases)} cases, {len(gen.dropped)} dropped, "
390
+ f"{gen.attempts} calls, model {use_model})"
391
+ )
392
+ if failed:
393
+ raise typer.Exit(code=1)
394
+
395
+
396
+ # --- run ----------------------------------------------------------------------
397
+
398
+
399
+ @app.command()
400
+ def run(
401
+ callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
402
+ evals: Path | None = typer.Option(
403
+ None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
404
+ ),
405
+ out: Path | None = typer.Option(
406
+ None, "--out", "-o", help="Results folder. Default: results/ next to --callsites."
407
+ ),
408
+ model: list[str] = typer.Option(
409
+ [], "--model", help="Model to run. Repeatable. Default: baseline + candidates."
410
+ ),
411
+ site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
412
+ limit: int | None = typer.Option(
413
+ None, "--limit", min=1, help="Only the first N cases per call site."
414
+ ),
415
+ config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
416
+ warmup: bool = typer.Option(
417
+ True, "--warmup/--no-warmup", help="One untimed call per model before timing."
418
+ ),
419
+ judge_model: str | None = typer.Option(
420
+ None, "--judge-model", help="Judge model. Default: models.judge or baseline."
421
+ ),
422
+ judge_base_url: str | None = typer.Option(
423
+ None, "--judge-base-url", help="Separate OpenAI-compatible endpoint for the judge."
424
+ ),
425
+ judge_api_key_env: str = typer.Option(
426
+ "JUDGE_API_KEY", "--judge-api-key-env", help="Env var with the judge API key."
427
+ ),
428
+ judge_max_tokens: int = typer.Option(
429
+ 1024, "--judge-max-tokens", min=16, help="Max tokens per judge reply."
430
+ ),
431
+ ) -> None:
432
+ """Run each call site's evals on the baseline and candidate models, and score them."""
433
+ try:
434
+ result = ScanResult.load(callsites)
435
+ cfg = resolve_config(config, callsites.parent)
436
+ client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
437
+ judge = _build_judge(
438
+ client,
439
+ judge_model or cfg.models.judge_model,
440
+ judge_base_url,
441
+ judge_api_key_env,
442
+ judge_max_tokens,
443
+ )
444
+ except (SchemaError, ConfigError, ValueError) as exc:
445
+ _fail(str(exc))
446
+ return
447
+
448
+ evals_dir = evals if evals is not None else callsites.parent / "evals"
449
+ results_dir = out if out is not None else callsites.parent / "results"
450
+ if not evals_dir.is_dir():
451
+ _fail(f"folder not found: {evals_dir}")
452
+ return
453
+
454
+ sites = result.call_sites
455
+ if site:
456
+ known = {s.id for s in sites}
457
+ unknown = [s for s in site if s not in known]
458
+ if unknown:
459
+ _fail(f"unknown call site id(s): {', '.join(unknown)}")
460
+ return
461
+ sites = [s for s in sites if s.id in site]
462
+
463
+ tasks = _load_run_tasks(sites, evals_dir)
464
+ if not tasks:
465
+ _fail("nothing to run: no call site has a valid eval file")
466
+ return
467
+
468
+ models = model or [cfg.models.baseline, *cfg.models.candidates]
469
+ runner = Runner(
470
+ client,
471
+ results_dir=results_dir,
472
+ judge=judge,
473
+ limit=limit,
474
+ warmup=warmup,
475
+ on_case=_case_mark,
476
+ )
477
+ summaries: list[RunSummary] = []
478
+ failed = False
479
+ for name in models:
480
+ for call_site, eval_set in tasks:
481
+ typer.echo(f"{name} {call_site.id} ", nl=False)
482
+ try:
483
+ summary = runner.run_site(call_site, eval_set, name)
484
+ except LLMError as exc:
485
+ typer.echo("")
486
+ typer.echo(f"error {name}: {exc}; skipping this model", err=True)
487
+ failed = True
488
+ break
489
+ summaries.append(summary)
490
+ typer.echo(f" {summary.passed}/{summary.cases} passed, {summary.new} new")
491
+
492
+ _print_run_summary(summaries)
493
+ errors = sum(s.errors for s in summaries)
494
+ typer.echo(f"{len(summaries)} site/model runs, {errors} errors. Results in {results_dir}")
495
+ if failed or errors:
496
+ raise typer.Exit(code=1)
497
+
498
+
499
+ def _load_run_tasks(sites: list[CallSite], evals_dir: Path) -> list[tuple[CallSite, EvalSet]]:
500
+ tasks: list[tuple[CallSite, EvalSet]] = []
501
+ for call_site in sites:
502
+ path = evals_dir / f"{slug_for(call_site.id)}{EVAL_SUFFIX}"
503
+ if not path.is_file():
504
+ typer.echo(f"skip {call_site.id}: no eval file at {path}")
505
+ continue
506
+ try:
507
+ eval_set = load_eval_set(path)
508
+ except EvalError as exc:
509
+ typer.echo(f"skip {call_site.id}: {exc}")
510
+ continue
511
+ report = validate_eval_set(eval_set, call_site)
512
+ if report.errors:
513
+ typer.echo(
514
+ f"skip {call_site.id}: {len(report.errors)} eval errors (run downshift check-evals)"
515
+ )
516
+ continue
517
+ tasks.append((call_site, eval_set))
518
+ return tasks
519
+
520
+
521
+ def _case_mark(row: ResultRow) -> None:
522
+ mark = "E" if row.error else ("." if row.passed else "x")
523
+ typer.echo(mark, nl=False)
524
+
525
+
526
+ def _print_run_summary(summaries: list[RunSummary]) -> None:
527
+ table = Table(title="Run results")
528
+ table.add_column("Call site", overflow="fold")
529
+ table.add_column("Model", no_wrap=True)
530
+ table.add_column("Cases", justify="right")
531
+ table.add_column("Passed", justify="right")
532
+ table.add_column("Score", justify="right")
533
+ table.add_column("Latency", justify="right")
534
+ table.add_column("Tokens in/out", justify="right")
535
+ table.add_column("Errors", justify="right")
536
+ for s in summaries:
537
+ rate = f"{s.passed}/{s.scored} ({s.pass_rate:.0%})" if s.pass_rate is not None else "-"
538
+ score = f"{s.mean_score:.2f}" if s.mean_score is not None else "-"
539
+ latency = f"{s.avg_latency_s:.2f}s" if s.avg_latency_s is not None else "-"
540
+ tokens = (
541
+ f"{s.avg_prompt_tokens:.0f}/{s.avg_completion_tokens:.0f}"
542
+ if s.avg_prompt_tokens is not None and s.avg_completion_tokens is not None
543
+ else "-"
544
+ )
545
+ errors = f"[red]{s.errors}[/red]" if s.errors else "0"
546
+ table.add_row(s.site_id, s.model, str(s.cases), rate, score, latency, tokens, errors)
547
+ Console().print(table)
548
+
549
+
550
+ # --- rescore ------------------------------------------------------------------
551
+
552
+
553
+ @app.command()
554
+ def rescore(
555
+ callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
556
+ evals: Path | None = typer.Option(
557
+ None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
558
+ ),
559
+ results: Path | None = typer.Option(
560
+ None, "--results", help="Results folder. Default: results/ next to --callsites."
561
+ ),
562
+ model: list[str] = typer.Option(
563
+ [], "--model", help="Only these models' results. Default: baseline + candidates."
564
+ ),
565
+ site: list[str] = typer.Option([], "--site", help="Only this call site id. Repeatable."),
566
+ config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
567
+ judge_model: str | None = typer.Option(
568
+ None, "--judge-model", help="Judge model. Default: models.judge or baseline."
569
+ ),
570
+ judge_base_url: str | None = typer.Option(
571
+ None, "--judge-base-url", help="Separate OpenAI-compatible endpoint for the judge."
572
+ ),
573
+ judge_api_key_env: str = typer.Option(
574
+ "JUDGE_API_KEY", "--judge-api-key-env", help="Env var with the judge API key."
575
+ ),
576
+ judge_max_tokens: int = typer.Option(
577
+ 1024, "--judge-max-tokens", min=16, help="Max tokens per judge reply."
578
+ ),
579
+ ) -> None:
580
+ """Re-grade saved outputs of judge-graded call sites with the configured judge."""
581
+ try:
582
+ result = ScanResult.load(callsites)
583
+ cfg = resolve_config(config, callsites.parent)
584
+ client = _make_client(cfg.provider.base_url, cfg.provider.api_key())
585
+ judge = _build_judge(
586
+ client,
587
+ judge_model or cfg.models.judge_model,
588
+ judge_base_url,
589
+ judge_api_key_env,
590
+ judge_max_tokens,
591
+ )
592
+ except (SchemaError, ConfigError, ValueError) as exc:
593
+ _fail(str(exc))
594
+ return
595
+
596
+ evals_dir = evals if evals is not None else callsites.parent / "evals"
597
+ results_dir = results if results is not None else callsites.parent / "results"
598
+ if not evals_dir.is_dir():
599
+ _fail(f"folder not found: {evals_dir}")
600
+ return
601
+
602
+ known = {s.id for s in result.call_sites}
603
+ unknown = [s for s in site if s not in known]
604
+ if unknown:
605
+ _fail(f"unknown call site id(s): {', '.join(unknown)}")
606
+ return
607
+ sites = [s for s in result.call_sites if s.grading == "judge" and (not site or s.id in site)]
608
+ tasks = _load_run_tasks(sites, evals_dir)
609
+ if not tasks:
610
+ _fail("nothing to rescore: no judge-graded call site with a valid eval file")
611
+ return
612
+
613
+ models = model or [cfg.models.baseline, *cfg.models.candidates]
614
+ typer.echo(f"judge: {judge.model}")
615
+ pairs: list[tuple[RunSummary, RunSummary]] = []
616
+ for name in models:
617
+ for call_site, eval_set in tasks:
618
+ if not results_path(results_dir, call_site.id, name).is_file():
619
+ typer.echo(f"skip {name} {call_site.id}: no results (run downshift run first)")
620
+ continue
621
+ typer.echo(f"{name} {call_site.id} ", nl=False)
622
+ before, after = rescore_site(
623
+ call_site,
624
+ eval_set,
625
+ name,
626
+ results_dir=results_dir,
627
+ judge=judge,
628
+ on_case=_case_mark,
629
+ )
630
+ pairs.append((before, after))
631
+ typer.echo(
632
+ f" {before.passed}/{before.scored} -> {after.passed}/{after.scored} passed, "
633
+ f"{after.new} rescored"
634
+ )
635
+
636
+ _print_rescore_summary(pairs, judge.model)
637
+ errors = sum(after.errors for _, after in pairs)
638
+ typer.echo(f"{len(pairs)} site/model results rescored, {errors} errors. Saved in {results_dir}")
639
+ if errors:
640
+ raise typer.Exit(code=1)
641
+
642
+
643
+ def _rate(summary: RunSummary) -> str:
644
+ if summary.pass_rate is None:
645
+ return "-"
646
+ return f"{summary.passed}/{summary.scored} ({summary.pass_rate:.0%})"
647
+
648
+
649
+ def _print_rescore_summary(pairs: list[tuple[RunSummary, RunSummary]], judge_model: str) -> None:
650
+ table = Table(title=f"Rescored with {judge_model}")
651
+ table.add_column("Call site", overflow="fold")
652
+ table.add_column("Model", no_wrap=True)
653
+ table.add_column("Before", justify="right")
654
+ table.add_column("After", justify="right")
655
+ table.add_column("Rescored", justify="right")
656
+ table.add_column("Errors", justify="right")
657
+ for before, after in pairs:
658
+ errors = f"[red]{after.errors}[/red]" if after.errors else "0"
659
+ table.add_row(
660
+ after.site_id, after.model, _rate(before), _rate(after), str(after.new), errors
661
+ )
662
+ Console().print(table)
663
+
664
+
665
+ # --- not implemented yet ------------------------------------------------------
666
+
667
+
668
+ @app.command()
669
+ def report(
670
+ callsites: Path = typer.Option(..., "--callsites", help="Audit (or callsites) JSON."),
671
+ evals: Path | None = typer.Option(
672
+ None, "--evals", help="Eval folder. Default: evals/ next to --callsites."
673
+ ),
674
+ results: Path | None = typer.Option(
675
+ None, "--results", help="Results folder. Default: results/ next to --callsites."
676
+ ),
677
+ config: Path | None = typer.Option(None, "--config", "-c", help="Config file."),
678
+ threshold: float | None = typer.Option(
679
+ None,
680
+ "--threshold",
681
+ min=0.0,
682
+ max=1.0,
683
+ help="Quality threshold override (0,1]. Default: from config.",
684
+ ),
685
+ min_pass_rate: float | None = typer.Option(
686
+ None,
687
+ "--min-pass-rate",
688
+ min=0.0,
689
+ max=1.0,
690
+ help="Minimum pass rate override [0,1]. Default: from config.",
691
+ ),
692
+ out: Path | None = typer.Option(None, "--out", help="Write Markdown to this file."),
693
+ ) -> None:
694
+ """Render the cost and quality report."""
695
+ if threshold is not None and threshold <= 0:
696
+ _fail("--threshold must be greater than 0")
697
+ return
698
+ try:
699
+ scan = ScanResult.load(callsites)
700
+ cfg = resolve_config(config, callsites.parent)
701
+ except (SchemaError, ConfigError) as exc:
702
+ _fail(str(exc))
703
+ return
704
+
705
+ evals_dir = evals if evals is not None else callsites.parent / "evals"
706
+ results_dir = results if results is not None else callsites.parent / "results"
707
+
708
+ try:
709
+ rep = build_report(
710
+ scan,
711
+ cfg,
712
+ evals_dir,
713
+ results_dir,
714
+ threshold=threshold,
715
+ min_pass_rate=min_pass_rate,
716
+ )
717
+ except ReportError as exc:
718
+ _fail(str(exc))
719
+ return
720
+
721
+ md = render_markdown(rep)
722
+
723
+ if out is None:
724
+ typer.echo(md, nl=False)
725
+ return
726
+
727
+ out.parent.mkdir(parents=True, exist_ok=True)
728
+ out.write_text(md, encoding="utf-8")
729
+
730
+ costs = rep.costs
731
+ n_down = len(rep.downgraded)
732
+ n_total = len(rep.decisions)
733
+ savings = costs.savings if costs.sites else 0.0
734
+ savings_pct = costs.savings_pct
735
+ pct_str = f"{savings_pct:.1%}" if savings_pct is not None else "n/a"
736
+ typer.echo(
737
+ f"Wrote {out}: downgraded {n_down} of {n_total} call sites,"
738
+ f" projected savings ${savings:,.2f}/month ({pct_str})."
739
+ )
740
+
741
+
742
+ @app.command()
743
+ def diff() -> None:
744
+ """Show the projected LLM cost change between two git refs."""
745
+ _not_implemented("diff")
746
+
747
+
748
+ @app.command()
749
+ def dashboard() -> None:
750
+ """Build the static HTML dashboard."""
751
+ _not_implemented("dashboard")
752
+
753
+
754
+ def main() -> None:
755
+ app()