coder-eval 0.8.2__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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,1659 @@
|
|
|
1
|
+
"""HTML report generation for coder_eval runs.
|
|
2
|
+
|
|
3
|
+
Produces self-contained HTML files (inline CSS/JS, no external fonts or
|
|
4
|
+
images) that visualize a single task's conversation trace and success
|
|
5
|
+
criteria, plus cross-variant experiment summaries.
|
|
6
|
+
|
|
7
|
+
Designed for offline viewing and for upload as CI artifacts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html as _html
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from coder_eval.models import FinalStatus
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from coder_eval.models import (
|
|
25
|
+
CommandTelemetry,
|
|
26
|
+
CriterionResult,
|
|
27
|
+
EvaluationResult,
|
|
28
|
+
ExperimentDefinition,
|
|
29
|
+
ExperimentResult,
|
|
30
|
+
TurnRecord,
|
|
31
|
+
VariantAggregate,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Styling — fully inline; dark theme with light override via `.light` class.
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_CSS = """
|
|
42
|
+
:root {
|
|
43
|
+
--bg-page: #0f1419;
|
|
44
|
+
--bg-card: #1a2129;
|
|
45
|
+
--bg-card-2: #232d38;
|
|
46
|
+
--bg-code: #0b0f14;
|
|
47
|
+
--fg: #e6edf3;
|
|
48
|
+
--fg-muted: #8b98a5;
|
|
49
|
+
--fg-dim: #6e7681;
|
|
50
|
+
--border: #30363d;
|
|
51
|
+
--accent: #58a6ff;
|
|
52
|
+
--score-high: #22c55e;
|
|
53
|
+
--score-mid: #f59e0b;
|
|
54
|
+
--score-low: #ef4444;
|
|
55
|
+
--status-success: #22c55e;
|
|
56
|
+
--status-failure: #f59e0b;
|
|
57
|
+
--status-error: #ef4444;
|
|
58
|
+
--status-neutral: #6e7681;
|
|
59
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
60
|
+
}
|
|
61
|
+
.light {
|
|
62
|
+
--bg-page: #ffffff;
|
|
63
|
+
--bg-card: #f6f8fa;
|
|
64
|
+
--bg-card-2: #eaeef2;
|
|
65
|
+
--bg-code: #f6f8fa;
|
|
66
|
+
--fg: #1f2328;
|
|
67
|
+
--fg-muted: #636c76;
|
|
68
|
+
--fg-dim: #8b949e;
|
|
69
|
+
--border: #d0d7de;
|
|
70
|
+
--accent: #0969da;
|
|
71
|
+
}
|
|
72
|
+
* { box-sizing: border-box; }
|
|
73
|
+
html, body {
|
|
74
|
+
margin: 0;
|
|
75
|
+
padding: 0;
|
|
76
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
|
|
77
|
+
sans-serif;
|
|
78
|
+
font-size: 14px;
|
|
79
|
+
line-height: 1.55;
|
|
80
|
+
color: var(--fg);
|
|
81
|
+
background: var(--bg-page);
|
|
82
|
+
}
|
|
83
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
84
|
+
a { color: var(--accent); text-decoration: none; }
|
|
85
|
+
a:hover { text-decoration: underline; }
|
|
86
|
+
h1, h2, h3, h4 { margin: 0 0 12px 0; line-height: 1.25; }
|
|
87
|
+
h1 { font-size: 22px; font-weight: 600; }
|
|
88
|
+
h2 { font-size: 18px; font-weight: 600; margin-top: 28px;
|
|
89
|
+
padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
90
|
+
h3 { font-size: 15px; font-weight: 600; color: var(--fg-muted); }
|
|
91
|
+
h4 { font-size: 13px; font-weight: 600; color: var(--fg-muted); }
|
|
92
|
+
p { margin: 0 0 8px 0; }
|
|
93
|
+
.muted { color: var(--fg-muted); }
|
|
94
|
+
.dim { color: var(--fg-dim); }
|
|
95
|
+
.mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
|
96
|
+
monospace; font-size: 12.5px; }
|
|
97
|
+
code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
|
98
|
+
monospace; font-size: 12.5px; background: var(--bg-code);
|
|
99
|
+
border: 1px solid var(--border); padding: 1px 5px; border-radius: 3px; }
|
|
100
|
+
pre { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
|
101
|
+
monospace; font-size: 12.5px; background: var(--bg-code);
|
|
102
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px;
|
|
103
|
+
margin: 6px 0; overflow-x: auto; white-space: pre-wrap;
|
|
104
|
+
word-wrap: break-word; }
|
|
105
|
+
.header-bar { display: flex; align-items: center; justify-content: space-between;
|
|
106
|
+
margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
|
107
|
+
.title-group { flex: 1; min-width: 0; }
|
|
108
|
+
.title-group .subtitle { color: var(--fg-muted); font-size: 13px;
|
|
109
|
+
margin-top: 2px; word-break: break-all; }
|
|
110
|
+
.badges { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
111
|
+
.badge { display: inline-flex; align-items: center; gap: 6px;
|
|
112
|
+
padding: 4px 10px; border-radius: 12px; font-size: 12px;
|
|
113
|
+
font-weight: 600; background: var(--bg-card-2);
|
|
114
|
+
border: 1px solid var(--border); color: var(--fg); }
|
|
115
|
+
.badge.success { background: color-mix(in srgb, var(--status-success) 20%, var(--bg-card-2));
|
|
116
|
+
border-color: var(--status-success); color: var(--status-success); }
|
|
117
|
+
.badge.failure { background: color-mix(in srgb, var(--status-failure) 20%, var(--bg-card-2));
|
|
118
|
+
border-color: var(--status-failure); color: var(--status-failure); }
|
|
119
|
+
.badge.error { background: color-mix(in srgb, var(--status-error) 20%, var(--bg-card-2));
|
|
120
|
+
border-color: var(--status-error); color: var(--status-error); }
|
|
121
|
+
.badge.warning { background: color-mix(in srgb, #f4a52810 20%, var(--bg-card-2));
|
|
122
|
+
border-color: #f4a528; color: #f4a528; }
|
|
123
|
+
.badge.neutral { color: var(--fg-muted); }
|
|
124
|
+
.score-pill { display: inline-block; padding: 2px 8px; border-radius: 10px;
|
|
125
|
+
font-size: 12px; font-weight: 700; min-width: 42px;
|
|
126
|
+
text-align: center; color: #0b0f14; }
|
|
127
|
+
.score-high { background: var(--score-high); }
|
|
128
|
+
.score-mid { background: var(--score-mid); }
|
|
129
|
+
.score-low { background: var(--score-low); }
|
|
130
|
+
.card { background: var(--bg-card); border: 1px solid var(--border);
|
|
131
|
+
border-radius: 8px; padding: 16px 20px; margin-bottom: 16px;
|
|
132
|
+
box-shadow: var(--shadow); }
|
|
133
|
+
.card.highlight { border-left: 4px solid var(--accent); }
|
|
134
|
+
.grid { display: grid; gap: 12px;
|
|
135
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
|
136
|
+
.stat { background: var(--bg-card-2); padding: 10px 12px; border-radius: 6px;
|
|
137
|
+
border: 1px solid var(--border); }
|
|
138
|
+
.stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
139
|
+
color: var(--fg-muted); margin-bottom: 4px; }
|
|
140
|
+
.stat .value { font-size: 16px; font-weight: 600; }
|
|
141
|
+
table { width: 100%; border-collapse: collapse; margin: 4px 0 12px 0; }
|
|
142
|
+
th, td { text-align: left; padding: 8px 10px;
|
|
143
|
+
border-bottom: 1px solid var(--border); vertical-align: top;
|
|
144
|
+
font-size: 13px; }
|
|
145
|
+
th { color: var(--fg-muted); font-weight: 600; font-size: 12px;
|
|
146
|
+
text-transform: uppercase; letter-spacing: 0.3px; }
|
|
147
|
+
tr:last-child td { border-bottom: none; }
|
|
148
|
+
details { margin: 6px 0; border: 1px solid var(--border); border-radius: 6px;
|
|
149
|
+
background: var(--bg-card-2); }
|
|
150
|
+
details > summary { cursor: pointer; padding: 8px 12px; user-select: none;
|
|
151
|
+
font-weight: 500; list-style: none; }
|
|
152
|
+
details > summary::-webkit-details-marker { display: none; }
|
|
153
|
+
details > summary::before { content: "▸ "; color: var(--fg-dim);
|
|
154
|
+
display: inline-block; width: 14px;
|
|
155
|
+
transition: transform 0.15s; }
|
|
156
|
+
details[open] > summary::before { content: "▾ "; }
|
|
157
|
+
details .details-body { padding: 0 12px 10px 12px; }
|
|
158
|
+
.tool-row { display: flex; align-items: center; gap: 10px;
|
|
159
|
+
flex-wrap: wrap; font-size: 13px; }
|
|
160
|
+
.tool-name { font-weight: 600; color: var(--accent); font-family: ui-monospace,
|
|
161
|
+
SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
|
|
162
|
+
.tool-status-ok { color: var(--status-success); font-size: 11px;
|
|
163
|
+
font-weight: 600; }
|
|
164
|
+
.tool-status-err { color: var(--status-error); font-size: 11px;
|
|
165
|
+
font-weight: 600; }
|
|
166
|
+
.tool-duration { color: var(--fg-muted); font-size: 12px; }
|
|
167
|
+
.tool-seq { color: var(--fg-dim); font-size: 11px; font-weight: 600;
|
|
168
|
+
min-width: 24px; text-align: right; font-family: ui-monospace,
|
|
169
|
+
SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
|
|
170
|
+
.turn-header { display: flex; justify-content: space-between;
|
|
171
|
+
align-items: baseline; flex-wrap: wrap; gap: 10px;
|
|
172
|
+
margin-bottom: 10px; }
|
|
173
|
+
.turn-meta { font-size: 12px; color: var(--fg-muted); }
|
|
174
|
+
.criterion-row td:first-child { font-family: ui-monospace, SFMono-Regular,
|
|
175
|
+
"SF Mono", Menlo, Consolas, monospace;
|
|
176
|
+
color: var(--fg-muted); font-size: 12px; }
|
|
177
|
+
.judge-section { display: flex; flex-direction: column; gap: 12px; }
|
|
178
|
+
.judge-card { padding: 14px 16px; }
|
|
179
|
+
.judge-card-head { display: flex; justify-content: space-between;
|
|
180
|
+
align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
|
181
|
+
.judge-card-title { font-size: 14px; line-height: 1.4; flex: 1; min-width: 0; }
|
|
182
|
+
.judge-card-score { flex-shrink: 0; }
|
|
183
|
+
.judge-rationale { margin-top: 10px; padding: 10px 12px;
|
|
184
|
+
background: var(--bg-code); border-radius: 4px;
|
|
185
|
+
font-size: 13px; line-height: 1.5; }
|
|
186
|
+
.judge-findings { margin: 8px 0 0 18px; padding-left: 4px; font-size: 13px;
|
|
187
|
+
line-height: 1.6; }
|
|
188
|
+
.judge-findings li { margin: 2px 0; }
|
|
189
|
+
.nav-toggle { display: inline-block; padding: 5px 10px; font-size: 12px;
|
|
190
|
+
background: var(--bg-card-2); border: 1px solid var(--border);
|
|
191
|
+
border-radius: 5px; color: var(--fg); cursor: pointer; }
|
|
192
|
+
.nav-toggle:hover { background: var(--bg-card); }
|
|
193
|
+
.llm-next-steps { margin: 6px 0 0 20px; padding-left: 0; }
|
|
194
|
+
.llm-next-steps li { margin: 3px 0; }
|
|
195
|
+
.iteration-group { padding: 12px 16px 4px 16px; margin-bottom: 20px;
|
|
196
|
+
border-radius: 0 6px 6px 0; border-left: 4px solid var(--status-neutral); }
|
|
197
|
+
/* rgba fallback for browsers without color-mix (Chrome <111, Firefox <113, Safari <16.2). */
|
|
198
|
+
.iteration-group--recovered { border-left-color: var(--status-success);
|
|
199
|
+
background: rgba(34, 197, 94, 0.06);
|
|
200
|
+
background: color-mix(in srgb, var(--status-success) 6%, transparent); }
|
|
201
|
+
.iteration-group--terminal { border-left-color: var(--status-error);
|
|
202
|
+
background: rgba(239, 68, 68, 0.06);
|
|
203
|
+
background: color-mix(in srgb, var(--status-error) 6%, transparent); }
|
|
204
|
+
.iteration-group__banner { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; }
|
|
205
|
+
.iteration-group--recovered .iteration-group__banner { color: var(--status-success); }
|
|
206
|
+
.iteration-group--terminal .iteration-group__banner { color: var(--status-error); }
|
|
207
|
+
.attempt-transition { display: flex; align-items: center; gap: 8px;
|
|
208
|
+
margin: -6px 0 12px 8px; color: var(--status-error); font-size: 12.5px; }
|
|
209
|
+
.attempt-transition__icon { font-weight: 700; }
|
|
210
|
+
footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border);
|
|
211
|
+
color: var(--fg-dim); font-size: 12px; text-align: center; }
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_JS = """
|
|
216
|
+
function toggleTheme() {
|
|
217
|
+
document.documentElement.classList.toggle('light');
|
|
218
|
+
try {
|
|
219
|
+
const mode = document.documentElement.classList.contains('light') ? 'light' : 'dark';
|
|
220
|
+
localStorage.setItem('coder-eval-theme', mode);
|
|
221
|
+
} catch (e) { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
(function() {
|
|
224
|
+
try {
|
|
225
|
+
if (localStorage.getItem('coder-eval-theme') === 'light') {
|
|
226
|
+
document.documentElement.classList.add('light');
|
|
227
|
+
}
|
|
228
|
+
} catch (e) { /* ignore */ }
|
|
229
|
+
})();
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Formatting helpers
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_MAX_VALUE_LEN = 400
|
|
239
|
+
_MAX_RESULT_LEN = 1200
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _esc(value: Any) -> str:
|
|
243
|
+
"""HTML-escape a value after coercing to string."""
|
|
244
|
+
if value is None:
|
|
245
|
+
return ""
|
|
246
|
+
return _html.escape(str(value), quote=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
250
|
+
"""Truncate text to `limit` chars. Returns (text, was_truncated)."""
|
|
251
|
+
if text is None:
|
|
252
|
+
return "", False
|
|
253
|
+
s = str(text)
|
|
254
|
+
if len(s) <= limit:
|
|
255
|
+
return s, False
|
|
256
|
+
return s[:limit], True
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _score_class(score: float | None) -> str:
|
|
260
|
+
"""Return CSS class for a 0..1 score."""
|
|
261
|
+
if score is None:
|
|
262
|
+
return "score-mid"
|
|
263
|
+
if score >= 0.8:
|
|
264
|
+
return "score-high"
|
|
265
|
+
if score >= 0.5:
|
|
266
|
+
return "score-mid"
|
|
267
|
+
return "score-low"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _score_pill(score: float | None, suffix: str = "") -> str:
|
|
271
|
+
"""Render a colored score pill."""
|
|
272
|
+
if score is None:
|
|
273
|
+
return '<span class="score-pill score-mid">—</span>'
|
|
274
|
+
label = f"{score:.2f}{suffix}"
|
|
275
|
+
return f'<span class="score-pill {_score_class(score)}">{_esc(label)}</span>'
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _status_badge(status: Any) -> str:
|
|
279
|
+
"""Render a colored status badge for FinalStatus (dispatches on .category)."""
|
|
280
|
+
status_str = getattr(status, "value", None) or str(status)
|
|
281
|
+
try:
|
|
282
|
+
fs = status if isinstance(status, FinalStatus) else FinalStatus(str(status))
|
|
283
|
+
cls = {"succeeded": "success", "failed": "failure", "error": "error"}[fs.category]
|
|
284
|
+
except (ValueError, KeyError):
|
|
285
|
+
cls = "neutral" # unknown / non-FinalStatus input
|
|
286
|
+
return f'<span class="badge {_esc(cls)}">{_esc(status_str)}</span>'
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _format_duration(seconds: float | None) -> str:
|
|
290
|
+
if seconds is None:
|
|
291
|
+
return "—"
|
|
292
|
+
if seconds < 60:
|
|
293
|
+
return f"{seconds:.1f}s"
|
|
294
|
+
minutes = int(seconds // 60)
|
|
295
|
+
secs = seconds % 60
|
|
296
|
+
return f"{minutes}m {secs:.0f}s"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _format_ms(ms: float | None) -> str:
|
|
300
|
+
if ms is None:
|
|
301
|
+
return "—"
|
|
302
|
+
if ms < 1000:
|
|
303
|
+
return f"{ms:.0f}ms"
|
|
304
|
+
return f"{ms / 1000:.2f}s"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _format_params(params: dict[str, Any]) -> str:
|
|
308
|
+
"""Pretty-print tool parameters as JSON, truncating long values."""
|
|
309
|
+
try:
|
|
310
|
+
trimmed: dict[str, Any] = {}
|
|
311
|
+
for key, val in params.items():
|
|
312
|
+
if isinstance(val, str) and len(val) > _MAX_VALUE_LEN:
|
|
313
|
+
trimmed[key] = val[:_MAX_VALUE_LEN] + f"… ({len(val) - _MAX_VALUE_LEN} more chars)"
|
|
314
|
+
else:
|
|
315
|
+
trimmed[key] = val
|
|
316
|
+
return json.dumps(trimmed, indent=2, ensure_ascii=False, default=str)
|
|
317
|
+
except (TypeError, ValueError):
|
|
318
|
+
return repr(params)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Section renderers
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _render_header(result: EvaluationResult) -> str:
|
|
327
|
+
from .reports_stats import expected_turns_overage
|
|
328
|
+
|
|
329
|
+
started = result.started_at.isoformat(timespec="seconds") if result.started_at else "—"
|
|
330
|
+
duration = _format_duration(result.duration_seconds)
|
|
331
|
+
score_badge = _score_pill(result.weighted_score) if result.weighted_score is not None else ""
|
|
332
|
+
model = _esc(result.model_used or "—")
|
|
333
|
+
agent_type = _esc(result.agent_type)
|
|
334
|
+
subtitle = _esc(result.task_description.strip().splitlines()[0] if result.task_description else "")
|
|
335
|
+
turns_count = result.total_assistant_turns or 0
|
|
336
|
+
cost_badge = ""
|
|
337
|
+
if result.total_token_usage and result.total_token_usage.total_cost_usd is not None:
|
|
338
|
+
cost_badge = f'<span class="badge neutral">${result.total_token_usage.total_cost_usd:.4f}</span>'
|
|
339
|
+
expected_turns_badge = ""
|
|
340
|
+
overage = expected_turns_overage(result)
|
|
341
|
+
if overage is not None:
|
|
342
|
+
actual, expected = overage
|
|
343
|
+
expected_turns_badge = f'<span class="badge warning">expected_turns exceeded ({actual}/{expected})</span>'
|
|
344
|
+
return f"""
|
|
345
|
+
<div class="header-bar">
|
|
346
|
+
<div class="title-group">
|
|
347
|
+
<h1>Task: {_esc(result.task_id)}</h1>
|
|
348
|
+
<div class="subtitle">{subtitle}</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="badges">
|
|
351
|
+
{_status_badge(result.final_status)}
|
|
352
|
+
{score_badge}
|
|
353
|
+
<span class="badge neutral">{agent_type} · {model}</span>
|
|
354
|
+
<span class="badge neutral">{_esc(duration)}</span>
|
|
355
|
+
{cost_badge}
|
|
356
|
+
{expected_turns_badge}
|
|
357
|
+
<span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
<div class="card">
|
|
361
|
+
<div class="grid">
|
|
362
|
+
<div class="stat"><div class="label">Variant</div><div class="value">{_esc(result.variant_id)}</div></div>
|
|
363
|
+
<div class="stat"><div class="label">Started</div><div class="value mono">{_esc(started)}</div></div>
|
|
364
|
+
<div class="stat"><div class="label">Duration</div><div class="value">{_esc(duration)}</div></div>
|
|
365
|
+
<div class="stat"><div class="label">Turns (SDK)</div><div class="value">{turns_count}</div></div>
|
|
366
|
+
<div class="stat"><div class="label">Commands</div><div class="value">{result.actual_commands or 0}</div></div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _render_criteria_details(cr: CriterionResult) -> str:
|
|
373
|
+
"""Render the Details cell for a criterion.
|
|
374
|
+
|
|
375
|
+
Short details (no newline) render inline to keep the table scannable.
|
|
376
|
+
Multiline details (typical for `run_command` which includes command,
|
|
377
|
+
exit, stdout, and stderr) render as a collapsible `<details>` block
|
|
378
|
+
whose summary shows just the first line — the user expands to see the
|
|
379
|
+
full captured output.
|
|
380
|
+
|
|
381
|
+
Judge criteria render their findings + transcript in a dedicated
|
|
382
|
+
"Judge Verdicts" section below the criteria table; this cell stays
|
|
383
|
+
compact and just shows the score=...\\nrationale: ... summary.
|
|
384
|
+
"""
|
|
385
|
+
err = _esc(cr.error) if cr.error else ""
|
|
386
|
+
details_raw = (cr.details or "").strip()
|
|
387
|
+
details_txt = _esc(details_raw)
|
|
388
|
+
|
|
389
|
+
if not details_raw:
|
|
390
|
+
return err or '<span class="dim">—</span>'
|
|
391
|
+
|
|
392
|
+
lines = details_raw.splitlines()
|
|
393
|
+
if len(lines) <= 1:
|
|
394
|
+
body = err + ("<br/>" if err and details_txt else "") + details_txt
|
|
395
|
+
return f'<div class="dim mono" style="white-space:pre-wrap">{body}</div>'
|
|
396
|
+
|
|
397
|
+
summary = _esc(lines[0])
|
|
398
|
+
full_pre = f'<pre style="margin:6px 0 0 0">{details_txt}</pre>'
|
|
399
|
+
prefix = err + "<br/>" if err else ""
|
|
400
|
+
return (
|
|
401
|
+
f"{prefix}"
|
|
402
|
+
f'<details><summary class="mono dim">{summary}</summary>'
|
|
403
|
+
f'<div class="details-body">{full_pre}</div></details>'
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _render_judge_section(criteria: list[CriterionResult]) -> str:
|
|
408
|
+
"""Render the dedicated 'Judge Verdicts' section containing per-judge cards.
|
|
409
|
+
|
|
410
|
+
Each judge result that carries verdict evidence (findings, transcript) gets
|
|
411
|
+
a card with: type + description, score badge, rationale extracted from the
|
|
412
|
+
details string, an expanded-by-default Findings list, and a collapsed
|
|
413
|
+
Judge transcript disclosure.
|
|
414
|
+
|
|
415
|
+
Returns an empty string when there's no judge result with anything to show
|
|
416
|
+
— non-judge tasks render no Judge Verdicts heading.
|
|
417
|
+
"""
|
|
418
|
+
cards: list[str] = []
|
|
419
|
+
for cr in criteria:
|
|
420
|
+
# criterion_type identifies judges; details/findings/transcript may all
|
|
421
|
+
# be present (typed JudgeCriterionResult) or in model_extra (round-tripped).
|
|
422
|
+
if cr.criterion_type not in ("llm_judge", "agent_judge"):
|
|
423
|
+
continue
|
|
424
|
+
findings_raw = getattr(cr, "findings", []) or []
|
|
425
|
+
findings = [str(f).strip() for f in findings_raw if str(f).strip()]
|
|
426
|
+
transcript = getattr(cr, "transcript", None)
|
|
427
|
+
if not findings and transcript in (None, {}, []):
|
|
428
|
+
continue
|
|
429
|
+
cards.append(_render_judge_card(cr, findings, transcript))
|
|
430
|
+
|
|
431
|
+
if not cards:
|
|
432
|
+
return ""
|
|
433
|
+
|
|
434
|
+
return f'<h2>Judge Verdicts ({len(cards)})</h2><div class="judge-section">' + "".join(cards) + "</div>"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _extract_rationale(details: str | None) -> str:
|
|
438
|
+
"""Pull the rationale line out of the standard format_details payload.
|
|
439
|
+
|
|
440
|
+
``format_details`` writes ``score=0.750\\nrationale: ...\\nnotes: ...``.
|
|
441
|
+
Reach into the second line and strip the ``rationale: `` prefix.
|
|
442
|
+
Returns an empty string when the details aren't in this shape.
|
|
443
|
+
"""
|
|
444
|
+
if not details:
|
|
445
|
+
return ""
|
|
446
|
+
for line in details.splitlines():
|
|
447
|
+
prefix = "rationale: "
|
|
448
|
+
if line.startswith(prefix):
|
|
449
|
+
return line[len(prefix) :].strip()
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _render_judge_card(cr: CriterionResult, findings: list[str], transcript: Any) -> str:
|
|
454
|
+
"""Render one judge result as a card (type + score + rationale + findings + transcript)."""
|
|
455
|
+
rationale = _extract_rationale(cr.details)
|
|
456
|
+
rationale_html = f'<div class="judge-rationale">{_esc(rationale)}</div>' if rationale else ""
|
|
457
|
+
|
|
458
|
+
findings_html = ""
|
|
459
|
+
if findings:
|
|
460
|
+
items = "".join(f"<li>{_esc(f)}</li>" for f in findings)
|
|
461
|
+
# Findings open by default — they're the audit highlight reviewers came for.
|
|
462
|
+
findings_html = (
|
|
463
|
+
f'<details open style="margin-top:8px">'
|
|
464
|
+
f'<summary class="mono dim">Findings ({len(findings)})</summary>'
|
|
465
|
+
f'<ul class="judge-findings">{items}</ul>'
|
|
466
|
+
f"</details>"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
transcript_html = _render_judge_transcript(transcript)
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
'<div class="card judge-card">'
|
|
473
|
+
+ '<div class="judge-card-head">'
|
|
474
|
+
+ f'<div class="judge-card-title"><span class="mono dim">{_esc(cr.criterion_type)}</span> '
|
|
475
|
+
+ f"· {_esc(cr.description)}</div>"
|
|
476
|
+
+ f'<div class="judge-card-score">{_score_pill(cr.score)}</div>'
|
|
477
|
+
+ "</div>"
|
|
478
|
+
+ rationale_html
|
|
479
|
+
+ findings_html
|
|
480
|
+
+ transcript_html
|
|
481
|
+
+ "</div>"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _render_judge_transcript(transcript: Any) -> str:
|
|
486
|
+
"""Render a JudgeTranscript (or its dict round-trip form) as a disclosure block."""
|
|
487
|
+
if transcript is None:
|
|
488
|
+
return ""
|
|
489
|
+
# Normalize to a plain dict so dict-form (round-tripped) and model-form work the same.
|
|
490
|
+
if hasattr(transcript, "model_dump"):
|
|
491
|
+
data = transcript.model_dump()
|
|
492
|
+
elif isinstance(transcript, dict):
|
|
493
|
+
data = transcript
|
|
494
|
+
else:
|
|
495
|
+
return ""
|
|
496
|
+
|
|
497
|
+
tool_calls = data.get("tool_calls") or []
|
|
498
|
+
raw_verdict = (data.get("raw_verdict") or "").strip()
|
|
499
|
+
duration = data.get("duration_seconds") or 0.0
|
|
500
|
+
truncated = bool(data.get("truncated"))
|
|
501
|
+
token_usage = data.get("token_usage")
|
|
502
|
+
|
|
503
|
+
if not tool_calls and not raw_verdict and not token_usage:
|
|
504
|
+
return ""
|
|
505
|
+
|
|
506
|
+
rows: list[str] = []
|
|
507
|
+
for tc in tool_calls:
|
|
508
|
+
if isinstance(tc, dict):
|
|
509
|
+
tool = _esc(str(tc.get("tool_name", "")))
|
|
510
|
+
detail = _esc(str(tc.get("detail", "")))
|
|
511
|
+
status = _esc(str(tc.get("status", "")))
|
|
512
|
+
preview = _esc(str(tc.get("result_preview", "")))
|
|
513
|
+
else:
|
|
514
|
+
tool = _esc(getattr(tc, "tool_name", ""))
|
|
515
|
+
detail = _esc(getattr(tc, "detail", ""))
|
|
516
|
+
status = _esc(getattr(tc, "status", ""))
|
|
517
|
+
preview = _esc(getattr(tc, "result_preview", ""))
|
|
518
|
+
rows.append(
|
|
519
|
+
f"<tr><td class='mono'>{tool}</td><td class='mono dim'>{status}</td>"
|
|
520
|
+
+ f"<td class='mono' style='white-space:pre-wrap'>{detail}</td>"
|
|
521
|
+
+ f"<td class='dim' style='white-space:pre-wrap'>{preview}</td></tr>"
|
|
522
|
+
)
|
|
523
|
+
table = ""
|
|
524
|
+
if rows:
|
|
525
|
+
table = (
|
|
526
|
+
"<table style='margin-top:6px;font-size:0.9em'>"
|
|
527
|
+
"<thead><tr><th>Tool</th><th>Status</th><th>Detail</th><th>Result</th></tr></thead>"
|
|
528
|
+
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
raw_block = ""
|
|
532
|
+
if raw_verdict:
|
|
533
|
+
raw_block = (
|
|
534
|
+
f'<details style="margin-top:6px"><summary class="mono dim">Raw verdict</summary>'
|
|
535
|
+
f'<pre style="margin:6px 0 0 0;white-space:pre-wrap">{_esc(raw_verdict)}</pre></details>'
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
meta_bits: list[str] = []
|
|
539
|
+
if duration:
|
|
540
|
+
meta_bits.append(f"duration: {float(duration):.1f}s")
|
|
541
|
+
if isinstance(token_usage, dict):
|
|
542
|
+
in_tok = token_usage.get("input_tokens")
|
|
543
|
+
out_tok = token_usage.get("output_tokens")
|
|
544
|
+
if in_tok is not None or out_tok is not None:
|
|
545
|
+
meta_bits.append(f"tokens: in={in_tok or 0}, out={out_tok or 0}")
|
|
546
|
+
if truncated:
|
|
547
|
+
meta_bits.append("truncated")
|
|
548
|
+
meta = f'<div class="dim mono" style="margin-top:4px">{_esc(" • ".join(meta_bits))}</div>' if meta_bits else ""
|
|
549
|
+
|
|
550
|
+
summary_label = f"Judge transcript ({len(tool_calls)} tool calls)" if tool_calls else "Judge transcript"
|
|
551
|
+
return (
|
|
552
|
+
f'<details style="margin-top:4px"><summary class="mono dim">{_esc(summary_label)}</summary>'
|
|
553
|
+
f'<div class="details-body">{meta}{table}{raw_block}</div></details>'
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _render_criteria(results: list[CriterionResult]) -> str:
|
|
558
|
+
if not results:
|
|
559
|
+
return """
|
|
560
|
+
<div class="card">
|
|
561
|
+
<h2 style="margin-top:0;border:none;padding:0">Success Criteria</h2>
|
|
562
|
+
<p class="muted">No criteria were evaluated (task may have errored before
|
|
563
|
+
the checker ran).</p>
|
|
564
|
+
</div>
|
|
565
|
+
"""
|
|
566
|
+
rows: list[str] = []
|
|
567
|
+
for cr in results:
|
|
568
|
+
rows.append(
|
|
569
|
+
f"""
|
|
570
|
+
<tr class="criterion-row">
|
|
571
|
+
<td>{_esc(cr.criterion_type)}</td>
|
|
572
|
+
<td>{_esc(cr.description)}</td>
|
|
573
|
+
<td style="text-align:center">{_score_pill(cr.score)}</td>
|
|
574
|
+
<td>{_render_criteria_details(cr)}</td>
|
|
575
|
+
</tr>
|
|
576
|
+
"""
|
|
577
|
+
)
|
|
578
|
+
passed = sum(1 for r in results if r.score >= r.pass_threshold)
|
|
579
|
+
total = len(results)
|
|
580
|
+
return f"""
|
|
581
|
+
<h2>Success Criteria <span class="muted" style="font-weight:400">({passed}/{total} passed)</span></h2>
|
|
582
|
+
<div class="card" style="padding:0">
|
|
583
|
+
<table>
|
|
584
|
+
<thead>
|
|
585
|
+
<tr>
|
|
586
|
+
<th style="width:15%">Type</th>
|
|
587
|
+
<th style="width:35%">Description</th>
|
|
588
|
+
<th style="width:10%">Score</th>
|
|
589
|
+
<th>Details</th>
|
|
590
|
+
</tr>
|
|
591
|
+
</thead>
|
|
592
|
+
<tbody>
|
|
593
|
+
{"".join(rows)}
|
|
594
|
+
</tbody>
|
|
595
|
+
</table>
|
|
596
|
+
</div>
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _render_command(cmd: CommandTelemetry) -> str:
|
|
601
|
+
status_cls = "tool-status-ok" if cmd.result_status == "success" else "tool-status-err"
|
|
602
|
+
status_label = (cmd.result_status or "unknown").upper()
|
|
603
|
+
params_pretty = _format_params(cmd.parameters or {})
|
|
604
|
+
params_pretty_trunc, _ = _truncate(params_pretty, 4000)
|
|
605
|
+
result_text = cmd.result_summary or ""
|
|
606
|
+
result_trunc, was_trunc = _truncate(result_text, _MAX_RESULT_LEN)
|
|
607
|
+
if was_trunc:
|
|
608
|
+
result_trunc += f"\n\n… (truncated, {len(result_text) - _MAX_RESULT_LEN} more chars)"
|
|
609
|
+
err_block = ""
|
|
610
|
+
if cmd.error_message:
|
|
611
|
+
err_trunc, _ = _truncate(cmd.error_message, 2000)
|
|
612
|
+
err_block = f"""<h4>Error</h4><pre>{_esc(err_trunc)}</pre>"""
|
|
613
|
+
return f"""
|
|
614
|
+
<details>
|
|
615
|
+
<summary>
|
|
616
|
+
<span class="tool-row">
|
|
617
|
+
<span class="tool-seq">#{cmd.sequence_number}</span>
|
|
618
|
+
<span class="tool-name">{_esc(cmd.tool_name)}</span>
|
|
619
|
+
<span class="{status_cls}">{_esc(status_label)}</span>
|
|
620
|
+
<span class="tool-duration">{_esc(_format_ms(cmd.duration_ms))}</span>
|
|
621
|
+
</span>
|
|
622
|
+
</summary>
|
|
623
|
+
<div class="details-body">
|
|
624
|
+
<h4>Parameters</h4>
|
|
625
|
+
<pre>{_esc(params_pretty_trunc)}</pre>
|
|
626
|
+
<h4>Result</h4>
|
|
627
|
+
<pre>{_esc(result_trunc) if result_trunc else '<span class="dim">(no summary)</span>'}</pre>
|
|
628
|
+
{err_block}
|
|
629
|
+
</div>
|
|
630
|
+
</details>
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _group_turns_by_iteration(
|
|
635
|
+
turns: list[TurnRecord],
|
|
636
|
+
) -> list[tuple[int, list[TurnRecord]]]:
|
|
637
|
+
"""Group consecutive TurnRecords by iteration as ``(iteration, group)`` tuples for the renderer."""
|
|
638
|
+
from .reports import group_consecutive_by_iteration
|
|
639
|
+
|
|
640
|
+
groups = group_consecutive_by_iteration(turns, lambda t: t.iteration)
|
|
641
|
+
return [(group[0].iteration, group) for group in groups]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _render_iteration_group(iteration: int, group: list[TurnRecord]) -> str:
|
|
645
|
+
"""Render an iteration's turns; multi-attempt groups get a coloured wrapper + banner."""
|
|
646
|
+
if len(group) == 1:
|
|
647
|
+
return _render_turn(group[0])
|
|
648
|
+
|
|
649
|
+
n = len(group)
|
|
650
|
+
crashed_count = sum(1 for t in group if t.crashed)
|
|
651
|
+
recovered = any(not t.crashed for t in group)
|
|
652
|
+
if recovered:
|
|
653
|
+
plural = "s" if crashed_count != 1 else ""
|
|
654
|
+
banner = f"Iteration {iteration} — recovered after {crashed_count} crashed attempt{plural}"
|
|
655
|
+
modifier = "iteration-group--recovered"
|
|
656
|
+
else:
|
|
657
|
+
banner = f"Iteration {iteration} — terminal failure ({n} crashed attempts)"
|
|
658
|
+
modifier = "iteration-group--terminal"
|
|
659
|
+
|
|
660
|
+
parts: list[str] = []
|
|
661
|
+
for i, t in enumerate(group):
|
|
662
|
+
parts.append(_render_turn(t, attempt_index=i + 1, attempt_total=n, recovered=(not t.crashed and recovered)))
|
|
663
|
+
# Marker between a crashed attempt and the next; suppressed on terminal (no following card).
|
|
664
|
+
if t.crashed and i + 1 < len(group):
|
|
665
|
+
reason = t.crash_reason or "Agent crashed"
|
|
666
|
+
parts.append(_render_attempt_transition(reason))
|
|
667
|
+
return f"""
|
|
668
|
+
<div class="iteration-group {modifier}">
|
|
669
|
+
<h3 class="iteration-group__banner">{_esc(banner)}</h3>
|
|
670
|
+
{"".join(parts)}
|
|
671
|
+
</div>
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _render_attempt_transition(reason: str) -> str:
|
|
676
|
+
"""Render a small inline marker between two attempts of the same iteration."""
|
|
677
|
+
return f"""
|
|
678
|
+
<div class="attempt-transition">
|
|
679
|
+
<span class="attempt-transition__icon">⚠</span>
|
|
680
|
+
<span><strong>{_esc(reason)}</strong> · resuming ↓</span>
|
|
681
|
+
</div>
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _render_turn(
|
|
686
|
+
turn: TurnRecord,
|
|
687
|
+
*,
|
|
688
|
+
attempt_index: int | None = None,
|
|
689
|
+
attempt_total: int | None = None,
|
|
690
|
+
recovered: bool = False,
|
|
691
|
+
) -> str:
|
|
692
|
+
prompt_trunc, _ = _truncate(turn.user_input or "", 2000)
|
|
693
|
+
response_trunc, _ = _truncate(turn.agent_output or "", 4000)
|
|
694
|
+
cmds_html = "".join(_render_command(c) for c in (turn.commands or []))
|
|
695
|
+
if not cmds_html:
|
|
696
|
+
cmds_html = '<p class="muted">No tool calls recorded for this turn.</p>'
|
|
697
|
+
tokens_label = ""
|
|
698
|
+
if turn.token_usage:
|
|
699
|
+
tu = turn.token_usage
|
|
700
|
+
tokens_label = (
|
|
701
|
+
f'<span class="badge neutral">'
|
|
702
|
+
f"in {tu.uncached_input_tokens} · out {tu.output_tokens}"
|
|
703
|
+
f" · cache {tu.cache_read_input_tokens}"
|
|
704
|
+
f"</span>"
|
|
705
|
+
)
|
|
706
|
+
duration_label = f'<span class="badge neutral">{_esc(_format_duration(turn.duration_seconds))}</span>'
|
|
707
|
+
exhausted = '<span class="badge failure">max_turns exhausted</span>' if turn.max_turns_exhausted else ""
|
|
708
|
+
response_block = (
|
|
709
|
+
f"""
|
|
710
|
+
<details>
|
|
711
|
+
<summary>Agent response</summary>
|
|
712
|
+
<div class="details-body"><pre>{_esc(response_trunc)}</pre></div>
|
|
713
|
+
</details>"""
|
|
714
|
+
if turn.agent_output
|
|
715
|
+
else ""
|
|
716
|
+
)
|
|
717
|
+
crashed = '<span class="badge error">crashed (partial)</span>' if turn.crashed else ""
|
|
718
|
+
recovered_badge = '<span class="badge success">recovered</span>' if recovered and not turn.crashed else ""
|
|
719
|
+
if attempt_total is not None and attempt_total > 1 and attempt_index is not None:
|
|
720
|
+
# Iteration number lives on the group banner; card heading is just the attempt.
|
|
721
|
+
heading = f"Attempt {attempt_index} of {attempt_total}"
|
|
722
|
+
else:
|
|
723
|
+
heading = f"Iteration {turn.iteration}"
|
|
724
|
+
return f"""
|
|
725
|
+
<div class="card">
|
|
726
|
+
<div class="turn-header">
|
|
727
|
+
<h3>{_esc(heading)}</h3>
|
|
728
|
+
<div class="badges">
|
|
729
|
+
<span class="badge neutral">{turn.assistant_turn_count} assistant turns</span>
|
|
730
|
+
{tokens_label}
|
|
731
|
+
{duration_label}
|
|
732
|
+
{exhausted}
|
|
733
|
+
{crashed}
|
|
734
|
+
{recovered_badge}
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
<details>
|
|
738
|
+
<summary>Prompt to agent</summary>
|
|
739
|
+
<div class="details-body"><pre>{_esc(prompt_trunc)}</pre></div>
|
|
740
|
+
</details>{response_block}
|
|
741
|
+
<h4 style="margin-top:12px">Tool Calls ({len(turn.commands or [])})</h4>
|
|
742
|
+
{cmds_html}
|
|
743
|
+
</div>
|
|
744
|
+
"""
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _render_command_stats(stats: Any | None) -> str:
|
|
748
|
+
if stats is None:
|
|
749
|
+
return ""
|
|
750
|
+
rows: list[str] = []
|
|
751
|
+
for tool, count in sorted((stats.commands_by_tool or {}).items(), key=lambda x: x[1], reverse=True):
|
|
752
|
+
rows.append(f"<tr><td class='mono'>{_esc(tool)}</td><td>{count}</td></tr>")
|
|
753
|
+
rows_html = "".join(rows) or "<tr><td colspan='2' class='muted'>No commands</td></tr>"
|
|
754
|
+
from .reports import SLOW_PARAMS_PREVIEW_CHARS
|
|
755
|
+
|
|
756
|
+
slow_rows_list: list[str] = []
|
|
757
|
+
for c in stats.slowest_commands or []:
|
|
758
|
+
params_full = str(c.parameters)
|
|
759
|
+
params_preview = params_full[:SLOW_PARAMS_PREVIEW_CHARS]
|
|
760
|
+
if len(params_full) > SLOW_PARAMS_PREVIEW_CHARS:
|
|
761
|
+
params_preview += "..."
|
|
762
|
+
slow_rows_list.append(
|
|
763
|
+
f"<tr><td class='mono'>{_esc(c.tool)}</td><td>{_esc(_format_ms(c.duration_ms))}</td>"
|
|
764
|
+
+ f"<td class='mono dim'>{_esc(params_preview)}</td></tr>"
|
|
765
|
+
)
|
|
766
|
+
slow_rows = "".join(slow_rows_list) or "<tr><td colspan='3' class='muted'>—</td></tr>"
|
|
767
|
+
success_pct = (stats.successful_commands / stats.total_commands * 100) if stats.total_commands else 0.0
|
|
768
|
+
successful_str = f"{stats.successful_commands} ({success_pct:.0f}%)"
|
|
769
|
+
avg_str = _esc(_format_ms(stats.avg_command_time_ms))
|
|
770
|
+
|
|
771
|
+
extras: list[str] = []
|
|
772
|
+
if stats.most_common_sequence:
|
|
773
|
+
extras.append(f"<p><strong>Most Common Pattern:</strong> <code>{_esc(stats.most_common_sequence)}</code></p>")
|
|
774
|
+
skill_count = (stats.commands_by_tool or {}).get("Skill", 0)
|
|
775
|
+
if skill_count > 0:
|
|
776
|
+
extras.append(f"<p><strong>Skill Tool Invoked:</strong> {skill_count} time(s)</p>")
|
|
777
|
+
extras_html = "".join(extras)
|
|
778
|
+
|
|
779
|
+
return f"""
|
|
780
|
+
<h2>Command Telemetry</h2>
|
|
781
|
+
<div class="card">
|
|
782
|
+
<div class="grid">
|
|
783
|
+
<div class="stat"><div class="label">Total</div><div class="value">{stats.total_commands}</div></div>
|
|
784
|
+
<div class="stat"><div class="label">Successful</div><div class="value">{successful_str}</div></div>
|
|
785
|
+
<div class="stat"><div class="label">Failed</div><div class="value">{stats.failed_commands}</div></div>
|
|
786
|
+
<div class="stat"><div class="label">Avg / cmd</div><div class="value">{avg_str}</div></div>
|
|
787
|
+
</div>
|
|
788
|
+
<div style="display:grid;grid-template-columns:1fr 2fr;gap:16px;margin-top:12px">
|
|
789
|
+
<div>
|
|
790
|
+
<h4>By Tool</h4>
|
|
791
|
+
<table>
|
|
792
|
+
<thead><tr><th>Tool</th><th>Count</th></tr></thead>
|
|
793
|
+
<tbody>{rows_html}</tbody>
|
|
794
|
+
</table>
|
|
795
|
+
</div>
|
|
796
|
+
<div>
|
|
797
|
+
<h4>Slowest</h4>
|
|
798
|
+
<table>
|
|
799
|
+
<thead><tr><th>Tool</th><th>Duration</th><th>Parameters</th></tr></thead>
|
|
800
|
+
<tbody>{slow_rows}</tbody>
|
|
801
|
+
</table>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
{extras_html}
|
|
805
|
+
</div>
|
|
806
|
+
"""
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _render_error_details(result: EvaluationResult) -> str:
|
|
810
|
+
if not result.error_message and not result.error_details:
|
|
811
|
+
return ""
|
|
812
|
+
details = result.error_details or {}
|
|
813
|
+
category = details.get("error_category", "unknown") if isinstance(details, dict) else ""
|
|
814
|
+
component = details.get("component", "") if isinstance(details, dict) else ""
|
|
815
|
+
retryable = details.get("is_retryable") if isinstance(details, dict) else None
|
|
816
|
+
retry_badge = ""
|
|
817
|
+
if retryable is not None:
|
|
818
|
+
retry_badge = (
|
|
819
|
+
'<span class="badge neutral">retryable</span>'
|
|
820
|
+
if retryable
|
|
821
|
+
else '<span class="badge neutral">non-retryable</span>'
|
|
822
|
+
)
|
|
823
|
+
# Prefer the in-result tail captured at run time (sanitised, bounded). Fall
|
|
824
|
+
# back to the legacy stack_trace from error_details so reports regenerated
|
|
825
|
+
# against archived runs (pre-error_log_tail) still surface diagnostics.
|
|
826
|
+
log_text = result.error_log_tail or ""
|
|
827
|
+
if not log_text and isinstance(details, dict):
|
|
828
|
+
stack = details.get("stack_trace")
|
|
829
|
+
if stack:
|
|
830
|
+
log_text = str(stack)
|
|
831
|
+
logs_html = ""
|
|
832
|
+
if log_text:
|
|
833
|
+
logs_body = f'<div class="details-body"><pre>{_esc(log_text)}</pre></div>'
|
|
834
|
+
logs_html = f"<details><summary>Logs</summary>{logs_body}</details>"
|
|
835
|
+
component_html = f'<span class="badge neutral">{_esc(component)}</span>' if component else ""
|
|
836
|
+
return f"""
|
|
837
|
+
<h2>Error</h2>
|
|
838
|
+
<div class="card" style="border-left:4px solid var(--status-error)">
|
|
839
|
+
<div class="badges" style="margin-bottom:10px">
|
|
840
|
+
<span class="badge error">{_esc(category)}</span>
|
|
841
|
+
{component_html}
|
|
842
|
+
{retry_badge}
|
|
843
|
+
</div>
|
|
844
|
+
<h4>Message</h4>
|
|
845
|
+
<pre>{_esc(result.error_message or "(no message)")}</pre>
|
|
846
|
+
{logs_html}
|
|
847
|
+
</div>
|
|
848
|
+
"""
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _render_token_usage(result: EvaluationResult) -> str:
|
|
852
|
+
"""Render Token Usage section — totals + cost."""
|
|
853
|
+
tu = result.total_token_usage
|
|
854
|
+
if tu is None:
|
|
855
|
+
return ""
|
|
856
|
+
total = tu.total_tokens
|
|
857
|
+
cost_str = f"${tu.total_cost_usd:.4f}" if tu.total_cost_usd is not None else "N/A"
|
|
858
|
+
uncached_fmt = f"{tu.uncached_input_tokens:,}"
|
|
859
|
+
cache_write_fmt = f"{tu.cache_creation_input_tokens:,}"
|
|
860
|
+
cache_read_fmt = f"{tu.cache_read_input_tokens:,}"
|
|
861
|
+
return f"""
|
|
862
|
+
<h2>Token Usage</h2>
|
|
863
|
+
<div class="card">
|
|
864
|
+
<div class="grid">
|
|
865
|
+
<div class="stat"><div class="label">Input (uncached)</div><div class="value">{uncached_fmt}</div></div>
|
|
866
|
+
<div class="stat"><div class="label">Output</div><div class="value">{tu.output_tokens:,}</div></div>
|
|
867
|
+
<div class="stat"><div class="label">Cache Write</div><div class="value">{cache_write_fmt}</div></div>
|
|
868
|
+
<div class="stat"><div class="label">Cache Read</div><div class="value">{cache_read_fmt}</div></div>
|
|
869
|
+
<div class="stat"><div class="label">Total</div><div class="value">{total:,}</div></div>
|
|
870
|
+
<div class="stat"><div class="label">Cost</div><div class="value">{_esc(cost_str)}</div></div>
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
"""
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _render_generation_metrics(result: EvaluationResult) -> str:
|
|
877
|
+
"""Render Generation Metrics — latency, turns."""
|
|
878
|
+
from .reports import count_partials_by_outcome, group_consecutive_by_iteration
|
|
879
|
+
|
|
880
|
+
turns = result.iterations or []
|
|
881
|
+
num_turns = len(turns)
|
|
882
|
+
asst_turns = result.total_assistant_turns or 0
|
|
883
|
+
avg_turn = (sum(t.duration_seconds for t in turns) / num_turns) if num_turns else 0.0
|
|
884
|
+
total_latency = _esc(_format_duration(result.duration_seconds))
|
|
885
|
+
avg_latency = _esc(_format_duration(avg_turn))
|
|
886
|
+
crashed_stat = ""
|
|
887
|
+
groups = group_consecutive_by_iteration(turns, lambda t: t.iteration)
|
|
888
|
+
total_partials, recovered_partials, terminal_partials = count_partials_by_outcome(groups, lambda t: t.crashed)
|
|
889
|
+
if total_partials:
|
|
890
|
+
breakdown = f"{total_partials} ({recovered_partials} recovered, {terminal_partials} terminal)"
|
|
891
|
+
crashed_stat = (
|
|
892
|
+
f'<div class="stat"><div class="label">Crashed Partials</div>'
|
|
893
|
+
f'<div class="value">{_esc(breakdown)}</div></div>'
|
|
894
|
+
)
|
|
895
|
+
return f"""
|
|
896
|
+
<h2>Generation Metrics</h2>
|
|
897
|
+
<div class="card">
|
|
898
|
+
<div class="grid">
|
|
899
|
+
<div class="stat"><div class="label">Total Latency</div><div class="value">{total_latency}</div></div>
|
|
900
|
+
<div class="stat"><div class="label">Turns</div><div class="value">{num_turns}</div></div>
|
|
901
|
+
<div class="stat"><div class="label">Assistant Turns</div><div class="value">{asst_turns}</div></div>
|
|
902
|
+
<div class="stat"><div class="label">Avg Turn Latency</div><div class="value">{avg_latency}</div></div>
|
|
903
|
+
{crashed_stat}
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
"""
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _render_commands_efficiency(result: EvaluationResult) -> str:
|
|
910
|
+
"""Render Commands Efficiency card. Empty when data missing."""
|
|
911
|
+
if result.commands_efficiency is None or result.expected_commands is None or result.actual_commands is None:
|
|
912
|
+
return ""
|
|
913
|
+
pct = result.commands_efficiency * 100
|
|
914
|
+
ratio = f"{result.expected_commands}/{result.actual_commands}"
|
|
915
|
+
return f"""
|
|
916
|
+
<h2>Commands Efficiency</h2>
|
|
917
|
+
<div class="card">
|
|
918
|
+
<div class="grid">
|
|
919
|
+
<div class="stat"><div class="label">Efficiency</div><div class="value">{pct:.1f}%</div></div>
|
|
920
|
+
<div class="stat"><div class="label">Expected / Actual</div><div class="value">{ratio}</div></div>
|
|
921
|
+
</div>
|
|
922
|
+
</div>
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _render_agent_settings(result: EvaluationResult) -> str:
|
|
927
|
+
"""Render Agent Settings section. Prefers sdk_options, falls back to agent_config."""
|
|
928
|
+
from .reports import collect_agent_settings_rows
|
|
929
|
+
|
|
930
|
+
if result.sdk_options:
|
|
931
|
+
settings: dict[str, Any] = result.sdk_options
|
|
932
|
+
is_sdk = True
|
|
933
|
+
elif result.agent_config is not None:
|
|
934
|
+
settings = result.agent_config.model_dump(warnings=False)
|
|
935
|
+
is_sdk = False
|
|
936
|
+
else:
|
|
937
|
+
return ""
|
|
938
|
+
|
|
939
|
+
rows = collect_agent_settings_rows(settings, is_sdk)
|
|
940
|
+
body = "".join(f"<tr><td class='mono dim'>{_esc(label)}</td><td>{_esc(value)}</td></tr>" for label, value in rows)
|
|
941
|
+
return f"""
|
|
942
|
+
<h2>Agent Settings</h2>
|
|
943
|
+
<div class="card" style="padding:0">
|
|
944
|
+
<table>
|
|
945
|
+
<tbody>{body}</tbody>
|
|
946
|
+
</table>
|
|
947
|
+
</div>
|
|
948
|
+
"""
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _render_installed_tools(result: EvaluationResult) -> str:
|
|
952
|
+
"""Render Installed Tools section. Empty when absent."""
|
|
953
|
+
tools = result.environment_info.get("installed_tools") if result.environment_info else None
|
|
954
|
+
if not tools or not isinstance(tools, dict):
|
|
955
|
+
return ""
|
|
956
|
+
rows = "".join(
|
|
957
|
+
f"<tr><td class='mono'>{_esc(name)}</td><td class='mono'>{_esc(ver)}</td></tr>"
|
|
958
|
+
for name, ver in sorted(tools.items())
|
|
959
|
+
)
|
|
960
|
+
return f"""
|
|
961
|
+
<h2>Installed Tools</h2>
|
|
962
|
+
<div class="card" style="padding:0">
|
|
963
|
+
<table>
|
|
964
|
+
<thead><tr><th>Tool</th><th>Version</th></tr></thead>
|
|
965
|
+
<tbody>{rows}</tbody>
|
|
966
|
+
</table>
|
|
967
|
+
</div>
|
|
968
|
+
"""
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
_SIMULATION_STOP_REASON_LABELS = {
|
|
972
|
+
"criteria_passed": ("success", "criteria passed"),
|
|
973
|
+
"stop_token": ("neutral", "simulator ended dialog"),
|
|
974
|
+
"max_turns": ("failure", "turn cap reached"),
|
|
975
|
+
"budget": ("failure", "token budget exhausted"),
|
|
976
|
+
"error": ("failure", "simulator error"),
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _render_simulation(result: EvaluationResult) -> str:
|
|
981
|
+
"""Render the Simulation section (only when simulation telemetry is present)."""
|
|
982
|
+
sim = result.simulation
|
|
983
|
+
if sim is None:
|
|
984
|
+
return ""
|
|
985
|
+
badge_class, label = _SIMULATION_STOP_REASON_LABELS.get(sim.stop_reason, ("neutral", sim.stop_reason))
|
|
986
|
+
trial_line = (
|
|
987
|
+
f"<tr><td class='mono dim'>Trial</td><td>{sim.replicate_index + 1} of {sim.n_trials}</td></tr>"
|
|
988
|
+
if sim.n_trials > 1
|
|
989
|
+
else ""
|
|
990
|
+
)
|
|
991
|
+
failure_line = (
|
|
992
|
+
f"<tr><td class='mono dim'>Simulator failures</td><td>{sim.simulator_failures}</td></tr>"
|
|
993
|
+
if sim.simulator_failures > 0
|
|
994
|
+
else ""
|
|
995
|
+
)
|
|
996
|
+
return f"""
|
|
997
|
+
<h2>Simulation</h2>
|
|
998
|
+
<div class="card" style="padding:0">
|
|
999
|
+
<table>
|
|
1000
|
+
<tbody>
|
|
1001
|
+
<tr><td class='mono dim'>Stop reason</td>
|
|
1002
|
+
<td><span class="badge {badge_class}">{_esc(label)}</span>
|
|
1003
|
+
<span class="mono dim"> ({_esc(sim.stop_reason)})</span></td></tr>
|
|
1004
|
+
<tr><td class='mono dim'>Total turns</td><td>{sim.total_turns}</td></tr>
|
|
1005
|
+
{trial_line}
|
|
1006
|
+
<tr><td class='mono dim'>Simulator tokens</td>
|
|
1007
|
+
<td>in {sim.simulator_input_tokens} · out {sim.simulator_output_tokens}</td></tr>
|
|
1008
|
+
{failure_line}
|
|
1009
|
+
</tbody>
|
|
1010
|
+
</table>
|
|
1011
|
+
</div>
|
|
1012
|
+
"""
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _render_environment(result: EvaluationResult) -> str:
|
|
1016
|
+
"""Render Environment section (excluding installed_tools, which has its own)."""
|
|
1017
|
+
env = {k: v for k, v in (result.environment_info or {}).items() if k != "installed_tools"}
|
|
1018
|
+
if not env:
|
|
1019
|
+
return ""
|
|
1020
|
+
rows = "".join(f"<tr><td class='mono dim'>{_esc(k)}</td><td>{_esc(v)}</td></tr>" for k, v in env.items())
|
|
1021
|
+
return f"""
|
|
1022
|
+
<h2>Environment</h2>
|
|
1023
|
+
<div class="card" style="padding:0">
|
|
1024
|
+
<table>
|
|
1025
|
+
<tbody>{rows}</tbody>
|
|
1026
|
+
</table>
|
|
1027
|
+
</div>
|
|
1028
|
+
"""
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _wrap_document(title: str, body: str) -> str:
|
|
1032
|
+
"""Wrap body HTML in a complete standalone document."""
|
|
1033
|
+
return f"""<!DOCTYPE html>
|
|
1034
|
+
<html lang="en">
|
|
1035
|
+
<head>
|
|
1036
|
+
<meta charset="UTF-8">
|
|
1037
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1038
|
+
<title>{_esc(title)}</title>
|
|
1039
|
+
<style>{_CSS}</style>
|
|
1040
|
+
</head>
|
|
1041
|
+
<body>
|
|
1042
|
+
<div class="container">
|
|
1043
|
+
{body}
|
|
1044
|
+
<footer>Generated by coder_eval · {_esc(datetime.now().isoformat(timespec="seconds"))}</footer>
|
|
1045
|
+
</div>
|
|
1046
|
+
<script>{_JS}</script>
|
|
1047
|
+
</body>
|
|
1048
|
+
</html>
|
|
1049
|
+
"""
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
# ---------------------------------------------------------------------------
|
|
1053
|
+
# Variant / Experiment helpers
|
|
1054
|
+
# ---------------------------------------------------------------------------
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _variant_stddev_lines(variant_id: str, result: ExperimentResult | None) -> str:
|
|
1058
|
+
"""Render Score/Duration stddev stats inside the summary card.
|
|
1059
|
+
|
|
1060
|
+
Returns an empty string when ``result`` is not provided or data is insufficient.
|
|
1061
|
+
"""
|
|
1062
|
+
if result is None:
|
|
1063
|
+
return ""
|
|
1064
|
+
from .reports_stats import stddev
|
|
1065
|
+
|
|
1066
|
+
vrs = [vr for ts in result.task_summaries for vr in ts.variant_results if vr.variant_id == variant_id]
|
|
1067
|
+
scores = [vr.weighted_score for vr in vrs]
|
|
1068
|
+
durations = [vr.duration_seconds for vr in vrs]
|
|
1069
|
+
extras: list[str] = []
|
|
1070
|
+
if len(scores) >= 2:
|
|
1071
|
+
extras.append(f"<li><strong>Score Stddev:</strong> {stddev(scores):.3f}</li>")
|
|
1072
|
+
if len(durations) >= 2:
|
|
1073
|
+
extras.append(f"<li><strong>Duration Stddev:</strong> {stddev(durations):.1f}s</li>")
|
|
1074
|
+
if not extras:
|
|
1075
|
+
return ""
|
|
1076
|
+
return f'<ul style="margin-top:10px">{"".join(extras)}</ul>'
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _variant_rich_sections(variant_id: str, result: ExperimentResult | None, run_dir: Path | None) -> str:
|
|
1080
|
+
"""Render Generation Metrics, Token Usage, Command Telemetry, Agent Settings,
|
|
1081
|
+
Installed Tools, and Environment sections by loading per-task JSON from disk.
|
|
1082
|
+
|
|
1083
|
+
Aggregates across all tasks in the variant (matching the markdown reporter).
|
|
1084
|
+
Returns "" when either ``result`` or ``run_dir`` is missing or no task data
|
|
1085
|
+
loads successfully.
|
|
1086
|
+
"""
|
|
1087
|
+
if result is None or run_dir is None:
|
|
1088
|
+
return ""
|
|
1089
|
+
|
|
1090
|
+
from .analysis import calculate_command_statistics
|
|
1091
|
+
from .reports_stats import load_variant_eval_results
|
|
1092
|
+
|
|
1093
|
+
eval_results = load_variant_eval_results(run_dir, variant_id, result.task_summaries)
|
|
1094
|
+
if not eval_results:
|
|
1095
|
+
return ""
|
|
1096
|
+
|
|
1097
|
+
sections: list[str] = []
|
|
1098
|
+
sections.append(_render_variant_generation_metrics(eval_results))
|
|
1099
|
+
sections.append(_render_variant_token_usage(eval_results))
|
|
1100
|
+
|
|
1101
|
+
# Command Telemetry — aggregate turns across all variant tasks
|
|
1102
|
+
all_turns = [t for r in eval_results for t in r.iterations]
|
|
1103
|
+
if all_turns:
|
|
1104
|
+
stats = calculate_command_statistics(all_turns)
|
|
1105
|
+
if stats.total_commands > 0:
|
|
1106
|
+
sections.append(_render_command_stats(stats))
|
|
1107
|
+
|
|
1108
|
+
# Agent Settings and Environment are per-task snapshots — first task with
|
|
1109
|
+
# data wins, matching the markdown reporter's `first task` fallback.
|
|
1110
|
+
settings_result = next((r for r in eval_results if r.sdk_options or r.agent_config is not None), None)
|
|
1111
|
+
if settings_result is not None:
|
|
1112
|
+
sections.append(_render_agent_settings(settings_result))
|
|
1113
|
+
|
|
1114
|
+
sections.append(_render_variant_installed_tools(eval_results))
|
|
1115
|
+
|
|
1116
|
+
env_result = next((r for r in eval_results if r.environment_info), None)
|
|
1117
|
+
if env_result is not None:
|
|
1118
|
+
sections.append(_render_environment(env_result))
|
|
1119
|
+
return "".join(sections)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _render_variant_generation_metrics(eval_results: list[EvaluationResult]) -> str:
|
|
1123
|
+
"""Aggregate generation metrics across all tasks in a variant."""
|
|
1124
|
+
if not eval_results:
|
|
1125
|
+
return ""
|
|
1126
|
+
total_tasks = len(eval_results)
|
|
1127
|
+
total_duration = sum(r.duration_seconds for r in eval_results)
|
|
1128
|
+
total_turns = sum(len(r.iterations) for r in eval_results)
|
|
1129
|
+
total_asst = sum(r.total_assistant_turns or 0 for r in eval_results)
|
|
1130
|
+
per_turn_latencies = [t.duration_seconds for r in eval_results for t in r.iterations]
|
|
1131
|
+
avg_turn = (sum(per_turn_latencies) / len(per_turn_latencies)) if per_turn_latencies else 0.0
|
|
1132
|
+
total_latency_fmt = _esc(_format_duration(total_duration))
|
|
1133
|
+
avg_turn_fmt = _esc(_format_duration(avg_turn))
|
|
1134
|
+
return f"""
|
|
1135
|
+
<h2>Generation Metrics</h2>
|
|
1136
|
+
<div class="card">
|
|
1137
|
+
<div class="grid">
|
|
1138
|
+
<div class="stat"><div class="label">Tasks</div><div class="value">{total_tasks}</div></div>
|
|
1139
|
+
<div class="stat"><div class="label">Total Latency</div><div class="value">{total_latency_fmt}</div></div>
|
|
1140
|
+
<div class="stat"><div class="label">Turns</div><div class="value">{total_turns}</div></div>
|
|
1141
|
+
<div class="stat"><div class="label">Assistant Turns</div><div class="value">{total_asst}</div></div>
|
|
1142
|
+
<div class="stat"><div class="label">Avg Turn Latency</div><div class="value">{avg_turn_fmt}</div></div>
|
|
1143
|
+
</div>
|
|
1144
|
+
</div>
|
|
1145
|
+
"""
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _render_variant_token_usage(eval_results: list[EvaluationResult]) -> str:
|
|
1149
|
+
"""Aggregate token usage across all tasks in a variant."""
|
|
1150
|
+
usages = [r.total_token_usage for r in eval_results if r.total_token_usage is not None]
|
|
1151
|
+
if not usages:
|
|
1152
|
+
return ""
|
|
1153
|
+
input_tok = sum(u.uncached_input_tokens for u in usages)
|
|
1154
|
+
output_tok = sum(u.output_tokens for u in usages)
|
|
1155
|
+
cache_write = sum(u.cache_creation_input_tokens for u in usages)
|
|
1156
|
+
cache_read = sum(u.cache_read_input_tokens for u in usages)
|
|
1157
|
+
total = input_tok + output_tok + cache_write + cache_read
|
|
1158
|
+
costs = [u.total_cost_usd for u in usages if u.total_cost_usd is not None]
|
|
1159
|
+
cost_str = f"${sum(costs):.4f}" if costs else "N/A"
|
|
1160
|
+
return f"""
|
|
1161
|
+
<h2>Token Usage</h2>
|
|
1162
|
+
<div class="card">
|
|
1163
|
+
<div class="grid">
|
|
1164
|
+
<div class="stat"><div class="label">Input</div><div class="value">{input_tok:,}</div></div>
|
|
1165
|
+
<div class="stat"><div class="label">Output</div><div class="value">{output_tok:,}</div></div>
|
|
1166
|
+
<div class="stat"><div class="label">Cache Write</div><div class="value">{cache_write:,}</div></div>
|
|
1167
|
+
<div class="stat"><div class="label">Cache Read</div><div class="value">{cache_read:,}</div></div>
|
|
1168
|
+
<div class="stat"><div class="label">Total</div><div class="value">{total:,}</div></div>
|
|
1169
|
+
<div class="stat"><div class="label">Cost</div><div class="value">{_esc(cost_str)}</div></div>
|
|
1170
|
+
</div>
|
|
1171
|
+
</div>
|
|
1172
|
+
"""
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _render_variant_installed_tools(eval_results: list[EvaluationResult]) -> str:
|
|
1176
|
+
"""Render Installed Tools rows aggregated from every task in the variant."""
|
|
1177
|
+
rows: list[str] = []
|
|
1178
|
+
for r in eval_results:
|
|
1179
|
+
tools = r.environment_info.get("installed_tools") if r.environment_info else None
|
|
1180
|
+
if not tools or not isinstance(tools, dict):
|
|
1181
|
+
continue
|
|
1182
|
+
for name, ver in sorted(tools.items()):
|
|
1183
|
+
rows.append(
|
|
1184
|
+
f"<tr><td class='mono'>{_esc(r.task_id)}</td>"
|
|
1185
|
+
+ f"<td class='mono'>{_esc(name)}</td>"
|
|
1186
|
+
+ f"<td class='mono'>{_esc(ver)}</td></tr>"
|
|
1187
|
+
)
|
|
1188
|
+
if not rows:
|
|
1189
|
+
return ""
|
|
1190
|
+
return f"""
|
|
1191
|
+
<h2>Installed Tools</h2>
|
|
1192
|
+
<div class="card" style="padding:0">
|
|
1193
|
+
<table>
|
|
1194
|
+
<thead><tr><th>Task</th><th>Tool</th><th>Version</th></tr></thead>
|
|
1195
|
+
<tbody>{"".join(rows)}</tbody>
|
|
1196
|
+
</table>
|
|
1197
|
+
</div>
|
|
1198
|
+
"""
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _experiment_prompt_config(experiment: ExperimentDefinition | None, variant_ids: list[str]) -> str:
|
|
1202
|
+
"""Render the Prompt Configuration section when the experiment definition
|
|
1203
|
+
actually specifies any mutations or overrides."""
|
|
1204
|
+
if experiment is None:
|
|
1205
|
+
return ""
|
|
1206
|
+
from .reports_stats import describe_prompt_config
|
|
1207
|
+
|
|
1208
|
+
has_config = bool(experiment.defaults and experiment.defaults.prompt_mutations) or any(
|
|
1209
|
+
v.prompt_mutations or v.initial_prompt or v.initial_prompt_file for v in experiment.variants
|
|
1210
|
+
)
|
|
1211
|
+
if not has_config:
|
|
1212
|
+
return ""
|
|
1213
|
+
variant_map = {v.variant_id: v for v in experiment.variants}
|
|
1214
|
+
items = []
|
|
1215
|
+
for vid in variant_ids:
|
|
1216
|
+
v = variant_map.get(vid)
|
|
1217
|
+
desc = describe_prompt_config(v) if v else "(unknown)"
|
|
1218
|
+
items.append(f"<li><strong>{_esc(vid)}</strong>: {_esc(desc)}</li>")
|
|
1219
|
+
return f"""
|
|
1220
|
+
<h2>Prompt Configuration</h2>
|
|
1221
|
+
<div class="card">
|
|
1222
|
+
<ul>{"".join(items)}</ul>
|
|
1223
|
+
</div>
|
|
1224
|
+
"""
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _collect_variant_series(result: ExperimentResult) -> dict[str, dict[str, list[float]]]:
|
|
1228
|
+
"""Group per-variant numeric series (scores, durations, etc.)."""
|
|
1229
|
+
series: dict[str, dict[str, list[float]]] = {
|
|
1230
|
+
vid: {"scores": [], "durations": [], "asst_turns": [], "tokens": []} for vid in result.variant_ids
|
|
1231
|
+
}
|
|
1232
|
+
for ts in result.task_summaries:
|
|
1233
|
+
for vr in ts.variant_results:
|
|
1234
|
+
s = series.get(vr.variant_id)
|
|
1235
|
+
if s is None:
|
|
1236
|
+
continue
|
|
1237
|
+
s["scores"].append(vr.weighted_score)
|
|
1238
|
+
s["durations"].append(vr.duration_seconds)
|
|
1239
|
+
if vr.total_tokens is not None:
|
|
1240
|
+
s["tokens"].append(float(vr.total_tokens))
|
|
1241
|
+
if vr.total_assistant_turns is not None:
|
|
1242
|
+
s["asst_turns"].append(float(vr.total_assistant_turns))
|
|
1243
|
+
return series
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _experiment_aggregate_metrics(result: ExperimentResult) -> str:
|
|
1247
|
+
"""Render the Aggregate Metrics table (with p-values when exactly 2 variants)."""
|
|
1248
|
+
from .reports_stats import fmt_mean_sd, fmt_p, welch_t_test
|
|
1249
|
+
|
|
1250
|
+
show_p = len(result.variant_ids) == 2
|
|
1251
|
+
vid_a, vid_b = (result.variant_ids[0], result.variant_ids[1]) if show_p else ("", "")
|
|
1252
|
+
|
|
1253
|
+
header_cells = ["<th>Metric</th>"] + [f"<th>{_esc(vid)}</th>" for vid in result.variant_ids]
|
|
1254
|
+
if show_p:
|
|
1255
|
+
header_cells.append("<th>p-value</th>")
|
|
1256
|
+
header_html = "<tr>" + "".join(header_cells) + "</tr>"
|
|
1257
|
+
|
|
1258
|
+
series = _collect_variant_series(result)
|
|
1259
|
+
|
|
1260
|
+
def _row(label: str, values: list[str], p: str | None) -> str:
|
|
1261
|
+
cells = [f"<td>{_esc(label)}</td>"] + [f"<td>{_esc(v)}</td>" for v in values]
|
|
1262
|
+
if show_p:
|
|
1263
|
+
cells.append(f"<td>{_esc(p) if p is not None else '—'}</td>")
|
|
1264
|
+
return "<tr>" + "".join(cells) + "</tr>"
|
|
1265
|
+
|
|
1266
|
+
rows: list[str] = []
|
|
1267
|
+
rows.append(_row("Tasks Run", [str(result.variant_aggregates[vid].tasks_run) for vid in result.variant_ids], None))
|
|
1268
|
+
rows.append(
|
|
1269
|
+
_row(
|
|
1270
|
+
"Succeeded",
|
|
1271
|
+
[str(result.variant_aggregates[vid].tasks_succeeded) for vid in result.variant_ids],
|
|
1272
|
+
None,
|
|
1273
|
+
)
|
|
1274
|
+
)
|
|
1275
|
+
rows.append(_row("Failed", [str(result.variant_aggregates[vid].tasks_failed) for vid in result.variant_ids], None))
|
|
1276
|
+
rows.append(_row("Errors", [str(result.variant_aggregates[vid].tasks_error) for vid in result.variant_ids], None))
|
|
1277
|
+
|
|
1278
|
+
def _success_rate(vid: str) -> str:
|
|
1279
|
+
agg = result.variant_aggregates[vid]
|
|
1280
|
+
evaluable = agg.tasks_run - agg.tasks_error
|
|
1281
|
+
rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0.0
|
|
1282
|
+
return f"{rate:.1f}%"
|
|
1283
|
+
|
|
1284
|
+
rows.append(_row("Success Rate", [_success_rate(vid) for vid in result.variant_ids], None))
|
|
1285
|
+
|
|
1286
|
+
rows.append(
|
|
1287
|
+
_row(
|
|
1288
|
+
"Score",
|
|
1289
|
+
[fmt_mean_sd(series[vid]["scores"]) for vid in result.variant_ids],
|
|
1290
|
+
fmt_p(welch_t_test(series[vid_a]["scores"], series[vid_b]["scores"])) if show_p else None,
|
|
1291
|
+
)
|
|
1292
|
+
)
|
|
1293
|
+
rows.append(
|
|
1294
|
+
_row(
|
|
1295
|
+
"Duration (s)",
|
|
1296
|
+
[fmt_mean_sd(series[vid]["durations"], ".1f") for vid in result.variant_ids],
|
|
1297
|
+
fmt_p(welch_t_test(series[vid_a]["durations"], series[vid_b]["durations"])) if show_p else None,
|
|
1298
|
+
)
|
|
1299
|
+
)
|
|
1300
|
+
if any(series[vid]["asst_turns"] for vid in result.variant_ids):
|
|
1301
|
+
rows.append(
|
|
1302
|
+
_row(
|
|
1303
|
+
"Assistant Turns",
|
|
1304
|
+
[fmt_mean_sd(series[vid]["asst_turns"], ".1f") for vid in result.variant_ids],
|
|
1305
|
+
fmt_p(welch_t_test(series[vid_a]["asst_turns"], series[vid_b]["asst_turns"])) if show_p else None,
|
|
1306
|
+
)
|
|
1307
|
+
)
|
|
1308
|
+
if any(series[vid]["tokens"] for vid in result.variant_ids):
|
|
1309
|
+
rows.append(
|
|
1310
|
+
_row(
|
|
1311
|
+
"Tokens",
|
|
1312
|
+
[fmt_mean_sd(series[vid]["tokens"], ",.0f") for vid in result.variant_ids],
|
|
1313
|
+
fmt_p(welch_t_test(series[vid_a]["tokens"], series[vid_b]["tokens"])) if show_p else None,
|
|
1314
|
+
)
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
return f"""
|
|
1318
|
+
<h2>Aggregate Metrics</h2>
|
|
1319
|
+
<div class="card" style="padding:0">
|
|
1320
|
+
<table>
|
|
1321
|
+
<thead>{header_html}</thead>
|
|
1322
|
+
<tbody>{"".join(rows)}</tbody>
|
|
1323
|
+
</table>
|
|
1324
|
+
</div>
|
|
1325
|
+
"""
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _experiment_win_rates(result: ExperimentResult) -> str:
|
|
1329
|
+
"""Render the Win Rates bullet list."""
|
|
1330
|
+
if not result.task_summaries:
|
|
1331
|
+
return ""
|
|
1332
|
+
total = len(result.task_summaries)
|
|
1333
|
+
win_counts: dict[str, int] = {vid: 0 for vid in result.variant_ids}
|
|
1334
|
+
tie_count = 0
|
|
1335
|
+
for ts in result.task_summaries:
|
|
1336
|
+
if ts.is_tie:
|
|
1337
|
+
tie_count += 1
|
|
1338
|
+
else:
|
|
1339
|
+
win_counts[ts.best_variant] = win_counts.get(ts.best_variant, 0) + 1
|
|
1340
|
+
items = []
|
|
1341
|
+
for vid in result.variant_ids:
|
|
1342
|
+
wins = win_counts.get(vid, 0)
|
|
1343
|
+
pct = wins / total * 100 if total else 0.0
|
|
1344
|
+
items.append(f"<li><strong>{_esc(vid)}</strong>: {wins}/{total} tasks ({pct:.0f}%)</li>")
|
|
1345
|
+
if tie_count > 0:
|
|
1346
|
+
pct = tie_count / total * 100
|
|
1347
|
+
items.append(f"<li><strong>Ties</strong>: {tie_count}/{total} tasks ({pct:.0f}%)</li>")
|
|
1348
|
+
return f"""
|
|
1349
|
+
<h2>Win Rates</h2>
|
|
1350
|
+
<div class="card">
|
|
1351
|
+
<ul>{"".join(items)}</ul>
|
|
1352
|
+
</div>
|
|
1353
|
+
"""
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _experiment_per_task_comparison(result: ExperimentResult) -> str:
|
|
1357
|
+
"""Render the Per-Task Comparison table."""
|
|
1358
|
+
if not result.task_summaries:
|
|
1359
|
+
return ""
|
|
1360
|
+
header_cells = ["<th>Task</th>"] + [f"<th>{_esc(vid)}</th>" for vid in result.variant_ids]
|
|
1361
|
+
header_cells += ["<th>Best</th>", "<th>Spread</th>"]
|
|
1362
|
+
header = "<tr>" + "".join(header_cells) + "</tr>"
|
|
1363
|
+
rows: list[str] = []
|
|
1364
|
+
for ts in result.task_summaries:
|
|
1365
|
+
scores_by_variant = {vr.variant_id: vr for vr in ts.variant_results}
|
|
1366
|
+
cells = [f"<td class='mono'>{_esc(ts.task_id)}</td>"]
|
|
1367
|
+
for vid in result.variant_ids:
|
|
1368
|
+
vr = scores_by_variant.get(vid)
|
|
1369
|
+
if vr is None:
|
|
1370
|
+
cells.append("<td>N/A</td>")
|
|
1371
|
+
else:
|
|
1372
|
+
cells.append(f"<td>{vr.weighted_score:.3f} ({_esc(vr.final_status.icon)})</td>")
|
|
1373
|
+
best = "TIE" if ts.is_tie else ts.best_variant
|
|
1374
|
+
cells.append(f"<td>{_esc(best)}</td>")
|
|
1375
|
+
cells.append(f"<td>{ts.score_spread:.3f}</td>")
|
|
1376
|
+
rows.append("<tr>" + "".join(cells) + "</tr>")
|
|
1377
|
+
return f"""
|
|
1378
|
+
<h2>Per-Task Comparison</h2>
|
|
1379
|
+
<div class="card" style="padding:0">
|
|
1380
|
+
<table>
|
|
1381
|
+
<thead>{header}</thead>
|
|
1382
|
+
<tbody>{"".join(rows)}</tbody>
|
|
1383
|
+
</table>
|
|
1384
|
+
</div>
|
|
1385
|
+
"""
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _experiment_most_divergent(result: ExperimentResult) -> str:
|
|
1389
|
+
"""Render the Most Divergent Tasks section — omitted when all spreads are 0."""
|
|
1390
|
+
if not result.task_summaries:
|
|
1391
|
+
return ""
|
|
1392
|
+
sorted_tasks = sorted(result.task_summaries, key=lambda t: t.score_spread, reverse=True)
|
|
1393
|
+
if not sorted_tasks or sorted_tasks[0].score_spread == 0:
|
|
1394
|
+
return ""
|
|
1395
|
+
items = [
|
|
1396
|
+
f"<li><strong>{_esc(ts.task_id)}</strong>: spread={ts.score_spread:.3f}, best={_esc(ts.best_variant)}</li>"
|
|
1397
|
+
for ts in sorted_tasks[:5]
|
|
1398
|
+
]
|
|
1399
|
+
return f"""
|
|
1400
|
+
<h2>Most Divergent Tasks</h2>
|
|
1401
|
+
<div class="card">
|
|
1402
|
+
<ul>{"".join(items)}</ul>
|
|
1403
|
+
</div>
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
# ---------------------------------------------------------------------------
|
|
1408
|
+
# Public API
|
|
1409
|
+
# ---------------------------------------------------------------------------
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
class HTMLReportGenerator:
|
|
1413
|
+
"""Generates self-contained HTML reports from coder_eval run data."""
|
|
1414
|
+
|
|
1415
|
+
@staticmethod
|
|
1416
|
+
def generate_task_html(result: EvaluationResult) -> str:
|
|
1417
|
+
"""Render a single task's EvaluationResult as a standalone HTML page.
|
|
1418
|
+
|
|
1419
|
+
The page shows the run header, success-criteria table, per-turn
|
|
1420
|
+
conversation trace with tool calls, command telemetry stats, and
|
|
1421
|
+
error details (if any). When ``result.error_log_tail`` is set, the
|
|
1422
|
+
captured tail is embedded under a "Logs" disclosure in the error
|
|
1423
|
+
section; otherwise the renderer falls back to the legacy
|
|
1424
|
+
``error_details["stack_trace"]`` so archived runs predating the tail
|
|
1425
|
+
capture still surface diagnostics.
|
|
1426
|
+
"""
|
|
1427
|
+
groups = _group_turns_by_iteration(result.iterations or [])
|
|
1428
|
+
turns_html = "".join(_render_iteration_group(it, group) for it, group in groups)
|
|
1429
|
+
if not turns_html:
|
|
1430
|
+
no_turn_msg = "No turn data (agent communication failed before producing any turns)."
|
|
1431
|
+
turns_html = f'<div class="card"><p class="muted">{no_turn_msg}</p></div>'
|
|
1432
|
+
|
|
1433
|
+
n_iterations = len(groups)
|
|
1434
|
+
n_attempts = len(result.iterations or [])
|
|
1435
|
+
has_partials = any(t.crashed for t in (result.iterations or []))
|
|
1436
|
+
iter_label = f"{n_iterations} iteration" + ("s" if n_iterations != 1 else "")
|
|
1437
|
+
if has_partials:
|
|
1438
|
+
attempts_label = f", {n_attempts} attempt" + ("s" if n_attempts != 1 else "")
|
|
1439
|
+
trace_count = f"{iter_label}{attempts_label}"
|
|
1440
|
+
else:
|
|
1441
|
+
trace_count = iter_label
|
|
1442
|
+
|
|
1443
|
+
body = (
|
|
1444
|
+
_render_header(result)
|
|
1445
|
+
+ _render_simulation(result)
|
|
1446
|
+
+ _render_criteria(result.success_criteria_results or [])
|
|
1447
|
+
+ _render_judge_section(result.success_criteria_results or [])
|
|
1448
|
+
+ _render_error_details(result)
|
|
1449
|
+
+ f"<h2>Conversation Trace ({trace_count})</h2>"
|
|
1450
|
+
+ turns_html
|
|
1451
|
+
+ _render_command_stats(result.command_stats)
|
|
1452
|
+
+ _render_generation_metrics(result)
|
|
1453
|
+
+ _render_token_usage(result)
|
|
1454
|
+
+ _render_commands_efficiency(result)
|
|
1455
|
+
+ _render_agent_settings(result)
|
|
1456
|
+
+ _render_installed_tools(result)
|
|
1457
|
+
+ _render_environment(result)
|
|
1458
|
+
)
|
|
1459
|
+
title = f"coder_eval · {result.task_id} · {result.variant_id}"
|
|
1460
|
+
return _wrap_document(title, body)
|
|
1461
|
+
|
|
1462
|
+
@staticmethod
|
|
1463
|
+
def generate_variant_html(
|
|
1464
|
+
variant_id: str,
|
|
1465
|
+
agg: VariantAggregate,
|
|
1466
|
+
task_links: list[tuple[str, str, float | None, str]],
|
|
1467
|
+
result: ExperimentResult | None = None,
|
|
1468
|
+
run_dir: Path | None = None,
|
|
1469
|
+
) -> str:
|
|
1470
|
+
"""Render a variant-level summary page linking to per-task HTMLs.
|
|
1471
|
+
|
|
1472
|
+
Args:
|
|
1473
|
+
variant_id: The variant identifier.
|
|
1474
|
+
agg: VariantAggregate with summary metrics.
|
|
1475
|
+
task_links: List of (task_id, relative_html_path, score, status).
|
|
1476
|
+
result: Optional full ExperimentResult — enables stddev rows.
|
|
1477
|
+
run_dir: Optional top-level run dir — enables Generation Metrics,
|
|
1478
|
+
Token Usage, Command Telemetry, Agent Settings, Installed
|
|
1479
|
+
Tools, and Environment sections loaded from per-task JSON.
|
|
1480
|
+
"""
|
|
1481
|
+
rows = "".join(
|
|
1482
|
+
f"""
|
|
1483
|
+
<tr>
|
|
1484
|
+
<td><a href="{_esc(link)}">{_esc(tid)}</a></td>
|
|
1485
|
+
<td style="text-align:center">{_score_pill(score) if score is not None else "—"}</td>
|
|
1486
|
+
<td>{_status_badge(status)}</td>
|
|
1487
|
+
</tr>
|
|
1488
|
+
"""
|
|
1489
|
+
for tid, link, score, status in task_links
|
|
1490
|
+
)
|
|
1491
|
+
stddev_lines = _variant_stddev_lines(variant_id, result)
|
|
1492
|
+
rich_sections = _variant_rich_sections(variant_id, result, run_dir)
|
|
1493
|
+
budget_stats = ""
|
|
1494
|
+
if agg.tasks_token_budget_exceeded > 0:
|
|
1495
|
+
budget_stats += (
|
|
1496
|
+
f'<div class="stat"><div class="label">Token Budget</div>'
|
|
1497
|
+
f'<div class="value">{agg.tasks_token_budget_exceeded}</div></div>'
|
|
1498
|
+
)
|
|
1499
|
+
if agg.tasks_cost_budget_exceeded > 0:
|
|
1500
|
+
budget_stats += (
|
|
1501
|
+
f'<div class="stat"><div class="label">Cost Budget</div>'
|
|
1502
|
+
f'<div class="value">{agg.tasks_cost_budget_exceeded}</div></div>'
|
|
1503
|
+
)
|
|
1504
|
+
body = f"""
|
|
1505
|
+
<div class="header-bar">
|
|
1506
|
+
<div class="title-group">
|
|
1507
|
+
<h1>Variant: {_esc(variant_id)}</h1>
|
|
1508
|
+
<div class="subtitle">Aggregate summary for variant <code>{_esc(variant_id)}</code></div>
|
|
1509
|
+
</div>
|
|
1510
|
+
<div class="badges">
|
|
1511
|
+
{_score_pill(agg.average_score, suffix=" avg")}
|
|
1512
|
+
<span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
|
|
1513
|
+
</div>
|
|
1514
|
+
</div>
|
|
1515
|
+
<div class="card">
|
|
1516
|
+
<div class="grid">
|
|
1517
|
+
<div class="stat"><div class="label">Tasks Run</div><div class="value">{agg.tasks_run}</div></div>
|
|
1518
|
+
<div class="stat"><div class="label">Succeeded</div><div class="value">{agg.tasks_succeeded}</div></div>
|
|
1519
|
+
<div class="stat"><div class="label">Failed</div><div class="value">{agg.tasks_failed}</div></div>
|
|
1520
|
+
<div class="stat"><div class="label">Errors</div><div class="value">{agg.tasks_error}</div></div>
|
|
1521
|
+
{budget_stats}
|
|
1522
|
+
</div>
|
|
1523
|
+
{stddev_lines}
|
|
1524
|
+
</div>
|
|
1525
|
+
<h2>Tasks</h2>
|
|
1526
|
+
<div class="card" style="padding:0">
|
|
1527
|
+
<table>
|
|
1528
|
+
<thead><tr><th>Task</th><th style="width:80px">Score</th><th style="width:140px">Status</th></tr></thead>
|
|
1529
|
+
<tbody>{rows or '<tr><td colspan="3" class="muted">No tasks</td></tr>'}</tbody>
|
|
1530
|
+
</table>
|
|
1531
|
+
</div>
|
|
1532
|
+
{rich_sections}
|
|
1533
|
+
"""
|
|
1534
|
+
return _wrap_document(f"Variant {variant_id}", body)
|
|
1535
|
+
|
|
1536
|
+
@staticmethod
|
|
1537
|
+
def generate_experiment_html(
|
|
1538
|
+
result: ExperimentResult,
|
|
1539
|
+
experiment: ExperimentDefinition | None = None,
|
|
1540
|
+
variant_links: list[tuple[str, str]] | None = None,
|
|
1541
|
+
) -> str:
|
|
1542
|
+
"""Render an experiment-level index linking to each variant's page.
|
|
1543
|
+
|
|
1544
|
+
Matches ``ExperimentReportGenerator.generate_experiment_report`` in
|
|
1545
|
+
content: header, optional Prompt Configuration, Aggregate Metrics,
|
|
1546
|
+
Win Rates, Per-Task Comparison, Most Divergent Tasks.
|
|
1547
|
+
"""
|
|
1548
|
+
exp_name = experiment.experiment_id if experiment else result.experiment_id
|
|
1549
|
+
exp_desc = experiment.description if experiment else ""
|
|
1550
|
+
|
|
1551
|
+
rows: list[str] = []
|
|
1552
|
+
for vid in result.variant_ids:
|
|
1553
|
+
agg = result.variant_aggregates.get(vid)
|
|
1554
|
+
link = next((ln for v, ln in (variant_links or []) if v == vid), f"{vid}/variant.html")
|
|
1555
|
+
if agg is None:
|
|
1556
|
+
continue
|
|
1557
|
+
rows.append(
|
|
1558
|
+
f"""
|
|
1559
|
+
<tr>
|
|
1560
|
+
<td><a href="{_esc(link)}">{_esc(vid)}</a></td>
|
|
1561
|
+
<td style="text-align:center">{_score_pill(agg.average_score)}</td>
|
|
1562
|
+
<td>{agg.tasks_succeeded}/{agg.tasks_run}</td>
|
|
1563
|
+
</tr>
|
|
1564
|
+
"""
|
|
1565
|
+
)
|
|
1566
|
+
body = (
|
|
1567
|
+
f"""
|
|
1568
|
+
<div class="header-bar">
|
|
1569
|
+
<div class="title-group">
|
|
1570
|
+
<h1>Experiment: {_esc(exp_name)}</h1>
|
|
1571
|
+
<div class="subtitle">{_esc(exp_desc)}</div>
|
|
1572
|
+
</div>
|
|
1573
|
+
<div class="badges">
|
|
1574
|
+
<span class="badge neutral">{len(result.variant_ids)} variants</span>
|
|
1575
|
+
<span class="badge neutral">{_esc(_format_duration(result.total_duration_seconds))}</span>
|
|
1576
|
+
<span class="nav-toggle" onclick="toggleTheme()">Toggle theme</span>
|
|
1577
|
+
</div>
|
|
1578
|
+
</div>
|
|
1579
|
+
<h2>Variants</h2>
|
|
1580
|
+
<div class="card" style="padding:0">
|
|
1581
|
+
<table>
|
|
1582
|
+
<thead><tr><th>Variant</th><th style="width:100px">Avg Score</th><th style="width:120px">Pass Ratio</th></tr></thead>
|
|
1583
|
+
<tbody>{"".join(rows) or '<tr><td colspan="3" class="muted">No variants</td></tr>'}</tbody>
|
|
1584
|
+
</table>
|
|
1585
|
+
</div>
|
|
1586
|
+
"""
|
|
1587
|
+
+ _experiment_prompt_config(experiment, result.variant_ids)
|
|
1588
|
+
+ _experiment_aggregate_metrics(result)
|
|
1589
|
+
+ _experiment_win_rates(result)
|
|
1590
|
+
+ _experiment_per_task_comparison(result)
|
|
1591
|
+
+ _experiment_most_divergent(result)
|
|
1592
|
+
)
|
|
1593
|
+
return _wrap_document(f"Experiment {exp_name}", body)
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def safe_write(generate: Callable[[], str], output_path: Path, *, label: str) -> Path | None:
|
|
1597
|
+
"""Render and write an HTML report. Log and return None on failure.
|
|
1598
|
+
|
|
1599
|
+
Used by the orchestrator and experiment writers so a render bug in one
|
|
1600
|
+
report never masks the underlying run outcome. The CLI regen path
|
|
1601
|
+
surfaces the ``None`` return as a per-task failure.
|
|
1602
|
+
"""
|
|
1603
|
+
try:
|
|
1604
|
+
html_text = generate()
|
|
1605
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1606
|
+
output_path.write_text(html_text, encoding="utf-8")
|
|
1607
|
+
return output_path
|
|
1608
|
+
except Exception:
|
|
1609
|
+
logger.exception("Failed to generate %s", label)
|
|
1610
|
+
return None
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def write_task_html(result: EvaluationResult, output_path: Path) -> Path | None:
|
|
1614
|
+
"""Render a per-task HTML report via ``safe_write``.
|
|
1615
|
+
|
|
1616
|
+
The renderer is a pure function of ``result``; the captured
|
|
1617
|
+
``error_log_tail`` (if any) drives the error section's "Logs" disclosure.
|
|
1618
|
+
"""
|
|
1619
|
+
return safe_write(
|
|
1620
|
+
lambda: HTMLReportGenerator.generate_task_html(result),
|
|
1621
|
+
output_path,
|
|
1622
|
+
label=f"task.html for {result.task_id}",
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def write_variant_html(
|
|
1627
|
+
variant_id: str,
|
|
1628
|
+
agg: VariantAggregate,
|
|
1629
|
+
task_links: list[tuple[str, str, float | None, str]],
|
|
1630
|
+
output_path: Path,
|
|
1631
|
+
*,
|
|
1632
|
+
result: ExperimentResult | None = None,
|
|
1633
|
+
run_dir: Path | None = None,
|
|
1634
|
+
) -> Path | None:
|
|
1635
|
+
"""Render a per-variant HTML report via ``safe_write``.
|
|
1636
|
+
|
|
1637
|
+
When ``result`` and ``run_dir`` are provided, the page also includes
|
|
1638
|
+
per-variant Generation Metrics, Token Usage, Command Telemetry, Agent
|
|
1639
|
+
Settings, Installed Tools, and Environment sections.
|
|
1640
|
+
"""
|
|
1641
|
+
return safe_write(
|
|
1642
|
+
lambda: HTMLReportGenerator.generate_variant_html(variant_id, agg, task_links, result, run_dir),
|
|
1643
|
+
output_path,
|
|
1644
|
+
label=f"variant.html for {variant_id}",
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def write_experiment_html(
|
|
1649
|
+
result: ExperimentResult,
|
|
1650
|
+
experiment: ExperimentDefinition | None,
|
|
1651
|
+
variant_links: list[tuple[str, str]] | None,
|
|
1652
|
+
output_path: Path,
|
|
1653
|
+
) -> Path | None:
|
|
1654
|
+
"""Render a per-experiment HTML report via ``safe_write``."""
|
|
1655
|
+
return safe_write(
|
|
1656
|
+
lambda: HTMLReportGenerator.generate_experiment_html(result, experiment, variant_links),
|
|
1657
|
+
output_path,
|
|
1658
|
+
label=f"experiment.html for {result.experiment_id}",
|
|
1659
|
+
)
|