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/mcp/client.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""High-level MCP client used by the inspector, runner, and overlay proxy."""
|
|
2
|
+
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import mcp.types as types
|
|
8
|
+
from mcp import ClientSession
|
|
9
|
+
|
|
10
|
+
from whetkit.mcp.transport import HttpMode, HttpSpec, ServerSpec, open_session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MCPClient:
|
|
14
|
+
"""Async client over any ServerSpec.
|
|
15
|
+
|
|
16
|
+
For stdio and stateful HTTP the client holds one session for its whole
|
|
17
|
+
lifetime. For stateless HTTP (2026-07-28 spec semantics) every operation
|
|
18
|
+
runs on a fresh exchange, so no session state is assumed server-side.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, spec: ServerSpec):
|
|
22
|
+
self.spec = spec
|
|
23
|
+
self._stack: AsyncExitStack | None = None
|
|
24
|
+
self._session: ClientSession | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def _stateless(self) -> bool:
|
|
28
|
+
return isinstance(self.spec, HttpSpec) and self.spec.mode == HttpMode.STATELESS
|
|
29
|
+
|
|
30
|
+
async def __aenter__(self) -> Self:
|
|
31
|
+
if not self._stateless:
|
|
32
|
+
self._stack = AsyncExitStack()
|
|
33
|
+
self._session = await self._stack.enter_async_context(open_session(self.spec))
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
async def __aexit__(
|
|
37
|
+
self,
|
|
38
|
+
exc_type: type[BaseException] | None,
|
|
39
|
+
exc: BaseException | None,
|
|
40
|
+
tb: TracebackType | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
if self._stack is not None:
|
|
43
|
+
await self._stack.aclose()
|
|
44
|
+
self._stack = None
|
|
45
|
+
self._session = None
|
|
46
|
+
|
|
47
|
+
async def list_tools(self) -> list[types.Tool]:
|
|
48
|
+
if self._session is not None:
|
|
49
|
+
result = await self._session.list_tools()
|
|
50
|
+
return list(result.tools)
|
|
51
|
+
async with open_session(self.spec) as session:
|
|
52
|
+
result = await session.list_tools()
|
|
53
|
+
return list(result.tools)
|
|
54
|
+
|
|
55
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
|
56
|
+
if self._session is not None:
|
|
57
|
+
return await self._session.call_tool(name, arguments)
|
|
58
|
+
async with open_session(self.spec) as session:
|
|
59
|
+
return await session.call_tool(name, arguments)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tool introspection: inventory a server's tools and summarize their cost."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from whetkit.mcp.client import MCPClient
|
|
8
|
+
from whetkit.mcp.transport import ServerSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def estimate_tokens(text: str) -> int:
|
|
12
|
+
"""Rough token estimate (~4 chars/token for English prose). Good enough
|
|
13
|
+
for comparing tool-set sizes; not a billing meter."""
|
|
14
|
+
return max(1, round(len(text) / 4)) if text else 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def schema_complexity(schema: dict[str, Any] | None) -> int:
|
|
18
|
+
"""Score a JSON schema: one point per property/branch, plus nesting depth.
|
|
19
|
+
|
|
20
|
+
A flat 2-arg tool scores ~3; deeply nested or union-heavy schemas score
|
|
21
|
+
much higher — which correlates with how hard the schema is for a model
|
|
22
|
+
to fill correctly.
|
|
23
|
+
"""
|
|
24
|
+
if not schema:
|
|
25
|
+
return 0
|
|
26
|
+
|
|
27
|
+
def walk(node: Any, depth: int) -> int:
|
|
28
|
+
if not isinstance(node, dict):
|
|
29
|
+
return 0
|
|
30
|
+
score = 0
|
|
31
|
+
props = node.get("properties")
|
|
32
|
+
if isinstance(props, dict):
|
|
33
|
+
for sub in props.values():
|
|
34
|
+
score += 1 + walk(sub, depth + 1)
|
|
35
|
+
for branch_key in ("anyOf", "oneOf", "allOf"):
|
|
36
|
+
branches = node.get(branch_key)
|
|
37
|
+
if isinstance(branches, list):
|
|
38
|
+
for sub in branches:
|
|
39
|
+
score += 1 + walk(sub, depth + 1)
|
|
40
|
+
items = node.get("items")
|
|
41
|
+
if isinstance(items, dict):
|
|
42
|
+
score += walk(items, depth + 1)
|
|
43
|
+
return score + (1 if depth == 0 else 0)
|
|
44
|
+
|
|
45
|
+
return walk(schema, 0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolInfo(BaseModel):
|
|
49
|
+
name: str
|
|
50
|
+
title: str | None = None
|
|
51
|
+
description: str = ""
|
|
52
|
+
input_schema: dict[str, Any] = {}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def description_tokens(self) -> int:
|
|
56
|
+
return estimate_tokens(self.description)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complexity(self) -> int:
|
|
60
|
+
return schema_complexity(self.input_schema)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def param_count(self) -> int:
|
|
64
|
+
props = self.input_schema.get("properties")
|
|
65
|
+
return len(props) if isinstance(props, dict) else 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerInventory(BaseModel):
|
|
69
|
+
server: str
|
|
70
|
+
tools: list[ToolInfo]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def tool_count(self) -> int:
|
|
74
|
+
return len(self.tools)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def total_description_tokens(self) -> int:
|
|
78
|
+
return sum(t.description_tokens for t in self.tools)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total_complexity(self) -> int:
|
|
82
|
+
return sum(t.complexity for t in self.tools)
|
|
83
|
+
|
|
84
|
+
def summary_lines(self) -> list[str]:
|
|
85
|
+
avg = self.total_complexity / self.tool_count if self.tools else 0.0
|
|
86
|
+
return [
|
|
87
|
+
f"Server: {self.server}",
|
|
88
|
+
f"Tools: {self.tool_count}",
|
|
89
|
+
f"Total description tokens (est.): {self.total_description_tokens}",
|
|
90
|
+
f"Schema complexity: total {self.total_complexity}, avg {avg:.1f}",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def inspect_server(spec: ServerSpec) -> ServerInventory:
|
|
95
|
+
async with MCPClient(spec) as client:
|
|
96
|
+
tools = await client.list_tools()
|
|
97
|
+
infos = [
|
|
98
|
+
ToolInfo(
|
|
99
|
+
name=t.name,
|
|
100
|
+
title=t.title,
|
|
101
|
+
description=t.description or "",
|
|
102
|
+
input_schema=t.inputSchema or {},
|
|
103
|
+
)
|
|
104
|
+
for t in tools
|
|
105
|
+
]
|
|
106
|
+
return ServerInventory(server=spec.label(), tools=infos)
|
whetkit/mcp/transport.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Transport layer: how to reach an MCP server.
|
|
2
|
+
|
|
3
|
+
All construction of SDK transports is isolated here so the planned move to
|
|
4
|
+
the MCP Python SDK v2 (see MIGRATION.md) stays contained in this module.
|
|
5
|
+
|
|
6
|
+
Three connection modes are supported, because real-world servers are mixed:
|
|
7
|
+
|
|
8
|
+
- ``stdio``: a local server subprocess speaking JSON-RPC over stdin/stdout.
|
|
9
|
+
- ``http`` with ``mode="stateful"``: legacy (2025 spec) streamable HTTP —
|
|
10
|
+
one long-lived session per connection, identified by a server-issued
|
|
11
|
+
session id.
|
|
12
|
+
- ``http`` with ``mode="stateless"``: the 2026-07-28 stateless spec —
|
|
13
|
+
no session affinity; the client opens a fresh exchange per operation.
|
|
14
|
+
On the v1 SDK this is modeled by re-connecting per operation (see
|
|
15
|
+
``MCPClient``), which matches the per-POST semantics from the client side.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated, Literal
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from mcp import ClientSession, StdioServerParameters
|
|
29
|
+
from mcp.client.stdio import stdio_client
|
|
30
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
31
|
+
from pydantic import BaseModel, Field
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpMode(StrEnum):
|
|
35
|
+
STATEFUL = "stateful"
|
|
36
|
+
STATELESS = "stateless"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StdioSpec(BaseModel):
|
|
40
|
+
kind: Literal["stdio"] = "stdio"
|
|
41
|
+
command: str
|
|
42
|
+
args: list[str] = []
|
|
43
|
+
env: dict[str, str] | None = None
|
|
44
|
+
cwd: str | None = None
|
|
45
|
+
|
|
46
|
+
def label(self) -> str:
|
|
47
|
+
return f"stdio: {self.command} {' '.join(self.args)}".strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HttpSpec(BaseModel):
|
|
51
|
+
kind: Literal["http"] = "http"
|
|
52
|
+
url: str
|
|
53
|
+
headers: dict[str, str] | None = None
|
|
54
|
+
mode: HttpMode = HttpMode.STATEFUL
|
|
55
|
+
|
|
56
|
+
def label(self) -> str:
|
|
57
|
+
return f"http ({self.mode}): {self.url}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ServerSpec = Annotated[StdioSpec | HttpSpec, Field(discriminator="kind")]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _SpecAdapter(BaseModel):
|
|
64
|
+
spec: ServerSpec
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def spec_from_dict(data: dict) -> ServerSpec:
|
|
68
|
+
return _SpecAdapter(spec=data).spec
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _python_command(command: str) -> str:
|
|
72
|
+
"""Map a bare ``python`` to the running interpreter so stdio servers get
|
|
73
|
+
the project virtualenv."""
|
|
74
|
+
if command in ("python", "python3"):
|
|
75
|
+
return sys.executable
|
|
76
|
+
return shutil.which(command) or command
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_server_spec(value: str, http_mode: HttpMode = HttpMode.STATEFUL) -> ServerSpec:
|
|
80
|
+
"""Turn a CLI-friendly string into a ServerSpec.
|
|
81
|
+
|
|
82
|
+
Accepted forms:
|
|
83
|
+
- ``http(s)://...`` -> streamable-HTTP server
|
|
84
|
+
- directory -> ``server.json`` inside it, else ``server.py`` via stdio
|
|
85
|
+
- ``*.json`` file -> full spec document
|
|
86
|
+
- ``*.py`` file -> stdio python server
|
|
87
|
+
"""
|
|
88
|
+
if value.startswith(("http://", "https://")):
|
|
89
|
+
return HttpSpec(url=value, mode=http_mode)
|
|
90
|
+
|
|
91
|
+
path = Path(value)
|
|
92
|
+
if path.is_dir():
|
|
93
|
+
config = path / "server.json"
|
|
94
|
+
if config.is_file():
|
|
95
|
+
return _spec_from_json(config)
|
|
96
|
+
script = path / "server.py"
|
|
97
|
+
if script.is_file():
|
|
98
|
+
return StdioSpec(command=sys.executable, args=[str(script)])
|
|
99
|
+
raise ValueError(f"{value}: directory has neither server.json nor server.py")
|
|
100
|
+
if path.suffix == ".json" and path.is_file():
|
|
101
|
+
return _spec_from_json(path)
|
|
102
|
+
if path.suffix == ".py" and path.is_file():
|
|
103
|
+
return StdioSpec(command=sys.executable, args=[str(path)])
|
|
104
|
+
raise ValueError(f"{value}: not a URL, directory, .json spec, or .py server")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _spec_from_json(path: Path) -> ServerSpec:
|
|
108
|
+
data = json.loads(path.read_text())
|
|
109
|
+
spec = spec_from_dict(data)
|
|
110
|
+
if isinstance(spec, StdioSpec):
|
|
111
|
+
spec.command = _python_command(spec.command)
|
|
112
|
+
# Relative script paths are relative to the spec file, not the CWD.
|
|
113
|
+
spec.args = [
|
|
114
|
+
str((path.parent / a).resolve()) if (path.parent / a).is_file() else a
|
|
115
|
+
for a in spec.args
|
|
116
|
+
]
|
|
117
|
+
if spec.cwd is None:
|
|
118
|
+
spec.cwd = str(path.parent)
|
|
119
|
+
return spec
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@asynccontextmanager
|
|
123
|
+
async def open_session(spec: ServerSpec) -> AsyncIterator[ClientSession]:
|
|
124
|
+
"""Open one initialized MCP session over the spec's transport."""
|
|
125
|
+
if isinstance(spec, StdioSpec):
|
|
126
|
+
params = StdioServerParameters(
|
|
127
|
+
command=spec.command, args=spec.args, env=spec.env, cwd=spec.cwd
|
|
128
|
+
)
|
|
129
|
+
async with (
|
|
130
|
+
stdio_client(params) as (read, write),
|
|
131
|
+
ClientSession(read, write) as session,
|
|
132
|
+
):
|
|
133
|
+
await session.initialize()
|
|
134
|
+
yield session
|
|
135
|
+
else:
|
|
136
|
+
http_client = (
|
|
137
|
+
httpx.AsyncClient(headers=spec.headers, follow_redirects=True, timeout=30)
|
|
138
|
+
if spec.headers
|
|
139
|
+
else None
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
async with (
|
|
143
|
+
streamable_http_client(spec.url, http_client=http_client) as (read, write, _sid),
|
|
144
|
+
ClientSession(read, write) as session,
|
|
145
|
+
):
|
|
146
|
+
await session.initialize()
|
|
147
|
+
yield session
|
|
148
|
+
finally:
|
|
149
|
+
if http_client is not None:
|
|
150
|
+
await http_client.aclose()
|
whetkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Before/after comparison reports (JSON + self-contained HTML)."""
|
|
2
|
+
|
|
3
|
+
from whetkit.report.builder import ComparisonReport, TaskComparison, build_report
|
|
4
|
+
from whetkit.report.html import render_html
|
|
5
|
+
|
|
6
|
+
__all__ = ["ComparisonReport", "TaskComparison", "build_report", "render_html"]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Assemble the before/after comparison from two scored eval batches."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from whetkit.curation.plan import CurationPlan, ToolOverride
|
|
9
|
+
from whetkit.datasets import TaskSpec
|
|
10
|
+
from whetkit.scoring import EvalSummary, TaskScore
|
|
11
|
+
from whetkit.tracing import TaskRun
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CallView(BaseModel):
|
|
15
|
+
"""One tool call as shown in the report's trace panel."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
args: str = ""
|
|
19
|
+
is_error: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SideView(BaseModel):
|
|
23
|
+
"""One side (before or after) of a task comparison."""
|
|
24
|
+
|
|
25
|
+
hit: bool
|
|
26
|
+
tool_hit: bool
|
|
27
|
+
judge_passed: bool | None = None
|
|
28
|
+
judge_rationale: str | None = None
|
|
29
|
+
tools_called: list[str] = []
|
|
30
|
+
missing_slots: list[list[str]] = []
|
|
31
|
+
calls: list[CallView] = []
|
|
32
|
+
final_text: str | None = None
|
|
33
|
+
input_tokens: int = 0
|
|
34
|
+
output_tokens: int = 0
|
|
35
|
+
latency_ms: float = 0.0
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_score(cls, score: TaskScore, run: TaskRun | None) -> "SideView":
|
|
39
|
+
usage = run.total_usage if run else None
|
|
40
|
+
calls = []
|
|
41
|
+
if run:
|
|
42
|
+
for turn in run.turns:
|
|
43
|
+
for call in turn.tool_calls:
|
|
44
|
+
args = json.dumps(call.arguments, separators=(", ", ": "))
|
|
45
|
+
if len(args) > 80:
|
|
46
|
+
args = args[:77] + "…"
|
|
47
|
+
calls.append(CallView(name=call.name, args=args, is_error=call.is_error))
|
|
48
|
+
return cls(
|
|
49
|
+
hit=score.hit,
|
|
50
|
+
tool_hit=score.tool_hit,
|
|
51
|
+
judge_passed=score.judge.passed if score.judge else None,
|
|
52
|
+
judge_rationale=score.judge.rationale if score.judge else None,
|
|
53
|
+
tools_called=score.tool_match.called,
|
|
54
|
+
missing_slots=score.tool_match.missing_slots,
|
|
55
|
+
calls=calls,
|
|
56
|
+
final_text=run.final_text if run else None,
|
|
57
|
+
input_tokens=usage.input_tokens if usage else 0,
|
|
58
|
+
output_tokens=usage.output_tokens if usage else 0,
|
|
59
|
+
latency_ms=run.total_latency_ms if run else 0.0,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TaskComparison(BaseModel):
|
|
64
|
+
task_id: str
|
|
65
|
+
prompt: str
|
|
66
|
+
expected_slots: list[list[str]]
|
|
67
|
+
before: SideView
|
|
68
|
+
after: SideView
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def outcome(self) -> str:
|
|
72
|
+
if not self.before.hit and self.after.hit:
|
|
73
|
+
return "improved"
|
|
74
|
+
if self.before.hit and not self.after.hit:
|
|
75
|
+
return "regressed"
|
|
76
|
+
return "unchanged"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ActionImpact(BaseModel):
|
|
80
|
+
"""One curation action and the tasks it plausibly affected.
|
|
81
|
+
|
|
82
|
+
Attribution is heuristic: an override is linked to a task when the tool
|
|
83
|
+
it touches appears in the task's expected slots or in either run's calls.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
override: ToolOverride
|
|
87
|
+
action: str
|
|
88
|
+
touched_tasks: list[str] = []
|
|
89
|
+
improved_tasks: list[str] = []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Totals(BaseModel):
|
|
93
|
+
hit_rate: float
|
|
94
|
+
tool_hit_rate: float
|
|
95
|
+
judge_pass_rate: float | None
|
|
96
|
+
avg_precision: float
|
|
97
|
+
avg_recall: float
|
|
98
|
+
input_tokens: int
|
|
99
|
+
output_tokens: int
|
|
100
|
+
latency_ms: float
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_summary(cls, summary: EvalSummary, runs: list[TaskRun]) -> "Totals":
|
|
104
|
+
return cls(
|
|
105
|
+
hit_rate=summary.hit_rate,
|
|
106
|
+
tool_hit_rate=summary.tool_hit_rate,
|
|
107
|
+
judge_pass_rate=summary.judge_pass_rate,
|
|
108
|
+
avg_precision=summary.avg_precision,
|
|
109
|
+
avg_recall=summary.avg_recall,
|
|
110
|
+
input_tokens=sum(r.total_usage.input_tokens for r in runs),
|
|
111
|
+
output_tokens=sum(r.total_usage.output_tokens for r in runs),
|
|
112
|
+
latency_ms=sum(r.total_latency_ms for r in runs),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ComparisonReport(BaseModel):
|
|
117
|
+
title: str = "whetkit before/after report"
|
|
118
|
+
server: str = ""
|
|
119
|
+
model: str = ""
|
|
120
|
+
generated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
121
|
+
tools_before: int | None = None
|
|
122
|
+
tools_after: int | None = None
|
|
123
|
+
before: Totals
|
|
124
|
+
after: Totals
|
|
125
|
+
tasks: list[TaskComparison]
|
|
126
|
+
plan: CurationPlan
|
|
127
|
+
action_impacts: list[ActionImpact]
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def run_id(self) -> str:
|
|
131
|
+
"""Short stable id derived from the report's content."""
|
|
132
|
+
import hashlib
|
|
133
|
+
|
|
134
|
+
payload = (
|
|
135
|
+
json.dumps([self.server, self.model, [t.task_id for t in self.tasks]], sort_keys=True)
|
|
136
|
+
+ self.generated_at.isoformat()
|
|
137
|
+
)
|
|
138
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:6]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def improved(self) -> list[TaskComparison]:
|
|
142
|
+
return [t for t in self.tasks if t.outcome == "improved"]
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def regressed(self) -> list[TaskComparison]:
|
|
146
|
+
return [t for t in self.tasks if t.outcome == "regressed"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _override_action(override: ToolOverride) -> str:
|
|
150
|
+
if override.hidden:
|
|
151
|
+
return "prune"
|
|
152
|
+
if override.new_name and override.new_description:
|
|
153
|
+
return "rename + rewrite"
|
|
154
|
+
if override.new_name:
|
|
155
|
+
return "rename"
|
|
156
|
+
if override.new_description:
|
|
157
|
+
return "rewrite"
|
|
158
|
+
return "keep"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _attribute_actions(
|
|
162
|
+
plan: CurationPlan, comparisons: list[TaskComparison], tasks: list[TaskSpec]
|
|
163
|
+
) -> list[ActionImpact]:
|
|
164
|
+
tasks_by_id = {t.id: t for t in tasks}
|
|
165
|
+
impacts: list[ActionImpact] = []
|
|
166
|
+
for override in plan.overrides:
|
|
167
|
+
names = {override.original_name, override.presented_name}
|
|
168
|
+
impact = ActionImpact(override=override, action=_override_action(override))
|
|
169
|
+
for comparison in comparisons:
|
|
170
|
+
task = tasks_by_id.get(comparison.task_id)
|
|
171
|
+
slot_names = {n for slot in (task.expected_tool_slots if task else []) for n in slot}
|
|
172
|
+
involved = bool(
|
|
173
|
+
names & slot_names
|
|
174
|
+
or names & set(comparison.before.tools_called)
|
|
175
|
+
or names & set(comparison.after.tools_called)
|
|
176
|
+
)
|
|
177
|
+
if involved:
|
|
178
|
+
impact.touched_tasks.append(comparison.task_id)
|
|
179
|
+
if comparison.outcome == "improved":
|
|
180
|
+
impact.improved_tasks.append(comparison.task_id)
|
|
181
|
+
impacts.append(impact)
|
|
182
|
+
return impacts
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_report(
|
|
186
|
+
tasks: list[TaskSpec],
|
|
187
|
+
baseline_runs: list[TaskRun],
|
|
188
|
+
baseline_summary: EvalSummary,
|
|
189
|
+
curated_runs: list[TaskRun],
|
|
190
|
+
curated_summary: EvalSummary,
|
|
191
|
+
plan: CurationPlan,
|
|
192
|
+
model: str = "",
|
|
193
|
+
server: str = "",
|
|
194
|
+
tools_before: int | None = None,
|
|
195
|
+
tools_after: int | None = None,
|
|
196
|
+
) -> ComparisonReport:
|
|
197
|
+
baseline_runs_by_id = {r.task_id: r for r in baseline_runs}
|
|
198
|
+
curated_runs_by_id = {r.task_id: r for r in curated_runs}
|
|
199
|
+
baseline_scores = {s.task_id: s for s in baseline_summary.scores}
|
|
200
|
+
curated_scores = {s.task_id: s for s in curated_summary.scores}
|
|
201
|
+
|
|
202
|
+
comparisons: list[TaskComparison] = []
|
|
203
|
+
for task in tasks:
|
|
204
|
+
before_score = baseline_scores.get(task.id)
|
|
205
|
+
after_score = curated_scores.get(task.id)
|
|
206
|
+
if before_score is None or after_score is None:
|
|
207
|
+
continue
|
|
208
|
+
comparisons.append(
|
|
209
|
+
TaskComparison(
|
|
210
|
+
task_id=task.id,
|
|
211
|
+
prompt=task.prompt.strip(),
|
|
212
|
+
expected_slots=task.expected_tool_slots,
|
|
213
|
+
before=SideView.from_score(before_score, baseline_runs_by_id.get(task.id)),
|
|
214
|
+
after=SideView.from_score(after_score, curated_runs_by_id.get(task.id)),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return ComparisonReport(
|
|
219
|
+
server=server,
|
|
220
|
+
model=model,
|
|
221
|
+
tools_before=tools_before,
|
|
222
|
+
tools_after=tools_after,
|
|
223
|
+
before=Totals.from_summary(baseline_summary, baseline_runs),
|
|
224
|
+
after=Totals.from_summary(curated_summary, curated_runs),
|
|
225
|
+
tasks=comparisons,
|
|
226
|
+
plan=plan,
|
|
227
|
+
action_impacts=_attribute_actions(plan, comparisons, tasks),
|
|
228
|
+
)
|