whetkit 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.
whetkit/report/html.py ADDED
@@ -0,0 +1,420 @@
1
+ """Self-contained HTML rendering of a ComparisonReport.
2
+
3
+ Implements the whetkit report design (dark, mono, before→after headline).
4
+ No external assets and no scripts: all CSS is inline and the trace panels
5
+ use CSS-only <details> accordions, so the file works offline anywhere.
6
+ """
7
+
8
+ import html
9
+ from statistics import median
10
+
11
+ from whetkit import __version__
12
+ from whetkit.report.builder import ComparisonReport, SideView
13
+
14
+ GREEN = "#5AD8A0"
15
+ AMBER = "#E9B24E"
16
+ BLUE = "#8FB4E8"
17
+ PURPLE = "#C79BE8"
18
+ MUTED = "#656C74"
19
+ SUBTLE = "#9AA1A9"
20
+ FAINT = "#5A6069"
21
+
22
+ _CSS = f"""
23
+ * {{ box-sizing: border-box; }}
24
+ html, body {{ margin: 0; padding: 0; }}
25
+ body {{
26
+ background: #08090A;
27
+ color: #EAECEE;
28
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
29
+ -webkit-font-smoothing: antialiased;
30
+ }}
31
+ .mono {{ font-family: ui-monospace, "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; }}
32
+ a {{ color: {GREEN}; text-decoration: none; }}
33
+ a:hover {{ color: #7BE6B8; }}
34
+ ::selection {{ background: {GREEN}; color: #05100B; }}
35
+ .panel {{ border: 1px solid rgba(255,255,255,0.09); border-radius: 16px;
36
+ background: linear-gradient(180deg,#121519,#0C0E10); }}
37
+ .statcard {{ border: 1px solid rgba(255,255,255,0.08); border-radius: 11px; padding: 16px; }}
38
+ .bar {{ height: 8px; background: rgba(255,255,255,0.06); border-radius: 100px;
39
+ margin-top: 12px; overflow: hidden; }}
40
+ .bar > div {{ height: 100%; border-radius: 100px; }}
41
+ .grid-row {{ display: grid; grid-template-columns: 26px 1fr 190px 60px 60px; gap: 12px;
42
+ padding: 12px 18px; font-size: 12.5px;
43
+ border-bottom: 1px solid rgba(255,255,255,0.05); }}
44
+ details > summary {{ list-style: none; cursor: pointer; }}
45
+ details > summary::-webkit-details-marker {{ display: none; }}
46
+ details .chev {{ display: inline-block; transition: transform .2s ease; }}
47
+ details[open] .chev {{ transform: rotate(180deg); }}
48
+ .callbox {{ border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; word-break: break-all; }}
49
+ .call-ok {{ background: rgba(90,216,160,0.08); border: 1px solid rgba(90,216,160,0.22);
50
+ color: {GREEN}; }}
51
+ .call-bad {{ background: rgba(233,178,78,0.08); border: 1px solid rgba(233,178,78,0.22);
52
+ color: {AMBER}; }}
53
+ .badge {{ font-size: 11px; padding: 3px 9px; border-radius: 6px; }}
54
+ @media (max-width: 720px) {{
55
+ .cols2 {{ grid-template-columns: 1fr !important; }}
56
+ .headline-grid {{ grid-template-columns: 1fr !important; }}
57
+ }}
58
+ """
59
+
60
+
61
+ def _e(value: object) -> str:
62
+ return html.escape(str(value))
63
+
64
+
65
+ def _pct_num(value: float) -> int:
66
+ return round(value * 100)
67
+
68
+
69
+ def _shorten(text: str, limit: int) -> str:
70
+ text = " ".join(text.split())
71
+ return text if len(text) <= limit else text[: limit - 1] + "…"
72
+
73
+
74
+ def _delta_chip(before: float, after: float, lower_is_better: bool = False) -> str:
75
+ if before <= 0:
76
+ return f'<span style="color:{MUTED};">—</span>'
77
+ change = (after - before) / before
78
+ good = (change < 0) == lower_is_better if change != 0 else True
79
+ color = GREEN if good else AMBER
80
+ return f'<span style="color:{color};">{change:+.0%}</span>'
81
+
82
+
83
+ def _header(report: ComparisonReport) -> str:
84
+ date = report.generated_at.strftime("%Y-%m-%d")
85
+ return f"""
86
+ <header class="panel" style="overflow:hidden;">
87
+ <div style="display:flex; align-items:center; justify-content:space-between; padding:20px 26px; border-bottom:1px solid rgba(255,255,255,0.07); flex-wrap:wrap; gap:10px;">
88
+ <div style="display:flex; align-items:center; gap:11px;">
89
+ <div style="width:20px; height:20px; background:{GREEN}; transform:rotate(45deg); border-radius:4px;"></div>
90
+ <span class="mono" style="font-weight:600; font-size:15px;">whetkit</span>
91
+ <span class="mono" style="font-size:12px; color:{MUTED};">curate report</span>
92
+ </div>
93
+ <span class="mono" style="display:inline-flex; align-items:center; gap:8px; font-size:12px; color:{GREEN}; border:1px solid rgba(90,216,160,0.28); background:rgba(90,216,160,0.07); padding:5px 12px; border-radius:100px;">
94
+ <span style="width:6px;height:6px;border-radius:50%;background:{GREEN};"></span>curate complete
95
+ </span>
96
+ </div>
97
+ <div style="padding:26px;">
98
+ <div class="mono" style="font-size:12px; color:{MUTED}; letter-spacing:0.06em; margin-bottom:8px;">MCP SERVER EVALUATED</div>
99
+ <div class="mono" style="font-size:22px; font-weight:600; letter-spacing:-0.01em; margin-bottom:22px; word-break:break-all;">{_e(report.server)}</div>
100
+ <div class="cols2" style="display:grid; grid-template-columns:repeat(4,1fr); gap:20px;">
101
+ <div><div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:6px;">TASKS</div><div class="mono" style="font-size:16px;">{len(report.tasks)}</div></div>
102
+ <div><div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:6px;">MODEL</div><div class="mono" style="font-size:16px;">{_e(report.model or "—")}</div></div>
103
+ <div><div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:6px;">DATE</div><div class="mono" style="font-size:16px;">{date}</div></div>
104
+ <div><div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:6px;">RUN ID</div><div class="mono" style="font-size:16px;">{report.run_id}</div></div>
105
+ </div>
106
+ </div>
107
+ </header>"""
108
+
109
+
110
+ def _headline(report: ComparisonReport) -> str:
111
+ before_pct = _pct_num(report.before.hit_rate)
112
+ after_pct = _pct_num(report.after.hit_rate)
113
+ total = len(report.tasks)
114
+ before_hits = sum(t.before.hit for t in report.tasks)
115
+ after_hits = sum(t.after.hit for t in report.tasks)
116
+ delta = after_pct - before_pct
117
+ delta_color = GREEN if delta >= 0 else AMBER
118
+
119
+ def tokens_per_task(side) -> int:
120
+ return round((side.input_tokens + side.output_tokens) / total) if total else 0
121
+
122
+ tok_before, tok_after = tokens_per_task(report.before), tokens_per_task(report.after)
123
+ p50_before = median([t.before.latency_ms for t in report.tasks]) / 1000 if total else 0
124
+ p50_after = median([t.after.latency_ms for t in report.tasks]) / 1000 if total else 0
125
+
126
+ if report.tools_before is not None and report.tools_after is not None:
127
+ tools_card = f"""
128
+ <div class="statcard">
129
+ <div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:8px;">TOOLS EXPOSED</div>
130
+ <div class="mono" style="font-size:20px;">{report.tools_before} <span style="color:{MUTED};">→</span> <span style="color:{GREEN};">{report.tools_after}</span></div>
131
+ <div class="mono" style="font-size:11.5px; margin-top:6px;">{_delta_chip(report.tools_before, report.tools_after, lower_is_better=True)}</div>
132
+ </div>"""
133
+ else:
134
+ tools_card = f"""
135
+ <div class="statcard">
136
+ <div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:8px;">TOOLS EXPOSED</div>
137
+ <div class="mono" style="font-size:20px; color:{MUTED};">—</div>
138
+ <div class="mono" style="font-size:11.5px; color:{MUTED}; margin-top:6px;">n/a</div>
139
+ </div>"""
140
+
141
+ return f"""
142
+ <section class="panel" style="margin-top:20px; padding:34px 30px;">
143
+ <div class="mono" style="font-size:12px; color:{MUTED}; letter-spacing:0.08em; margin-bottom:24px;">TOOL-SELECTION ACCURACY</div>
144
+ <div class="headline-grid" style="display:grid; grid-template-columns:1fr auto 1fr auto; gap:28px; align-items:center;">
145
+ <div>
146
+ <div class="mono" style="font-size:13px; color:{SUBTLE}; margin-bottom:10px;">before curation</div>
147
+ <div style="display:flex; align-items:baseline; gap:4px; color:{AMBER};"><span class="mono" style="font-size:64px; font-weight:600; letter-spacing:-0.04em; line-height:0.9;">{before_pct}</span><span class="mono" style="font-size:26px;">%</span></div>
148
+ <div class="mono" style="font-size:12px; color:{MUTED}; margin-top:10px;">{before_hits} / {total} tasks correct</div>
149
+ <div class="bar"><div style="width:{before_pct}%; background:{AMBER};"></div></div>
150
+ </div>
151
+ <div style="color:{MUTED}; font-size:30px; padding:0 4px;">→</div>
152
+ <div>
153
+ <div class="mono" style="font-size:13px; color:{SUBTLE}; margin-bottom:10px;">after curation</div>
154
+ <div style="display:flex; align-items:baseline; gap:4px; color:{GREEN};"><span class="mono" style="font-size:64px; font-weight:600; letter-spacing:-0.04em; line-height:0.9;">{after_pct}</span><span class="mono" style="font-size:26px;">%</span></div>
155
+ <div class="mono" style="font-size:12px; color:{MUTED}; margin-top:10px;">{after_hits} / {total} tasks correct</div>
156
+ <div class="bar"><div style="width:{after_pct}%; background:{GREEN};"></div></div>
157
+ </div>
158
+ <div style="text-align:center; padding-left:8px;">
159
+ <div class="mono" style="font-size:34px; font-weight:600; color:{delta_color}; letter-spacing:-0.03em;">{delta:+d}</div>
160
+ <div class="mono" style="font-size:12px; color:{MUTED}; margin-top:4px;">points</div>
161
+ </div>
162
+ </div>
163
+ <div class="cols2" style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-top:30px; padding-top:26px; border-top:1px solid rgba(255,255,255,0.07);">
164
+ {tools_card}
165
+ <div class="statcard">
166
+ <div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:8px;">TOKENS / TASK</div>
167
+ <div class="mono" style="font-size:20px;">{tok_before:,} <span style="color:{MUTED};">→</span> <span style="color:{GREEN};">{tok_after:,}</span></div>
168
+ <div class="mono" style="font-size:11.5px; margin-top:6px;">{_delta_chip(tok_before, tok_after, lower_is_better=True)}</div>
169
+ </div>
170
+ <div class="statcard">
171
+ <div class="mono" style="font-size:11.5px; color:{MUTED}; margin-bottom:8px;">LATENCY p50</div>
172
+ <div class="mono" style="font-size:20px;">{p50_before:.1f}s <span style="color:{MUTED};">→</span> <span style="color:{GREEN};">{p50_after:.1f}s</span></div>
173
+ <div class="mono" style="font-size:11.5px; margin-top:6px;">{_delta_chip(p50_before, p50_after, lower_is_better=True)}</div>
174
+ </div>
175
+ </div>
176
+ </section>"""
177
+
178
+
179
+ def _mark(hit: bool) -> tuple[str, str]:
180
+ return ("✓", GREEN) if hit else ("✗", AMBER)
181
+
182
+
183
+ def _task_table(report: ComparisonReport) -> str:
184
+ rows = []
185
+ for i, task in enumerate(report.tasks):
186
+ before_mark, before_color = _mark(task.before.hit)
187
+ after_mark, after_color = _mark(task.after.hit)
188
+ if task.outcome == "improved":
189
+ row_bg = "rgba(90,216,160,0.04)"
190
+ elif task.outcome == "regressed":
191
+ row_bg = "rgba(233,178,78,0.05)"
192
+ else:
193
+ row_bg = "rgba(255,255,255,0.012)" if i % 2 else "transparent"
194
+ expected = " · ".join(" / ".join(slot) for slot in task.expected_slots)
195
+ rows.append(f"""
196
+ <div class="grid-row mono" style="background:{row_bg};">
197
+ <div style="color:{MUTED};">{i + 1:02d}</div>
198
+ <div style="color:#EAECEE; font-family:ui-sans-serif,system-ui,sans-serif;">{_e(_shorten(task.prompt, 90))}</div>
199
+ <div style="color:{SUBTLE}; word-break:break-all;">{_e(expected)}</div>
200
+ <div style="text-align:center; color:{before_color};">{before_mark}</div>
201
+ <div style="text-align:center; color:{after_color};">{after_mark}</div>
202
+ </div>""")
203
+
204
+ improved = len(report.improved)
205
+ still_failing = sum(1 for t in report.tasks if not t.after.hit)
206
+ before_hits = sum(t.before.hit for t in report.tasks)
207
+ after_hits = sum(t.after.hit for t in report.tasks)
208
+ return f"""
209
+ <section style="margin-top:34px;">
210
+ <div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
211
+ <h2 style="font-size:18px; font-weight:600; margin:0;">Per-task breakdown</h2>
212
+ <div class="mono" style="font-size:12px; color:{MUTED};">✓ hit · ✗ miss</div>
213
+ </div>
214
+ <div style="border:1px solid rgba(255,255,255,0.09); border-radius:14px; overflow:hidden;">
215
+ <div class="grid-row mono" style="padding:11px 18px; background:#101316; font-size:11px; color:{MUTED}; letter-spacing:0.04em; border-bottom:1px solid rgba(255,255,255,0.07);">
216
+ <div>#</div><div>TASK</div><div>EXPECTED TOOL</div><div style="text-align:center;">BEFORE</div><div style="text-align:center;">AFTER</div>
217
+ </div>
218
+ {"".join(rows)}
219
+ <div class="mono" style="display:flex; justify-content:space-between; padding:12px 18px; font-size:12px; background:#101316; color:{SUBTLE}; flex-wrap:wrap; gap:8px;">
220
+ <span>improved on {improved} task{"s" if improved != 1 else ""} · {still_failing} still failing</span>
221
+ <span><span style="color:{AMBER};">{before_hits} ✓</span> <span style="color:{MUTED};">→</span> <span style="color:{GREEN};">{after_hits} ✓</span></span>
222
+ </div>
223
+ </div>
224
+ </section>"""
225
+
226
+
227
+ def _names_line(names: list[str], limit: int = 8) -> str:
228
+ shown = " · ".join(_e(n) for n in names[:limit])
229
+ more = len(names) - limit
230
+ if more > 0:
231
+ shown += f' · <span style="color:{FAINT};">+ {more} more</span>'
232
+ return shown
233
+
234
+
235
+ def _curation_section(report: ComparisonReport) -> str:
236
+ pruned = [o for o in report.plan.overrides if o.hidden]
237
+ renamed = [o for o in report.plan.overrides if not o.hidden and o.new_name]
238
+ rewritten = [
239
+ o for o in report.plan.overrides if not o.hidden and o.new_description and not o.new_name
240
+ ]
241
+
242
+ cards = []
243
+ if pruned:
244
+ cards.append(f"""
245
+ <div style="border:1px solid rgba(255,255,255,0.09); border-radius:13px; padding:18px; grid-column:1 / -1;">
246
+ <div style="display:flex; align-items:center; gap:10px; margin-bottom:14px; flex-wrap:wrap;">
247
+ <span class="mono badge" style="color:{AMBER}; border:1px solid rgba(233,178,78,0.3);">PRUNED · {len(pruned)}</span>
248
+ <span style="font-size:13px; color:{SUBTLE};">Hidden from the exposed set; still present on the origin server.</span>
249
+ </div>
250
+ <div class="mono" style="font-size:12px; color:{MUTED}; line-height:1.9;">{_names_line([o.original_name for o in pruned])}</div>
251
+ </div>""")
252
+ if renamed:
253
+ rows = "".join(
254
+ f'<div><span style="color:{SUBTLE};">{_e(o.original_name)}</span> '
255
+ f'<span style="color:{MUTED};">→</span> '
256
+ f'<span style="color:{GREEN};">{_e(o.new_name)}</span></div>'
257
+ for o in renamed[:6]
258
+ )
259
+ if len(renamed) > 6:
260
+ rows += (
261
+ f'<div class="mono" style="color:{FAINT}; font-size:11.5px;">'
262
+ f"+ {len(renamed) - 6} more</div>"
263
+ )
264
+ cards.append(f"""
265
+ <div style="border:1px solid rgba(255,255,255,0.09); border-radius:13px; padding:18px;">
266
+ <div style="display:flex; align-items:center; gap:10px; margin-bottom:14px;">
267
+ <span class="mono badge" style="color:{BLUE}; border:1px solid rgba(143,180,232,0.3);">RENAMED · {len(renamed)}</span>
268
+ </div>
269
+ <div class="mono" style="font-size:12px; line-height:2.1; word-break:break-all;">{rows}</div>
270
+ </div>""")
271
+ if rewritten:
272
+ rows = "".join(
273
+ f'<div><span style="color:{GREEN};">{_e(o.original_name)}</span> '
274
+ f'<span style="color:{MUTED};">— {_e(_shorten(o.new_description or "", 64))}</span></div>'
275
+ for o in rewritten[:6]
276
+ )
277
+ if len(rewritten) > 6:
278
+ rows += (
279
+ f'<div class="mono" style="color:{FAINT}; font-size:11.5px;">'
280
+ f"+ {len(rewritten) - 6} more</div>"
281
+ )
282
+ cards.append(f"""
283
+ <div style="border:1px solid rgba(255,255,255,0.09); border-radius:13px; padding:18px;">
284
+ <div style="display:flex; align-items:center; gap:10px; margin-bottom:14px;">
285
+ <span class="mono badge" style="color:{PURPLE}; border:1px solid rgba(199,155,232,0.3);">REWRITTEN · {len(rewritten)}</span>
286
+ </div>
287
+ <div class="mono" style="font-size:12px; line-height:2.1;">{rows}</div>
288
+ </div>""")
289
+
290
+ if not cards:
291
+ cards.append(f"""
292
+ <div style="border:1px solid rgba(255,255,255,0.09); border-radius:13px; padding:18px; grid-column:1 / -1;">
293
+ <span class="mono" style="font-size:12px; color:{MUTED};">The optimizer proposed no changes.</span>
294
+ </div>""")
295
+
296
+ notes = (
297
+ f'<p style="font-size:14px; color:{SUBTLE}; margin:0 0 16px;">{_e(report.plan.notes)}</p>'
298
+ if report.plan.notes
299
+ else ""
300
+ )
301
+ return f"""
302
+ <section style="margin-top:34px;">
303
+ <h2 style="font-size:18px; font-weight:600; margin:0 0 6px;">What curation changed</h2>
304
+ <p style="font-size:14px; color:{SUBTLE}; margin:0 0 6px;">Non-destructive overlay — origin tools untouched. {len(report.plan.overrides)} change{"s" if len(report.plan.overrides) != 1 else ""}.</p>
305
+ {notes}
306
+ <div class="cols2" style="display:grid; grid-template-columns:1fr 1fr; gap:14px;">
307
+ {"".join(cards)}
308
+ </div>
309
+ </section>"""
310
+
311
+
312
+ def _trace_side(side: SideView, label: str, color: str, expected: set[str]) -> str:
313
+ calls = []
314
+ for call in side.calls:
315
+ ok = not call.is_error and call.name in expected
316
+ box_class = "call-ok" if ok else "call-bad"
317
+ calls.append(
318
+ f'<div class="callbox mono {box_class}">{_e(call.name)}({_e(call.args)})</div>'
319
+ )
320
+ if not calls:
321
+ calls.append(
322
+ f'<div class="mono" style="color:{MUTED}; font-size:12px;">(no tool calls)</div>'
323
+ )
324
+
325
+ mark, mark_color = _mark(side.hit)
326
+ verdict = f'<div class="mono" style="margin-top:14px; color:{mark_color}; font-size:12px;">{mark} {"hit" if side.hit else "miss"}'
327
+ if side.missing_slots:
328
+ missing = ", ".join(" / ".join(slot) for slot in side.missing_slots)
329
+ verdict += f" — never called: {_e(missing)}"
330
+ if side.judge_passed is False and side.judge_rationale:
331
+ verdict += f" — judge: {_e(_shorten(side.judge_rationale, 100))}"
332
+ verdict += "</div>"
333
+
334
+ answer = ""
335
+ if side.final_text:
336
+ answer = (
337
+ f'<div class="mono" style="margin-top:10px; color:{MUTED}; font-size:11.5px;">'
338
+ f"final answer › {_e(_shorten(side.final_text, 160))}</div>"
339
+ )
340
+ return f"""
341
+ <div style="padding:20px;">
342
+ <div class="mono" style="font-size:11px; color:{color}; letter-spacing:0.06em; margin-bottom:16px;">{label}</div>
343
+ <div class="mono" style="font-size:12px; line-height:1.7; color:{SUBTLE};">
344
+ {"".join(calls)}
345
+ {verdict}
346
+ {answer}
347
+ </div>
348
+ </div>"""
349
+
350
+
351
+ def _trace_section(report: ComparisonReport) -> str:
352
+ if not report.tasks:
353
+ return ""
354
+ first_improved = next((t.task_id for t in report.improved), None)
355
+ blocks = []
356
+ for i, task in enumerate(report.tasks):
357
+ expected = {name for slot in task.expected_slots for name in slot}
358
+ # calls in the "after" run use curated names — accept those too
359
+ curated_expected = expected | {
360
+ o.presented_name
361
+ for o in report.plan.overrides
362
+ if not o.hidden and o.original_name in expected
363
+ }
364
+ before_mark, before_color = _mark(task.before.hit)
365
+ after_mark, after_color = _mark(task.after.hit)
366
+ open_attr = " open" if task.task_id == first_improved else ""
367
+ blocks.append(f"""
368
+ <details{open_attr} style="border:1px solid rgba(255,255,255,0.09); border-radius:14px; overflow:hidden; margin-bottom:12px;">
369
+ <summary style="display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 20px; background:#101316;">
370
+ <span style="display:flex; align-items:center; gap:14px; min-width:0;">
371
+ <span class="mono" style="font-size:12px; color:{MUTED};">#{i + 1:02d}</span>
372
+ <span style="font-size:14px; color:#EAECEE; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">“{_e(_shorten(task.prompt, 80))}”</span>
373
+ </span>
374
+ <span style="display:flex; align-items:center; gap:14px; flex-shrink:0;">
375
+ <span class="mono" style="font-size:12px; color:{before_color};">{before_mark} before</span>
376
+ <span class="mono" style="font-size:12px; color:{after_color};">{after_mark} after</span>
377
+ <span class="mono chev" style="font-size:16px; color:{MUTED};">⌄</span>
378
+ </span>
379
+ </summary>
380
+ <div class="cols2" style="display:grid; grid-template-columns:1fr 1fr; gap:0; border-top:1px solid rgba(255,255,255,0.07);">
381
+ <div style="border-right:1px solid rgba(255,255,255,0.07);">
382
+ {_trace_side(task.before, "BEFORE · raw tool set", AMBER, expected)}
383
+ </div>
384
+ {_trace_side(task.after, "AFTER · curated tool set", GREEN, curated_expected)}
385
+ </div>
386
+ </details>""")
387
+
388
+ return f"""
389
+ <section style="margin-top:34px;">
390
+ <h2 style="font-size:18px; font-weight:600; margin:0 0 6px;">Reasoning-path traces</h2>
391
+ <p style="font-size:14px; color:{SUBTLE}; margin:0 0 16px;">Every task's tool calls, before and after curation. Click a row to expand.</p>
392
+ {"".join(blocks)}
393
+ </section>"""
394
+
395
+
396
+ def render_html(report: ComparisonReport) -> str:
397
+ timestamp = report.generated_at.strftime("%Y-%m-%dT%H:%MZ")
398
+ return f"""<!DOCTYPE html>
399
+ <html lang="en">
400
+ <head>
401
+ <meta charset="utf-8">
402
+ <meta name="viewport" content="width=device-width, initial-scale=1">
403
+ <title>{_e(report.title)}</title>
404
+ <style>{_CSS}</style>
405
+ </head>
406
+ <body>
407
+ <div style="max-width:940px; margin:0 auto; padding:48px 28px 64px;">
408
+ {_header(report)}
409
+ {_headline(report)}
410
+ {_task_table(report)}
411
+ {_curation_section(report)}
412
+ {_trace_section(report)}
413
+ <footer style="margin-top:44px; padding-top:24px; border-top:1px solid rgba(255,255,255,0.08); display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:14px;">
414
+ <div class="mono" style="font-size:12.5px; color:{MUTED};">generated by <span style="color:{SUBTLE};">whetkit v{__version__}</span> · {timestamp} · origin server never modified</div>
415
+ <a href="https://github.com/benlamlih/whetkit" class="mono" style="font-size:12.5px;">github.com/benlamlih/whetkit ↗</a>
416
+ </footer>
417
+ </div>
418
+ </body>
419
+ </html>
420
+ """
@@ -0,0 +1,5 @@
1
+ """The eval runner: an agentic loop with real MCP tool execution."""
2
+
3
+ from whetkit.runner.agent import DEFAULT_SYSTEM_PROMPT, RunConfig, run_task, run_tasks
4
+
5
+ __all__ = ["DEFAULT_SYSTEM_PROMPT", "RunConfig", "run_task", "run_tasks"]
@@ -0,0 +1,172 @@
1
+ """The core eval loop.
2
+
3
+ Give the model the task prompt and the server's tools; execute every tool
4
+ call it makes against the real MCP server; feed results back; repeat until
5
+ the model answers without tool calls or the turn limit is hit. Tool failures
6
+ are returned to the model as error results, not raised — an agent that
7
+ recovers from a bad call can still succeed.
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from collections.abc import Callable
13
+
14
+ import mcp.types as types
15
+ from pydantic import BaseModel
16
+
17
+ from whetkit.datasets import TaskSpec
18
+ from whetkit.llm import ChatMessage, LLMProvider, ToolDef, ToolResult, get_provider, parse_model
19
+ from whetkit.mcp import MCPClient, ServerSpec
20
+ from whetkit.tracing import TaskRun, ToolCallRecord, TurnRecord
21
+ from whetkit.tracing.records import RunStatus, utc_now
22
+
23
+ DEFAULT_SYSTEM_PROMPT = (
24
+ "You are a capable assistant with access to tools from an MCP server. "
25
+ "Use the tools to complete the user's request. When you are done, reply "
26
+ "with a clear final answer and no further tool calls."
27
+ )
28
+
29
+ DEFAULT_MODEL = "anthropic:claude-sonnet-5"
30
+
31
+
32
+ class RunConfig(BaseModel):
33
+ model: str = DEFAULT_MODEL
34
+ max_turns: int = 10
35
+ max_tokens: int = 1024
36
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT
37
+
38
+
39
+ def _render_tool_result(result: types.CallToolResult) -> str:
40
+ parts: list[str] = []
41
+ for block in result.content:
42
+ if isinstance(block, types.TextContent):
43
+ parts.append(block.text)
44
+ else:
45
+ parts.append(f"[{block.type} content]")
46
+ return "\n".join(parts) if parts else "(empty result)"
47
+
48
+
49
+ async def _execute_call(client: MCPClient, name: str, arguments: dict) -> tuple[str, bool]:
50
+ """Run one tool call; never raises. Returns (result_text, is_error)."""
51
+ try:
52
+ result = await client.call_tool(name, arguments)
53
+ return _render_tool_result(result), bool(result.isError)
54
+ except Exception as exc:
55
+ return f"Tool call failed: {exc}", True
56
+
57
+
58
+ async def run_task(
59
+ task: TaskSpec,
60
+ server: ServerSpec,
61
+ config: RunConfig | None = None,
62
+ provider: LLMProvider | None = None,
63
+ client_factory: Callable[[ServerSpec], MCPClient] = MCPClient,
64
+ ) -> TaskRun:
65
+ """Run one task's agent loop against a live MCP server.
66
+
67
+ ``client_factory`` lets callers interpose a transforming client (e.g. the
68
+ curation overlay) between the agent and the origin server.
69
+ """
70
+ config = config or RunConfig()
71
+ provider_name, model_id = parse_model(config.model)
72
+ provider = provider or get_provider(provider_name)
73
+
74
+ run = TaskRun(task_id=task.id, server=server.label(), model=config.model)
75
+ messages: list[ChatMessage] = [ChatMessage(role="user", content=task.prompt)]
76
+
77
+ try:
78
+ async with client_factory(server) as client:
79
+ mcp_tools = await client.list_tools()
80
+ tool_defs = [
81
+ ToolDef(
82
+ name=t.name,
83
+ description=t.description or "",
84
+ input_schema=t.inputSchema or {"type": "object"},
85
+ )
86
+ for t in mcp_tools
87
+ ]
88
+
89
+ for turn_index in range(config.max_turns):
90
+ started = time.perf_counter()
91
+ turn = await provider.complete(
92
+ model=model_id,
93
+ system=config.system_prompt,
94
+ messages=messages,
95
+ tools=tool_defs,
96
+ max_tokens=config.max_tokens,
97
+ )
98
+ turn_latency = (time.perf_counter() - started) * 1000
99
+
100
+ record = TurnRecord(
101
+ index=turn_index,
102
+ assistant_text=turn.text,
103
+ usage=turn.usage,
104
+ latency_ms=turn_latency,
105
+ stop_reason=turn.stop_reason,
106
+ )
107
+ run.turns.append(record)
108
+
109
+ if not turn.tool_calls:
110
+ run.final_text = turn.text
111
+ run.status = RunStatus.COMPLETED
112
+ break
113
+
114
+ messages.append(
115
+ ChatMessage(role="assistant", content=turn.text, tool_calls=turn.tool_calls)
116
+ )
117
+ results: list[ToolResult] = []
118
+ for call in turn.tool_calls:
119
+ arguments = call.arguments if isinstance(call.arguments, dict) else {}
120
+ call_started = time.perf_counter()
121
+ result_text, is_error = await _execute_call(client, call.name, arguments)
122
+ call_latency = (time.perf_counter() - call_started) * 1000
123
+ record.tool_calls.append(
124
+ ToolCallRecord(
125
+ call_id=call.id,
126
+ name=call.name,
127
+ arguments=arguments,
128
+ result_text=result_text,
129
+ is_error=is_error,
130
+ latency_ms=call_latency,
131
+ )
132
+ )
133
+ results.append(
134
+ ToolResult(call_id=call.id, content=result_text, is_error=is_error)
135
+ )
136
+ messages.append(ChatMessage(role="user", tool_results=results))
137
+ else:
138
+ run.status = RunStatus.MAX_TURNS
139
+ except Exception as exc:
140
+ run.status = RunStatus.ERROR
141
+ run.error = f"{type(exc).__name__}: {exc}"
142
+
143
+ run.finished_at = utc_now()
144
+ return run
145
+
146
+
147
+ async def run_tasks(
148
+ tasks: list[TaskSpec],
149
+ servers: dict[str, ServerSpec],
150
+ config: RunConfig | None = None,
151
+ provider: LLMProvider | None = None,
152
+ ) -> list[TaskRun]:
153
+ """Run several tasks sequentially. ``servers`` maps task.server strings
154
+ (as stored on each task) to resolved specs."""
155
+ runs = []
156
+ for task in tasks:
157
+ runs.append(await run_task(task, servers[task.server], config, provider))
158
+ return runs
159
+
160
+
161
+ def summarize_run(run: TaskRun) -> str:
162
+ """One-line human summary, used by the CLI."""
163
+ tools = " -> ".join(run.called_tool_names) or "(no tool calls)"
164
+ usage = run.total_usage
165
+ return (
166
+ f"{run.task_id}: {run.status} in {len(run.turns)} turn(s), "
167
+ f"tools: {tools}, tokens in/out: {usage.input_tokens}/{usage.output_tokens}"
168
+ )
169
+
170
+
171
+ def dump_run_json(run: TaskRun) -> str:
172
+ return json.dumps(run.model_dump(mode="json"), indent=2)
@@ -0,0 +1,18 @@
1
+ """Scoring: deterministic tool-selection matching + LLM-as-judge grading."""
2
+
3
+ from whetkit.scoring.aggregate import EvalSummary, TaskScore, score_runs
4
+ from whetkit.scoring.deterministic import MatchMode, ToolMatchResult, score_tool_match
5
+ from whetkit.scoring.judge import JudgeCache, JudgeConfig, JudgeVerdict, judge_run
6
+
7
+ __all__ = [
8
+ "EvalSummary",
9
+ "JudgeCache",
10
+ "JudgeConfig",
11
+ "JudgeVerdict",
12
+ "MatchMode",
13
+ "TaskScore",
14
+ "ToolMatchResult",
15
+ "judge_run",
16
+ "score_runs",
17
+ "score_tool_match",
18
+ ]