whetkit 0.1.1__tar.gz → 0.2.0__tar.gz
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-0.1.1 → whetkit-0.2.0}/PKG-INFO +1 -1
- {whetkit-0.1.1 → whetkit-0.2.0}/pyproject.toml +1 -1
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/cli.py +158 -26
- whetkit-0.2.0/src/whetkit/curation/export.py +73 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/__init__.py +2 -1
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/aggregate.py +45 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/LICENSE +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/README.md +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/optimizer.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/overlay.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/curation/plan.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/datasets/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/datasets/tasks.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/doctor.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/generate.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/anthropic_provider.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/base.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/openai_provider.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/llm/registry.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/client.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/introspect.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/mcp/transport.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/py.typed +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/builder.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/report/html.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/runner/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/runner/agent.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/deterministic.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/scoring/judge.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/tracing/__init__.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/tracing/records.py +0 -0
- {whetkit-0.1.1 → whetkit-0.2.0}/src/whetkit/tracing/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whetkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Measure and improve how well LLM agents select and use the tools exposed by an MCP server
|
|
5
5
|
Keywords: mcp,model-context-protocol,llm,agents,evals,tool-use,tool-selection,benchmark
|
|
6
6
|
Author: Mohammed Benlamlih
|
|
@@ -51,6 +51,34 @@ def _write_report(report, out_dir: str) -> tuple[str, str]:
|
|
|
51
51
|
return str(html_path), str(json_path)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _summary_payload(group_name: str, summary, task_runs: list) -> dict:
|
|
55
|
+
"""One run's metrics as plain data — what --summary-json emits per run."""
|
|
56
|
+
return {
|
|
57
|
+
"group": group_name,
|
|
58
|
+
"hit_rate": summary.hit_rate,
|
|
59
|
+
"tool_hit_rate": summary.tool_hit_rate,
|
|
60
|
+
"judge_pass_rate": summary.judge_pass_rate,
|
|
61
|
+
"avg_precision": summary.avg_precision,
|
|
62
|
+
"avg_recall": summary.avg_recall,
|
|
63
|
+
"avg_extra_calls": summary.avg_extra_calls,
|
|
64
|
+
"tokens_in": sum(r.total_usage.input_tokens for r in task_runs),
|
|
65
|
+
"tokens_out": sum(r.total_usage.output_tokens for r in task_runs),
|
|
66
|
+
"latency_ms": sum(r.total_latency_ms for r in task_runs),
|
|
67
|
+
"tasks": [
|
|
68
|
+
{
|
|
69
|
+
"id": score.task_id,
|
|
70
|
+
"hit": score.hit,
|
|
71
|
+
"tool_hit": score.tool_hit,
|
|
72
|
+
"judge_passed": score.judge.passed if score.judge else None,
|
|
73
|
+
"called": score.tool_match.called,
|
|
74
|
+
"missing": score.tool_match.missing_slots,
|
|
75
|
+
"extra_calls": score.tool_match.extra_calls,
|
|
76
|
+
}
|
|
77
|
+
for score in summary.scores
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
54
82
|
def _version_callback(value: bool) -> None:
|
|
55
83
|
if value:
|
|
56
84
|
from importlib.metadata import version
|
|
@@ -204,6 +232,47 @@ def generate(
|
|
|
204
232
|
typer.echo(f" whetkit run --server {server} --tasks {out}")
|
|
205
233
|
|
|
206
234
|
|
|
235
|
+
@app.command()
|
|
236
|
+
def export(
|
|
237
|
+
plan: Annotated[
|
|
238
|
+
str, typer.Option("--plan", help="Curation plan YAML to export")
|
|
239
|
+
] = ".whetkit/curation-plan.yaml",
|
|
240
|
+
to: Annotated[
|
|
241
|
+
str,
|
|
242
|
+
typer.Option(
|
|
243
|
+
"--to",
|
|
244
|
+
help=(
|
|
245
|
+
"'markdown' — a fix report ready for an upstream issue/PR; "
|
|
246
|
+
"'json' — a neutral override list for gateways and scripts"
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
] = "markdown",
|
|
250
|
+
out: Annotated[str | None, typer.Option("--out", help="Write here instead of stdout")] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Export a curation plan as a shareable fix report or neutral JSON."""
|
|
253
|
+
from whetkit.curation import load_plan
|
|
254
|
+
from whetkit.curation.export import plan_to_json, plan_to_markdown
|
|
255
|
+
|
|
256
|
+
if to not in ("markdown", "json"):
|
|
257
|
+
raise typer.BadParameter("--to must be 'markdown' or 'json'")
|
|
258
|
+
if not Path(plan).is_file():
|
|
259
|
+
raise typer.BadParameter(
|
|
260
|
+
f"no curation plan at {plan} — run 'whetkit curate' first, or pass --plan"
|
|
261
|
+
)
|
|
262
|
+
curation_plan = load_plan(plan)
|
|
263
|
+
if not curation_plan.overrides:
|
|
264
|
+
typer.echo("plan has no overrides — nothing to export", err=True)
|
|
265
|
+
raise typer.Exit(code=1)
|
|
266
|
+
|
|
267
|
+
rendered = (plan_to_markdown if to == "markdown" else plan_to_json)(curation_plan)
|
|
268
|
+
if out:
|
|
269
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
Path(out).write_text(rendered)
|
|
271
|
+
typer.echo(f"wrote {out}")
|
|
272
|
+
else:
|
|
273
|
+
typer.echo(rendered, nl=False)
|
|
274
|
+
|
|
275
|
+
|
|
207
276
|
@app.command()
|
|
208
277
|
def run(
|
|
209
278
|
tasks: Annotated[
|
|
@@ -250,21 +319,45 @@ def run(
|
|
|
250
319
|
help="Completion-token budget per model turn (raise for reasoning models)",
|
|
251
320
|
),
|
|
252
321
|
] = 1024,
|
|
322
|
+
runs: Annotated[
|
|
323
|
+
int,
|
|
324
|
+
typer.Option(
|
|
325
|
+
"--runs",
|
|
326
|
+
help=(
|
|
327
|
+
"Repeat the whole task set N times and report mean plus range "
|
|
328
|
+
"— single runs are noise. Trace groups get a -1..-N suffix."
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
] = 1,
|
|
253
332
|
store: Annotated[
|
|
254
333
|
str | None,
|
|
255
334
|
typer.Option("--store", help="Trace SQLite path (default ./.whetkit/traces.sqlite3)"),
|
|
256
335
|
] = None,
|
|
257
336
|
jsonl: Annotated[
|
|
258
|
-
str | None,
|
|
337
|
+
str | None,
|
|
338
|
+
typer.Option(
|
|
339
|
+
"--jsonl",
|
|
340
|
+
help="Also write traces to this JSONL file (suffixed per run when --runs > 1)",
|
|
341
|
+
),
|
|
342
|
+
] = None,
|
|
343
|
+
summary_json: Annotated[
|
|
344
|
+
str | None,
|
|
345
|
+
typer.Option(
|
|
346
|
+
"--summary-json",
|
|
347
|
+
help="Write a machine-readable summary (metrics + per-task outcomes) to this path",
|
|
348
|
+
),
|
|
259
349
|
] = None,
|
|
260
350
|
http_mode: Annotated[HttpMode, typer.Option("--http-mode")] = HttpMode.STATEFUL,
|
|
261
351
|
) -> None:
|
|
262
352
|
"""Run eval tasks against an MCP server and print scored results."""
|
|
263
353
|
from whetkit.datasets import load_tasks
|
|
264
354
|
from whetkit.runner import RunConfig, run_task
|
|
265
|
-
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, score_runs
|
|
355
|
+
from whetkit.scoring import JudgeCache, JudgeConfig, MatchMode, MultiRunSummary, score_runs
|
|
266
356
|
from whetkit.tracing import TraceStore, default_store_path, write_jsonl
|
|
267
357
|
|
|
358
|
+
if runs < 1:
|
|
359
|
+
raise typer.BadParameter("--runs must be at least 1")
|
|
360
|
+
|
|
268
361
|
task_list = load_tasks(tasks)
|
|
269
362
|
servers = _resolve_task_servers(task_list, server, http_mode)
|
|
270
363
|
config = RunConfig(model=model, max_turns=max_turns, max_tokens=max_tokens)
|
|
@@ -286,34 +379,28 @@ def run(
|
|
|
286
379
|
client_factory = partial(CuratedMCPClient, plan=curation_plan)
|
|
287
380
|
name_map = curation_plan.rename_map()
|
|
288
381
|
|
|
289
|
-
async def
|
|
290
|
-
|
|
382
|
+
async def _run_once(group_name: str, cache: JudgeCache):
|
|
383
|
+
task_runs = []
|
|
291
384
|
for task in task_list:
|
|
292
385
|
typer.echo(f"running {task.id} ...", err=True)
|
|
293
|
-
|
|
386
|
+
task_runs.append(
|
|
294
387
|
await run_task(task, servers[task.server], config, client_factory=client_factory)
|
|
295
388
|
)
|
|
296
389
|
|
|
297
390
|
with TraceStore(store_path) as trace_store:
|
|
298
|
-
trace_store.save_runs(
|
|
299
|
-
if jsonl:
|
|
300
|
-
write_jsonl(runs, jsonl)
|
|
391
|
+
trace_store.save_runs(task_runs, run_group=group_name)
|
|
301
392
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
name_map=name_map,
|
|
312
|
-
)
|
|
313
|
-
finally:
|
|
314
|
-
cache.close()
|
|
393
|
+
summary = await score_runs(
|
|
394
|
+
task_list,
|
|
395
|
+
task_runs,
|
|
396
|
+
mode=MatchMode(match_mode),
|
|
397
|
+
judge_config=JudgeConfig(model=judge_model),
|
|
398
|
+
judge_cache=cache,
|
|
399
|
+
use_judge=use_judge,
|
|
400
|
+
name_map=name_map,
|
|
401
|
+
)
|
|
315
402
|
|
|
316
|
-
typer.echo(f"\nResults (group '{
|
|
403
|
+
typer.echo(f"\nResults (group '{group_name}', model {model}):")
|
|
317
404
|
for score in summary.scores:
|
|
318
405
|
mark = "PASS" if score.hit else "MISS"
|
|
319
406
|
tools = " -> ".join(score.tool_match.called) or "(no tool calls)"
|
|
@@ -326,17 +413,62 @@ def run(
|
|
|
326
413
|
typer.echo("")
|
|
327
414
|
for line in summary.summary_lines():
|
|
328
415
|
typer.echo(line)
|
|
329
|
-
tokens_in = sum(r.total_usage.input_tokens for r in
|
|
330
|
-
tokens_out = sum(r.total_usage.output_tokens for r in
|
|
416
|
+
tokens_in = sum(r.total_usage.input_tokens for r in task_runs)
|
|
417
|
+
tokens_out = sum(r.total_usage.output_tokens for r in task_runs)
|
|
331
418
|
typer.echo(
|
|
332
419
|
f"Tokens in/out: {tokens_in}/{tokens_out} "
|
|
333
|
-
f"Total latency: {sum(r.total_latency_ms for r in
|
|
420
|
+
f"Total latency: {sum(r.total_latency_ms for r in task_runs) / 1000:.1f}s"
|
|
334
421
|
)
|
|
422
|
+
return task_runs, summary
|
|
423
|
+
|
|
424
|
+
async def _run() -> None:
|
|
425
|
+
summaries = []
|
|
426
|
+
payloads = []
|
|
427
|
+
cache = JudgeCache(default_store_path().parent / "judge_cache.sqlite3")
|
|
428
|
+
try:
|
|
429
|
+
for run_index in range(1, runs + 1):
|
|
430
|
+
group_name = group if runs == 1 else f"{group}-{run_index}"
|
|
431
|
+
task_runs, summary = await _run_once(group_name, cache)
|
|
432
|
+
summaries.append(summary)
|
|
433
|
+
payloads.append(_summary_payload(group_name, summary, task_runs))
|
|
434
|
+
if jsonl:
|
|
435
|
+
write_jsonl(task_runs, jsonl if runs == 1 else f"{jsonl}.{run_index}")
|
|
436
|
+
finally:
|
|
437
|
+
cache.close()
|
|
438
|
+
|
|
439
|
+
multi = MultiRunSummary(summaries=summaries)
|
|
440
|
+
if runs > 1:
|
|
441
|
+
typer.echo(f"\n== across {runs} runs ==")
|
|
442
|
+
for line in multi.summary_lines():
|
|
443
|
+
typer.echo(line)
|
|
335
444
|
if judge == "auto" and not use_judge:
|
|
336
445
|
typer.echo(
|
|
337
446
|
"(LLM judge skipped: no API key found — set ANTHROPIC_API_KEY or pass --judge on)"
|
|
338
447
|
)
|
|
339
|
-
|
|
448
|
+
suffix = f" (groups '{group}-1'..'-{runs}')" if runs > 1 else f" (group '{group}')"
|
|
449
|
+
typer.echo(f"Traces saved to {store_path}{suffix}")
|
|
450
|
+
|
|
451
|
+
if summary_json:
|
|
452
|
+
import json as jsonlib
|
|
453
|
+
|
|
454
|
+
document = {
|
|
455
|
+
"model": model,
|
|
456
|
+
"judge_model": judge_model if use_judge else None,
|
|
457
|
+
"match_mode": match_mode,
|
|
458
|
+
"plan": plan,
|
|
459
|
+
"runs": payloads,
|
|
460
|
+
}
|
|
461
|
+
if runs > 1:
|
|
462
|
+
document["aggregate"] = {
|
|
463
|
+
"n": multi.n,
|
|
464
|
+
"hit_rate_mean": sum(s.hit_rate for s in summaries) / len(summaries),
|
|
465
|
+
"hit_rate_min": min(s.hit_rate for s in summaries),
|
|
466
|
+
"hit_rate_max": max(s.hit_rate for s in summaries),
|
|
467
|
+
"flaky_tasks": multi.flaky_tasks(),
|
|
468
|
+
}
|
|
469
|
+
Path(summary_json).parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
Path(summary_json).write_text(jsonlib.dumps(document, indent=2) + "\n")
|
|
471
|
+
typer.echo(f"Summary JSON: {summary_json}")
|
|
340
472
|
|
|
341
473
|
asyncio.run(_run())
|
|
342
474
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Export a curation plan into shareable formats.
|
|
2
|
+
|
|
3
|
+
A plan is most useful when it leaves the machine it was made on: as a
|
|
4
|
+
Markdown fix report you can paste into an upstream issue or PR, or as
|
|
5
|
+
neutral JSON for gateways and scripts that apply metadata overrides
|
|
6
|
+
themselves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from whetkit.curation.plan import CurationPlan, ToolOverride
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _action(override: ToolOverride) -> str:
|
|
15
|
+
if override.hidden:
|
|
16
|
+
return "hide"
|
|
17
|
+
if override.new_name and override.new_description:
|
|
18
|
+
return "rename + rewrite"
|
|
19
|
+
if override.new_name:
|
|
20
|
+
return "rename"
|
|
21
|
+
return "rewrite"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_to_markdown(plan: CurationPlan) -> str:
|
|
25
|
+
"""A fix report for humans — upstream-issue / PR-description ready."""
|
|
26
|
+
lines = [
|
|
27
|
+
"## Proposed tool-surface fixes",
|
|
28
|
+
"",
|
|
29
|
+
"Metadata-only changes — no schemas or behavior are touched. "
|
|
30
|
+
"Measured with [whetkit](https://github.com/benlamlih/whetkit); "
|
|
31
|
+
"each change is reversible (it can be served as an overlay without "
|
|
32
|
+
"modifying the server).",
|
|
33
|
+
"",
|
|
34
|
+
]
|
|
35
|
+
if plan.notes:
|
|
36
|
+
lines += [f"> {plan.notes.strip()}", ""]
|
|
37
|
+
lines += [
|
|
38
|
+
"| Tool | Change | Proposal | Why |",
|
|
39
|
+
"|---|---|---|---|",
|
|
40
|
+
]
|
|
41
|
+
for override in plan.overrides:
|
|
42
|
+
if override.hidden:
|
|
43
|
+
proposal = "hide from the tool list"
|
|
44
|
+
else:
|
|
45
|
+
parts = []
|
|
46
|
+
if override.new_name:
|
|
47
|
+
parts.append(f"rename to `{override.new_name}`")
|
|
48
|
+
if override.new_description:
|
|
49
|
+
parts.append(f'description: "{override.new_description.strip()}"')
|
|
50
|
+
proposal = "; ".join(parts)
|
|
51
|
+
cell = lambda s: " ".join(str(s).split()).replace("|", "\\|") # noqa: E731
|
|
52
|
+
lines.append(
|
|
53
|
+
f"| `{override.original_name}` | {_action(override)} "
|
|
54
|
+
f"| {cell(proposal)} | {cell(override.reason) or '—'} |"
|
|
55
|
+
)
|
|
56
|
+
return "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def plan_to_json(plan: CurationPlan) -> str:
|
|
60
|
+
"""Neutral override list for gateways/scripts that apply their own
|
|
61
|
+
metadata overrides (Cloudflare portal aliases, MetaMCP namespaces, …)."""
|
|
62
|
+
overrides = [
|
|
63
|
+
{
|
|
64
|
+
"original_name": o.original_name,
|
|
65
|
+
"action": _action(o),
|
|
66
|
+
"new_name": o.new_name,
|
|
67
|
+
"new_description": o.new_description,
|
|
68
|
+
"hidden": o.hidden,
|
|
69
|
+
"reason": o.reason,
|
|
70
|
+
}
|
|
71
|
+
for o in plan.overrides
|
|
72
|
+
]
|
|
73
|
+
return json.dumps({"notes": plan.notes, "overrides": overrides}, indent=2) + "\n"
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""Scoring: deterministic tool-selection matching + LLM-as-judge grading."""
|
|
2
2
|
|
|
3
|
-
from whetkit.scoring.aggregate import EvalSummary, TaskScore, score_runs
|
|
3
|
+
from whetkit.scoring.aggregate import EvalSummary, MultiRunSummary, TaskScore, score_runs
|
|
4
4
|
from whetkit.scoring.deterministic import MatchMode, ToolMatchResult, score_tool_match
|
|
5
5
|
from whetkit.scoring.judge import JudgeCache, JudgeConfig, JudgeVerdict, judge_run
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"EvalSummary",
|
|
9
|
+
"MultiRunSummary",
|
|
9
10
|
"JudgeCache",
|
|
10
11
|
"JudgeConfig",
|
|
11
12
|
"JudgeVerdict",
|
|
@@ -88,6 +88,51 @@ class EvalSummary(BaseModel):
|
|
|
88
88
|
return lines
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
class MultiRunSummary(BaseModel):
|
|
92
|
+
"""Aggregate over repeated executions of the same task set.
|
|
93
|
+
|
|
94
|
+
Single runs are noise: the same server, tasks, and model can flip a
|
|
95
|
+
task between runs. The honest headline is mean plus range."""
|
|
96
|
+
|
|
97
|
+
summaries: list[EvalSummary]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def n(self) -> int:
|
|
101
|
+
return len(self.summaries)
|
|
102
|
+
|
|
103
|
+
def _spread(self, values: list[float]) -> str:
|
|
104
|
+
mean = sum(values) / len(values)
|
|
105
|
+
low, high = min(values), max(values)
|
|
106
|
+
if low == high:
|
|
107
|
+
return f"{mean:.0%}"
|
|
108
|
+
return f"{mean:.0%} [{low:.0%}–{high:.0%}]"
|
|
109
|
+
|
|
110
|
+
def summary_lines(self) -> list[str]:
|
|
111
|
+
lines = [
|
|
112
|
+
f"Runs: {self.n} × {self.summaries[0].task_count} task(s)",
|
|
113
|
+
f"Hit-rate: {self._spread([s.hit_rate for s in self.summaries])}",
|
|
114
|
+
f"Tool-selection hit-rate: {self._spread([s.tool_hit_rate for s in self.summaries])}",
|
|
115
|
+
f"Tool precision (avg): {self._spread([s.avg_precision for s in self.summaries])}",
|
|
116
|
+
]
|
|
117
|
+
judged = [s.judge_pass_rate for s in self.summaries if s.judge_pass_rate is not None]
|
|
118
|
+
if judged:
|
|
119
|
+
lines.insert(2, f"Judge pass-rate: {self._spread(judged)}")
|
|
120
|
+
extras = [s.avg_extra_calls for s in self.summaries]
|
|
121
|
+
lines.append(f"Unnecessary calls (avg): {sum(extras) / len(extras):.1f}/task")
|
|
122
|
+
flaky = self.flaky_tasks()
|
|
123
|
+
if flaky:
|
|
124
|
+
lines.append(f"Flaky tasks (hit in some runs, missed in others): {', '.join(flaky)}")
|
|
125
|
+
return lines
|
|
126
|
+
|
|
127
|
+
def flaky_tasks(self) -> list[str]:
|
|
128
|
+
"""Task ids whose hit outcome differs across runs."""
|
|
129
|
+
outcomes: dict[str, set[bool]] = {}
|
|
130
|
+
for summary in self.summaries:
|
|
131
|
+
for score in summary.scores:
|
|
132
|
+
outcomes.setdefault(score.task_id, set()).add(score.hit)
|
|
133
|
+
return sorted(task_id for task_id, seen in outcomes.items() if len(seen) > 1)
|
|
134
|
+
|
|
135
|
+
|
|
91
136
|
async def score_runs(
|
|
92
137
|
tasks: list[TaskSpec],
|
|
93
138
|
runs: list[TaskRun],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|