evals-viewer-io 0.0.3__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ dist/
3
+ .DS_Store
4
+ *.log
5
+ .venv/
6
+ __pycache__/
7
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Lester
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: evals-viewer-io
3
+ Version: 0.0.3
4
+ Summary: Pydantic schemas and a writer for the evals-viewer on-disk format — the Python writer side of the evals-viewer framework.
5
+ Project-URL: Homepage, https://github.com/ideonate/evals-viewer
6
+ Project-URL: Repository, https://github.com/ideonate/evals-viewer
7
+ Project-URL: Issues, https://github.com/ideonate/evals-viewer/issues
8
+ Project-URL: Documentation, https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md
9
+ Author: Dan Lester
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: evals,evaluation,llm,pydantic,viewer
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: pydantic>=2.0
24
+ Provides-Extra: pytest
25
+ Requires-Dist: pytest>=7; extra == 'pytest'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # evals-viewer-io
29
+
30
+ Pydantic schemas and a writer for the [**evals-viewer**](https://github.com/ideonate/evals-viewer) on-disk format. This is the Python writer side of the framework — it produces the JSON tree that [`@ideonate/evals-viewer-server`](https://www.npmjs.com/package/@ideonate/evals-viewer-server) reads and the Vue frontend [`@ideonate/evals-viewer-core`](https://www.npmjs.com/package/@ideonate/evals-viewer-core) renders.
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ pip install evals-viewer-io
36
+ ```
37
+
38
+ Requires Python 3.10+ and Pydantic 2.
39
+
40
+ ## What's in the box
41
+
42
+ | Symbol | Purpose |
43
+ | --- | --- |
44
+ | `RunMetadata`, `EvalSummary`, `CaseSummary`, `AggregateStats` | Pydantic models matching the on-disk format |
45
+ | `TokenUsage` | Token / cost model with addition, `from_pydantic_ai` adapter, per-model breakdown |
46
+ | `save_run_metadata`, `save_eval_results` | Filesystem writers — given models and dicts, write JSON in the layout the viewer expects |
47
+ | `compute_aggregates(cases)` | Group `case.scores[evaluator]` across cases → `{evaluator: {mean, min, max}}` |
48
+ | `compute_token_totals(cases)` | Sum token usage / cost / per-model breakdown across cases |
49
+ | `eval_run_dir` (pytest fixture) | Optional fixture creating a fresh run directory under `EVALS_RESULTS_DIR` |
50
+
51
+ ## Quickstart: minimal end-to-end
52
+
53
+ ```python
54
+ from evals_viewer_io import (
55
+ RunMetadata, EvalSummary, CaseSummary, TokenUsage,
56
+ compute_aggregates, compute_token_totals,
57
+ save_eval_results,
58
+ )
59
+
60
+ # 1. Build per-case rows. The output_summary dict is a free-form bag of
61
+ # fields the viewer can show in the eval-detail table; token fields
62
+ # use the canonical input_tokens / output_tokens / cost_usd / usage_by_model.
63
+ cases = [
64
+ CaseSummary(
65
+ name="case_001",
66
+ scores={"Accuracy": 0.9, "Coverage": 0.8},
67
+ judge_reasons={"Accuracy": "All key facts present."},
68
+ output_summary={
69
+ "input_tokens": 1234,
70
+ "output_tokens": 567,
71
+ "cost_usd": 0.012,
72
+ },
73
+ ),
74
+ CaseSummary(
75
+ name="case_002",
76
+ scores={"Accuracy": 0.7, "Coverage": 0.9},
77
+ output_summary={"input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
78
+ ),
79
+ CaseSummary(name="case_003", success=False, error="Timeout"),
80
+ ]
81
+
82
+ # 2. Compute the per-eval aggregates and write the run.
83
+ summary = EvalSummary(
84
+ timestamp="2026-04-07T10:30:00Z",
85
+ aggregates=compute_aggregates(cases),
86
+ cases=cases,
87
+ )
88
+
89
+ save_eval_results(
90
+ results_dir="./tests/test-results/evals",
91
+ run_id="2026-04-07_103000",
92
+ eval_name="my_eval",
93
+ summary=summary,
94
+ outputs={
95
+ "case_001": {"answer": "...", "input_tokens": 1234, "output_tokens": 567, "cost_usd": 0.012},
96
+ "case_002": {"answer": "...", "input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
97
+ },
98
+ run=RunMetadata(timestamp="2026-04-07T10:30:00Z", git_commit="abc1234"),
99
+ )
100
+ ```
101
+
102
+ That writes:
103
+
104
+ ```
105
+ tests/test-results/evals/2026-04-07_103000/
106
+ ├── run.json
107
+ └── my_eval/
108
+ ├── summary.json
109
+ └── outputs/
110
+ ├── case_001.json
111
+ └── case_002.json
112
+ ```
113
+
114
+ Open the viewer and the run shows up.
115
+
116
+ ## Token usage
117
+
118
+ `TokenUsage` is a normal Pydantic model with `__add__` so you can sum across cases or across model calls:
119
+
120
+ ```python
121
+ from evals_viewer_io import TokenUsage
122
+
123
+ opus_call = TokenUsage(input_tokens=1200, output_tokens=300, cost_usd=0.018)
124
+ haiku_call = TokenUsage(input_tokens=800, output_tokens=200, cost_usd=0.0009)
125
+
126
+ # Per-model breakdown for one case
127
+ case_total = TokenUsage(
128
+ input_tokens=opus_call.input_tokens + haiku_call.input_tokens,
129
+ output_tokens=opus_call.output_tokens + haiku_call.output_tokens,
130
+ cost_usd=(opus_call.cost_usd or 0) + (haiku_call.cost_usd or 0),
131
+ usage_by_model={"opus": opus_call, "haiku": haiku_call},
132
+ )
133
+
134
+ # Or just use sum() across multiple cases:
135
+ total = sum([case1_usage, case2_usage, case3_usage])
136
+ ```
137
+
138
+ The viewer reads `input_tokens`, `output_tokens`, `cost_usd`, and `usage_by_model` from both each case's full output JSON and from the per-case row in `summary.json`'s `output_summary`.
139
+
140
+ ### Pydantic-AI adapter
141
+
142
+ If you use [pydantic-ai](https://ai.pydantic.dev), there's a one-liner to convert its `Usage` / `RunUsage` objects (which use `request_tokens` / `response_tokens` rather than `input` / `output`):
143
+
144
+ ```python
145
+ from evals_viewer_io import TokenUsage
146
+
147
+ usage = TokenUsage.from_pydantic_ai(result.usage(), cost_usd=my_cost_calc(result))
148
+ ```
149
+
150
+ The adapter uses `getattr` so this package never imports pydantic-ai itself. Other frameworks (OpenAI SDK, Anthropic SDK, …) can be mapped just as easily — `TokenUsage(input_tokens=resp.usage.prompt_tokens, output_tokens=resp.usage.completion_tokens)` etc.
151
+
152
+ Cost is the caller's responsibility. Pricing tables go stale fast and don't belong in this package.
153
+
154
+ ## Aggregating tokens across cases
155
+
156
+ ```python
157
+ from evals_viewer_io import compute_token_totals
158
+
159
+ totals = compute_token_totals(cases)
160
+ print(totals.input_tokens, totals.output_tokens, totals.cost_usd)
161
+ print(totals.usage_by_model) # per-model breakdown summed across all cases
162
+ ```
163
+
164
+ The function reads `input_tokens` / `output_tokens` / `cost_usd` / `usage_by_model` from each case's `output_summary`. Cases that don't have those fields contribute zero.
165
+
166
+ ## pytest fixture
167
+
168
+ ```python
169
+ # tests/conftest.py
170
+ from evals_viewer_io.pytest import eval_run_dir # noqa: F401
171
+ ```
172
+
173
+ ```python
174
+ # tests/test_my_eval.py
175
+ def test_my_eval(eval_run_dir):
176
+ # eval_run_dir is a pathlib.Path under EVALS_RESULTS_DIR (or a tmp dir),
177
+ # and run.json has already been written.
178
+ ...
179
+ save_eval_results(
180
+ results_dir=eval_run_dir.parent,
181
+ run_id=eval_run_dir.name,
182
+ eval_name="my_eval",
183
+ summary=summary,
184
+ outputs=outputs,
185
+ )
186
+ ```
187
+
188
+ Set `EVALS_RESULTS_DIR=tests/test-results/evals` (or wherever your project keeps them) so the run lands somewhere the viewer can find.
189
+
190
+ ## What this package deliberately does *not* do
191
+
192
+ This is intentionally a small package — schemas plus the smallest set of helpers that every consumer would need to write themselves. It does **not** include:
193
+
194
+ - **Token field extraction from arbitrary model outputs.** Different LLM SDKs name fields differently; the caller knows their own output schema.
195
+ - **A pricing table.** Costs are pricing × tokens; pricing changes weekly. You compute it, you pass it in via `cost_usd`.
196
+ - **Pydantic→dict serialization.** If your case output is a Pydantic model, call `.model_dump()` yourself before passing it to `save_eval_results`. Hiding that behind a wrapper would just suppress errors.
197
+ - **Coupling to a specific eval framework** like `pydantic-evals` or `inspect_ai`. The writer takes plain dicts. Frameworks can be added as adapters when there's demand.
198
+ - **Schema versioning.** The on-disk format is forward-compatible by design (`extra="allow"` everywhere). If a breaking change ever lands, that's the time for a `schema_version` field, not now.
199
+
200
+ ## On-disk contract
201
+
202
+ See [`docs/data-layout.md`](https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md) in the monorepo for the full directory tree and per-file schemas. The TL;DR:
203
+
204
+ ```
205
+ {results_dir}/{run_id}/
206
+ ├── run.json (RunMetadata)
207
+ └── {eval_name}/
208
+ ├── summary.json (EvalSummary: aggregates + per-case rows)
209
+ ├── outputs/{case_name}.json (full per-case output)
210
+ ├── inputs/{case_name}.json (optional; saved input fixture)
211
+ └── case-scores/{case_name}.json (optional; per-question scores)
212
+ ```
213
+
214
+ ## License
215
+
216
+ MIT
@@ -0,0 +1,189 @@
1
+ # evals-viewer-io
2
+
3
+ Pydantic schemas and a writer for the [**evals-viewer**](https://github.com/ideonate/evals-viewer) on-disk format. This is the Python writer side of the framework — it produces the JSON tree that [`@ideonate/evals-viewer-server`](https://www.npmjs.com/package/@ideonate/evals-viewer-server) reads and the Vue frontend [`@ideonate/evals-viewer-core`](https://www.npmjs.com/package/@ideonate/evals-viewer-core) renders.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install evals-viewer-io
9
+ ```
10
+
11
+ Requires Python 3.10+ and Pydantic 2.
12
+
13
+ ## What's in the box
14
+
15
+ | Symbol | Purpose |
16
+ | --- | --- |
17
+ | `RunMetadata`, `EvalSummary`, `CaseSummary`, `AggregateStats` | Pydantic models matching the on-disk format |
18
+ | `TokenUsage` | Token / cost model with addition, `from_pydantic_ai` adapter, per-model breakdown |
19
+ | `save_run_metadata`, `save_eval_results` | Filesystem writers — given models and dicts, write JSON in the layout the viewer expects |
20
+ | `compute_aggregates(cases)` | Group `case.scores[evaluator]` across cases → `{evaluator: {mean, min, max}}` |
21
+ | `compute_token_totals(cases)` | Sum token usage / cost / per-model breakdown across cases |
22
+ | `eval_run_dir` (pytest fixture) | Optional fixture creating a fresh run directory under `EVALS_RESULTS_DIR` |
23
+
24
+ ## Quickstart: minimal end-to-end
25
+
26
+ ```python
27
+ from evals_viewer_io import (
28
+ RunMetadata, EvalSummary, CaseSummary, TokenUsage,
29
+ compute_aggregates, compute_token_totals,
30
+ save_eval_results,
31
+ )
32
+
33
+ # 1. Build per-case rows. The output_summary dict is a free-form bag of
34
+ # fields the viewer can show in the eval-detail table; token fields
35
+ # use the canonical input_tokens / output_tokens / cost_usd / usage_by_model.
36
+ cases = [
37
+ CaseSummary(
38
+ name="case_001",
39
+ scores={"Accuracy": 0.9, "Coverage": 0.8},
40
+ judge_reasons={"Accuracy": "All key facts present."},
41
+ output_summary={
42
+ "input_tokens": 1234,
43
+ "output_tokens": 567,
44
+ "cost_usd": 0.012,
45
+ },
46
+ ),
47
+ CaseSummary(
48
+ name="case_002",
49
+ scores={"Accuracy": 0.7, "Coverage": 0.9},
50
+ output_summary={"input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
51
+ ),
52
+ CaseSummary(name="case_003", success=False, error="Timeout"),
53
+ ]
54
+
55
+ # 2. Compute the per-eval aggregates and write the run.
56
+ summary = EvalSummary(
57
+ timestamp="2026-04-07T10:30:00Z",
58
+ aggregates=compute_aggregates(cases),
59
+ cases=cases,
60
+ )
61
+
62
+ save_eval_results(
63
+ results_dir="./tests/test-results/evals",
64
+ run_id="2026-04-07_103000",
65
+ eval_name="my_eval",
66
+ summary=summary,
67
+ outputs={
68
+ "case_001": {"answer": "...", "input_tokens": 1234, "output_tokens": 567, "cost_usd": 0.012},
69
+ "case_002": {"answer": "...", "input_tokens": 980, "output_tokens": 440, "cost_usd": 0.009},
70
+ },
71
+ run=RunMetadata(timestamp="2026-04-07T10:30:00Z", git_commit="abc1234"),
72
+ )
73
+ ```
74
+
75
+ That writes:
76
+
77
+ ```
78
+ tests/test-results/evals/2026-04-07_103000/
79
+ ├── run.json
80
+ └── my_eval/
81
+ ├── summary.json
82
+ └── outputs/
83
+ ├── case_001.json
84
+ └── case_002.json
85
+ ```
86
+
87
+ Open the viewer and the run shows up.
88
+
89
+ ## Token usage
90
+
91
+ `TokenUsage` is a normal Pydantic model with `__add__` so you can sum across cases or across model calls:
92
+
93
+ ```python
94
+ from evals_viewer_io import TokenUsage
95
+
96
+ opus_call = TokenUsage(input_tokens=1200, output_tokens=300, cost_usd=0.018)
97
+ haiku_call = TokenUsage(input_tokens=800, output_tokens=200, cost_usd=0.0009)
98
+
99
+ # Per-model breakdown for one case
100
+ case_total = TokenUsage(
101
+ input_tokens=opus_call.input_tokens + haiku_call.input_tokens,
102
+ output_tokens=opus_call.output_tokens + haiku_call.output_tokens,
103
+ cost_usd=(opus_call.cost_usd or 0) + (haiku_call.cost_usd or 0),
104
+ usage_by_model={"opus": opus_call, "haiku": haiku_call},
105
+ )
106
+
107
+ # Or just use sum() across multiple cases:
108
+ total = sum([case1_usage, case2_usage, case3_usage])
109
+ ```
110
+
111
+ The viewer reads `input_tokens`, `output_tokens`, `cost_usd`, and `usage_by_model` from both each case's full output JSON and from the per-case row in `summary.json`'s `output_summary`.
112
+
113
+ ### Pydantic-AI adapter
114
+
115
+ If you use [pydantic-ai](https://ai.pydantic.dev), there's a one-liner to convert its `Usage` / `RunUsage` objects (which use `request_tokens` / `response_tokens` rather than `input` / `output`):
116
+
117
+ ```python
118
+ from evals_viewer_io import TokenUsage
119
+
120
+ usage = TokenUsage.from_pydantic_ai(result.usage(), cost_usd=my_cost_calc(result))
121
+ ```
122
+
123
+ The adapter uses `getattr` so this package never imports pydantic-ai itself. Other frameworks (OpenAI SDK, Anthropic SDK, …) can be mapped just as easily — `TokenUsage(input_tokens=resp.usage.prompt_tokens, output_tokens=resp.usage.completion_tokens)` etc.
124
+
125
+ Cost is the caller's responsibility. Pricing tables go stale fast and don't belong in this package.
126
+
127
+ ## Aggregating tokens across cases
128
+
129
+ ```python
130
+ from evals_viewer_io import compute_token_totals
131
+
132
+ totals = compute_token_totals(cases)
133
+ print(totals.input_tokens, totals.output_tokens, totals.cost_usd)
134
+ print(totals.usage_by_model) # per-model breakdown summed across all cases
135
+ ```
136
+
137
+ The function reads `input_tokens` / `output_tokens` / `cost_usd` / `usage_by_model` from each case's `output_summary`. Cases that don't have those fields contribute zero.
138
+
139
+ ## pytest fixture
140
+
141
+ ```python
142
+ # tests/conftest.py
143
+ from evals_viewer_io.pytest import eval_run_dir # noqa: F401
144
+ ```
145
+
146
+ ```python
147
+ # tests/test_my_eval.py
148
+ def test_my_eval(eval_run_dir):
149
+ # eval_run_dir is a pathlib.Path under EVALS_RESULTS_DIR (or a tmp dir),
150
+ # and run.json has already been written.
151
+ ...
152
+ save_eval_results(
153
+ results_dir=eval_run_dir.parent,
154
+ run_id=eval_run_dir.name,
155
+ eval_name="my_eval",
156
+ summary=summary,
157
+ outputs=outputs,
158
+ )
159
+ ```
160
+
161
+ Set `EVALS_RESULTS_DIR=tests/test-results/evals` (or wherever your project keeps them) so the run lands somewhere the viewer can find.
162
+
163
+ ## What this package deliberately does *not* do
164
+
165
+ This is intentionally a small package — schemas plus the smallest set of helpers that every consumer would need to write themselves. It does **not** include:
166
+
167
+ - **Token field extraction from arbitrary model outputs.** Different LLM SDKs name fields differently; the caller knows their own output schema.
168
+ - **A pricing table.** Costs are pricing × tokens; pricing changes weekly. You compute it, you pass it in via `cost_usd`.
169
+ - **Pydantic→dict serialization.** If your case output is a Pydantic model, call `.model_dump()` yourself before passing it to `save_eval_results`. Hiding that behind a wrapper would just suppress errors.
170
+ - **Coupling to a specific eval framework** like `pydantic-evals` or `inspect_ai`. The writer takes plain dicts. Frameworks can be added as adapters when there's demand.
171
+ - **Schema versioning.** The on-disk format is forward-compatible by design (`extra="allow"` everywhere). If a breaking change ever lands, that's the time for a `schema_version` field, not now.
172
+
173
+ ## On-disk contract
174
+
175
+ See [`docs/data-layout.md`](https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md) in the monorepo for the full directory tree and per-file schemas. The TL;DR:
176
+
177
+ ```
178
+ {results_dir}/{run_id}/
179
+ ├── run.json (RunMetadata)
180
+ └── {eval_name}/
181
+ ├── summary.json (EvalSummary: aggregates + per-case rows)
182
+ ├── outputs/{case_name}.json (full per-case output)
183
+ ├── inputs/{case_name}.json (optional; saved input fixture)
184
+ └── case-scores/{case_name}.json (optional; per-question scores)
185
+ ```
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "evals-viewer-io"
7
+ version = "0.0.3"
8
+ description = "Pydantic schemas and a writer for the evals-viewer on-disk format — the Python writer side of the evals-viewer framework."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Dan Lester" },
15
+ ]
16
+ keywords = ["evals", "llm", "pydantic", "evaluation", "viewer"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Software Development :: Testing",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "pydantic>=2.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ pytest = ["pytest>=7"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/ideonate/evals-viewer"
37
+ Repository = "https://github.com/ideonate/evals-viewer"
38
+ Issues = "https://github.com/ideonate/evals-viewer/issues"
39
+ Documentation = "https://github.com/ideonate/evals-viewer/blob/main/docs/data-layout.md"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/evals_viewer_io"]
@@ -0,0 +1,23 @@
1
+ """Pydantic schemas and writer for the evals-viewer on-disk format."""
2
+
3
+ from .aggregates import compute_aggregates, compute_token_totals
4
+ from .schema import (
5
+ AggregateStats,
6
+ CaseSummary,
7
+ EvalSummary,
8
+ RunMetadata,
9
+ TokenUsage,
10
+ )
11
+ from .writer import save_eval_results, save_run_metadata
12
+
13
+ __all__ = [
14
+ "AggregateStats",
15
+ "CaseSummary",
16
+ "EvalSummary",
17
+ "RunMetadata",
18
+ "TokenUsage",
19
+ "compute_aggregates",
20
+ "compute_token_totals",
21
+ "save_eval_results",
22
+ "save_run_metadata",
23
+ ]
@@ -0,0 +1,88 @@
1
+ """Helpers for computing per-eval aggregates from a list of cases.
2
+
3
+ These are deliberately small and unambiguous: any consumer with multiple
4
+ cases per eval will need them, and the math has no domain knowledge.
5
+ Anything fancier (token cost tables, framework-specific extraction) lives
6
+ in caller code, not here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Iterable
12
+
13
+ from .schema import AggregateStats, CaseSummary, TokenUsage
14
+
15
+
16
+ def compute_aggregates(cases: Iterable[CaseSummary]) -> dict[str, AggregateStats]:
17
+ """Group ``case.scores[evaluator]`` across cases and return mean/min/max.
18
+
19
+ Failed cases (``success=False``) and cases with no scores are skipped.
20
+ Evaluators are listed in first-seen order across the input.
21
+ """
22
+ grouped: dict[str, list[float]] = {}
23
+ for case in cases:
24
+ if case.success is False or not case.scores:
25
+ continue
26
+ for evaluator, value in case.scores.items():
27
+ if value is None:
28
+ continue
29
+ grouped.setdefault(evaluator, []).append(float(value))
30
+
31
+ return {
32
+ evaluator: AggregateStats(
33
+ mean=sum(values) / len(values),
34
+ min=min(values),
35
+ max=max(values),
36
+ )
37
+ for evaluator, values in grouped.items()
38
+ if values
39
+ }
40
+
41
+
42
+ def compute_token_totals(cases: Iterable[CaseSummary]) -> TokenUsage:
43
+ """Sum token usage across cases.
44
+
45
+ Reads ``input_tokens``, ``output_tokens``, ``cost_usd``, and
46
+ ``usage_by_model`` from each case's ``output_summary``. Cases without
47
+ those fields contribute zero. The returned ``TokenUsage.usage_by_model``
48
+ is the per-model breakdown summed across all cases that had one.
49
+ """
50
+ total = TokenUsage()
51
+ for case in cases:
52
+ summary = case.output_summary or {}
53
+ contribution = _token_usage_from_dict(summary)
54
+ if contribution is not None:
55
+ total = total + contribution
56
+ return total
57
+
58
+
59
+ def _token_usage_from_dict(d: dict[str, Any]) -> TokenUsage | None:
60
+ """Best-effort extraction from a free-form output_summary dict."""
61
+ if not any(k in d for k in ("input_tokens", "output_tokens", "cost_usd", "usage_by_model")):
62
+ return None
63
+
64
+ raw_by_model = d.get("usage_by_model")
65
+ by_model: dict[str, TokenUsage] | None
66
+ if raw_by_model:
67
+ by_model = {}
68
+ for model, usage in raw_by_model.items():
69
+ if isinstance(usage, TokenUsage):
70
+ entry = usage
71
+ elif isinstance(usage, dict):
72
+ entry = TokenUsage(
73
+ input_tokens=int(usage.get("input_tokens", 0) or 0),
74
+ output_tokens=int(usage.get("output_tokens", 0) or 0),
75
+ cost_usd=usage.get("cost_usd"),
76
+ )
77
+ else:
78
+ continue
79
+ by_model[model] = entry
80
+ else:
81
+ by_model = None
82
+
83
+ return TokenUsage(
84
+ input_tokens=int(d.get("input_tokens", 0) or 0),
85
+ output_tokens=int(d.get("output_tokens", 0) or 0),
86
+ cost_usd=d.get("cost_usd"),
87
+ usage_by_model=by_model,
88
+ )
@@ -0,0 +1,39 @@
1
+ """Optional pytest fixture helpers for evals-viewer-io.
2
+
3
+ Apps can write their own fixtures, but this provides a sensible default:
4
+
5
+ # conftest.py
6
+ from evals_viewer_io.pytest import eval_run_dir # noqa: F401
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from .schema import RunMetadata
18
+ from .writer import save_run_metadata
19
+
20
+
21
+ def _now_run_id() -> str:
22
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
23
+
24
+
25
+ @pytest.fixture(scope="session")
26
+ def eval_run_dir(tmp_path_factory) -> Path:
27
+ """Create a fresh run directory for the test session and return its path.
28
+
29
+ Honours ``EVALS_RESULTS_DIR`` if set, otherwise uses a tmp dir. Writes a
30
+ minimal ``run.json`` so the viewer will list it.
31
+ """
32
+ base = Path(os.environ.get("EVALS_RESULTS_DIR", tmp_path_factory.mktemp("evals")))
33
+ run_id = _now_run_id()
34
+ save_run_metadata(
35
+ base,
36
+ run_id,
37
+ RunMetadata(timestamp=datetime.now(timezone.utc).isoformat()),
38
+ )
39
+ return base / run_id
@@ -0,0 +1,141 @@
1
+ """Pydantic models matching the evals-viewer on-disk format.
2
+
3
+ See ``docs/data-layout.md`` in the monorepo for the canonical contract.
4
+ These models are intentionally permissive (extra fields allowed) so writers
5
+ can include domain-specific extras without coordinating schema bumps.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+
15
+ class _Permissive(BaseModel):
16
+ model_config = ConfigDict(extra="allow")
17
+
18
+
19
+ class AggregateStats(_Permissive):
20
+ """Aggregate stats for one evaluator across all cases in an eval."""
21
+
22
+ mean: float
23
+ min: float
24
+ max: float
25
+
26
+
27
+ class TokenUsage(_Permissive):
28
+ """Token usage and cost for one model call, one case, or a whole eval.
29
+
30
+ Field names follow the Anthropic / viewer convention (input/output rather
31
+ than request/response). Adapter classmethods like ``from_pydantic_ai``
32
+ rename other frameworks' fields onto these.
33
+
34
+ Cost is the caller's responsibility — pricing tables go stale fast and
35
+ don't belong in this package.
36
+
37
+ The ``usage_by_model`` map is one level deep: entries inside it should
38
+ leave their own ``usage_by_model`` as None.
39
+ """
40
+
41
+ input_tokens: int = 0
42
+ output_tokens: int = 0
43
+ cost_usd: float | None = None
44
+ usage_by_model: dict[str, "TokenUsage"] | None = None
45
+
46
+ def __add__(self, other: TokenUsage) -> TokenUsage:
47
+ if not isinstance(other, TokenUsage):
48
+ return NotImplemented
49
+
50
+ # Sum cost only if at least one side reports it; preserve None when
51
+ # nobody knows the cost.
52
+ if self.cost_usd is None and other.cost_usd is None:
53
+ cost = None
54
+ else:
55
+ cost = (self.cost_usd or 0.0) + (other.cost_usd or 0.0)
56
+
57
+ # Merge per-model breakdowns. Only one level deep — don't recurse.
58
+ merged: dict[str, TokenUsage] | None
59
+ if self.usage_by_model is None and other.usage_by_model is None:
60
+ merged = None
61
+ else:
62
+ merged = {}
63
+ for source in (self.usage_by_model or {}, other.usage_by_model or {}):
64
+ for model, usage in source.items():
65
+ if model in merged:
66
+ merged[model] = TokenUsage(
67
+ input_tokens=merged[model].input_tokens + usage.input_tokens,
68
+ output_tokens=merged[model].output_tokens + usage.output_tokens,
69
+ cost_usd=_sum_optional(merged[model].cost_usd, usage.cost_usd),
70
+ )
71
+ else:
72
+ merged[model] = TokenUsage(
73
+ input_tokens=usage.input_tokens,
74
+ output_tokens=usage.output_tokens,
75
+ cost_usd=usage.cost_usd,
76
+ )
77
+
78
+ return TokenUsage(
79
+ input_tokens=self.input_tokens + other.input_tokens,
80
+ output_tokens=self.output_tokens + other.output_tokens,
81
+ cost_usd=cost,
82
+ usage_by_model=merged,
83
+ )
84
+
85
+ def __radd__(self, other: Any) -> TokenUsage:
86
+ # Lets `sum([usage1, usage2, ...])` work without an explicit start value.
87
+ if other == 0:
88
+ return self
89
+ return NotImplemented
90
+
91
+ @classmethod
92
+ def from_pydantic_ai(cls, usage: Any, *, cost_usd: float | None = None) -> TokenUsage:
93
+ """Build a TokenUsage from a pydantic-ai Usage or RunUsage object.
94
+
95
+ Maps ``request_tokens`` → ``input_tokens`` and ``response_tokens`` →
96
+ ``output_tokens``. Tolerates None values (treats them as zero).
97
+ Cost is not derived from tokens — pass it in if you have it.
98
+
99
+ Uses ``getattr`` so this package never imports pydantic-ai itself.
100
+ """
101
+ return cls(
102
+ input_tokens=getattr(usage, "request_tokens", 0) or 0,
103
+ output_tokens=getattr(usage, "response_tokens", 0) or 0,
104
+ cost_usd=cost_usd,
105
+ )
106
+
107
+
108
+ def _sum_optional(a: float | None, b: float | None) -> float | None:
109
+ if a is None and b is None:
110
+ return None
111
+ return (a or 0.0) + (b or 0.0)
112
+
113
+
114
+ class CaseSummary(_Permissive):
115
+ """One row in ``summary.json``'s ``cases`` array."""
116
+
117
+ name: str
118
+ success: bool = True
119
+ error: str | None = None
120
+ scores: dict[str, float] | None = None
121
+ judge_reasons: dict[str, str] | None = None
122
+ integrity: dict[str, Any] | None = None
123
+ output_summary: dict[str, Any] | None = None
124
+
125
+
126
+ class EvalSummary(_Permissive):
127
+ """Contents of ``summary.json`` for one eval inside one run."""
128
+
129
+ timestamp: str | None = None
130
+ aggregates: dict[str, AggregateStats] = Field(default_factory=dict)
131
+ cases: list[CaseSummary] = Field(default_factory=list)
132
+
133
+
134
+ class RunMetadata(_Permissive):
135
+ """Contents of ``run.json`` at the run-id directory root."""
136
+
137
+ timestamp: str
138
+ git_commit: str | None = None
139
+ git_branch: str | None = None
140
+ git_dirty: bool = False
141
+ tags: list[str] = Field(default_factory=list)
@@ -0,0 +1,64 @@
1
+ """Filesystem writer for the evals-viewer on-disk format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .schema import EvalSummary, RunMetadata
10
+
11
+
12
+ def _dump(path: Path, data: Any) -> None:
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ path.write_text(json.dumps(data, indent=2, default=str))
15
+
16
+
17
+ def save_run_metadata(
18
+ results_dir: str | Path,
19
+ run_id: str,
20
+ run: RunMetadata,
21
+ ) -> Path:
22
+ """Write ``{results_dir}/{run_id}/run.json`` and return its path."""
23
+ run_dir = Path(results_dir) / run_id
24
+ path = run_dir / "run.json"
25
+ _dump(path, run.model_dump(mode="json", exclude_none=True))
26
+ return path
27
+
28
+
29
+ def save_eval_results(
30
+ results_dir: str | Path,
31
+ run_id: str,
32
+ eval_name: str,
33
+ summary: EvalSummary,
34
+ outputs: Mapping[str, Any],
35
+ *,
36
+ inputs: Mapping[str, Any] | None = None,
37
+ case_scores: Mapping[str, Any] | None = None,
38
+ run: RunMetadata | None = None,
39
+ ) -> Path:
40
+ """Write a complete eval result tree under ``{results_dir}/{run_id}/{eval_name}/``.
41
+
42
+ If ``run`` is supplied and ``run.json`` does not yet exist, it is written too.
43
+ """
44
+ base = Path(results_dir) / run_id / eval_name
45
+
46
+ _dump(base / "summary.json", summary.model_dump(mode="json", exclude_none=True))
47
+
48
+ for case_name, output in outputs.items():
49
+ _dump(base / "outputs" / f"{case_name}.json", output)
50
+
51
+ if inputs:
52
+ for case_name, value in inputs.items():
53
+ _dump(base / "inputs" / f"{case_name}.json", value)
54
+
55
+ if case_scores:
56
+ for case_name, value in case_scores.items():
57
+ _dump(base / "case-scores" / f"{case_name}.json", value)
58
+
59
+ if run is not None:
60
+ run_path = Path(results_dir) / run_id / "run.json"
61
+ if not run_path.exists():
62
+ save_run_metadata(results_dir, run_id, run)
63
+
64
+ return base