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/__init__.py +3 -0
- whetkit/cli.py +434 -0
- whetkit/curation/__init__.py +17 -0
- whetkit/curation/optimizer.py +189 -0
- whetkit/curation/overlay.py +73 -0
- whetkit/curation/plan.py +109 -0
- whetkit/datasets/__init__.py +5 -0
- whetkit/datasets/tasks.py +118 -0
- whetkit/llm/__init__.py +24 -0
- whetkit/llm/anthropic_provider.py +90 -0
- whetkit/llm/base.py +88 -0
- whetkit/llm/openai_provider.py +100 -0
- whetkit/llm/registry.py +37 -0
- whetkit/mcp/__init__.py +17 -0
- whetkit/mcp/client.py +59 -0
- whetkit/mcp/introspect.py +106 -0
- whetkit/mcp/transport.py +150 -0
- whetkit/py.typed +0 -0
- whetkit/report/__init__.py +6 -0
- whetkit/report/builder.py +228 -0
- whetkit/report/html.py +420 -0
- whetkit/runner/__init__.py +5 -0
- whetkit/runner/agent.py +172 -0
- whetkit/scoring/__init__.py +18 -0
- whetkit/scoring/aggregate.py +117 -0
- whetkit/scoring/deterministic.py +99 -0
- whetkit/scoring/judge.py +170 -0
- whetkit/tracing/__init__.py +21 -0
- whetkit/tracing/records.py +76 -0
- whetkit/tracing/store.py +172 -0
- whetkit-0.1.0.dist-info/METADATA +182 -0
- whetkit-0.1.0.dist-info/RECORD +35 -0
- whetkit-0.1.0.dist-info/WHEEL +4 -0
- whetkit-0.1.0.dist-info/entry_points.txt +3 -0
- whetkit-0.1.0.dist-info/licenses/LICENSE +202 -0
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
|
+
"""
|
whetkit/runner/agent.py
ADDED
|
@@ -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
|
+
]
|