harnessweaver-cli 0.7.1__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.
- harnessweaver_cli/__init__.py +7 -0
- harnessweaver_cli/_load.py +119 -0
- harnessweaver_cli/_yaml.py +49 -0
- harnessweaver_cli/add.py +238 -0
- harnessweaver_cli/dev.py +141 -0
- harnessweaver_cli/doctor.py +185 -0
- harnessweaver_cli/evals.py +276 -0
- harnessweaver_cli/gateway.py +301 -0
- harnessweaver_cli/main.py +62 -0
- harnessweaver_cli/plugins.py +105 -0
- harnessweaver_cli/policy.py +143 -0
- harnessweaver_cli/py.typed +0 -0
- harnessweaver_cli/replay.py +75 -0
- harnessweaver_cli/scaffold.py +158 -0
- harnessweaver_cli/sidecar.py +48 -0
- harnessweaver_cli/spec.py +160 -0
- harnessweaver_cli/templates/_dev/compose.yaml +183 -0
- harnessweaver_cli/templates/_dev/otel-collector.yaml +25 -0
- harnessweaver_cli/templates/_plugin/README.md +26 -0
- harnessweaver_cli/templates/_plugin/pyproject.toml +26 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/__init__.py +20 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/guards.py +48 -0
- harnessweaver_cli/templates/_plugin/src/__ns___harness/py.typed +0 -0
- harnessweaver_cli/templates/_plugin/tests/test_guards.py +26 -0
- harnessweaver_cli/templates/agent/README.md +18 -0
- harnessweaver_cli/templates/agent/app/__init__.py +1 -0
- harnessweaver_cli/templates/agent/app/agent.py +12 -0
- harnessweaver_cli/templates/agent/app/main.py +19 -0
- harnessweaver_cli/templates/agent/app/tools.py +28 -0
- harnessweaver_cli/templates/agent/evals/redteam.yaml +17 -0
- harnessweaver_cli/templates/agent/harness.offline.yaml +16 -0
- harnessweaver_cli/templates/agent/harness.yaml +38 -0
- harnessweaver_cli/templates/agent/pyproject.toml +13 -0
- harnessweaver_cli/templates/agent/tests/test_agent.py +54 -0
- harnessweaver_cli/templates/agent/tools/lookup_order.tool.yaml +15 -0
- harnessweaver_cli/templates/agent/tools/refund_payment.tool.yaml +17 -0
- harnessweaver_cli/templates/chat/README.md +15 -0
- harnessweaver_cli/templates/chat/app/__init__.py +1 -0
- harnessweaver_cli/templates/chat/app/chat.py +32 -0
- harnessweaver_cli/templates/chat/app/main.py +33 -0
- harnessweaver_cli/templates/chat/evals/golden.yaml +10 -0
- harnessweaver_cli/templates/chat/harness.offline.yaml +12 -0
- harnessweaver_cli/templates/chat/harness.yaml +39 -0
- harnessweaver_cli/templates/chat/pyproject.toml +13 -0
- harnessweaver_cli/templates/chat/tests/test_chat.py +63 -0
- harnessweaver_cli/templates/extraction/README.md +16 -0
- harnessweaver_cli/templates/extraction/app/__init__.py +1 -0
- harnessweaver_cli/templates/extraction/app/main.py +21 -0
- harnessweaver_cli/templates/extraction/app/models.py +20 -0
- harnessweaver_cli/templates/extraction/evals/golden.yaml +12 -0
- harnessweaver_cli/templates/extraction/harness.offline.yaml +18 -0
- harnessweaver_cli/templates/extraction/harness.yaml +33 -0
- harnessweaver_cli/templates/extraction/prompts/extract@1.md +7 -0
- harnessweaver_cli/templates/extraction/pyproject.toml +13 -0
- harnessweaver_cli/templates/extraction/tests/test_extract.py +56 -0
- harnessweaver_cli/templates/rag/README.md +17 -0
- harnessweaver_cli/templates/rag/app/__init__.py +1 -0
- harnessweaver_cli/templates/rag/app/flow.py +29 -0
- harnessweaver_cli/templates/rag/app/main.py +19 -0
- harnessweaver_cli/templates/rag/data/kb.yaml +13 -0
- harnessweaver_cli/templates/rag/evals/golden.yaml +14 -0
- harnessweaver_cli/templates/rag/harness.offline.yaml +13 -0
- harnessweaver_cli/templates/rag/harness.yaml +50 -0
- harnessweaver_cli/templates/rag/prompts/answer@1.md +9 -0
- harnessweaver_cli/templates/rag/pyproject.toml +13 -0
- harnessweaver_cli/templates/rag/tests/test_answer.py +50 -0
- harnessweaver_cli/templates/workflow/README.md +16 -0
- harnessweaver_cli/templates/workflow/app/__init__.py +1 -0
- harnessweaver_cli/templates/workflow/app/main.py +20 -0
- harnessweaver_cli/templates/workflow/app/steps.py +49 -0
- harnessweaver_cli/templates/workflow/evals/golden.yaml +9 -0
- harnessweaver_cli/templates/workflow/harness.offline.yaml +12 -0
- harnessweaver_cli/templates/workflow/harness.yaml +39 -0
- harnessweaver_cli/templates/workflow/prompts/classify@1.md +4 -0
- harnessweaver_cli/templates/workflow/prompts/reply@1.md +5 -0
- harnessweaver_cli/templates/workflow/prompts/summarize@1.md +3 -0
- harnessweaver_cli/templates/workflow/pyproject.toml +13 -0
- harnessweaver_cli/templates/workflow/tests/test_pipeline.py +32 -0
- harnessweaver_cli/tools.py +72 -0
- harnessweaver_cli-0.7.1.dist-info/METADATA +83 -0
- harnessweaver_cli-0.7.1.dist-info/RECORD +84 -0
- harnessweaver_cli-0.7.1.dist-info/WHEEL +4 -0
- harnessweaver_cli-0.7.1.dist-info/entry_points.txt +2 -0
- harnessweaver_cli-0.7.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""``harnessweaver doctor``: what a spec reaches and what to look at next.
|
|
2
|
+
|
|
3
|
+
Reports the adoption level, unreachable hook points (as load errors), duplicate
|
|
4
|
+
owners of retries (R5), unpinned profiles, tool manifests that aren't pinned or
|
|
5
|
+
changed since they were (S2), guards still in shadow mode (R8), content capture
|
|
6
|
+
(R9) and plugin installation problems.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from harnessweaver import Harness
|
|
17
|
+
from harnessweaver.core.errors import HarnessError
|
|
18
|
+
from harnessweaver.core.guards import GuardAction
|
|
19
|
+
from harnessweaver.core.registry import PluginCatalog
|
|
20
|
+
from harnessweaver.modules.guardrails import configured_guards
|
|
21
|
+
from harnessweaver.modules.tools import load_manifests
|
|
22
|
+
from harnessweaver.spec import REQUIREMENTS, Level
|
|
23
|
+
from harnessweaver.spec.models import HarnessSpec
|
|
24
|
+
from harnessweaver.spec.sources import parse_ref
|
|
25
|
+
from harnessweaver_cli._load import spec_path
|
|
26
|
+
|
|
27
|
+
__all__ = ["Finding", "diagnose", "doctor"]
|
|
28
|
+
|
|
29
|
+
type Severity = Literal["ok", "warn", "fail"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Finding:
|
|
34
|
+
severity: Severity
|
|
35
|
+
check: str
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _level(harness: Harness) -> list[Finding]:
|
|
40
|
+
report = harness.spec.level
|
|
41
|
+
tier = harness.spec.spec.metadata.risk_tier
|
|
42
|
+
level, required = report.level.value, report.required.value
|
|
43
|
+
findings = [
|
|
44
|
+
Finding("ok", "level", f"adoption level {level}; risk tier {tier!r} requires {required}")
|
|
45
|
+
]
|
|
46
|
+
following = next((lvl for lvl in Level if lvl.rank == report.level.rank + 1), None)
|
|
47
|
+
if following is not None:
|
|
48
|
+
missing = [REQUIREMENTS[code] for code in report.missing_for(following)]
|
|
49
|
+
if missing:
|
|
50
|
+
findings.append(
|
|
51
|
+
Finding("ok", "next level", f"to reach {following.value}: {'; '.join(missing)}")
|
|
52
|
+
)
|
|
53
|
+
return findings
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _owners(harness: Harness) -> list[Finding]:
|
|
57
|
+
models = harness.spec.spec.models
|
|
58
|
+
if models.provider is None:
|
|
59
|
+
return []
|
|
60
|
+
info = harness.registry.provider("ModelProvider", models.provider)
|
|
61
|
+
settings: Any = models.provider_settings()
|
|
62
|
+
config = info.config.model_validate(settings) if info.config is not None else None
|
|
63
|
+
base_url = getattr(config, "base_url", None)
|
|
64
|
+
retries = getattr(config, "max_retries", 0) or 0
|
|
65
|
+
if base_url and retries > 0:
|
|
66
|
+
return [
|
|
67
|
+
Finding(
|
|
68
|
+
"warn",
|
|
69
|
+
"retries",
|
|
70
|
+
f"{models.provider} retries {retries} time(s) behind the gateway at {base_url}; "
|
|
71
|
+
"let the gateway own retries and set max_retries: 0 (R5)",
|
|
72
|
+
)
|
|
73
|
+
]
|
|
74
|
+
return [Finding("ok", "retries", "retries have one owner")]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pins(spec: HarnessSpec) -> list[Finding]:
|
|
78
|
+
findings: list[Finding] = []
|
|
79
|
+
for text in spec.extends:
|
|
80
|
+
ref = parse_ref(text)
|
|
81
|
+
plugin_profile = ref.scheme == "profile" and "." in ref.name
|
|
82
|
+
if (ref.scheme == "org" or plugin_profile) and ref.range is None:
|
|
83
|
+
findings.append(
|
|
84
|
+
Finding("warn", "pins", f"{text} isn't pinned; use a range like {text}@^1 (V1)")
|
|
85
|
+
)
|
|
86
|
+
return findings or [Finding("ok", "pins", "every org and plugin profile is pinned")]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _shadow(spec: HarnessSpec) -> list[Finding]:
|
|
90
|
+
shadow = [
|
|
91
|
+
name
|
|
92
|
+
for name, g in configured_guards(spec)
|
|
93
|
+
if g.mode == "log_only" or g.action is GuardAction.LOG_ONLY
|
|
94
|
+
]
|
|
95
|
+
if not shadow:
|
|
96
|
+
return [Finding("ok", "shadow guards", "no guards in shadow mode")]
|
|
97
|
+
return [
|
|
98
|
+
Finding(
|
|
99
|
+
"warn",
|
|
100
|
+
"shadow guards",
|
|
101
|
+
f"{name} is in shadow mode; promote it to enforce once evals back it (R8)",
|
|
102
|
+
)
|
|
103
|
+
for name in shadow
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _tools(harness: Harness) -> list[Finding]:
|
|
108
|
+
"""Every manifest should pin its definition; a stale pin blocks the tool's calls (S2)."""
|
|
109
|
+
spec = harness.spec.spec
|
|
110
|
+
if not spec.tools.manifests:
|
|
111
|
+
return []
|
|
112
|
+
base = harness.spec.path.parent if harness.spec.path is not None else Path.cwd()
|
|
113
|
+
findings: list[Finding] = []
|
|
114
|
+
manifests = load_manifests(spec.tools.manifests, base)
|
|
115
|
+
for manifest in manifests:
|
|
116
|
+
pinned, computed = manifest.spec.definition_digest, manifest.computed_digest()
|
|
117
|
+
if pinned is None:
|
|
118
|
+
message = f"tool {manifest.name} isn't pinned; add definition_digest: {computed} (S2)"
|
|
119
|
+
findings.append(Finding("warn", "tools", message))
|
|
120
|
+
elif pinned != computed:
|
|
121
|
+
message = (
|
|
122
|
+
f"tool {manifest.name} changed since it was pinned, so its calls are blocked; "
|
|
123
|
+
f"review it, then set definition_digest: {computed} (S2)"
|
|
124
|
+
)
|
|
125
|
+
findings.append(Finding("fail", "tools", message))
|
|
126
|
+
return findings or [Finding("ok", "tools", f"{len(manifests)} tool manifest(s), all pinned")]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _content(spec: HarnessSpec) -> list[Finding]:
|
|
130
|
+
if spec.observability.capture_content:
|
|
131
|
+
return [
|
|
132
|
+
Finding(
|
|
133
|
+
"warn",
|
|
134
|
+
"content",
|
|
135
|
+
"observability.capture_content is on: post-redaction content goes into spans (R9)",
|
|
136
|
+
)
|
|
137
|
+
]
|
|
138
|
+
return [Finding("ok", "content", "no prompt or output content in telemetry (R9)")]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def diagnose(path: Path, env: str | None, profile_dirs: list[Path]) -> list[Finding]:
|
|
142
|
+
"""Everything ``doctor`` reports. The spec's directory becomes importable, as for the app."""
|
|
143
|
+
path = spec_path(path)
|
|
144
|
+
findings = [
|
|
145
|
+
Finding("warn", "plugins", problem) for problem in PluginCatalog.default().problems()
|
|
146
|
+
]
|
|
147
|
+
try:
|
|
148
|
+
harness = Harness.load(path, env=env, profile_dirs=profile_dirs)
|
|
149
|
+
except HarnessError as exc:
|
|
150
|
+
return [*findings, Finding("fail", "load", exc.message)]
|
|
151
|
+
spec = harness.spec.spec
|
|
152
|
+
findings += _level(harness)
|
|
153
|
+
for task in harness.tasks:
|
|
154
|
+
runner = harness.runner_of(task)
|
|
155
|
+
findings.append(
|
|
156
|
+
Finding("ok", "hooks", f"task {task}: runner {runner.name} reaches every hook it needs")
|
|
157
|
+
)
|
|
158
|
+
findings += _owners(harness) + _pins(spec) + _tools(harness) + _shadow(spec) + _content(spec)
|
|
159
|
+
return findings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def doctor(
|
|
163
|
+
path: Annotated[
|
|
164
|
+
Path | None, typer.Argument(help="The app spec (default: harness.yaml).")
|
|
165
|
+
] = None,
|
|
166
|
+
env: Annotated[
|
|
167
|
+
str | None, typer.Option("--env", "-e", help="Overlay: harness.<env>.yaml.")
|
|
168
|
+
] = None,
|
|
169
|
+
profile_dir: Annotated[list[Path] | None, typer.Option("--profile-dir")] = None,
|
|
170
|
+
strict: Annotated[bool, typer.Option("--strict", help="Exit 1 on warnings too.")] = False,
|
|
171
|
+
output: Annotated[Literal["text", "json"], typer.Option("--format")] = "text",
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Check a spec: adoption level, hooks, retry owners, pins, shadow guards, content."""
|
|
174
|
+
findings = diagnose(spec_path(path), env, profile_dir or [])
|
|
175
|
+
if output == "json":
|
|
176
|
+
typer.echo(json.dumps([asdict(f) for f in findings], indent=2))
|
|
177
|
+
else:
|
|
178
|
+
width = max(len(f.check) for f in findings)
|
|
179
|
+
for finding in findings:
|
|
180
|
+
typer.echo(
|
|
181
|
+
f"{finding.severity.upper():<4} {finding.check:<{width}} {finding.message}"
|
|
182
|
+
)
|
|
183
|
+
worst = {f.severity for f in findings}
|
|
184
|
+
if "fail" in worst or (strict and "warn" in worst):
|
|
185
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""``harnessweaver eval run`` and ``eval report`` (DESIGNS No. 23).
|
|
2
|
+
|
|
3
|
+
``eval run`` runs the spec's eval suites (``evals: {task: [files]}``) with the
|
|
4
|
+
runs kept in memory, compares each with its baseline in
|
|
5
|
+
``.harness/baselines/<task>.<suite>.json`` and reports the guards in shadow mode.
|
|
6
|
+
It exits 1 when a suite misses its threshold or a case that passed in the
|
|
7
|
+
baseline fails, and 2 when the suites can't run. ``eval report`` summarizes the
|
|
8
|
+
production runs ``evals.online`` sampled.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
import anyio
|
|
18
|
+
import typer
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from harnessweaver import Harness
|
|
22
|
+
from harnessweaver.core.errors import HarnessError, SpecError
|
|
23
|
+
from harnessweaver.core.evals import (
|
|
24
|
+
Baseline,
|
|
25
|
+
Comparison,
|
|
26
|
+
EvalDataset,
|
|
27
|
+
OnlineSample,
|
|
28
|
+
ShadowGuardReport,
|
|
29
|
+
SuiteResult,
|
|
30
|
+
)
|
|
31
|
+
from harnessweaver.modules.evals import (
|
|
32
|
+
compare,
|
|
33
|
+
load_dataset,
|
|
34
|
+
online_shadow_report,
|
|
35
|
+
shadow_guards,
|
|
36
|
+
suite_shadow_report,
|
|
37
|
+
)
|
|
38
|
+
from harnessweaver.modules.state import MemoryStateStore
|
|
39
|
+
from harnessweaver_cli._load import fail, load_harness
|
|
40
|
+
from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
|
|
41
|
+
|
|
42
|
+
__all__ = ["eval_app"]
|
|
43
|
+
|
|
44
|
+
eval_app = typer.Typer(help="Run eval suites and read online samples.", no_args_is_help=True)
|
|
45
|
+
|
|
46
|
+
CANT_RUN = 2
|
|
47
|
+
BASELINES = Path(".harness") / "baselines"
|
|
48
|
+
|
|
49
|
+
Names = Annotated[list[str] | None, typer.Option(help="Only these (repeatable).")]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _Suite:
|
|
54
|
+
dataset: EvalDataset
|
|
55
|
+
result: SuiteResult
|
|
56
|
+
baseline: Path
|
|
57
|
+
comparison: Comparison
|
|
58
|
+
had_baseline: bool
|
|
59
|
+
shadow: list[ShadowGuardReport]
|
|
60
|
+
updated: bool = False
|
|
61
|
+
|
|
62
|
+
def ok(self, updating: bool) -> bool:
|
|
63
|
+
return self.result.passed and (updating or not self.comparison.regression)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _base(harness: Harness) -> Path:
|
|
67
|
+
path = harness.spec.path
|
|
68
|
+
return path.parent if path is not None else Path.cwd()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _datasets(harness: Harness, tasks: list[str], suites: list[str]) -> list[EvalDataset]:
|
|
72
|
+
found: list[EvalDataset] = []
|
|
73
|
+
for task, files in harness.spec.spec.evals.suites().items():
|
|
74
|
+
if tasks and task not in tasks:
|
|
75
|
+
continue
|
|
76
|
+
for file in files:
|
|
77
|
+
path = Path(file) if Path(file).is_absolute() else _base(harness) / file
|
|
78
|
+
dataset = load_dataset(path)
|
|
79
|
+
if dataset.task != task:
|
|
80
|
+
raise SpecError(f"evals.{task} lists {file}, whose task is {dataset.task}")
|
|
81
|
+
if not suites or dataset.suite in suites:
|
|
82
|
+
found.append(dataset)
|
|
83
|
+
if not found:
|
|
84
|
+
raise SpecError("no eval suites to run; list datasets under evals: {task: [files]}")
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_baseline(path: Path) -> Baseline | None:
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return Baseline.model_validate_json(path.read_text(encoding="utf-8"))
|
|
93
|
+
except ValidationError:
|
|
94
|
+
raise SpecError(
|
|
95
|
+
f"{path} isn't a baseline; delete it or run with --update-baseline"
|
|
96
|
+
) from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _run_suites(
|
|
100
|
+
harness: Harness, datasets: list[EvalDataset], baselines: Path, update: bool
|
|
101
|
+
) -> list[_Suite]:
|
|
102
|
+
done: list[_Suite] = []
|
|
103
|
+
for dataset in datasets:
|
|
104
|
+
result = await harness.evaluate(dataset)
|
|
105
|
+
path = baselines / f"{dataset.task}.{dataset.suite}.json"
|
|
106
|
+
baseline = _read_baseline(path)
|
|
107
|
+
suite = _Suite(
|
|
108
|
+
dataset=dataset,
|
|
109
|
+
result=result,
|
|
110
|
+
baseline=path,
|
|
111
|
+
comparison=compare(baseline, {c.id: c.passed for c in result.cases}),
|
|
112
|
+
had_baseline=baseline is not None,
|
|
113
|
+
shadow=suite_shadow_report(harness.spec.spec, dataset, result),
|
|
114
|
+
)
|
|
115
|
+
if update and result.passed:
|
|
116
|
+
written = Baseline(
|
|
117
|
+
suite=dataset.suite,
|
|
118
|
+
task=dataset.task,
|
|
119
|
+
pass_rate=result.pass_rate,
|
|
120
|
+
cases={c.id: c.passed for c in result.cases},
|
|
121
|
+
)
|
|
122
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
path.write_text(written.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
124
|
+
suite.updated = True
|
|
125
|
+
done.append(suite)
|
|
126
|
+
return done
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _listed(ids: tuple[str, ...] | None) -> str:
|
|
130
|
+
return ", ".join(ids) if ids else "none"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _print(suite: _Suite, updating: bool) -> None:
|
|
134
|
+
result = suite.result
|
|
135
|
+
passed = sum(1 for c in result.cases if c.passed)
|
|
136
|
+
typer.echo(
|
|
137
|
+
f"{'ok' if suite.ok(updating) else 'FAIL'}: {result.suite} ({result.task}): "
|
|
138
|
+
f"{passed}/{len(result.cases)} passed, pass rate {result.pass_rate} "
|
|
139
|
+
f"(threshold {result.threshold})"
|
|
140
|
+
)
|
|
141
|
+
for case in result.cases:
|
|
142
|
+
if not case.passed:
|
|
143
|
+
failed = "; ".join(
|
|
144
|
+
g.grader + (f" ({g.reason})" if g.reason else "")
|
|
145
|
+
for g in case.grades
|
|
146
|
+
if not g.passed
|
|
147
|
+
)
|
|
148
|
+
typer.echo(f" failed {case.id} [{case.status.value}]: {failed}")
|
|
149
|
+
found = suite.comparison
|
|
150
|
+
if found.regressed:
|
|
151
|
+
typer.echo(f" regressed since the baseline: {', '.join(found.regressed)}")
|
|
152
|
+
if found.fixed:
|
|
153
|
+
typer.echo(f" fixed since the baseline: {', '.join(found.fixed)}")
|
|
154
|
+
if not suite.had_baseline and not suite.updated:
|
|
155
|
+
typer.echo(" no baseline yet; --update-baseline writes one")
|
|
156
|
+
for guard in suite.shadow:
|
|
157
|
+
typer.echo(
|
|
158
|
+
f" shadow {guard.guard}: would fire on {guard.would_fire}/{guard.runs} "
|
|
159
|
+
f"({guard.rate}); false positives: {_listed(guard.false_positives)}; "
|
|
160
|
+
f"misses: {_listed(guard.misses)}"
|
|
161
|
+
)
|
|
162
|
+
if suite.updated:
|
|
163
|
+
typer.echo(f" baseline written to {suite.baseline}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@eval_app.command("run")
|
|
167
|
+
def run(
|
|
168
|
+
path: Path_ = None,
|
|
169
|
+
env: Env = None,
|
|
170
|
+
profile_dir: ProfileDirs = None,
|
|
171
|
+
suite: Names = None,
|
|
172
|
+
task: Names = None,
|
|
173
|
+
baselines: Annotated[
|
|
174
|
+
Path | None,
|
|
175
|
+
typer.Option(help="Where baselines live (default: .harness/baselines next to the spec)."),
|
|
176
|
+
] = None,
|
|
177
|
+
update_baseline: Annotated[
|
|
178
|
+
bool, typer.Option("--update-baseline", help="Accept these results as the baselines.")
|
|
179
|
+
] = False,
|
|
180
|
+
output: Format = "text",
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Run the eval suites, compare them with their baselines and report shadow guards.
|
|
183
|
+
|
|
184
|
+
Exits 1 when a suite misses its pass-rate threshold or a case that passed in
|
|
185
|
+
the baseline fails; 2 when the suites can't run.
|
|
186
|
+
"""
|
|
187
|
+
harness = load_harness(path, env, profile_dir or [], state=MemoryStateStore(), code=CANT_RUN)
|
|
188
|
+
try:
|
|
189
|
+
datasets = _datasets(harness, task or [], suite or [])
|
|
190
|
+
where = baselines or _base(harness) / BASELINES
|
|
191
|
+
suites = anyio.run(_run_suites, harness, datasets, where, update_baseline)
|
|
192
|
+
except HarnessError as exc:
|
|
193
|
+
raise fail(exc, CANT_RUN) from None
|
|
194
|
+
if output == "json":
|
|
195
|
+
body = [
|
|
196
|
+
{
|
|
197
|
+
"suite": s.dataset.suite,
|
|
198
|
+
"task": s.dataset.task,
|
|
199
|
+
"ok": s.ok(update_baseline),
|
|
200
|
+
"result": s.result.model_dump(mode="json"),
|
|
201
|
+
"comparison": {**s.comparison.model_dump(), "regression": s.comparison.regression},
|
|
202
|
+
"baseline": str(s.baseline) if s.had_baseline or s.updated else None,
|
|
203
|
+
"baseline_updated": s.updated,
|
|
204
|
+
"shadow": [g.model_dump(mode="json") for g in s.shadow],
|
|
205
|
+
}
|
|
206
|
+
for s in suites
|
|
207
|
+
]
|
|
208
|
+
typer.echo(json.dumps(body, indent=2))
|
|
209
|
+
else:
|
|
210
|
+
for s in suites:
|
|
211
|
+
_print(s, update_baseline)
|
|
212
|
+
if not all(s.ok(update_baseline) for s in suites):
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _summary(task: str | None, samples: list[OnlineSample], guards: list[str]) -> dict[str, Any]:
|
|
217
|
+
rates: dict[str, list[int]] = {}
|
|
218
|
+
for sample in samples:
|
|
219
|
+
for grade in sample.grades:
|
|
220
|
+
counts = rates.setdefault(grade.grader, [0, 0])
|
|
221
|
+
counts[0] += grade.passed
|
|
222
|
+
counts[1] += 1
|
|
223
|
+
return {
|
|
224
|
+
"task": task,
|
|
225
|
+
"samples": len(samples),
|
|
226
|
+
"statuses": dict(Counter(s.status.value for s in samples)),
|
|
227
|
+
"graders": {label: round(ok / of, 6) for label, (ok, of) in rates.items()},
|
|
228
|
+
"shadow": [g.model_dump(mode="json") for g in online_shadow_report(guards, samples)],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@eval_app.command("report")
|
|
233
|
+
def report(
|
|
234
|
+
path: Path_ = None,
|
|
235
|
+
env: Env = None,
|
|
236
|
+
profile_dir: ProfileDirs = None,
|
|
237
|
+
task: Annotated[str | None, typer.Option(help="Only this task.")] = None,
|
|
238
|
+
output: Format = "text",
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Summarize the production runs evals.online sampled: statuses, grader pass rates and
|
|
241
|
+
how often each guard in shadow mode would have fired (R8)."""
|
|
242
|
+
harness = load_harness(path, env, profile_dir or [], code=CANT_RUN)
|
|
243
|
+
|
|
244
|
+
async def read() -> list[OnlineSample]:
|
|
245
|
+
try:
|
|
246
|
+
return await harness.samples(task)
|
|
247
|
+
finally:
|
|
248
|
+
await harness.aclose()
|
|
249
|
+
|
|
250
|
+
samples = anyio.run(read)
|
|
251
|
+
tasks = sorted({s.task or "" for s in samples})
|
|
252
|
+
spec = harness.spec.spec
|
|
253
|
+
body = [
|
|
254
|
+
_summary(
|
|
255
|
+
name or None,
|
|
256
|
+
[s for s in samples if (s.task or "") == name],
|
|
257
|
+
shadow_guards(spec, name or None),
|
|
258
|
+
)
|
|
259
|
+
for name in tasks
|
|
260
|
+
]
|
|
261
|
+
if output == "json":
|
|
262
|
+
typer.echo(json.dumps(body, indent=2))
|
|
263
|
+
return
|
|
264
|
+
if not body:
|
|
265
|
+
typer.echo("no online samples yet; set evals.online.sample_rate to sample runs")
|
|
266
|
+
return
|
|
267
|
+
for summary in body:
|
|
268
|
+
statuses = ", ".join(f"{n} {status}" for status, n in summary["statuses"].items())
|
|
269
|
+
typer.echo(f"{summary['task'] or '(no task)'}: {summary['samples']} samples ({statuses})")
|
|
270
|
+
for label, rate in summary["graders"].items():
|
|
271
|
+
typer.echo(f" grader {label}: passed {rate}")
|
|
272
|
+
for guard in summary["shadow"]:
|
|
273
|
+
typer.echo(
|
|
274
|
+
f" shadow {guard['guard']}: would fire on {guard['would_fire']}/{guard['runs']} "
|
|
275
|
+
f"({guard['rate']})"
|
|
276
|
+
)
|