python-yama 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.
yama/report.py ADDED
@@ -0,0 +1,854 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from html import escape
7
+ from pathlib import Path
8
+ from typing import Any, Sequence
9
+
10
+ from .models import ResolvedCase, StepResult
11
+ from .runner import CaseResult, RunResult, _tool_result_content
12
+
13
+
14
+ def _text(value: Any) -> str:
15
+ return escape(str(value), quote=True)
16
+
17
+
18
+ def _json(value: Any) -> str:
19
+ return escape(
20
+ json.dumps(value, ensure_ascii=False, indent=2, default=str), quote=False
21
+ )
22
+
23
+
24
+ def _stamp_angle(seed: str | None) -> float:
25
+ """A small deterministic tilt so repeated verdict stamps don't all sit
26
+ at an identical, obviously-templated angle."""
27
+ if not seed:
28
+ return 0.0
29
+ digest = hashlib.sha1(seed.encode("utf-8")).digest()
30
+ return (digest[0] / 255) * 6.4 - 3.2
31
+
32
+
33
+ def _status(
34
+ passed: bool,
35
+ *,
36
+ kind: str = "stamp",
37
+ size: str = "md",
38
+ seed: str | None = None,
39
+ ) -> str:
40
+ """Render a verdict. High-frequency, low-stakes rows (check/mock-event
41
+ lists) get a quiet inline tick; the handful of hierarchical verdicts
42
+ per report (report/case/run/step/judge) get a stamped chop instead."""
43
+ label = "PASS" if passed else "FAIL"
44
+ css_class = "pass" if passed else "fail"
45
+ if kind == "tick":
46
+ return (
47
+ f'<span class="verdict tick {css_class}">'
48
+ '<i class="tick-mark" aria-hidden="true"></i>'
49
+ f"{label}</span>"
50
+ )
51
+ angle = _stamp_angle(seed)
52
+ return (
53
+ f'<span class="verdict stamp size-{size} {css_class}" '
54
+ f'style="--tilt:{angle:.2f}deg">{label}</span>'
55
+ )
56
+
57
+
58
+ def _tally(score: Any, max_score: int = 5) -> str:
59
+ """Render a score as filled/unfilled tally strokes alongside the number,
60
+ echoing the 正-character five-stroke tally used for hand counting."""
61
+ try:
62
+ filled = max(0, min(max_score, round(float(score))))
63
+ except (TypeError, ValueError):
64
+ filled = 0
65
+ bars = "".join(
66
+ f'<i class="tally-bar{" filled" if i < filled else ""}"></i>'
67
+ for i in range(max_score)
68
+ )
69
+ return f'<span class="tally" role="img" aria-label="{filled}/{max_score}">{bars}</span>'
70
+
71
+
72
+ def _file_uri(value: Any) -> str | None:
73
+ if not isinstance(value, str) or not value:
74
+ return None
75
+ try:
76
+ return Path(value).resolve().as_uri()
77
+ except ValueError:
78
+ return None
79
+
80
+
81
+ def _render_content(content: Any) -> str:
82
+ if isinstance(content, str):
83
+ return f'<pre class="message-content">{_text(content)}</pre>'
84
+ if not isinstance(content, list):
85
+ return f'<pre class="message-content">{_json(content)}</pre>'
86
+
87
+ parts: list[str] = []
88
+ for part in content:
89
+ if not isinstance(part, dict):
90
+ parts.append(f'<pre class="message-content">{_json(part)}</pre>')
91
+ continue
92
+ part_type = part.get("type")
93
+ if part_type == "text":
94
+ parts.append(
95
+ f'<pre class="message-content">{_text(part.get("text", ""))}</pre>'
96
+ )
97
+ continue
98
+ fixture = part.get("fixture")
99
+ uri = _file_uri(fixture)
100
+ filename = part.get("filename") or (
101
+ Path(fixture).name if isinstance(fixture, str) else "attachment"
102
+ )
103
+ media_type = part.get("media_type", "unknown")
104
+ metadata = (
105
+ f'<span class="attachment-name">{_text(filename)}</span>'
106
+ f'<span class="muted">{_text(media_type)}</span>'
107
+ )
108
+ if part_type == "input_image" and uri:
109
+ parts.append(
110
+ '<figure class="attachment image-attachment">'
111
+ f'<img src="{_text(uri)}" alt="{_text(filename)}" loading="lazy">'
112
+ f"<figcaption>{metadata}</figcaption>"
113
+ "</figure>"
114
+ )
115
+ else:
116
+ link_start = f'<a href="{_text(uri)}">' if uri else ""
117
+ link_end = "</a>" if uri else ""
118
+ parts.append(
119
+ '<div class="attachment file-attachment">'
120
+ f"{link_start}{metadata}{link_end}"
121
+ f'<span class="path">{_text(fixture or "")}</span>'
122
+ "</div>"
123
+ )
124
+ return "".join(parts)
125
+
126
+
127
+ def _render_message(role: str, title: str, content: Any) -> str:
128
+ return (
129
+ f'<article class="event message {role}">'
130
+ '<div class="event-head">'
131
+ f'<span class="event-kind">{_text(role)}</span>'
132
+ f"<strong>{_text(title)}</strong>"
133
+ "</div>"
134
+ f'{_render_content(content)}'
135
+ "</article>"
136
+ )
137
+
138
+
139
+ def _render_tool_result(result: Any) -> str:
140
+ """Render a tool call's result the same way the model sees it.
141
+
142
+ Reuses `runner._tool_result_content` so bash results show their raw
143
+ `stdout + stderr` text and skill results show the raw file text,
144
+ instead of the report re-encoding them as JSON (which would quote and
145
+ escape plain-text output rather than displaying it verbatim).
146
+ """
147
+ return f'<pre><code>{_text(_tool_result_content(result))}</code></pre>'
148
+
149
+
150
+ def _render_tool_call(record: Any) -> str:
151
+ error_class = " error" if record.is_error else ""
152
+ return (
153
+ f'<article class="event tool{error_class}">'
154
+ '<div class="event-head">'
155
+ '<span class="event-kind">tool</span>'
156
+ f'<strong>{_text(record.call.name)}</strong>'
157
+ '<span class="muted">'
158
+ f'request {record.request_index} · call {record.call_index}'
159
+ "</span>"
160
+ "</div>"
161
+ '<div class="split">'
162
+ '<div><h5>Arguments</h5>'
163
+ f'<pre><code>{_json(record.call.arguments)}</code></pre></div>'
164
+ '<div><h5>Result</h5>'
165
+ f'{_render_tool_result(record.result)}</div>'
166
+ "</div>"
167
+ "</article>"
168
+ )
169
+
170
+
171
+ def _title_for_role(role: str, global_index: int) -> str:
172
+ if role == "system":
173
+ return "System prompt" if global_index == 0 else "System message"
174
+ if role == "user":
175
+ return "User message"
176
+ if role == "developer":
177
+ return "Developer message"
178
+ return f"{role.capitalize()} message"
179
+
180
+
181
+ def _render_tool_list(tools: Any) -> str:
182
+ if not isinstance(tools, list) or not tools:
183
+ return '<p class="muted">No tools available.</p>'
184
+ names: list[str] = []
185
+ cards: list[str] = []
186
+ for tool in tools:
187
+ function = tool.get("function", {}) if isinstance(tool, dict) else {}
188
+ name = function.get("name", "unknown") if isinstance(function, dict) else "unknown"
189
+ names.append(str(name))
190
+ description = (function.get("description") or "") if isinstance(function, dict) else ""
191
+ parameters = function.get("parameters", {}) if isinstance(function, dict) else {}
192
+ cards.append(
193
+ '<div class="tool-def">'
194
+ f'<div class="tool-def-head">{_text(name)}</div>'
195
+ f'<p class="muted">{_text(description)}</p>'
196
+ '<details><summary>Parameters</summary>'
197
+ f'<pre><code>{_json(parameters)}</code></pre></details>'
198
+ "</div>"
199
+ )
200
+ return (
201
+ '<details class="raw tool-list"><summary>'
202
+ f'Available tools ({len(names)}): {_text(", ".join(names))}'
203
+ "</summary>"
204
+ f'<div class="tool-list-grid">{"".join(cards)}</div>'
205
+ "</details>"
206
+ )
207
+
208
+
209
+ def _render_llm_request_details(request: dict[str, Any]) -> str:
210
+ invocation = request.get("invocation")
211
+ if not isinstance(invocation, dict):
212
+ invocation = {}
213
+ return (
214
+ '<h5>Method</h5>'
215
+ f'<pre><code>{_text(invocation.get("method", "unknown"))}</code></pre>'
216
+ '<h5>Arguments</h5>'
217
+ f'<pre><code>{_json(invocation.get("arguments", {}))}</code></pre>'
218
+ )
219
+
220
+
221
+ def _render_assistant_call(request: dict[str, Any]) -> str:
222
+ resolved_provider = request.get("resolved_provider")
223
+ provider_name = (
224
+ resolved_provider.get("name", "unknown")
225
+ if isinstance(resolved_provider, dict)
226
+ else "unknown"
227
+ )
228
+ resolved_model = (
229
+ resolved_provider.get("model")
230
+ if isinstance(resolved_provider, dict)
231
+ else None
232
+ )
233
+ invocation = request.get("invocation", {})
234
+ arguments = (
235
+ invocation.get("arguments", {}) if isinstance(invocation, dict) else {}
236
+ )
237
+ model_name = resolved_model or arguments.get("model", "unknown")
238
+ response = request.get("response")
239
+ error = request.get("error")
240
+ error_class = " error" if error else ""
241
+
242
+ content = response.get("content", "") if isinstance(response, dict) else ""
243
+ response_html = (
244
+ '<div class="assistant-content"><h5>Assistant text content</h5>'
245
+ + (
246
+ _render_content(content)
247
+ if content
248
+ else '<div class="empty-content">Empty assistant text content <code>""</code></div>'
249
+ )
250
+ + "</div>"
251
+ )
252
+ error_html = (
253
+ f'<div class="model-error"><h5>Error</h5><pre><code>{_text(error)}</code></pre></div>'
254
+ if error
255
+ else ""
256
+ )
257
+
258
+ return (
259
+ f'<article class="event assistant{error_class}">'
260
+ '<div class="event-head">'
261
+ '<span class="event-kind">assistant</span>'
262
+ '<strong>Assistant response</strong>'
263
+ f'<span class="muted">request {_text(request.get("request_index", "?"))} · '
264
+ f'{_text(provider_name)} · {_text(model_name)}</span>'
265
+ "</div>"
266
+ f'<div class="assistant-response">{response_html}{error_html}</div>'
267
+ '<details class="raw model-input"><summary>LLM request</summary>'
268
+ f'{_render_llm_request_details(request)}'
269
+ "</details>"
270
+ f'{_render_tool_list(arguments.get("tools", []) if isinstance(arguments, dict) else [])}'
271
+ "</article>"
272
+ )
273
+
274
+
275
+ def _render_checks(step: StepResult) -> str:
276
+ if not step.hard_checks:
277
+ return '<p class="muted">No hard checks configured.</p>'
278
+ rows: list[str] = []
279
+ for index, check in enumerate(step.hard_checks, 1):
280
+ passed = bool(check.get("passed", False))
281
+ rows.append(
282
+ f'<tr class="{"passed" if passed else "failed"}">'
283
+ f'<td>{_status(passed, kind="tick")}</td>'
284
+ f'<td><strong>{index:02d} · {_text(check.get("label") or check.get("type", "hard check"))}</strong></td>'
285
+ f'<td>{_text(check.get("expected", "assertion passes"))}</td>'
286
+ f'<td>{_text(check.get("actual", check.get("detail", "unknown")))}</td>'
287
+ f'<td>{_text(check.get("reason", check.get("detail", "")))}</td>'
288
+ "</tr>"
289
+ )
290
+ return (
291
+ '<div class="table-wrap"><table class="checks">'
292
+ "<thead><tr><th>Status</th><th>Check</th><th>Expected</th>"
293
+ "<th>Actual</th><th>Reason</th></tr></thead>"
294
+ f'<tbody>{"".join(rows)}</tbody></table></div>'
295
+ )
296
+
297
+
298
+ def _render_judge(judge: dict[str, Any] | None) -> str:
299
+ if judge is None:
300
+ return ""
301
+ passed = bool(judge.get("passed", False))
302
+ overall = judge.get("overall")
303
+ overall_label = (
304
+ f"{overall:.0%}" if isinstance(overall, (int, float)) else "unknown"
305
+ )
306
+ dimensions: list[str] = []
307
+ for item in judge.get("dimensions", []):
308
+ score = item.get("score", "?")
309
+ dimensions.append(
310
+ '<div class="judge-dimension">'
311
+ '<div class="judge-score">'
312
+ f'<strong>{_text(item.get("name", "unnamed"))}</strong>'
313
+ f'<span class="judge-score-value">{_tally(score)}<b>{_text(score)}/5</b></span>'
314
+ "</div>"
315
+ f'<p>{_text(item.get("reason", ""))}</p>'
316
+ f'<p class="muted">Evidence: {_text(item.get("evidence", ""))}</p>'
317
+ "</div>"
318
+ )
319
+ return (
320
+ '<section class="subsection">'
321
+ '<div class="section-heading"><h4>Judge</h4>'
322
+ f'{_status(passed, kind="stamp", size="sm", seed="judge")}'
323
+ f'<span class="metric-inline">Overall {overall_label}</span>'
324
+ "</div>"
325
+ f'<div class="judge-grid">{"".join(dimensions)}</div>'
326
+ "</section>"
327
+ )
328
+
329
+
330
+ def _new_turn_messages(
331
+ request_messages: Any, seen_messages: list[Any]
332
+ ) -> tuple[list[Any], int]:
333
+ """Diff a request's outgoing message list against the previous turn's.
334
+
335
+ Every LLM request in a run resends the full conversation so far, so
336
+ consecutive requests share an identical prefix. Returns the messages
337
+ that are new in this turn and a count of the (unchanged) prefix that
338
+ can be collapsed instead of re-rendered.
339
+ """
340
+ if not isinstance(request_messages, list):
341
+ return [], len(seen_messages)
342
+ if request_messages[: len(seen_messages)] == seen_messages:
343
+ return request_messages[len(seen_messages) :], len(seen_messages)
344
+ return request_messages, 0
345
+
346
+
347
+ def _render_step(
348
+ step: StepResult,
349
+ requests: Sequence[dict[str, Any]],
350
+ *,
351
+ prior_messages: list[Any],
352
+ ) -> tuple[str, list[Any]]:
353
+ failed_checks = sum(
354
+ not check.get("passed", False) for check in step.hard_checks
355
+ )
356
+ judge_passed = step.judge_result is None or bool(
357
+ step.judge_result.get("passed", False)
358
+ )
359
+ passed = step.hard_pass and judge_passed
360
+ step_requests = sorted(
361
+ (request for request in requests if request.get("step_id") == step.step_id),
362
+ key=lambda request: request.get("request_index", 0),
363
+ )
364
+ events: list[str] = []
365
+ seen_messages = prior_messages
366
+ rendered_records: set[int] = set()
367
+ for request in step_requests:
368
+ invocation = request.get("invocation")
369
+ arguments = (
370
+ invocation.get("arguments", {}) if isinstance(invocation, dict) else {}
371
+ )
372
+ request_messages = arguments.get("messages") if isinstance(arguments, dict) else None
373
+ new_messages, hidden_count = _new_turn_messages(request_messages, seen_messages)
374
+ if hidden_count:
375
+ events.append(
376
+ '<div class="turn-prefix-note muted">'
377
+ f'{hidden_count} earlier message(s) already shown above (collapsed)'
378
+ "</div>"
379
+ )
380
+ for offset, message in enumerate(new_messages):
381
+ if not isinstance(message, dict):
382
+ continue
383
+ role = message.get("role", "message")
384
+ if role not in ("system", "user", "developer"):
385
+ continue
386
+ title = _title_for_role(role, hidden_count + offset)
387
+ events.append(_render_message(role, title, message.get("content")))
388
+ if isinstance(request_messages, list):
389
+ seen_messages = request_messages
390
+ events.append(_render_assistant_call(request))
391
+ request_index = request.get("request_index")
392
+ for record_index, record in enumerate(step.tool_calls):
393
+ if record.request_index == request_index:
394
+ events.append(_render_tool_call(record))
395
+ rendered_records.add(record_index)
396
+ events.extend(
397
+ _render_tool_call(record)
398
+ for record_index, record in enumerate(step.tool_calls)
399
+ if record_index not in rendered_records
400
+ )
401
+ if not step_requests or not isinstance(step_requests[-1].get("response"), dict):
402
+ events.append(
403
+ _render_message(
404
+ "assistant",
405
+ "Assistant response",
406
+ step.assistant_message.get("content", ""),
407
+ )
408
+ )
409
+ next_prior_messages = step.messages if step.messages else prior_messages
410
+ html = (
411
+ f'<details class="step" {"open" if not passed else ""}>'
412
+ "<summary>"
413
+ f'{_status(passed, kind="stamp", size="sm", seed=step.step_id)}'
414
+ f'<strong>{_text(step.step_id)}</strong>'
415
+ '<span class="summary-meta">'
416
+ f'{len(step.tool_calls)} tool calls · '
417
+ f'{len(step.hard_checks) - failed_checks}/{len(step.hard_checks)} checks passed'
418
+ "</span>"
419
+ "</summary>"
420
+ '<div class="step-body">'
421
+ '<details class="subsection timeline-section" open>'
422
+ '<summary><span>Execution timeline</span>'
423
+ f'<span class="muted">{len(events)} events</span></summary>'
424
+ f'<div class="timeline">{"".join(events)}</div></details>'
425
+ '<section class="subsection"><h4>Hard checks</h4>'
426
+ f'{_render_checks(step)}</section>'
427
+ f'{_render_judge(step.judge_result)}'
428
+ '<details class="raw"><summary>Resolved transcript</summary>'
429
+ f'<pre><code>{_json(step.messages)}</code></pre></details>'
430
+ "</div>"
431
+ "</details>"
432
+ )
433
+ return html, next_prior_messages
434
+
435
+
436
+ def _render_request(request: dict[str, Any]) -> str:
437
+ resolved_provider = request.get("resolved_provider")
438
+ provider = (
439
+ f' · {_text(resolved_provider.get("name", ""))}'
440
+ if isinstance(resolved_provider, dict)
441
+ else ""
442
+ )
443
+ return (
444
+ '<details class="raw request">'
445
+ "<summary>"
446
+ f'Request {_text(request.get("request_index", "?"))} · '
447
+ f'{_text(request.get("step_id", "unknown step"))}{provider}'
448
+ "</summary>"
449
+ f'{_render_llm_request_details(request)}'
450
+ "</details>"
451
+ )
452
+
453
+
454
+ def _render_mock_event(event: dict[str, Any]) -> str:
455
+ failed = bool(event.get("is_error", False))
456
+ match_failed = bool(event.get("match_failed", False))
457
+ return (
458
+ '<details class="raw mock-event">'
459
+ "<summary>"
460
+ f'{_status(not failed, kind="tick")}<strong>{_text(event.get("tool", "unknown"))}</strong>'
461
+ '<span class="summary-meta">'
462
+ f'call {event.get("call_index", "?")} · '
463
+ f'{event.get("duration_ms", "?")} ms · '
464
+ f'{_text(event.get("handler_kind", "unknown"))}'
465
+ f'{" · match failed" if match_failed else ""}'
466
+ "</span>"
467
+ "</summary>"
468
+ '<div class="split">'
469
+ '<div><h5>Arguments</h5>'
470
+ f'<pre><code>{_json(event.get("arguments", {}))}</code></pre></div>'
471
+ '<div><h5>Result</h5>'
472
+ f'<pre><code>{_json(event.get("result"))}</code></pre></div>'
473
+ "</div>"
474
+ '<h5>State diff</h5>'
475
+ f'<pre><code>{_json(event.get("state_diff", []))}</code></pre>'
476
+ "</details>"
477
+ )
478
+
479
+
480
+ def _render_run(run: RunResult) -> str:
481
+ error = ""
482
+ if run.error:
483
+ error = (
484
+ '<div class="run-error"><strong>Run error</strong>'
485
+ f'<pre>{_text(run.error)}</pre>'
486
+ f'<details><summary>Traceback</summary><pre>{_text(run.traceback or "")}</pre></details>'
487
+ "</div>"
488
+ )
489
+ step_htmls: list[str] = []
490
+ seen_messages: list[Any] = []
491
+ for step in run.steps:
492
+ step_html, seen_messages = _render_step(step, run.requests, prior_messages=seen_messages)
493
+ step_htmls.append(step_html)
494
+ steps = "".join(step_htmls)
495
+ requests = "".join(_render_request(request) for request in run.requests)
496
+ mocks = "".join(_render_mock_event(event) for event in run.mock_events)
497
+ artifact = (
498
+ f'<span class="summary-meta">Artifacts: {_text(run.artifact_dir)}</span>'
499
+ if run.artifact_dir
500
+ else ""
501
+ )
502
+ return (
503
+ f'<details class="run" {"open" if not run.passed else ""}>'
504
+ "<summary>"
505
+ f'{_status(run.passed, kind="stamp", size="sm", seed=f"run-{run.run_index}")}'
506
+ f'<strong>Run {run.run_index}</strong>{artifact}'
507
+ "</summary>"
508
+ '<div class="run-body">'
509
+ f"{error}{steps}"
510
+ '<details class="diagnostics"><summary>LLM requests '
511
+ f'({len(run.requests)})</summary><div>{requests or "<p>No requests recorded.</p>"}</div></details>'
512
+ '<details class="diagnostics"><summary>Mock events '
513
+ f'({len(run.mock_events)})</summary><div>{mocks or "<p>No mock events recorded.</p>"}</div></details>'
514
+ '<details class="raw"><summary>Final mock state</summary>'
515
+ f'<pre><code>{_json(run.mock_state)}</code></pre></details>'
516
+ "</div>"
517
+ "</details>"
518
+ )
519
+
520
+
521
+ def _render_fixtures(case: ResolvedCase) -> str:
522
+ if not case.fixture_manifest:
523
+ return '<p class="muted">No fixtures.</p>'
524
+ return '<div class="fixture-grid">' + "".join(
525
+ _render_content([fixture]) for fixture in case.fixture_manifest
526
+ ) + "</div>"
527
+
528
+
529
+ def _render_case(case: ResolvedCase, result: CaseResult) -> str:
530
+ passed_runs = sum(run.passed for run in result.runs)
531
+ tools = ", ".join(tool.name for tool in case.tools) or "none"
532
+ skills = ", ".join(skill.name for skill in case.skills) or "none"
533
+ runs = "".join(_render_run(run) for run in result.runs)
534
+ return (
535
+ '<section class="case">'
536
+ '<header class="case-header">'
537
+ '<div><div class="eyebrow">Case</div>'
538
+ f'<h2>{_text(result.case_key)}</h2>'
539
+ f'<p class="path">{_text(case.path)}</p></div>'
540
+ '<div class="case-outcome">'
541
+ f'{_status(result.passed, kind="stamp", size="md", seed=result.case_key)}'
542
+ f'<strong>{result.pass_rate:.0%}</strong>'
543
+ f'<span>{passed_runs}/{len(result.runs)} runs passed</span>'
544
+ "</div>"
545
+ "</header>"
546
+ '<div class="metadata">'
547
+ f'<div><span>Model</span><strong>{_text(case.model.get("name", "unknown"))}</strong></div>'
548
+ f'<div><span>Tools</span><strong>{_text(tools)}</strong></div>'
549
+ f'<div><span>Skills</span><strong>{_text(skills)}</strong></div>'
550
+ "</div>"
551
+ '<details class="case-context"><summary>Resolved Case context</summary>'
552
+ '<div class="context-grid">'
553
+ '<div><h4>Execution</h4>'
554
+ f'<pre><code>{_json(case.execution)}</code></pre></div>'
555
+ '<div><h4>Model</h4>'
556
+ f'<pre><code>{_json(case.model)}</code></pre></div>'
557
+ "</div>"
558
+ '<h4>Input fixtures</h4>'
559
+ f'{_render_fixtures(case)}'
560
+ '<details class="raw"><summary>System prompt</summary>'
561
+ f'<pre>{_text(case.system_prompt)}</pre></details>'
562
+ "</details>"
563
+ f'<div class="runs">{runs}</div>'
564
+ "</section>"
565
+ )
566
+
567
+
568
+ def render_html_report(
569
+ cases: Sequence[ResolvedCase], results: Sequence[CaseResult]
570
+ ) -> str:
571
+ if len(cases) != len(results):
572
+ raise ValueError("HTML report requires one result for every resolved case")
573
+
574
+ total_runs = sum(len(result.runs) for result in results)
575
+ passed_runs = sum(run.passed for result in results for run in result.runs)
576
+ checks = [
577
+ check
578
+ for result in results
579
+ for run in result.runs
580
+ for step in run.steps
581
+ for check in step.hard_checks
582
+ ]
583
+ passed_checks = sum(check.get("passed", False) for check in checks)
584
+ passed_cases = sum(result.passed for result in results)
585
+ overall_passed = passed_cases == len(results) and bool(results)
586
+ generated_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
587
+ rendered_cases = "".join(
588
+ _render_case(case, result) for case, result in zip(cases, results, strict=True)
589
+ )
590
+
591
+ return f"""<!doctype html>
592
+ <html lang="en">
593
+ <head>
594
+ <meta charset="utf-8">
595
+ <meta name="viewport" content="width=device-width, initial-scale=1">
596
+ <title>Yama Eval Report</title>
597
+ <style>
598
+ :root {{
599
+ color-scheme: light dark;
600
+ --bg: #e7e6dd;
601
+ --surface: #f3f1e7;
602
+ --surface-2: #eae7da;
603
+ --text: #24261f;
604
+ --muted: #6b6c5e;
605
+ --border: #cfcdbb;
606
+ --accent: #33507d;
607
+ --pass: #2f6b4f;
608
+ --pass-bg: #dee7de;
609
+ --fail: #a6332a;
610
+ --fail-bg: #f1ded9;
611
+ --warn: #8a6a2c;
612
+ --system: #6b4c7a;
613
+ --system-bg: #e6dee8;
614
+ --user: #33507d;
615
+ --user-bg: #dce2ec;
616
+ --assistant: #2f6b4f;
617
+ --assistant-bg: #dee7de;
618
+ --tool: #8a6a2c;
619
+ --tool-bg: #ede4ce;
620
+ --code: #23241f;
621
+ --code-bg: #dbd9cb;
622
+ --font-display: "Iowan Old Style", "Palatino Linotype", "Songti SC", "Noto Serif SC", Georgia, "Times New Roman", serif;
623
+ --font-body: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", "Noto Sans SC", sans-serif;
624
+ --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, Menlo, "Liberation Mono", monospace;
625
+ }}
626
+ @media (prefers-color-scheme: dark) {{
627
+ :root {{
628
+ --bg: #14161b;
629
+ --surface: #1b1e24;
630
+ --surface-2: #23262e;
631
+ --text: #e8e3d2;
632
+ --muted: #9c9d8d;
633
+ --border: #383a31;
634
+ --accent: #93a9d6;
635
+ --pass: #6fbf97;
636
+ --pass-bg: #1e3128;
637
+ --fail: #e2756a;
638
+ --fail-bg: #3a2320;
639
+ --warn: #d9ae63;
640
+ --system: #b79bc7;
641
+ --system-bg: #2c2436;
642
+ --user: #93a9d6;
643
+ --user-bg: #212a3a;
644
+ --assistant: #6fbf97;
645
+ --assistant-bg: #1e3128;
646
+ --tool: #d9ae63;
647
+ --tool-bg: #362b18;
648
+ --code: #e7e2d1;
649
+ --code-bg: #1b1d17;
650
+ }}
651
+ }}
652
+ * {{ box-sizing: border-box; }}
653
+ body {{
654
+ margin: 0;
655
+ background-color: var(--bg);
656
+ background-image: repeating-linear-gradient(90deg, transparent 0 39px, color-mix(in srgb, var(--border) 55%, transparent) 39px 40px);
657
+ color: var(--text);
658
+ font-family: var(--font-body);
659
+ font-size: 14px;
660
+ line-height: 1.55;
661
+ }}
662
+ main {{ width: min(1500px, calc(100% - 32px)); margin: 32px auto 64px; }}
663
+ h1, h2, h3, h4, h5, p {{ margin-top: 0; }}
664
+ h1, h2, h3, h4 {{ font-family: var(--font-display); font-weight: 700; }}
665
+ h1 {{ margin-bottom: 4px; font-size: 30px; letter-spacing: -.01em; }}
666
+ h2 {{ margin-bottom: 4px; font-size: 21px; overflow-wrap: anywhere; }}
667
+ h3 {{ margin: 0; font-size: 16px; }}
668
+ h4 {{ margin-bottom: 12px; font-size: 15px; }}
669
+ h5 {{ margin: 0 0 6px; color: var(--muted); font-family: var(--font-display); text-transform: uppercase; letter-spacing: .08em; font-size: 11px; font-weight: 600; }}
670
+ a {{ color: var(--accent); }}
671
+ summary {{ cursor: pointer; list-style-position: outside; }}
672
+ summary:focus-visible, a:focus-visible, .verdict:focus-visible {{ outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px; }}
673
+ .report-header {{ display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; padding-bottom: 18px; margin-bottom: 26px; border-bottom: 5px double color-mix(in srgb, var(--text) 45%, transparent); }}
674
+ .report-header > div {{ min-width: 0; }}
675
+ .report-header p, .path, .muted, .summary-meta {{ color: var(--muted); }}
676
+ .report-header p {{ font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; }}
677
+ .overall {{ display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }}
678
+ .overall span {{ font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; color: var(--muted); }}
679
+ .summary-grid {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 30px; overflow: clip; }}
680
+ .summary-card {{ padding: 16px 22px; border-right: 1px solid var(--border); }}
681
+ .summary-card:last-child {{ border-right: 0; }}
682
+ .summary-card span {{ display: block; color: var(--muted); font-family: var(--font-display); text-transform: uppercase; letter-spacing: .08em; font-size: 11px; }}
683
+ .summary-card strong {{ display: block; margin-top: 6px; font-family: var(--font-mono); font-size: 25px; font-weight: 600; }}
684
+
685
+ /* Verdicts: quiet ticks for high-frequency rows (checks, mock events);
686
+ stamped chops for the handful of hierarchical calls per report
687
+ (report/case/run/step/judge) — ceremony scales with significance,
688
+ not with how often a verdict appears. */
689
+ .verdict {{ display: inline-flex; align-items: center; width: max-content; white-space: nowrap; }}
690
+ .verdict.tick {{ gap: 6px; font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .04em; }}
691
+ .verdict.tick.pass {{ color: var(--pass); }}
692
+ .verdict.tick.fail {{ color: var(--fail); }}
693
+ .tick-mark {{ width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }}
694
+ .verdict.stamp {{
695
+ --tilt: 0deg;
696
+ position: relative;
697
+ justify-content: center;
698
+ font-family: var(--font-display);
699
+ font-weight: 700;
700
+ text-transform: uppercase;
701
+ letter-spacing: .1em;
702
+ border: 2px solid currentColor;
703
+ border-radius: 3px 9px 4px 8px / 8px 4px 9px 3px;
704
+ padding: 3px 11px;
705
+ font-size: 11px;
706
+ background: color-mix(in srgb, currentColor 10%, transparent);
707
+ box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent);
708
+ transform: rotate(var(--tilt));
709
+ transition: transform .15s ease;
710
+ }}
711
+ .verdict.stamp.pass {{ color: var(--pass); }}
712
+ .verdict.stamp.fail {{ color: var(--fail); }}
713
+ .verdict.stamp.size-sm {{ font-size: 10px; padding: 2px 9px; }}
714
+ .verdict.stamp.size-md {{ font-size: 12px; padding: 4px 13px; }}
715
+ .verdict.stamp.size-lg {{ font-size: 17px; padding: 8px 22px; letter-spacing: .16em; }}
716
+ .verdict.stamp.size-lg::before {{
717
+ content: "";
718
+ position: absolute;
719
+ inset: -14px;
720
+ border-radius: inherit;
721
+ background: radial-gradient(circle, color-mix(in srgb, currentColor 16%, transparent), transparent 72%);
722
+ }}
723
+ .case {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 20px; overflow: clip; }}
724
+ .case-header {{ display: flex; justify-content: space-between; gap: 24px; padding: 20px; border-bottom: 1px solid var(--border); }}
725
+ .case-header > div {{ min-width: 0; }}
726
+ .case-header .path {{ margin: 0; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 12px; }}
727
+ .eyebrow {{ color: var(--muted); font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; }}
728
+ .case-outcome {{ min-width: 130px; display: grid; justify-items: end; align-content: start; gap: 5px; }}
729
+ .case-outcome strong {{ font-family: var(--font-mono); font-size: 22px; }}
730
+ .metadata {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: 1px solid var(--border); }}
731
+ .metadata > div {{ min-width: 0; padding: 14px 20px; border-right: 1px solid var(--border); }}
732
+ .metadata > div:last-child {{ border-right: 0; }}
733
+ .metadata span, .metadata strong {{ display: block; }}
734
+ .metadata span {{ color: var(--muted); font-family: var(--font-display); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }}
735
+ .metadata strong {{ overflow-wrap: anywhere; font-weight: 600; }}
736
+ .case-context {{ padding: 14px 20px; border-bottom: 1px solid var(--border); }}
737
+ .case-context > summary, .diagnostics > summary {{ font-weight: 650; }}
738
+ .case-context[open] > summary, .diagnostics[open] > summary {{ margin-bottom: 14px; }}
739
+ .context-grid, .split {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }}
740
+ .run {{ border-bottom: 1px solid var(--border); }}
741
+ .run:last-child {{ border-bottom: 0; }}
742
+ .run > summary, .step > summary {{ display: flex; align-items: center; gap: 10px; padding: 14px 20px; }}
743
+ .run > summary {{ background: var(--surface-2); }}
744
+ .run-body {{ padding: 0 20px 20px; }}
745
+ .step {{ margin-top: 16px; border: 1px solid var(--border); border-radius: 5px; overflow: clip; }}
746
+ .step > summary {{ background: var(--surface-2); }}
747
+ .step-body {{ padding: 18px; }}
748
+ .summary-meta {{ margin-left: auto; font-size: 12px; }}
749
+ .subsection {{ margin-bottom: 22px; }}
750
+ .section-heading {{ display: flex; align-items: center; gap: 10px; }}
751
+ .section-heading h4 {{ margin: 0; }}
752
+ .metric-inline {{ color: var(--muted); }}
753
+ .timeline {{ border-left: 2px solid var(--border); margin-left: 8px; padding-left: 20px; }}
754
+ .event {{ position: relative; margin-bottom: 14px; border: 1px solid transparent; border-radius: 6px; padding: 14px; }}
755
+ .event::before {{ content: ""; position: absolute; left: -29px; top: 21px; width: 8px; height: 8px; background: var(--accent); border: 2px solid var(--surface); transform: rotate(45deg); }}
756
+ .event.system {{ color: var(--text); background: var(--system-bg); border-color: color-mix(in srgb, var(--system) 35%, transparent); }}
757
+ .event.system::before {{ background: var(--system); }}
758
+ .event.user {{ color: var(--text); background: var(--user-bg); border-color: color-mix(in srgb, var(--user) 35%, transparent); }}
759
+ .event.user::before {{ background: var(--user); }}
760
+ .event.assistant {{ color: var(--text); background: var(--assistant-bg); border-color: color-mix(in srgb, var(--assistant) 35%, transparent); }}
761
+ .event.assistant::before {{ background: var(--assistant); }}
762
+ .event.tool {{ color: var(--text); background: var(--tool-bg); border-color: color-mix(in srgb, var(--tool) 35%, transparent); }}
763
+ .event.tool::before {{ background: var(--tool); }}
764
+ .event.error::before {{ background: var(--fail); }}
765
+ .event-head {{ display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }}
766
+ .event-kind {{ color: var(--muted); font-family: var(--font-display); text-transform: uppercase; letter-spacing: .08em; font-size: 10px; }}
767
+ .message-content {{ white-space: pre-wrap; overflow-wrap: anywhere; background: transparent; color: var(--text); padding: 0; margin: 0; font-family: var(--font-mono); }}
768
+ .model-input h5 {{ margin-top: 12px; }}
769
+ .empty-content {{ color: var(--muted); font-style: italic; }}
770
+ .empty-content code {{ font-style: normal; }}
771
+ .model-error {{ color: var(--fail); }}
772
+ .turn-prefix-note {{ margin: -4px 0 14px; font-size: 12px; font-style: italic; }}
773
+ .tool-list {{ margin-top: 12px; }}
774
+ .tool-list > summary {{ color: var(--muted); font-size: 12px; }}
775
+ .tool-list-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 10px; }}
776
+ .tool-def {{ background: var(--surface-2); border-radius: 6px; padding: 10px; min-width: 0; }}
777
+ .tool-def-head {{ font-weight: 650; font-family: var(--font-mono); }}
778
+ .tool-def p {{ margin: 4px 0; }}
779
+ .timeline-section > summary {{ display: flex; align-items: center; gap: 10px; margin-bottom: 12px; font-weight: 650; }}
780
+ .timeline-section > summary .muted {{ margin-left: auto; font-size: 12px; }}
781
+ .timeline-section:not([open]) > summary {{ margin-bottom: 0; }}
782
+ pre {{ margin: 0; max-height: 520px; overflow: auto; border-radius: 5px; padding: 12px; background: var(--code-bg); color: var(--code); white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); }}
783
+ .table-wrap {{ overflow-x: auto; }}
784
+ table {{ width: 100%; border-collapse: collapse; }}
785
+ th, td {{ padding: 10px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; min-width: 100px; }}
786
+ th {{ color: var(--muted); font-family: var(--font-display); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }}
787
+ tr.failed td {{ background: color-mix(in srgb, var(--fail-bg) 45%, transparent); }}
788
+ .judge-grid, .fixture-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; }}
789
+ .judge-dimension, .attachment {{ background: var(--surface-2); border-radius: 6px; padding: 12px; }}
790
+ .judge-score {{ display: flex; justify-content: space-between; align-items: center; gap: 12px; }}
791
+ .judge-score-value {{ display: inline-flex; align-items: center; }}
792
+ .judge-score-value b {{ font-family: var(--font-mono); font-weight: 600; margin-left: 6px; }}
793
+ .tally {{ display: inline-flex; align-items: center; gap: 2px; }}
794
+ .tally-bar {{ width: 3px; height: 14px; border-radius: 1px; background: color-mix(in srgb, var(--muted) 30%, transparent); }}
795
+ .tally-bar.filled {{ background: var(--pass); }}
796
+ .judge-dimension p {{ margin: 8px 0 0; }}
797
+ .attachment {{ min-width: 0; }}
798
+ .image-attachment img {{ display: block; width: 100%; max-height: 260px; object-fit: contain; border-radius: 5px; background: var(--code-bg); }}
799
+ .image-attachment figcaption, .file-attachment a {{ display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; }}
800
+ .attachment-name {{ font-weight: 650; }}
801
+ .file-attachment .path {{ display: block; margin-top: 8px; font-size: 12px; overflow-wrap: anywhere; }}
802
+ .raw, .diagnostics {{ margin-top: 12px; }}
803
+ .raw > summary, .diagnostics > summary {{ color: var(--accent); }}
804
+ .raw[open] > summary {{ margin-bottom: 8px; }}
805
+ .request, .mock-event {{ padding: 10px 0; border-bottom: 1px solid var(--border); }}
806
+ .request > summary, .mock-event > summary {{ display: flex; align-items: center; gap: 8px; }}
807
+ .run-error {{ margin-top: 16px; padding: 14px; border-radius: 6px; background: var(--fail-bg); color: var(--fail); }}
808
+ .run-error pre {{ margin-top: 8px; }}
809
+ @media (prefers-reduced-motion: reduce) {{
810
+ * {{ transition: none !important; animation: none !important; }}
811
+ }}
812
+ @media (max-width: 760px) {{
813
+ main {{ width: min(100% - 20px, 1500px); margin-top: 16px; }}
814
+ .report-header, .case-header {{ display: grid; }}
815
+ .overall {{ align-items: flex-start; }}
816
+ .case-outcome {{ justify-items: start; }}
817
+ .summary-grid, .metadata, .context-grid, .split {{ grid-template-columns: 1fr; }}
818
+ .summary-card {{ border-right: 0; border-bottom: 1px solid var(--border); }}
819
+ .summary-card:last-child {{ border-bottom: 0; }}
820
+ .metadata > div {{ border-right: 0; border-bottom: 1px solid var(--border); }}
821
+ .metadata > div:last-child {{ border-bottom: 0; }}
822
+ .run > summary, .step > summary {{ align-items: flex-start; flex-wrap: wrap; }}
823
+ .summary-meta {{ width: 100%; margin-left: 0; }}
824
+ .step-body, .run-body {{ padding-left: 12px; padding-right: 12px; }}
825
+ }}
826
+ </style>
827
+ </head>
828
+ <body>
829
+ <main>
830
+ <header class="report-header">
831
+ <div><h1>Yama Eval Report</h1><p>Generated {_text(generated_at)}</p></div>
832
+ <div class="overall">{_status(overall_passed, kind="stamp", size="lg", seed="overall")}<span>Overall result</span></div>
833
+ </header>
834
+ <section class="summary-grid" aria-label="Summary">
835
+ <div class="summary-card"><span>Cases passed</span><strong>{passed_cases}/{len(results)}</strong></div>
836
+ <div class="summary-card"><span>Runs passed</span><strong>{passed_runs}/{total_runs}</strong></div>
837
+ <div class="summary-card"><span>Hard checks passed</span><strong>{passed_checks}/{len(checks)}</strong></div>
838
+ </section>
839
+ {rendered_cases or '<p>No cases were included in this report.</p>'}
840
+ </main>
841
+ </body>
842
+ </html>
843
+ """
844
+
845
+
846
+ def write_html_report(
847
+ cases: Sequence[ResolvedCase],
848
+ results: Sequence[CaseResult],
849
+ path: Path,
850
+ ) -> Path:
851
+ target = path.expanduser().resolve()
852
+ target.parent.mkdir(parents=True, exist_ok=True)
853
+ target.write_text(render_html_report(cases, results), encoding="utf-8")
854
+ return target