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.
- python_yama-0.1.0.dist-info/METADATA +98 -0
- python_yama-0.1.0.dist-info/RECORD +27 -0
- python_yama-0.1.0.dist-info/WHEEL +4 -0
- python_yama-0.1.0.dist-info/entry_points.txt +2 -0
- python_yama-0.1.0.dist-info/licenses/LICENSE +21 -0
- yama/__init__.py +31 -0
- yama/__main__.py +3 -0
- yama/assertions.py +577 -0
- yama/cli.py +318 -0
- yama/llm.py +365 -0
- yama/loaders/__init__.py +6 -0
- yama/loaders/case.py +378 -0
- yama/loaders/common.py +29 -0
- yama/loaders/tool_loader.py +173 -0
- yama/mocks.py +383 -0
- yama/models.py +159 -0
- yama/report.py +854 -0
- yama/runner.py +531 -0
- yama/tools/__init__.py +0 -0
- yama/tools/base.py +19 -0
- yama/tools/bash/__init__.py +0 -0
- yama/tools/bash/_cli_shim.py +71 -0
- yama/tools/bash/bash.py +285 -0
- yama/tools/bash/fs.py +530 -0
- yama/tools/skill/__init__.py +0 -0
- yama/tools/skill/skill.py +57 -0
- yama/workspace.py +115 -0
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
|