evalbar 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.
- evalbar/__init__.py +34 -0
- evalbar/cli.py +181 -0
- evalbar/dataset.py +78 -0
- evalbar/diff.py +265 -0
- evalbar/html.py +156 -0
- evalbar/llm.py +50 -0
- evalbar/report.py +174 -0
- evalbar/runner.py +209 -0
- evalbar/scorers.py +238 -0
- evalbar/trace.py +163 -0
- evalbar-0.1.0.dist-info/METADATA +249 -0
- evalbar-0.1.0.dist-info/RECORD +15 -0
- evalbar-0.1.0.dist-info/WHEEL +4 -0
- evalbar-0.1.0.dist-info/entry_points.txt +2 -0
- evalbar-0.1.0.dist-info/licenses/LICENSE +21 -0
evalbar/html.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""A single self-contained HTML page for a run: no external assets, no scripts.
|
|
2
|
+
|
|
3
|
+
Everything that came from the pipeline, the dataset or a model is escaped, because LLM
|
|
4
|
+
output is untrusted text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from html import escape
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .report import format_cost, summarize
|
|
14
|
+
from .runner import CaseResult, RunResult
|
|
15
|
+
|
|
16
|
+
_CSS = """
|
|
17
|
+
:root { --bg:#f7f7f5; --card:#fff; --ink:#1d1d1f; --ink2:#5f6368; --line:#e3e3df;
|
|
18
|
+
--ok:#1a7f37; --ok-bg:#e6f4ea; --bad:#b42318; --bad-bg:#fdecea; --warn:#9a6700; --warn-bg:#fff4d6;
|
|
19
|
+
--bar:#2f6fde; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
20
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#141416; --card:#1d1d20; --ink:#ececf1; --ink2:#a1a1aa;
|
|
21
|
+
--line:#2e2e33; --ok:#4ac26b; --ok-bg:#12261a; --bad:#ff7b72; --bad-bg:#2d1414; --warn:#e3b341;
|
|
22
|
+
--warn-bg:#2b230f; --bar:#6c9ef8; } }
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
body { margin:0; background:var(--bg); color:var(--ink);
|
|
25
|
+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
26
|
+
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
27
|
+
h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 16px; margin: 28px 0 10px; }
|
|
28
|
+
.muted { color: var(--ink2); font-size: 13px; }
|
|
29
|
+
.chips span { display:inline-block; margin:6px 6px 0 0; padding:2px 8px; border:1px solid var(--line);
|
|
30
|
+
border-radius:999px; font-size:12px; color:var(--ink2); }
|
|
31
|
+
.cards { display:grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap:10px; }
|
|
32
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px; }
|
|
33
|
+
.card strong { display:block; font-size:20px; } .card small { color:var(--ink2); }
|
|
34
|
+
.tw { overflow-x:auto; background:var(--card); border:1px solid var(--line); border-radius:10px; }
|
|
35
|
+
table { width:100%; border-collapse:collapse; font-size:14px; }
|
|
36
|
+
tr:last-child > td { border-bottom:0; }
|
|
37
|
+
th, td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
38
|
+
th { color:var(--ink2); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
|
|
39
|
+
td.num, th.num { text-align:right; font-variant-numeric: tabular-nums; }
|
|
40
|
+
.bar { height:6px; background:var(--line); border-radius:3px; min-width:80px; }
|
|
41
|
+
.bar i { display:block; height:6px; background:var(--bar); border-radius:3px; }
|
|
42
|
+
details.case { background:var(--card); border:1px solid var(--line); border-radius:10px; margin:8px 0; }
|
|
43
|
+
details.case > summary { cursor:pointer; padding:10px 12px; display:flex; gap:10px; align-items:baseline;
|
|
44
|
+
flex-wrap:wrap; }
|
|
45
|
+
details.case > div { padding:0 12px 12px; }
|
|
46
|
+
.badge { font-size:12px; font-weight:600; padding:1px 8px; border-radius:6px; }
|
|
47
|
+
.pass .badge { color:var(--ok); background:var(--ok-bg); } .fail .badge { color:var(--bad); background:var(--bad-bg); }
|
|
48
|
+
.error .badge { color:var(--warn); background:var(--warn-bg); }
|
|
49
|
+
.cid { font-family:var(--mono); font-weight:600; }
|
|
50
|
+
pre { font-family:var(--mono); font-size:12.5px; background:var(--bg); border:1px solid var(--line);
|
|
51
|
+
border-radius:8px; padding:8px 10px; white-space:pre-wrap; word-break:break-word; margin:4px 0 10px; }
|
|
52
|
+
.grid2 { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:0 16px; }
|
|
53
|
+
.label { font-size:12px; color:var(--ink2); text-transform:uppercase; letter-spacing:.03em; margin-top:8px; }
|
|
54
|
+
.ok { color:var(--ok); } .bad { color:var(--bad); }
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_html(run: RunResult) -> str:
|
|
59
|
+
summary = summarize(run)
|
|
60
|
+
head = (f'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
61
|
+
f'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
62
|
+
f"<title>evalbar: {_e(run.name)}</title><style>{_CSS}</style></head><body><main>")
|
|
63
|
+
|
|
64
|
+
chips = "".join(f"<span>{_e(key)}: {_e(value)}</span>"
|
|
65
|
+
for key, value in {**run.metadata, **run.environment}.items())
|
|
66
|
+
header = (f"<h1>{_e(run.name)}</h1><div class=\"muted\">{summary.n_cases} cases, {summary.n_errors} errors, "
|
|
67
|
+
f"started {_e(run.started_at)}, finished {_e(run.finished_at)}</div><div class=\"chips\">{chips}</div>")
|
|
68
|
+
|
|
69
|
+
per_case = summary.n_cases or 1
|
|
70
|
+
cards = [
|
|
71
|
+
("cost (list price)", format_cost(summary.cost_usd), f"{format_cost(summary.cost_usd / per_case)} per case"),
|
|
72
|
+
("tokens", f"{summary.prompt_tokens + summary.completion_tokens:,}",
|
|
73
|
+
f"{summary.prompt_tokens:,} prompt / {summary.completion_tokens:,} completion"),
|
|
74
|
+
("LLM latency p50", f"{summary.llm_latency_p50_s:.2f}s", f"p95 {summary.llm_latency_p95_s:.2f}s"),
|
|
75
|
+
("errors", str(summary.n_errors), f"of {summary.n_cases} cases"),
|
|
76
|
+
]
|
|
77
|
+
if summary.judge_calls:
|
|
78
|
+
cards.append(("judge cost", format_cost(summary.judge_cost_usd), f"{summary.judge_calls} calls, not in cost"))
|
|
79
|
+
if summary.n_unpriced_cases:
|
|
80
|
+
cards.append(("unpriced cases", str(summary.n_unpriced_cases), "cost excludes them"))
|
|
81
|
+
cards_html = "".join(f'<div class="card"><small>{_e(title)}</small><strong>{_e(value)}</strong>'
|
|
82
|
+
f"<small>{_e(note)}</small></div>" for title, value, note in cards)
|
|
83
|
+
|
|
84
|
+
score_rows = "".join(
|
|
85
|
+
f"<tr><td>{_e(name)}</td><td class=\"num\">{s.n}</td><td class=\"num\">{s.passed}</td>"
|
|
86
|
+
f"<td class=\"num\">{s.pass_rate:.1%}</td><td><div class=\"bar\"><i style=\"width:{s.pass_rate * 100:.1f}%\">"
|
|
87
|
+
f"</i></div></td><td class=\"num\">{s.mean:.3f}</td></tr>"
|
|
88
|
+
for name, s in summary.scorers.items()
|
|
89
|
+
)
|
|
90
|
+
scores_html = ('<h2>Scores</h2><div class="tw"><table><tr><th>scorer</th><th class="num">cases</th>'
|
|
91
|
+
'<th class="num">passed</th><th class="num">pass rate</th><th></th>'
|
|
92
|
+
'<th class="num">mean</th></tr>'
|
|
93
|
+
f"{score_rows}</table></div>")
|
|
94
|
+
|
|
95
|
+
ordered = sorted(run.cases, key=_case_rank)
|
|
96
|
+
cases_html = "<h2>Cases (failures first)</h2>" + "".join(_case(case) for case in ordered)
|
|
97
|
+
return head + header + f'<h2>Usage</h2><div class="cards">{cards_html}</div>' + scores_html + cases_html + \
|
|
98
|
+
"</main></body></html>\n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _case_rank(case: CaseResult) -> int:
|
|
102
|
+
if case.error is not None:
|
|
103
|
+
return 0
|
|
104
|
+
return 1 if any(not score.passed for score in case.scores) else 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _case(case: CaseResult) -> str:
|
|
108
|
+
status = ["error", "fail", "pass"][_case_rank(case)]
|
|
109
|
+
failed = [score.name for score in case.scores if not score.passed]
|
|
110
|
+
cost = case.trace.cost_usd
|
|
111
|
+
summary_line = (f'<span class="badge">{status}</span><span class="cid">{_e(case.case_id)}</span>'
|
|
112
|
+
f'<span class="muted">{case.latency_s:.2f}s · '
|
|
113
|
+
f'{"unpriced" if cost is None else format_cost(cost)}</span>'
|
|
114
|
+
+ (f'<span class="bad">{_e(", ".join(failed))}</span>' if failed else ""))
|
|
115
|
+
|
|
116
|
+
body = ['<div class="grid2">',
|
|
117
|
+
f'<div><div class="label">input</div><pre>{_e(_pretty(case.input))}</pre></div>',
|
|
118
|
+
f'<div><div class="label">expected</div><pre>{_e(_pretty(case.expected))}</pre></div></div>',
|
|
119
|
+
f'<div class="label">output</div><pre>{_e(_pretty(case.output))}</pre>']
|
|
120
|
+
if case.error:
|
|
121
|
+
body.append(f'<div class="label">error</div><pre class="bad">{_e(case.error)}</pre>')
|
|
122
|
+
if case.metadata:
|
|
123
|
+
body.append(f'<div class="label">metadata</div><pre>{_e(_pretty(case.metadata))}</pre>')
|
|
124
|
+
if case.scores:
|
|
125
|
+
rows = "".join(f'<tr><td>{_e(s.name)}</td><td class="{"ok" if s.passed else "bad"}">'
|
|
126
|
+
f'{"pass" if s.passed else "fail"}</td><td class="num">{s.value:.2f}</td>'
|
|
127
|
+
f"<td>{_e(s.reason)}</td></tr>" for s in case.scores)
|
|
128
|
+
body.append('<div class="label">scores</div><div class="tw"><table><tr><th>scorer</th><th>result</th>'
|
|
129
|
+
f'<th class="num">value</th><th>reason</th></tr>{rows}</table></div>')
|
|
130
|
+
if case.trace.llm_calls:
|
|
131
|
+
rows = "".join(f"<tr><td>{_e(c.role)}</td><td>{_e(c.model)}</td><td class=\"num\">{c.prompt_tokens:,}</td>"
|
|
132
|
+
f"<td class=\"num\">{c.completion_tokens:,}</td><td class=\"num\">{c.latency_s:.2f}s</td>"
|
|
133
|
+
f"<td class=\"num\">{'unpriced' if c.cost_usd is None else format_cost(c.cost_usd)}</td></tr>"
|
|
134
|
+
for c in case.trace.llm_calls)
|
|
135
|
+
body.append('<div class="label">LLM calls</div><div class="tw"><table><tr><th>role</th><th>model</th>'
|
|
136
|
+
'<th class="num">prompt</th><th class="num">completion</th><th class="num">latency</th>'
|
|
137
|
+
f'<th class="num">cost</th></tr>{rows}</table></div>')
|
|
138
|
+
if case.trace.tool_calls:
|
|
139
|
+
rows = "".join(f"<tr><td>{_e(t.name)}</td><td><pre>{_e(_pretty(t.args))}</pre></td>"
|
|
140
|
+
f"<td><pre>{_e(t.error or _pretty(t.result))}</pre></td>"
|
|
141
|
+
f"<td class=\"num\">{t.latency_s:.3f}s</td></tr>" for t in case.trace.tool_calls)
|
|
142
|
+
body.append('<div class="label">tool calls</div><div class="tw"><table><tr><th>tool</th><th>args</th>'
|
|
143
|
+
f'<th>result</th><th class="num">latency</th></tr>{rows}</table></div>')
|
|
144
|
+
|
|
145
|
+
return (f'<details class="case {status}" id="case-{_e(case.case_id)}"><summary>{summary_line}</summary>'
|
|
146
|
+
f'<div>{"".join(body)}</div></details>')
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _pretty(value: Any) -> str:
|
|
150
|
+
if isinstance(value, str):
|
|
151
|
+
return value
|
|
152
|
+
return json.dumps(value, ensure_ascii=False, indent=2, default=repr)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _e(value: Any) -> str:
|
|
156
|
+
return escape(str(value), quote=True)
|
evalbar/llm.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Traced LLM calls through LiteLLM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Use the price table bundled with the installed LiteLLM instead of fetching the latest one
|
|
10
|
+
# at import time: runs stay reproducible, and the run file records the LiteLLM version.
|
|
11
|
+
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
|
12
|
+
|
|
13
|
+
import litellm # noqa: E402
|
|
14
|
+
|
|
15
|
+
from .trace import record_llm_call
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def completion(**kwargs: Any) -> Any:
|
|
19
|
+
"""Call litellm.completion with the same arguments and record usage, latency and cost.
|
|
20
|
+
|
|
21
|
+
Cost comes from LiteLLM's price table (list price). A model with no known price is
|
|
22
|
+
recorded as None so reports never treat it as free.
|
|
23
|
+
"""
|
|
24
|
+
started = time.perf_counter()
|
|
25
|
+
response = litellm.completion(**kwargs)
|
|
26
|
+
latency_s = time.perf_counter() - started
|
|
27
|
+
|
|
28
|
+
usage = getattr(response, "usage", None)
|
|
29
|
+
record_llm_call(
|
|
30
|
+
model=str(kwargs.get("model", getattr(response, "model", "unknown"))),
|
|
31
|
+
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
|
|
32
|
+
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
|
|
33
|
+
latency_s=latency_s,
|
|
34
|
+
cost_usd=_cost(response),
|
|
35
|
+
)
|
|
36
|
+
return response
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cost(response: Any) -> float | None:
|
|
40
|
+
# LiteLLM prices the call while it still knows the provider. completion_cost() on the
|
|
41
|
+
# response alone can guess wrong: Groq returns model="openai/gpt-oss-120b" with no
|
|
42
|
+
# provider hint, so the lookup goes to OpenAI and finds no price.
|
|
43
|
+
hidden = getattr(response, "_hidden_params", None) or {}
|
|
44
|
+
if hidden.get("response_cost") is not None:
|
|
45
|
+
return float(hidden["response_cost"])
|
|
46
|
+
try:
|
|
47
|
+
cost = litellm.completion_cost(completion_response=response)
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
return float(cost)
|
evalbar/report.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Aggregate a run, render it for the terminal, and read/write JSON run files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from .runner import RunResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ScorerSummary:
|
|
19
|
+
n: int
|
|
20
|
+
passed: int
|
|
21
|
+
mean: float
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def pass_rate(self) -> float:
|
|
25
|
+
return self.passed / self.n if self.n else 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Summary:
|
|
30
|
+
name: str
|
|
31
|
+
n_cases: int
|
|
32
|
+
n_errors: int
|
|
33
|
+
scorers: dict[str, ScorerSummary]
|
|
34
|
+
llm_calls: int
|
|
35
|
+
prompt_tokens: int
|
|
36
|
+
completion_tokens: int
|
|
37
|
+
cost_usd: float
|
|
38
|
+
n_unpriced_cases: int
|
|
39
|
+
judge_calls: int
|
|
40
|
+
judge_cost_usd: float
|
|
41
|
+
latency_p50_s: float
|
|
42
|
+
latency_p95_s: float
|
|
43
|
+
llm_latency_p50_s: float
|
|
44
|
+
llm_latency_p95_s: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def summarize(run: RunResult) -> Summary:
|
|
48
|
+
scorers = {}
|
|
49
|
+
for name in run.scorer_names:
|
|
50
|
+
scores = [score for case in run.cases for score in case.scores if score.name == name]
|
|
51
|
+
scorers[name] = ScorerSummary(
|
|
52
|
+
n=len(scores),
|
|
53
|
+
passed=sum(score.passed for score in scores),
|
|
54
|
+
mean=sum(score.value for score in scores) / len(scores) if scores else 0.0,
|
|
55
|
+
)
|
|
56
|
+
latencies = sorted(case.latency_s for case in run.cases)
|
|
57
|
+
# Time spent inside model calls only: excludes rate-limit waits and local work.
|
|
58
|
+
llm_latencies = sorted(case.trace.llm_latency_s for case in run.cases if case.trace.pipeline_calls)
|
|
59
|
+
return Summary(
|
|
60
|
+
name=run.name,
|
|
61
|
+
n_cases=len(run.cases),
|
|
62
|
+
n_errors=sum(case.error is not None for case in run.cases),
|
|
63
|
+
scorers=scorers,
|
|
64
|
+
llm_calls=sum(len(case.trace.pipeline_calls) for case in run.cases),
|
|
65
|
+
prompt_tokens=sum(case.trace.prompt_tokens for case in run.cases),
|
|
66
|
+
completion_tokens=sum(case.trace.completion_tokens for case in run.cases),
|
|
67
|
+
cost_usd=sum(call.cost_usd or 0.0 for case in run.cases for call in case.trace.pipeline_calls),
|
|
68
|
+
n_unpriced_cases=sum(
|
|
69
|
+
1 for case in run.cases if case.trace.pipeline_calls and case.trace.cost_usd is None
|
|
70
|
+
),
|
|
71
|
+
judge_calls=sum(len(case.trace.judge_calls) for case in run.cases),
|
|
72
|
+
judge_cost_usd=sum(call.cost_usd or 0.0 for case in run.cases for call in case.trace.judge_calls),
|
|
73
|
+
latency_p50_s=percentile(latencies, 0.50),
|
|
74
|
+
latency_p95_s=percentile(latencies, 0.95),
|
|
75
|
+
llm_latency_p50_s=percentile(llm_latencies, 0.50),
|
|
76
|
+
llm_latency_p95_s=percentile(llm_latencies, 0.95),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def report_renderable(run: RunResult, max_failures: int = 15) -> Group:
|
|
81
|
+
summary = summarize(run)
|
|
82
|
+
parts: list = []
|
|
83
|
+
|
|
84
|
+
header = Text()
|
|
85
|
+
header.append(f"evalbar run: {run.name}", style="bold")
|
|
86
|
+
header.append(f" {summary.n_cases} cases, {summary.n_errors} errors, started {run.started_at}")
|
|
87
|
+
parts.append(header)
|
|
88
|
+
if run.metadata:
|
|
89
|
+
parts.append(Text(" " + ", ".join(f"{key}={value}" for key, value in run.metadata.items()), style="dim"))
|
|
90
|
+
|
|
91
|
+
scores = Table(title="Scores", title_justify="left")
|
|
92
|
+
for column in ("scorer", "cases", "passed", "pass rate", "mean"):
|
|
93
|
+
scores.add_column(column, justify="left" if column == "scorer" else "right", no_wrap=True)
|
|
94
|
+
for name, scorer in summary.scorers.items():
|
|
95
|
+
scores.add_row(name, str(scorer.n), str(scorer.passed), f"{scorer.pass_rate:.1%}", f"{scorer.mean:.3f}")
|
|
96
|
+
parts.append(scores)
|
|
97
|
+
|
|
98
|
+
usage = Table(title="Usage", title_justify="left")
|
|
99
|
+
usage.add_column("metric")
|
|
100
|
+
usage.add_column("value", justify="right")
|
|
101
|
+
per_case = summary.n_cases or 1
|
|
102
|
+
usage.add_row("LLM calls", str(summary.llm_calls))
|
|
103
|
+
usage.add_row("prompt tokens", f"{summary.prompt_tokens:,}")
|
|
104
|
+
usage.add_row("completion tokens", f"{summary.completion_tokens:,}")
|
|
105
|
+
usage.add_row("cost (list price)", format_cost(summary.cost_usd))
|
|
106
|
+
usage.add_row("cost per case", format_cost(summary.cost_usd / per_case))
|
|
107
|
+
if summary.judge_calls:
|
|
108
|
+
usage.add_row("judge calls", str(summary.judge_calls))
|
|
109
|
+
usage.add_row("judge cost (not in cost above)", format_cost(summary.judge_cost_usd))
|
|
110
|
+
usage.add_row("case latency p50 / p95", f"{summary.latency_p50_s:.2f}s / {summary.latency_p95_s:.2f}s")
|
|
111
|
+
usage.add_row("LLM latency p50 / p95", f"{summary.llm_latency_p50_s:.2f}s / {summary.llm_latency_p95_s:.2f}s")
|
|
112
|
+
parts.append(usage)
|
|
113
|
+
if summary.n_unpriced_cases:
|
|
114
|
+
parts.append(Text(f"{summary.n_unpriced_cases} case(s) had no known price; cost excludes them.",
|
|
115
|
+
style="yellow"))
|
|
116
|
+
|
|
117
|
+
failures = [
|
|
118
|
+
(case.case_id, score.name, score.reason)
|
|
119
|
+
for case in run.cases
|
|
120
|
+
if case.error is None
|
|
121
|
+
for score in case.scores
|
|
122
|
+
if not score.passed
|
|
123
|
+
]
|
|
124
|
+
errors = [(case.case_id, case.error) for case in run.cases if case.error is not None]
|
|
125
|
+
if failures:
|
|
126
|
+
table = Table(title=f"Failures ({len(failures)})", title_justify="left")
|
|
127
|
+
table.add_column("case")
|
|
128
|
+
table.add_column("scorer")
|
|
129
|
+
table.add_column("reason", overflow="fold")
|
|
130
|
+
for case_id, name, reason in failures[:max_failures]:
|
|
131
|
+
table.add_row(case_id, name, reason)
|
|
132
|
+
parts.append(table)
|
|
133
|
+
if len(failures) > max_failures:
|
|
134
|
+
parts.append(Text(f"... {len(failures) - max_failures} more in the run file", style="dim"))
|
|
135
|
+
if errors:
|
|
136
|
+
table = Table(title=f"Errors ({len(errors)})", title_justify="left")
|
|
137
|
+
table.add_column("case")
|
|
138
|
+
table.add_column("error", overflow="fold")
|
|
139
|
+
for case_id, error in errors[:max_failures]:
|
|
140
|
+
table.add_row(case_id, error or "")
|
|
141
|
+
parts.append(table)
|
|
142
|
+
return Group(*parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render(run: RunResult, width: int | None = None, max_failures: int = 15) -> str:
|
|
146
|
+
"""The terminal report as plain text."""
|
|
147
|
+
console = Console(file=io.StringIO(), width=width or 120, record=True, color_system=None)
|
|
148
|
+
console.print(report_renderable(run, max_failures))
|
|
149
|
+
return console.export_text()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_run(run: RunResult, path: str | Path) -> Path:
|
|
153
|
+
path = Path(path)
|
|
154
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
path.write_text(json.dumps(run.to_dict(), indent=2, ensure_ascii=False, default=repr) + "\n",
|
|
156
|
+
encoding="utf-8")
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_run(path: str | Path) -> RunResult:
|
|
161
|
+
return RunResult.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def format_cost(usd: float) -> str:
|
|
165
|
+
return f"${usd:.4f}" if usd >= 0.01 or usd == 0 else f"${usd:.6f}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def percentile(sorted_values: list[float], fraction: float) -> float:
|
|
169
|
+
if not sorted_values:
|
|
170
|
+
return 0.0
|
|
171
|
+
position = (len(sorted_values) - 1) * fraction
|
|
172
|
+
lower = int(position)
|
|
173
|
+
upper = min(lower + 1, len(sorted_values) - 1)
|
|
174
|
+
return sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * (position - lower)
|
evalbar/runner.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Run a pipeline over a dataset, one trace per case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .dataset import Case
|
|
14
|
+
from .scorers import Score, ScoreFn, Scorer, as_scorer
|
|
15
|
+
from .trace import Trace
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Eval:
|
|
22
|
+
name: str
|
|
23
|
+
pipeline: Callable[[Any], Any]
|
|
24
|
+
dataset: Sequence[Case]
|
|
25
|
+
scorers: Sequence[Scorer | ScoreFn]
|
|
26
|
+
min_interval_s: float = 0.0
|
|
27
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
self.scorers = [as_scorer(scorer) for scorer in self.scorers]
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
for scorer in self.scorers:
|
|
33
|
+
if scorer.name in seen:
|
|
34
|
+
raise ValueError(f"duplicate scorer name '{scorer.name}'; pass name= to tell them apart")
|
|
35
|
+
seen.add(scorer.name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class CaseResult:
|
|
40
|
+
case_id: str
|
|
41
|
+
input: Any
|
|
42
|
+
expected: dict[str, Any]
|
|
43
|
+
output: Any
|
|
44
|
+
error: str | None
|
|
45
|
+
latency_s: float
|
|
46
|
+
trace: Trace
|
|
47
|
+
scores: list[Score]
|
|
48
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"case_id": self.case_id,
|
|
53
|
+
"input": self.input,
|
|
54
|
+
"expected": self.expected,
|
|
55
|
+
"metadata": self.metadata,
|
|
56
|
+
"output": self.output,
|
|
57
|
+
"error": self.error,
|
|
58
|
+
"latency_s": self.latency_s,
|
|
59
|
+
"trace": self.trace.to_dict(),
|
|
60
|
+
"scores": [score.__dict__ for score in self.scores],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict[str, Any]) -> CaseResult:
|
|
65
|
+
return cls(
|
|
66
|
+
case_id=data["case_id"],
|
|
67
|
+
input=data["input"],
|
|
68
|
+
expected=data["expected"],
|
|
69
|
+
metadata=data.get("metadata", {}),
|
|
70
|
+
output=data["output"],
|
|
71
|
+
error=data["error"],
|
|
72
|
+
latency_s=data["latency_s"],
|
|
73
|
+
trace=Trace.from_dict(data["trace"]),
|
|
74
|
+
scores=[Score(**score) for score in data["scores"]],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class RunResult:
|
|
80
|
+
name: str
|
|
81
|
+
started_at: str
|
|
82
|
+
finished_at: str
|
|
83
|
+
scorer_names: list[str]
|
|
84
|
+
cases: list[CaseResult]
|
|
85
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
environment: dict[str, str] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"schema_version": SCHEMA_VERSION,
|
|
91
|
+
"name": self.name,
|
|
92
|
+
"started_at": self.started_at,
|
|
93
|
+
"finished_at": self.finished_at,
|
|
94
|
+
"scorer_names": self.scorer_names,
|
|
95
|
+
"metadata": self.metadata,
|
|
96
|
+
"environment": self.environment,
|
|
97
|
+
"cases": [case.to_dict() for case in self.cases],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_dict(cls, data: dict[str, Any]) -> RunResult:
|
|
102
|
+
version = data.get("schema_version")
|
|
103
|
+
if version != SCHEMA_VERSION:
|
|
104
|
+
raise ValueError(f"unsupported run file schema_version {version!r} (expected {SCHEMA_VERSION})")
|
|
105
|
+
return cls(
|
|
106
|
+
name=data["name"],
|
|
107
|
+
started_at=data["started_at"],
|
|
108
|
+
finished_at=data["finished_at"],
|
|
109
|
+
scorer_names=list(data["scorer_names"]),
|
|
110
|
+
cases=[CaseResult.from_dict(case) for case in data["cases"]],
|
|
111
|
+
metadata=data.get("metadata", {}),
|
|
112
|
+
environment=data.get("environment", {}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run(
|
|
117
|
+
evaluation: Eval,
|
|
118
|
+
*,
|
|
119
|
+
limit: int | None = None,
|
|
120
|
+
on_case: Callable[[CaseResult], None] | None = None,
|
|
121
|
+
clock: Callable[[], float] = time.monotonic,
|
|
122
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
123
|
+
) -> RunResult:
|
|
124
|
+
"""Run every case sequentially. Pipeline exceptions become case errors, never crashes."""
|
|
125
|
+
cases = list(evaluation.dataset)[:limit] if limit is not None else list(evaluation.dataset)
|
|
126
|
+
started_at = _now()
|
|
127
|
+
results: list[CaseResult] = []
|
|
128
|
+
last_start: float | None = None
|
|
129
|
+
|
|
130
|
+
for case in cases:
|
|
131
|
+
if last_start is not None and evaluation.min_interval_s > 0:
|
|
132
|
+
wait = evaluation.min_interval_s - (clock() - last_start)
|
|
133
|
+
if wait > 0:
|
|
134
|
+
sleep(wait)
|
|
135
|
+
last_start = clock()
|
|
136
|
+
result = _run_case(evaluation, case)
|
|
137
|
+
results.append(result)
|
|
138
|
+
if on_case is not None:
|
|
139
|
+
on_case(result)
|
|
140
|
+
|
|
141
|
+
return RunResult(
|
|
142
|
+
name=evaluation.name,
|
|
143
|
+
started_at=started_at,
|
|
144
|
+
finished_at=_now(),
|
|
145
|
+
scorer_names=[scorer.name for scorer in evaluation.scorers],
|
|
146
|
+
cases=results,
|
|
147
|
+
metadata=dict(evaluation.metadata),
|
|
148
|
+
environment=_environment(),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_case(evaluation: Eval, case: Case) -> CaseResult:
|
|
153
|
+
output: Any = None
|
|
154
|
+
error: str | None = None
|
|
155
|
+
started = time.perf_counter()
|
|
156
|
+
with Trace() as trace:
|
|
157
|
+
try:
|
|
158
|
+
output = evaluation.pipeline(case.input)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
# Spans recorded before the failure stay on the trace: a failed call still cost money.
|
|
161
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
162
|
+
latency_s = time.perf_counter() - started
|
|
163
|
+
|
|
164
|
+
with trace.recording_as("judge"):
|
|
165
|
+
scores = _score(evaluation, case, output, error)
|
|
166
|
+
|
|
167
|
+
return CaseResult(
|
|
168
|
+
case_id=case.id,
|
|
169
|
+
input=case.input,
|
|
170
|
+
expected=case.expected,
|
|
171
|
+
output=output,
|
|
172
|
+
error=error,
|
|
173
|
+
latency_s=latency_s,
|
|
174
|
+
trace=trace,
|
|
175
|
+
scores=scores,
|
|
176
|
+
metadata=case.metadata,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _score(evaluation: Eval, case: Case, output: Any, error: str | None) -> list[Score]:
|
|
181
|
+
scores: list[Score] = []
|
|
182
|
+
for scorer in evaluation.scorers:
|
|
183
|
+
if error is not None:
|
|
184
|
+
if scorer.applies(case):
|
|
185
|
+
scores.append(Score(name=scorer.name, value=0.0, passed=False, reason="pipeline error"))
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
score = scorer(output, case)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
score = Score(name=scorer.name, value=0.0, passed=False,
|
|
191
|
+
reason=f"scorer error: {type(exc).__name__}: {exc}")
|
|
192
|
+
if score is not None:
|
|
193
|
+
# Reports group by scorer name, so a custom scorer cannot rename its scores.
|
|
194
|
+
scores.append(replace(score, name=scorer.name))
|
|
195
|
+
return scores
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _now() -> str:
|
|
199
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _environment() -> dict[str, str]:
|
|
203
|
+
env = {"python": platform.python_version()}
|
|
204
|
+
for package in ("evalbar", "litellm"):
|
|
205
|
+
try:
|
|
206
|
+
env[package] = importlib.metadata.version(package)
|
|
207
|
+
except importlib.metadata.PackageNotFoundError:
|
|
208
|
+
env[package] = "unknown"
|
|
209
|
+
return env
|