aehf 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.
- aehf/__init__.py +6 -0
- aehf/adapters/__init__.py +0 -0
- aehf/adapters/anthropic.py +92 -0
- aehf/adapters/base.py +14 -0
- aehf/adapters/fake.py +12 -0
- aehf/cli.py +295 -0
- aehf/core/__init__.py +0 -0
- aehf/core/case.py +47 -0
- aehf/core/loader.py +31 -0
- aehf/core/results.py +36 -0
- aehf/core/runner.py +51 -0
- aehf/core/transcript.py +39 -0
- aehf/judges/assertionjudge.py +42 -0
- aehf/judges/base.py +11 -0
- aehf/judges/calibration.py +104 -0
- aehf/judges/llmjudge.py +120 -0
- aehf/judges/runner.py +36 -0
- aehf/regression/diff.py +45 -0
- aehf/regression/store.py +35 -0
- aehf/reporting/markdown.py +24 -0
- aehf/stats/__init__.py +0 -0
- aehf/stats/aggregate.py +49 -0
- aehf/stats/compare.py +71 -0
- aehf/stats/sampling.py +18 -0
- aehf/tools/base.py +11 -0
- aehf/tools/mock.py +19 -0
- aehf/tools/replay.py +71 -0
- aehf-0.1.0.dist-info/METADATA +163 -0
- aehf-0.1.0.dist-info/RECORD +32 -0
- aehf-0.1.0.dist-info/WHEEL +4 -0
- aehf-0.1.0.dist-info/entry_points.txt +2 -0
- aehf-0.1.0.dist-info/licenses/LICENSE +0 -0
aehf/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from anthropic import AsyncAnthropic
|
|
4
|
+
from anthropic.types import MessageParam, ToolParam, ToolResultBlockParam
|
|
5
|
+
|
|
6
|
+
from aehf.core.case import EvalCase
|
|
7
|
+
from aehf.core.transcript import Step, Termination, ToolCall, Transcript
|
|
8
|
+
from aehf.tools.base import ToolProviderFactory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicAdapter:
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: AsyncAnthropic, provider_factory: ToolProviderFactory, model: str, max_tokens: int) -> None:
|
|
14
|
+
self.client = client
|
|
15
|
+
self.provider_factory = provider_factory
|
|
16
|
+
self.model = model
|
|
17
|
+
self.max_tokens = max_tokens
|
|
18
|
+
|
|
19
|
+
async def run(self, case: EvalCase) -> Transcript:
|
|
20
|
+
toolprovider = self.provider_factory(case)
|
|
21
|
+
tool_spec: list[ToolParam] = [
|
|
22
|
+
ToolParam(name=t.name, description=t.description or "", input_schema=t.parameters)
|
|
23
|
+
for t in case.tools
|
|
24
|
+
]
|
|
25
|
+
messages: list[MessageParam] = [{"role": "user", "content": case.task_prompt}]
|
|
26
|
+
steps: list[Step] = []
|
|
27
|
+
total_tokens = 0
|
|
28
|
+
final_text = ""
|
|
29
|
+
start_time = time.monotonic()
|
|
30
|
+
|
|
31
|
+
while True:
|
|
32
|
+
# enforce budgets before spending, not after
|
|
33
|
+
if len(steps) >= case.max_steps:
|
|
34
|
+
termination_reason = Termination.max_steps
|
|
35
|
+
break
|
|
36
|
+
if total_tokens >= case.token_budget:
|
|
37
|
+
termination_reason = Termination.budget_exceeded
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
response = await self.client.messages.create(
|
|
41
|
+
model=self.model,
|
|
42
|
+
max_tokens=self.max_tokens,
|
|
43
|
+
tools=tool_spec,
|
|
44
|
+
messages=messages,
|
|
45
|
+
)
|
|
46
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
47
|
+
# input tokens count every turn: each call re-reads the whole conversation
|
|
48
|
+
step_tokens = response.usage.input_tokens + response.usage.output_tokens
|
|
49
|
+
total_tokens += step_tokens
|
|
50
|
+
|
|
51
|
+
step_text = ""
|
|
52
|
+
toolcalls: list[ToolCall] = []
|
|
53
|
+
tool_results: list[ToolResultBlockParam] = []
|
|
54
|
+
for block in response.content:
|
|
55
|
+
if block.type == "text":
|
|
56
|
+
step_text = block.text
|
|
57
|
+
elif block.type == "tool_use":
|
|
58
|
+
tool_start_time = time.monotonic()
|
|
59
|
+
result = await toolprovider.execute(block.name, block.input)
|
|
60
|
+
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
|
|
61
|
+
toolcalls.append(ToolCall(
|
|
62
|
+
toolname=block.name,
|
|
63
|
+
arguments=block.input,
|
|
64
|
+
result=result,
|
|
65
|
+
latency=time.monotonic() - tool_start_time,
|
|
66
|
+
))
|
|
67
|
+
|
|
68
|
+
steps.append(Step(model_output=step_text, tool_calls=toolcalls, token_usage=step_tokens))
|
|
69
|
+
if step_text:
|
|
70
|
+
final_text = step_text
|
|
71
|
+
if tool_results:
|
|
72
|
+
messages.append({"role": "user", "content": tool_results})
|
|
73
|
+
|
|
74
|
+
if response.stop_reason == "end_turn":
|
|
75
|
+
termination_reason = Termination.finished
|
|
76
|
+
break
|
|
77
|
+
if response.stop_reason == "refusal":
|
|
78
|
+
termination_reason = Termination.refused
|
|
79
|
+
break
|
|
80
|
+
if response.stop_reason != "tool_use":
|
|
81
|
+
# max_tokens, pause_turn, or anything the API adds later: stop, don't spin
|
|
82
|
+
termination_reason = Termination.unexpected_stop
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
return Transcript(
|
|
86
|
+
id=case.id,
|
|
87
|
+
ordered_steps=steps,
|
|
88
|
+
final_answer=final_text,
|
|
89
|
+
total_tokens=total_tokens,
|
|
90
|
+
duration_seconds=time.monotonic() - start_time,
|
|
91
|
+
termination_reason=termination_reason,
|
|
92
|
+
)
|
aehf/adapters/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
from aehf.core.case import EvalCase
|
|
4
|
+
from aehf.core.transcript import Transcript
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class Agent(Protocol):
|
|
9
|
+
|
|
10
|
+
async def run(self, case: EvalCase) -> Transcript:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
aehf/adapters/fake.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
from aehf.core.case import EvalCase
|
|
3
|
+
from aehf.core.transcript import Transcript
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeAgent:
|
|
7
|
+
def __init__(self, transcripts:dict[str,Transcript]) -> None:
|
|
8
|
+
self.transcripts = transcripts
|
|
9
|
+
|
|
10
|
+
async def run(self, case : EvalCase) -> Transcript:
|
|
11
|
+
|
|
12
|
+
return self.transcripts[case.id]
|
aehf/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from anthropic import AsyncAnthropic
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
from aehf.adapters.anthropic import AnthropicAdapter
|
|
12
|
+
from aehf.adapters.base import Agent
|
|
13
|
+
from aehf.core.loader import SuiteLoadError, load_suite
|
|
14
|
+
from aehf.core.results import SuiteResult, Verdict, load_suite_result, save_suite_result
|
|
15
|
+
from aehf.core.runner import run_suite
|
|
16
|
+
from aehf.core.transcript import Termination
|
|
17
|
+
from aehf.judges.assertionjudge import AssertionJudge
|
|
18
|
+
from aehf.judges.base import Judge
|
|
19
|
+
from aehf.judges.calibration import (
|
|
20
|
+
LabeledTranscript,
|
|
21
|
+
LabelLoadError,
|
|
22
|
+
cohen_kappa,
|
|
23
|
+
export_unlabeled,
|
|
24
|
+
load_labeled,
|
|
25
|
+
render_for_label,
|
|
26
|
+
save_labeled,
|
|
27
|
+
)
|
|
28
|
+
from aehf.judges.llmjudge import LLMJudge
|
|
29
|
+
from aehf.judges.runner import judge_suite
|
|
30
|
+
from aehf.regression.diff import DiffResult, diff_runs, is_regression
|
|
31
|
+
from aehf.regression.store import RunNotFoundError, load_run, save_run
|
|
32
|
+
from aehf.reporting.markdown import render_markdown
|
|
33
|
+
from aehf.stats.aggregate import CaseStats, aggregate
|
|
34
|
+
from aehf.stats.compare import ComparisonResult, compare_result
|
|
35
|
+
from aehf.tools.base import ToolProviderFactory
|
|
36
|
+
from aehf.tools.mock import mock_provider_factory
|
|
37
|
+
from aehf.tools.replay import record_provider_factory, replay_provider_factory
|
|
38
|
+
|
|
39
|
+
app = typer.Typer()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgentChoice(Enum):
|
|
43
|
+
anthropic = "anthropic"
|
|
44
|
+
|
|
45
|
+
class ToolChoice(Enum):
|
|
46
|
+
mock = "mock"
|
|
47
|
+
record = "record"
|
|
48
|
+
replay = "replay"
|
|
49
|
+
|
|
50
|
+
class JudgeChoice(Enum):
|
|
51
|
+
assertion = "assertion"
|
|
52
|
+
llm = "llm"
|
|
53
|
+
def build_agent(agent: AgentChoice, tools : ToolChoice, recordings_dir:Path, model : str, max_tokens:int) -> Agent:
|
|
54
|
+
|
|
55
|
+
if tools == ToolChoice.mock:
|
|
56
|
+
factory : ToolProviderFactory = mock_provider_factory
|
|
57
|
+
elif tools == ToolChoice.record:
|
|
58
|
+
factory = record_provider_factory(recordings_dir,mock_provider_factory)
|
|
59
|
+
elif tools == ToolChoice.replay:
|
|
60
|
+
factory = replay_provider_factory(recordings_dir)
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"unknown tools choice {tools}")
|
|
63
|
+
|
|
64
|
+
if agent == AgentChoice.anthropic:
|
|
65
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
66
|
+
if not api_key:
|
|
67
|
+
print("ANTHROPIC_API_KEY not set")
|
|
68
|
+
raise typer.Exit(2)
|
|
69
|
+
client = AsyncAnthropic(api_key = api_key)
|
|
70
|
+
return AnthropicAdapter(client = client,provider_factory = factory,model = model, max_tokens = max_tokens)
|
|
71
|
+
raise ValueError(f"unknown agent choice {agent}")
|
|
72
|
+
|
|
73
|
+
def build_judge(judgechoice: JudgeChoice, prompt_version:str,model :str,max_tokens:int) -> Judge:
|
|
74
|
+
|
|
75
|
+
if judgechoice == JudgeChoice.assertion:
|
|
76
|
+
judge = AssertionJudge()
|
|
77
|
+
elif judgechoice == JudgeChoice.llm:
|
|
78
|
+
|
|
79
|
+
key = os.environ.get("ANTHROPIC_API_KEY")
|
|
80
|
+
if not key:
|
|
81
|
+
print("ANTHROPIC_API_KEY not set")
|
|
82
|
+
raise typer.Exit(2)
|
|
83
|
+
return LLMJudge(client=AsyncAnthropic(api_key=key), prompt_version=prompt_version, model=model, max_tokens=max_tokens)
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"unknown Judge choice : {judgechoice}")
|
|
86
|
+
|
|
87
|
+
return judge
|
|
88
|
+
|
|
89
|
+
def _current_sha() -> str:
|
|
90
|
+
# CI hands us the SHA directly; fall back to git for local runs
|
|
91
|
+
sha = os.environ.get("GITHUB_SHA")
|
|
92
|
+
if sha:
|
|
93
|
+
return sha
|
|
94
|
+
try:
|
|
95
|
+
return subprocess.run(
|
|
96
|
+
["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True
|
|
97
|
+
).stdout.strip()
|
|
98
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
99
|
+
return "unknown"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _score_labeled(judge:Judge, records : list[LabeledTranscript], concurrency : int) -> list[Verdict]:
|
|
103
|
+
sem = asyncio.Semaphore(concurrency)
|
|
104
|
+
|
|
105
|
+
async def one(record: LabeledTranscript) -> Verdict:
|
|
106
|
+
async with sem:
|
|
107
|
+
return await judge.score(record.case,record.transcript)
|
|
108
|
+
return await asyncio.gather(*(one(r) for r in records))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def run(path: Path, agent : AgentChoice, tools: ToolChoice, n_samples:int = 1, concurrency: int = 5, max_tokens : int = 1024, recordings_dir : Path = Path("output/"), model : str = "claude-haiku-4-5-20251001", prompt_version:str = "v1",judge_model :str = "claude-haiku-4-5-20251001",judgechoice : JudgeChoice = JudgeChoice.assertion, output :Path | None = None, store: Path | None = None) ->None:
|
|
115
|
+
load_dotenv()
|
|
116
|
+
try:
|
|
117
|
+
suite = load_suite(path)
|
|
118
|
+
except (SuiteLoadError, FileNotFoundError) as e:
|
|
119
|
+
print(e)
|
|
120
|
+
raise typer.Exit(2)
|
|
121
|
+
if n_samples == 0:
|
|
122
|
+
raise ValueError("n_samples value must be greater than 0")
|
|
123
|
+
if output is not None:
|
|
124
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
adapter = build_agent(agent,tools,recordings_dir,model,max_tokens)
|
|
126
|
+
judge = build_judge(judgechoice,prompt_version,judge_model,max_tokens)
|
|
127
|
+
runs : list[SuiteResult] = []
|
|
128
|
+
for _ in range(n_samples):
|
|
129
|
+
suite_result = asyncio.run(run_suite(adapter,suite,concurrency))
|
|
130
|
+
runs.append(asyncio.run(judge_suite(judge,suite,suite_result,concurrency)))
|
|
131
|
+
|
|
132
|
+
if store is not None:
|
|
133
|
+
# regression store holds ONE canonical run per (sha, model, judge_version);
|
|
134
|
+
# diff compares single runs. ponytail: store runs[0]; per-sample storage is
|
|
135
|
+
# future work if diff ever consumes n-sampled data.
|
|
136
|
+
save_run(store, _current_sha(), model, prompt_version, runs[0])
|
|
137
|
+
if n_samples == 1:
|
|
138
|
+
if output is not None:
|
|
139
|
+
save_suite_result(suite_result, output / f"{suite_result.suite_name}-{suite_result.run_id}.json")
|
|
140
|
+
failed = [r for r in suite_result.results if r.transcript.termination_reason != Termination.finished]
|
|
141
|
+
print(f"{len(suite_result.results)} cases: {len(suite_result.results) - len(failed)} completed, {len(failed)} failed")
|
|
142
|
+
print(f"{'id':>23} {'reason':<12} {'steps':>5} {'tokens':>7} {'secs':>6}")
|
|
143
|
+
for r in suite_result.results:
|
|
144
|
+
t = r.transcript
|
|
145
|
+
|
|
146
|
+
print(f"{r.case_id:>23} {t.termination_reason.value:<12} {len(t.ordered_steps):>5} {t.total_tokens:>7} {t.duration_seconds:>6.2f}")
|
|
147
|
+
if failed:
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
else:
|
|
150
|
+
if output is not None:
|
|
151
|
+
for sr in runs:
|
|
152
|
+
save_suite_result(sr, output / f"{sr.suite_name}-{sr.run_id}.json")
|
|
153
|
+
aggregate_results:list[CaseStats] = aggregate(runs)
|
|
154
|
+
print(f"{'id':>23} {'n':>5} {'pass':>5} {'rate':>7} {'95% CI':>16} {'flaky':>6}")
|
|
155
|
+
for case_stats in aggregate_results:
|
|
156
|
+
ci = f"[{case_stats.ci_low:.2f}, {case_stats.ci_high:.2f}]"
|
|
157
|
+
flag = "flaky" if case_stats.flaky else ""
|
|
158
|
+
print(f"{case_stats.case_id:>23} {case_stats.n:>5} {case_stats.passes:>5} {case_stats.pass_rate:>7.2f} {ci:>16} {flag:>6}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def calibrate(label: Path,judgechoice : JudgeChoice, prompt_version :str = "v1", judge_model :str = "claude-haiku-4-5-20251001", max_tokens : int = 1024 ,concurrency : int = 5) -> None:
|
|
164
|
+
load_dotenv()
|
|
165
|
+
try:
|
|
166
|
+
records = load_labeled(label)
|
|
167
|
+
except (LabelLoadError , FileNotFoundError) as e:
|
|
168
|
+
print(e)
|
|
169
|
+
raise typer.Exit(2)
|
|
170
|
+
|
|
171
|
+
if not records:
|
|
172
|
+
print("No labeled records found")
|
|
173
|
+
raise typer.Exit(2)
|
|
174
|
+
judge = build_judge(judgechoice,prompt_version,judge_model,max_tokens)
|
|
175
|
+
verdicts = asyncio.run(_score_labeled(judge, records,concurrency))
|
|
176
|
+
judge_labels = [v.passed for v in verdicts]
|
|
177
|
+
human_labels = [r.human_label for r in records]
|
|
178
|
+
kappa = cohen_kappa(judge_labels,human_labels)
|
|
179
|
+
agreement = sum(j == h for j,h in zip(judge_labels,human_labels,strict = True))
|
|
180
|
+
|
|
181
|
+
print(f"records : {len(records)}")
|
|
182
|
+
print(f"raw agreement : {agreement}/{len(records)}")
|
|
183
|
+
print(f"cohen kappa : {kappa:.3f}")
|
|
184
|
+
|
|
185
|
+
jp_hp = sum(v.passed and r.human_label for v, r in zip(verdicts, records, strict=True))
|
|
186
|
+
jp_hf = sum(v.passed and not r.human_label for v, r in zip(verdicts, records, strict=True))
|
|
187
|
+
jf_hp = sum(not v.passed and r.human_label for v, r in zip(verdicts, records, strict=True))
|
|
188
|
+
jf_hf = sum(not v.passed and not r.human_label for v, r in zip(verdicts, records, strict=True))
|
|
189
|
+
print()
|
|
190
|
+
print(f"{'':12}{'human_pass':>12}{'human_fail':>12}")
|
|
191
|
+
print(f"{'judge_pass':12}{jp_hp:>12}{jp_hf:>12}")
|
|
192
|
+
print(f"{'judge_fail':12}{jf_hp:>12}{jf_hf:>12}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
disagreements = [(v, r) for v, r in zip(verdicts, records, strict=True) if v.passed != r.human_label]
|
|
196
|
+
print(f"\nDISAGREEMENTS ({len(disagreements)}):")
|
|
197
|
+
for v, r in disagreements:
|
|
198
|
+
human = "PASS" if r.human_label else "FAIL"
|
|
199
|
+
judge_label = "PASS" if v.passed else "FAIL"
|
|
200
|
+
conf = f"{v.score:.2f}" if v.score is not None else "n/a"
|
|
201
|
+
print(f" {r.case.id:12} human={human} judge={judge_label} conf={conf}")
|
|
202
|
+
print(f" judge: {v.reasoning}")
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def export_labels(suite_path: Path, out: Path, results: list[Path]) -> None:
|
|
206
|
+
load_dotenv()
|
|
207
|
+
try:
|
|
208
|
+
suite = load_suite(suite_path)
|
|
209
|
+
except (SuiteLoadError, FileNotFoundError) as e:
|
|
210
|
+
print(e)
|
|
211
|
+
raise typer.Exit(2)
|
|
212
|
+
|
|
213
|
+
records: list[LabeledTranscript] = []
|
|
214
|
+
for path in results:
|
|
215
|
+
try:
|
|
216
|
+
suite_result = load_suite_result(path)
|
|
217
|
+
except FileNotFoundError as e:
|
|
218
|
+
print(e)
|
|
219
|
+
raise typer.Exit(2)
|
|
220
|
+
records.extend(export_unlabeled(suite, suite_result))
|
|
221
|
+
|
|
222
|
+
save_labeled(records, out)
|
|
223
|
+
print(f"wrote {len(records)} unlabeled records to {out}")
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def compare(path_a: Path, path_b: Path) -> None:
|
|
227
|
+
try:
|
|
228
|
+
a = load_suite_result(path_a)
|
|
229
|
+
b = load_suite_result(path_b)
|
|
230
|
+
result : ComparisonResult = compare_result(a,b)
|
|
231
|
+
print(f"n_cases : {result.n_cases}")
|
|
232
|
+
print(f"a passed : {result.a_passed}")
|
|
233
|
+
print(f"b passed : {result.b_passed}")
|
|
234
|
+
print(f"both pass : {result.both_pass}")
|
|
235
|
+
print(f"both fail : {result.both_fail}")
|
|
236
|
+
print(f" only a : {result.a_only}")
|
|
237
|
+
print(f" only b : {result.b_only}")
|
|
238
|
+
verdict = "significant" if result.p_value < 0.05 else "not significant"
|
|
239
|
+
print(f" P value : {result.p_value:.4f} → {verdict} at alpha=0.05")
|
|
240
|
+
except (FileNotFoundError,ValueError) as e:
|
|
241
|
+
print(e)
|
|
242
|
+
raise typer.Exit(2)
|
|
243
|
+
|
|
244
|
+
@app.command()
|
|
245
|
+
def diff(base_sha:str, head_sha:str, model: str = "claude-haiku-4-5-20251001", judge_version: str = "v1", store: Path = Path(".aehf"), out: Path = Path("scorecard.md") ) -> None:
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
base = load_run(base = store,sha = base_sha,model = model, judge_version =judge_version)
|
|
249
|
+
head = load_run(base = store,sha = head_sha,model = model, judge_version =judge_version)
|
|
250
|
+
diff_result : DiffResult = diff_runs(base,head)
|
|
251
|
+
except RunNotFoundError as e:
|
|
252
|
+
print(e)
|
|
253
|
+
raise typer.Exit(2)
|
|
254
|
+
except ValueError as e:
|
|
255
|
+
print(e)
|
|
256
|
+
raise typer.Exit(2)
|
|
257
|
+
|
|
258
|
+
markdown_string = render_markdown(diff_result,base_sha,head_sha)
|
|
259
|
+
print(markdown_string) # terminal user sees the scorecard
|
|
260
|
+
out.parent.mkdir(parents = True, exist_ok = True)
|
|
261
|
+
out.write_text(markdown_string, encoding = "utf-8")
|
|
262
|
+
|
|
263
|
+
if is_regression(diff_result):
|
|
264
|
+
raise typer.Exit(1)
|
|
265
|
+
@app.command()
|
|
266
|
+
def label(path: Path, only_provisional: bool = False) -> None:
|
|
267
|
+
"""Interactively set human_label and note on each record, saving after each."""
|
|
268
|
+
try:
|
|
269
|
+
records = load_labeled(path)
|
|
270
|
+
except (LabelLoadError, FileNotFoundError) as e:
|
|
271
|
+
print(e)
|
|
272
|
+
raise typer.Exit(2)
|
|
273
|
+
|
|
274
|
+
for i, rec in enumerate(records):
|
|
275
|
+
if only_provisional and not rec.note.startswith("provisional"):
|
|
276
|
+
continue
|
|
277
|
+
print("\n" + render_for_label(rec))
|
|
278
|
+
current = "PASS" if rec.human_label else "FAIL"
|
|
279
|
+
print(f"[{i + 1}/{len(records)}] current label: {current} note={rec.note!r}")
|
|
280
|
+
|
|
281
|
+
choice = typer.prompt("[p]ass / [f]ail / [s]kip / [q]uit", default="s")
|
|
282
|
+
if choice == "q":
|
|
283
|
+
break
|
|
284
|
+
if choice not in ("p", "f"):
|
|
285
|
+
continue # skip: leave the record as-is
|
|
286
|
+
|
|
287
|
+
note = typer.prompt("note", default="reviewed")
|
|
288
|
+
records[i] = rec.model_copy(update={"human_label": choice == "p", "note": note})
|
|
289
|
+
save_labeled(records, path) # persist after every label so quitting is safe
|
|
290
|
+
|
|
291
|
+
print("saved.")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__== "__main__":
|
|
295
|
+
app()
|
aehf/core/__init__.py
ADDED
|
File without changes
|
aehf/core/case.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SuccessCriteria(BaseModel):
|
|
8
|
+
model_config = ConfigDict(frozen = True)
|
|
9
|
+
|
|
10
|
+
answer_regex : str | None = None
|
|
11
|
+
required_tools: list[str] = Field(default_factory=list)
|
|
12
|
+
forbidden_tools: list[str] = Field(default_factory=list)
|
|
13
|
+
rubric : str | None = None
|
|
14
|
+
@field_validator("answer_regex")
|
|
15
|
+
@classmethod
|
|
16
|
+
def _must_compile(cls, v: str | None) -> str | None:
|
|
17
|
+
#
|
|
18
|
+
if v is not None:
|
|
19
|
+
try:
|
|
20
|
+
re.compile(v)
|
|
21
|
+
except re.error as e:
|
|
22
|
+
raise ValueError(f"invalid answer_regex {v!r}: {e}") from e
|
|
23
|
+
return v
|
|
24
|
+
|
|
25
|
+
class ToolSpec(BaseModel):
|
|
26
|
+
model_config = ConfigDict(frozen=True)
|
|
27
|
+
name: str
|
|
28
|
+
description: str | None = None
|
|
29
|
+
parameters: dict[str,Any] = Field(default_factory = dict[str,Any])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EvalCase(BaseModel):
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
id: str
|
|
35
|
+
task_prompt: str
|
|
36
|
+
tools : list[ToolSpec]
|
|
37
|
+
success_criteria: SuccessCriteria
|
|
38
|
+
max_steps: int = Field(..., description = "Maximum Number of Steps")
|
|
39
|
+
timeout_seconds: float = Field(..., description = "Time out Seconds")
|
|
40
|
+
token_budget: int = Field(..., description = "Token Budget")
|
|
41
|
+
tags: str | None = None
|
|
42
|
+
tool_fixtures: dict[str,str] | None = None
|
|
43
|
+
|
|
44
|
+
class Suite(BaseModel):
|
|
45
|
+
model_config = ConfigDict(frozen=True)
|
|
46
|
+
name: str
|
|
47
|
+
eval: list[EvalCase]
|
aehf/core/loader.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from aehf.core.case import Suite
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SuiteLoadError(Exception):
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def load_suite(path: Path) -> Suite:
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
with open(path,encoding = "utf-8") as file:
|
|
16
|
+
try:
|
|
17
|
+
data = yaml.safe_load(file)
|
|
18
|
+
except yaml.YAMLError as e:
|
|
19
|
+
raise SuiteLoadError(f"{path}: {e}") from e
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
result = Suite.model_validate(data)
|
|
24
|
+
|
|
25
|
+
except ValidationError as e:
|
|
26
|
+
raise SuiteLoadError(f"{path}: {e}") from e
|
|
27
|
+
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
aehf/core/results.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from aehf.core.transcript import Transcript
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Verdict(BaseModel):
|
|
9
|
+
model_config = ConfigDict(frozen=True)
|
|
10
|
+
passed : bool
|
|
11
|
+
score: float | None = None
|
|
12
|
+
reasoning: str
|
|
13
|
+
judge_name: str
|
|
14
|
+
version: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CaseResult(BaseModel):
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
case_id : str
|
|
20
|
+
transcript : Transcript
|
|
21
|
+
verdicts: list[Verdict]
|
|
22
|
+
run_metadata: dict[str,str]
|
|
23
|
+
|
|
24
|
+
class SuiteResult(BaseModel):
|
|
25
|
+
model_config = ConfigDict(frozen=True)
|
|
26
|
+
suite_name: str
|
|
27
|
+
results: list[CaseResult]
|
|
28
|
+
run_id: str
|
|
29
|
+
|
|
30
|
+
def save_suite_result(result: SuiteResult, path: Path) -> None:
|
|
31
|
+
path.write_text(result.model_dump_json(indent=2))
|
|
32
|
+
def load_suite_result(path: Path) -> SuiteResult:
|
|
33
|
+
return SuiteResult.model_validate_json(path.read_text())
|
|
34
|
+
|
|
35
|
+
def case_passed(cr: CaseResult) -> bool:
|
|
36
|
+
return bool(cr.verdicts) and cr.verdicts[0].passed
|
aehf/core/runner.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import traceback
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from aehf.adapters.base import Agent
|
|
8
|
+
from aehf.core.case import EvalCase, Suite
|
|
9
|
+
from aehf.core.results import CaseResult, SuiteResult
|
|
10
|
+
from aehf.core.transcript import Termination, Transcript
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_case(agent: Agent, case:EvalCase) -> CaseResult:
|
|
14
|
+
run_metadata : dict[str,Any] = {}
|
|
15
|
+
start_time = time.monotonic()
|
|
16
|
+
try:
|
|
17
|
+
transcript = await asyncio.wait_for(agent.run(case),timeout = case.timeout_seconds)
|
|
18
|
+
duration = time.monotonic()-start_time
|
|
19
|
+
transcript = transcript.model_copy(update = {"duration_seconds" : duration})
|
|
20
|
+
if len(transcript.ordered_steps) > case.max_steps and transcript.total_tokens >case.token_budget:
|
|
21
|
+
transcript = transcript.model_copy(update ={"termination_reason" : Termination.budget_and_steps_exceeded})
|
|
22
|
+
elif transcript.total_tokens >case.token_budget:
|
|
23
|
+
transcript = transcript.model_copy(update ={"termination_reason" : Termination.budget_exceeded})
|
|
24
|
+
|
|
25
|
+
elif len(transcript.ordered_steps) > case.max_steps:
|
|
26
|
+
transcript = transcript.model_copy(update ={"termination_reason" :Termination.max_steps})
|
|
27
|
+
except TimeoutError:
|
|
28
|
+
transcript = Transcript(id = case.id,ordered_steps = [], final_answer ="",total_tokens= -1,duration_seconds = time.monotonic()-start_time,termination_reason = Termination.timeout)
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
transcript = Transcript(id = case.id,ordered_steps = [], final_answer ="",total_tokens= -1,duration_seconds = time.monotonic() - start_time,termination_reason = Termination.crashed)
|
|
31
|
+
run_metadata['error_type'] = type(exc).__name__
|
|
32
|
+
run_metadata['error_message'] = str(exc)
|
|
33
|
+
run_metadata['error_traceback'] = traceback.format_exc()
|
|
34
|
+
|
|
35
|
+
case_result = CaseResult(case_id = case.id,transcript = transcript, verdicts = [], run_metadata = run_metadata)
|
|
36
|
+
return case_result
|
|
37
|
+
|
|
38
|
+
async def _run_case_bounded(agent:Agent, case:EvalCase, sem:asyncio.Semaphore) -> CaseResult:
|
|
39
|
+
async with sem:
|
|
40
|
+
return await run_case(agent,case)
|
|
41
|
+
async def run_suite(agent:Agent, suite:Suite, max_concurrency :int = 5) -> SuiteResult:
|
|
42
|
+
sem = asyncio.Semaphore(max_concurrency)
|
|
43
|
+
|
|
44
|
+
tasks = [_run_case_bounded(agent,c,sem) for c in suite.eval]
|
|
45
|
+
result = await asyncio.gather(*tasks)
|
|
46
|
+
|
|
47
|
+
suiteresult = SuiteResult(suite_name = suite.name, results = result,run_id = uuid4().hex[:8])
|
|
48
|
+
return suiteresult
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
aehf/core/transcript.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Termination(Enum):
|
|
9
|
+
finished = "finished"
|
|
10
|
+
max_steps = "max_steps"
|
|
11
|
+
timeout = "timeout"
|
|
12
|
+
crashed= "crashed"
|
|
13
|
+
refused = "refused"
|
|
14
|
+
unexpected_stop = "unexpected_stop"
|
|
15
|
+
budget_exceeded = "budget_exceeded"
|
|
16
|
+
budget_and_steps_exceeded = "budget_and_steps_exceeded"
|
|
17
|
+
|
|
18
|
+
class ToolCall(BaseModel):
|
|
19
|
+
model_config = ConfigDict(frozen=True)
|
|
20
|
+
toolname: str
|
|
21
|
+
arguments: dict[str,Any]
|
|
22
|
+
result: str
|
|
23
|
+
error_flag: str | None =None
|
|
24
|
+
latency: float
|
|
25
|
+
|
|
26
|
+
class Step(BaseModel):
|
|
27
|
+
model_config = ConfigDict(frozen=True)
|
|
28
|
+
model_output:str
|
|
29
|
+
tool_calls: list[ToolCall] | None=None
|
|
30
|
+
token_usage: int= Field(..., description ="Token Usage")
|
|
31
|
+
|
|
32
|
+
class Transcript(BaseModel):
|
|
33
|
+
model_config = ConfigDict(frozen=True)
|
|
34
|
+
id: str
|
|
35
|
+
ordered_steps: list[Step]
|
|
36
|
+
final_answer: str
|
|
37
|
+
total_tokens: int
|
|
38
|
+
duration_seconds: float
|
|
39
|
+
termination_reason: Termination
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from aehf.core.case import EvalCase
|
|
4
|
+
from aehf.core.results import Verdict
|
|
5
|
+
from aehf.core.transcript import Transcript
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AssertionJudge:
|
|
9
|
+
|
|
10
|
+
async def score(self, case : EvalCase, transcript: Transcript) -> Verdict:
|
|
11
|
+
criteria = case.success_criteria
|
|
12
|
+
reasons: list[str] = []
|
|
13
|
+
passed = True
|
|
14
|
+
|
|
15
|
+
if criteria.answer_regex is not None:
|
|
16
|
+
if re.search(criteria.answer_regex, transcript.final_answer):
|
|
17
|
+
reasons.append("answer matched")
|
|
18
|
+
else:
|
|
19
|
+
reasons.append("answer did not match")
|
|
20
|
+
passed = False
|
|
21
|
+
|
|
22
|
+
called = [tc.toolname for step in transcript.ordered_steps for tc in (step.tool_calls or [])]
|
|
23
|
+
|
|
24
|
+
forbidden_hits = sorted({t for t in called if t in criteria.forbidden_tools})
|
|
25
|
+
if forbidden_hits:
|
|
26
|
+
reasons.append(f"forbidden tool(s) called: {', '.join(forbidden_hits)}")
|
|
27
|
+
passed = False
|
|
28
|
+
|
|
29
|
+
missing = sorted(t for t in criteria.required_tools if t not in called)
|
|
30
|
+
if missing:
|
|
31
|
+
reasons.append(f"required tool(s) not called: {', '.join(missing)}")
|
|
32
|
+
passed = False
|
|
33
|
+
elif criteria.required_tools:
|
|
34
|
+
reasons.append("all required tools called")
|
|
35
|
+
|
|
36
|
+
return Verdict(
|
|
37
|
+
passed=passed,
|
|
38
|
+
score=1.0 if passed else 0.0,
|
|
39
|
+
reasoning="; ".join(reasons) or "no criteria specified",
|
|
40
|
+
judge_name="AssertionJudge",
|
|
41
|
+
version="1",
|
|
42
|
+
)
|