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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Aggregation: per-task scores and eval-level hit-rate metrics."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from whetkit.datasets import TaskSpec
|
|
6
|
+
from whetkit.llm import LLMProvider
|
|
7
|
+
from whetkit.scoring.deterministic import MatchMode, ToolMatchResult, score_tool_match
|
|
8
|
+
from whetkit.scoring.judge import JudgeCache, JudgeConfig, JudgeVerdict, judge_run
|
|
9
|
+
from whetkit.tracing import TaskRun
|
|
10
|
+
from whetkit.tracing.records import RunStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskScore(BaseModel):
|
|
14
|
+
task_id: str
|
|
15
|
+
run_status: RunStatus
|
|
16
|
+
tool_match: ToolMatchResult
|
|
17
|
+
judge: JudgeVerdict | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tool_hit(self) -> bool:
|
|
21
|
+
return self.tool_match.matched
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def hit(self) -> bool:
|
|
25
|
+
"""The headline metric: right tools AND (when judged) task success."""
|
|
26
|
+
if self.judge is not None and not self.judge.passed:
|
|
27
|
+
return False
|
|
28
|
+
return self.tool_hit
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EvalSummary(BaseModel):
|
|
32
|
+
scores: list[TaskScore]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def task_count(self) -> int:
|
|
36
|
+
return len(self.scores)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def hit_rate(self) -> float:
|
|
40
|
+
if not self.scores:
|
|
41
|
+
return 0.0
|
|
42
|
+
return sum(s.hit for s in self.scores) / len(self.scores)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def tool_hit_rate(self) -> float:
|
|
46
|
+
if not self.scores:
|
|
47
|
+
return 0.0
|
|
48
|
+
return sum(s.tool_hit for s in self.scores) / len(self.scores)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def judge_pass_rate(self) -> float | None:
|
|
52
|
+
judged = [s for s in self.scores if s.judge is not None]
|
|
53
|
+
if not judged:
|
|
54
|
+
return None
|
|
55
|
+
return sum(s.judge.passed for s in judged) / len(judged)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def avg_precision(self) -> float:
|
|
59
|
+
if not self.scores:
|
|
60
|
+
return 0.0
|
|
61
|
+
return sum(s.tool_match.precision for s in self.scores) / len(self.scores)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def avg_recall(self) -> float:
|
|
65
|
+
if not self.scores:
|
|
66
|
+
return 0.0
|
|
67
|
+
return sum(s.tool_match.recall for s in self.scores) / len(self.scores)
|
|
68
|
+
|
|
69
|
+
def summary_lines(self) -> list[str]:
|
|
70
|
+
lines = [
|
|
71
|
+
f"Tasks: {self.task_count}",
|
|
72
|
+
f"Hit-rate: {self.hit_rate:.0%}",
|
|
73
|
+
f"Tool-selection hit-rate: {self.tool_hit_rate:.0%}",
|
|
74
|
+
f"Tool precision (avg): {self.avg_precision:.0%}",
|
|
75
|
+
f"Tool recall (avg): {self.avg_recall:.0%}",
|
|
76
|
+
]
|
|
77
|
+
if (rate := self.judge_pass_rate) is not None:
|
|
78
|
+
lines.insert(2, f"Judge pass-rate: {rate:.0%}")
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def score_runs(
|
|
83
|
+
tasks: list[TaskSpec],
|
|
84
|
+
runs: list[TaskRun],
|
|
85
|
+
mode: MatchMode = MatchMode.ORDER_TOLERANT,
|
|
86
|
+
judge_config: JudgeConfig | None = None,
|
|
87
|
+
judge_provider: LLMProvider | None = None,
|
|
88
|
+
judge_cache: JudgeCache | None = None,
|
|
89
|
+
use_judge: bool = False,
|
|
90
|
+
) -> EvalSummary:
|
|
91
|
+
"""Score each run against its task. Runs and tasks are matched by id;
|
|
92
|
+
a task with no run is scored as a total miss."""
|
|
93
|
+
runs_by_task = {run.task_id: run for run in runs}
|
|
94
|
+
scores: list[TaskScore] = []
|
|
95
|
+
for task in tasks:
|
|
96
|
+
run = runs_by_task.get(task.id)
|
|
97
|
+
if run is None:
|
|
98
|
+
scores.append(
|
|
99
|
+
TaskScore(
|
|
100
|
+
task_id=task.id,
|
|
101
|
+
run_status=RunStatus.ERROR,
|
|
102
|
+
tool_match=score_tool_match(task, [], mode),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
verdict = None
|
|
107
|
+
if use_judge:
|
|
108
|
+
verdict = await judge_run(task, run, judge_config, judge_provider, judge_cache)
|
|
109
|
+
scores.append(
|
|
110
|
+
TaskScore(
|
|
111
|
+
task_id=task.id,
|
|
112
|
+
run_status=run.status,
|
|
113
|
+
tool_match=score_tool_match(task, run.called_tool_names, mode),
|
|
114
|
+
judge=verdict,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return EvalSummary(scores=scores)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Deterministic tool-selection scoring.
|
|
2
|
+
|
|
3
|
+
Compares the tools an agent actually called against a task's
|
|
4
|
+
``expected_tools`` slots (each slot lists acceptable alternatives).
|
|
5
|
+
|
|
6
|
+
Two modes:
|
|
7
|
+
|
|
8
|
+
- ``order_tolerant`` (default): every slot must be satisfied by a distinct
|
|
9
|
+
call; extra calls are allowed (they cost precision, not the hit). When the
|
|
10
|
+
task sets ``ordered: true``, the satisfying calls must appear as a
|
|
11
|
+
subsequence of the call sequence.
|
|
12
|
+
- ``exact``: the call sequence must be exactly one call per slot, in order,
|
|
13
|
+
with no extra calls.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from whetkit.datasets import TaskSpec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MatchMode(StrEnum):
|
|
24
|
+
EXACT = "exact"
|
|
25
|
+
ORDER_TOLERANT = "order_tolerant"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolMatchResult(BaseModel):
|
|
29
|
+
matched: bool
|
|
30
|
+
mode: MatchMode
|
|
31
|
+
expected_slots: list[list[str]]
|
|
32
|
+
called: list[str]
|
|
33
|
+
satisfied_slots: int
|
|
34
|
+
missing_slots: list[list[str]]
|
|
35
|
+
extra_calls: list[str]
|
|
36
|
+
precision: float
|
|
37
|
+
recall: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _assign_unordered(slots: list[list[str]], called: list[str]) -> list[int | None]:
|
|
41
|
+
"""Greedily assign calls to slots ignoring order. Returns, per call, the
|
|
42
|
+
slot index it satisfied (or None if it satisfied nothing)."""
|
|
43
|
+
remaining = set(range(len(slots)))
|
|
44
|
+
assignment: list[int | None] = []
|
|
45
|
+
for name in called:
|
|
46
|
+
hit = next((i for i in sorted(remaining) if name in slots[i]), None)
|
|
47
|
+
if hit is not None:
|
|
48
|
+
remaining.discard(hit)
|
|
49
|
+
assignment.append(hit)
|
|
50
|
+
return assignment
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _assign_ordered(slots: list[list[str]], called: list[str]) -> list[int | None]:
|
|
54
|
+
"""Greedily match slots as a subsequence of the call sequence."""
|
|
55
|
+
next_slot = 0
|
|
56
|
+
assignment: list[int | None] = []
|
|
57
|
+
for name in called:
|
|
58
|
+
if next_slot < len(slots) and name in slots[next_slot]:
|
|
59
|
+
assignment.append(next_slot)
|
|
60
|
+
next_slot += 1
|
|
61
|
+
else:
|
|
62
|
+
assignment.append(None)
|
|
63
|
+
return assignment
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def score_tool_match(
|
|
67
|
+
task: TaskSpec,
|
|
68
|
+
called: list[str],
|
|
69
|
+
mode: MatchMode = MatchMode.ORDER_TOLERANT,
|
|
70
|
+
) -> ToolMatchResult:
|
|
71
|
+
slots = task.expected_tool_slots
|
|
72
|
+
|
|
73
|
+
exact_ok = len(called) == len(slots) and all(
|
|
74
|
+
name in slot for name, slot in zip(called, slots, strict=True)
|
|
75
|
+
)
|
|
76
|
+
if exact_ok:
|
|
77
|
+
assignment: list[int | None] = list(range(len(called)))
|
|
78
|
+
else:
|
|
79
|
+
assignment = (_assign_ordered if task.ordered else _assign_unordered)(slots, called)
|
|
80
|
+
|
|
81
|
+
satisfied = {i for i in assignment if i is not None}
|
|
82
|
+
missing = [slot for i, slot in enumerate(slots) if i not in satisfied]
|
|
83
|
+
extras = [name for name, hit in zip(called, assignment, strict=True) if hit is None]
|
|
84
|
+
|
|
85
|
+
precision = (len(called) - len(extras)) / len(called) if called else 0.0
|
|
86
|
+
recall = len(satisfied) / len(slots) if slots else 1.0
|
|
87
|
+
matched = exact_ok if mode == MatchMode.EXACT else not missing
|
|
88
|
+
|
|
89
|
+
return ToolMatchResult(
|
|
90
|
+
matched=matched,
|
|
91
|
+
mode=mode,
|
|
92
|
+
expected_slots=slots,
|
|
93
|
+
called=called,
|
|
94
|
+
satisfied_slots=len(satisfied),
|
|
95
|
+
missing_slots=missing,
|
|
96
|
+
extra_calls=extras,
|
|
97
|
+
precision=precision,
|
|
98
|
+
recall=recall,
|
|
99
|
+
)
|
whetkit/scoring/judge.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""LLM-as-judge grading of task success against the task's rubric.
|
|
2
|
+
|
|
3
|
+
The judge sees the task, the rubric, a compact transcript of tool calls and
|
|
4
|
+
results, and the agent's final answer. It returns a structured verdict.
|
|
5
|
+
Judgments are cached in SQLite keyed by a content hash, so re-scoring the
|
|
6
|
+
same run with the same judge model costs nothing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from whetkit.datasets import TaskSpec
|
|
18
|
+
from whetkit.llm import ChatMessage, LLMProvider, get_provider, parse_model
|
|
19
|
+
from whetkit.tracing import TaskRun
|
|
20
|
+
|
|
21
|
+
DEFAULT_JUDGE_MODEL = "anthropic:claude-sonnet-5"
|
|
22
|
+
|
|
23
|
+
JUDGE_SYSTEM_PROMPT = """\
|
|
24
|
+
You are a strict, impartial grader of AI agent runs. You are given a task a
|
|
25
|
+
user asked an agent to do, the success criteria a correct run must satisfy,
|
|
26
|
+
the tool calls the agent made (with results), and the agent's final answer.
|
|
27
|
+
|
|
28
|
+
Grade ONLY against the success criteria:
|
|
29
|
+
- If the criteria name specific facts or values, the final answer must state
|
|
30
|
+
them (equivalent phrasing and formatting are fine; wrong or missing values
|
|
31
|
+
are a fail).
|
|
32
|
+
- Actions count only if the transcript shows they actually happened; the
|
|
33
|
+
agent claiming success without a supporting tool result is a fail.
|
|
34
|
+
- Do not reward effort, verbosity, or plausible-sounding but unverified
|
|
35
|
+
claims. Do not penalize style.
|
|
36
|
+
|
|
37
|
+
Respond with ONLY a JSON object, no markdown fences:
|
|
38
|
+
{"passed": true or false, "rationale": "one or two sentences citing the evidence"}
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JudgeVerdict(BaseModel):
|
|
43
|
+
passed: bool
|
|
44
|
+
rationale: str
|
|
45
|
+
judge_model: str
|
|
46
|
+
valid: bool = True # False when the judge's output could not be parsed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class JudgeConfig(BaseModel):
|
|
50
|
+
model: str = DEFAULT_JUDGE_MODEL
|
|
51
|
+
max_tokens: int = 512
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class JudgeCache:
|
|
55
|
+
"""Content-addressed cache of judge verdicts (SQLite key/value)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, path: str | Path):
|
|
58
|
+
self.path = Path(path)
|
|
59
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._conn = sqlite3.connect(self.path)
|
|
61
|
+
with self._conn:
|
|
62
|
+
self._conn.execute(
|
|
63
|
+
"CREATE TABLE IF NOT EXISTS judgments (key TEXT PRIMARY KEY, verdict TEXT NOT NULL)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def get(self, key: str) -> JudgeVerdict | None:
|
|
67
|
+
row = self._conn.execute("SELECT verdict FROM judgments WHERE key = ?", (key,)).fetchone()
|
|
68
|
+
return JudgeVerdict.model_validate_json(row[0]) if row else None
|
|
69
|
+
|
|
70
|
+
def put(self, key: str, verdict: JudgeVerdict) -> None:
|
|
71
|
+
with self._conn:
|
|
72
|
+
self._conn.execute(
|
|
73
|
+
"INSERT OR REPLACE INTO judgments (key, verdict) VALUES (?, ?)",
|
|
74
|
+
(key, verdict.model_dump_json()),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._conn.close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _transcript(run: TaskRun, max_result_chars: int = 500) -> str:
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
for turn in run.turns:
|
|
84
|
+
for call in turn.tool_calls:
|
|
85
|
+
result = call.result_text
|
|
86
|
+
if len(result) > max_result_chars:
|
|
87
|
+
result = result[:max_result_chars] + "…(truncated)"
|
|
88
|
+
status = "ERROR" if call.is_error else "ok"
|
|
89
|
+
lines.append(f"- {call.name}({json.dumps(call.arguments)}) [{status}] -> {result}")
|
|
90
|
+
return "\n".join(lines) or "(the agent made no tool calls)"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_judge_prompt(task: TaskSpec, run: TaskRun) -> str:
|
|
94
|
+
return (
|
|
95
|
+
f"<task>\n{task.prompt}\n</task>\n\n"
|
|
96
|
+
f"<success_criteria>\n{task.success_criteria}\n</success_criteria>\n\n"
|
|
97
|
+
f"<tool_calls>\n{_transcript(run)}\n</tool_calls>\n\n"
|
|
98
|
+
f"<final_answer>\n{run.final_text or '(the agent never gave a final answer)'}\n"
|
|
99
|
+
f"</final_answer>"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cache_key(task: TaskSpec, run: TaskRun, judge_model: str) -> str:
|
|
104
|
+
payload = json.dumps(
|
|
105
|
+
{
|
|
106
|
+
"judge_model": judge_model,
|
|
107
|
+
"task_id": task.id,
|
|
108
|
+
"criteria": task.success_criteria,
|
|
109
|
+
"prompt": build_judge_prompt(task, run),
|
|
110
|
+
},
|
|
111
|
+
sort_keys=True,
|
|
112
|
+
)
|
|
113
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_verdict(raw: str, judge_model: str) -> JudgeVerdict | None:
|
|
117
|
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
118
|
+
if not match:
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(match.group(0))
|
|
122
|
+
return JudgeVerdict(
|
|
123
|
+
passed=bool(data["passed"]),
|
|
124
|
+
rationale=str(data.get("rationale", "")),
|
|
125
|
+
judge_model=judge_model,
|
|
126
|
+
)
|
|
127
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def judge_run(
|
|
132
|
+
task: TaskSpec,
|
|
133
|
+
run: TaskRun,
|
|
134
|
+
config: JudgeConfig | None = None,
|
|
135
|
+
provider: LLMProvider | None = None,
|
|
136
|
+
cache: JudgeCache | None = None,
|
|
137
|
+
) -> JudgeVerdict:
|
|
138
|
+
"""Grade one run. Never raises on judge misbehavior: an unparseable
|
|
139
|
+
verdict comes back as ``passed=False, valid=False``."""
|
|
140
|
+
config = config or JudgeConfig()
|
|
141
|
+
key = _cache_key(task, run, config.model)
|
|
142
|
+
if cache is not None and (cached := cache.get(key)) is not None:
|
|
143
|
+
return cached
|
|
144
|
+
|
|
145
|
+
provider_name, model_id = parse_model(config.model)
|
|
146
|
+
provider = provider or get_provider(provider_name)
|
|
147
|
+
|
|
148
|
+
verdict: JudgeVerdict | None = None
|
|
149
|
+
for _attempt in range(2):
|
|
150
|
+
turn = await provider.complete(
|
|
151
|
+
model=model_id,
|
|
152
|
+
system=JUDGE_SYSTEM_PROMPT,
|
|
153
|
+
messages=[ChatMessage(role="user", content=build_judge_prompt(task, run))],
|
|
154
|
+
tools=[],
|
|
155
|
+
max_tokens=config.max_tokens,
|
|
156
|
+
)
|
|
157
|
+
verdict = _parse_verdict(turn.text or "", config.model)
|
|
158
|
+
if verdict is not None:
|
|
159
|
+
break
|
|
160
|
+
if verdict is None:
|
|
161
|
+
verdict = JudgeVerdict(
|
|
162
|
+
passed=False,
|
|
163
|
+
rationale="Judge output was not valid JSON after 2 attempts.",
|
|
164
|
+
judge_model=config.model,
|
|
165
|
+
valid=False,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if cache is not None and verdict.valid:
|
|
169
|
+
cache.put(key, verdict)
|
|
170
|
+
return verdict
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Reasoning-path traces: structured records of every eval run."""
|
|
2
|
+
|
|
3
|
+
from whetkit.tracing.records import TaskRun, ToolCallRecord, TurnRecord
|
|
4
|
+
from whetkit.tracing.store import (
|
|
5
|
+
SCHEMA_VERSION,
|
|
6
|
+
TraceStore,
|
|
7
|
+
default_store_path,
|
|
8
|
+
read_jsonl,
|
|
9
|
+
write_jsonl,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SCHEMA_VERSION",
|
|
14
|
+
"TaskRun",
|
|
15
|
+
"ToolCallRecord",
|
|
16
|
+
"TraceStore",
|
|
17
|
+
"TurnRecord",
|
|
18
|
+
"default_store_path",
|
|
19
|
+
"read_jsonl",
|
|
20
|
+
"write_jsonl",
|
|
21
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Structured records of an agent run: every turn, tool call, and outcome.
|
|
2
|
+
|
|
3
|
+
These are the atoms the scorer, curator, and report all consume.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from whetkit.llm.base import Usage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def utc_now() -> datetime:
|
|
16
|
+
return datetime.now(UTC)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RunStatus(StrEnum):
|
|
20
|
+
COMPLETED = "completed" # model produced a final answer
|
|
21
|
+
MAX_TURNS = "max_turns" # loop hit the turn limit before finishing
|
|
22
|
+
ERROR = "error" # provider/transport failure aborted the run
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolCallRecord(BaseModel):
|
|
26
|
+
"""One tool invocation and its outcome."""
|
|
27
|
+
|
|
28
|
+
call_id: str
|
|
29
|
+
name: str
|
|
30
|
+
arguments: dict[str, Any] = {}
|
|
31
|
+
result_text: str = ""
|
|
32
|
+
is_error: bool = False
|
|
33
|
+
latency_ms: float = 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TurnRecord(BaseModel):
|
|
37
|
+
"""One model completion plus the tool calls it triggered."""
|
|
38
|
+
|
|
39
|
+
index: int
|
|
40
|
+
assistant_text: str | None = None
|
|
41
|
+
tool_calls: list[ToolCallRecord] = []
|
|
42
|
+
usage: Usage = Usage()
|
|
43
|
+
latency_ms: float = 0.0
|
|
44
|
+
stop_reason: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TaskRun(BaseModel):
|
|
48
|
+
"""A full agent run of one task against one server."""
|
|
49
|
+
|
|
50
|
+
task_id: str
|
|
51
|
+
server: str
|
|
52
|
+
model: str
|
|
53
|
+
started_at: datetime = Field(default_factory=utc_now)
|
|
54
|
+
finished_at: datetime | None = None
|
|
55
|
+
turns: list[TurnRecord] = []
|
|
56
|
+
final_text: str | None = None
|
|
57
|
+
status: RunStatus = RunStatus.COMPLETED
|
|
58
|
+
error: str | None = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def called_tool_names(self) -> list[str]:
|
|
62
|
+
"""Every tool called, in call order across turns."""
|
|
63
|
+
return [call.name for turn in self.turns for call in turn.tool_calls]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_usage(self) -> Usage:
|
|
67
|
+
total = Usage()
|
|
68
|
+
for turn in self.turns:
|
|
69
|
+
total = total + turn.usage
|
|
70
|
+
return total
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def total_latency_ms(self) -> float:
|
|
74
|
+
return sum(t.latency_ms for t in self.turns) + sum(
|
|
75
|
+
c.latency_ms for t in self.turns for c in t.tool_calls
|
|
76
|
+
)
|
whetkit/tracing/store.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Trace persistence: local SQLite (queryable) + JSONL (portable).
|
|
2
|
+
|
|
3
|
+
The full pydantic record is the source of truth and is stored verbatim in
|
|
4
|
+
``record_json``; the indexed columns exist for querying and reporting.
|
|
5
|
+
The schema is versioned via the ``meta`` table — opening a store written by
|
|
6
|
+
a newer schema fails loudly instead of corrupting it.
|
|
7
|
+
|
|
8
|
+
Stage 1 is local-first by design: SQLite ships with CPython, needs no
|
|
9
|
+
daemon, and one file per project is easy to inspect and delete.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from whetkit.tracing.records import TaskRun
|
|
18
|
+
|
|
19
|
+
SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
_SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
23
|
+
key TEXT PRIMARY KEY,
|
|
24
|
+
value TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
run_group TEXT NOT NULL,
|
|
29
|
+
task_id TEXT NOT NULL,
|
|
30
|
+
server TEXT NOT NULL,
|
|
31
|
+
model TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL,
|
|
33
|
+
error TEXT,
|
|
34
|
+
started_at TEXT NOT NULL,
|
|
35
|
+
finished_at TEXT,
|
|
36
|
+
input_tokens INTEGER NOT NULL,
|
|
37
|
+
output_tokens INTEGER NOT NULL,
|
|
38
|
+
latency_ms REAL NOT NULL,
|
|
39
|
+
record_json TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_runs_group ON runs (run_group);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_runs_task ON runs (task_id);
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TraceStore:
|
|
47
|
+
"""A local store of TaskRun traces, grouped by run label.
|
|
48
|
+
|
|
49
|
+
``run_group`` names one eval batch (e.g. ``baseline`` or ``curated``) so
|
|
50
|
+
before/after comparisons can pull exactly the runs they need.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, path: str | Path):
|
|
54
|
+
self.path = Path(path)
|
|
55
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
self._conn = sqlite3.connect(self.path)
|
|
57
|
+
self._conn.row_factory = sqlite3.Row
|
|
58
|
+
self._init_schema()
|
|
59
|
+
|
|
60
|
+
def _init_schema(self) -> None:
|
|
61
|
+
with self._conn:
|
|
62
|
+
self._conn.executescript(_SCHEMA)
|
|
63
|
+
row = self._conn.execute(
|
|
64
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
65
|
+
).fetchone()
|
|
66
|
+
if row is None:
|
|
67
|
+
self._conn.execute(
|
|
68
|
+
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
69
|
+
(str(SCHEMA_VERSION),),
|
|
70
|
+
)
|
|
71
|
+
elif int(row["value"]) > SCHEMA_VERSION:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"{self.path} uses trace schema v{row['value']}, but this "
|
|
74
|
+
f"whetkit only understands up to v{SCHEMA_VERSION} — upgrade whetkit"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def save_run(self, run: TaskRun, run_group: str) -> int:
|
|
78
|
+
usage = run.total_usage
|
|
79
|
+
with self._conn:
|
|
80
|
+
cursor = self._conn.execute(
|
|
81
|
+
"""
|
|
82
|
+
INSERT INTO runs (
|
|
83
|
+
run_group, task_id, server, model, status, error,
|
|
84
|
+
started_at, finished_at, input_tokens, output_tokens,
|
|
85
|
+
latency_ms, record_json
|
|
86
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
""",
|
|
88
|
+
(
|
|
89
|
+
run_group,
|
|
90
|
+
run.task_id,
|
|
91
|
+
run.server,
|
|
92
|
+
run.model,
|
|
93
|
+
str(run.status),
|
|
94
|
+
run.error,
|
|
95
|
+
run.started_at.isoformat(),
|
|
96
|
+
run.finished_at.isoformat() if run.finished_at else None,
|
|
97
|
+
usage.input_tokens,
|
|
98
|
+
usage.output_tokens,
|
|
99
|
+
run.total_latency_ms,
|
|
100
|
+
run.model_dump_json(),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
assert cursor.lastrowid is not None
|
|
104
|
+
return cursor.lastrowid
|
|
105
|
+
|
|
106
|
+
def save_runs(self, runs: Iterable[TaskRun], run_group: str) -> list[int]:
|
|
107
|
+
return [self.save_run(run, run_group) for run in runs]
|
|
108
|
+
|
|
109
|
+
def load_runs(self, run_group: str | None = None) -> list[TaskRun]:
|
|
110
|
+
query = "SELECT record_json FROM runs"
|
|
111
|
+
params: tuple = ()
|
|
112
|
+
if run_group is not None:
|
|
113
|
+
query += " WHERE run_group = ?"
|
|
114
|
+
params = (run_group,)
|
|
115
|
+
query += " ORDER BY id"
|
|
116
|
+
rows = self._conn.execute(query, params).fetchall()
|
|
117
|
+
return [TaskRun.model_validate_json(row["record_json"]) for row in rows]
|
|
118
|
+
|
|
119
|
+
def list_groups(self) -> list[dict]:
|
|
120
|
+
rows = self._conn.execute(
|
|
121
|
+
"""
|
|
122
|
+
SELECT run_group, COUNT(*) AS runs,
|
|
123
|
+
MIN(started_at) AS first_started, MAX(started_at) AS last_started
|
|
124
|
+
FROM runs GROUP BY run_group ORDER BY MIN(id)
|
|
125
|
+
"""
|
|
126
|
+
).fetchall()
|
|
127
|
+
return [dict(row) for row in rows]
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
self._conn.close()
|
|
131
|
+
|
|
132
|
+
def __enter__(self) -> "TraceStore":
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
136
|
+
self.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_jsonl(runs: Iterable[TaskRun], path: str | Path) -> None:
|
|
140
|
+
"""Write one JSON record per line — greppable and diffable."""
|
|
141
|
+
path = Path(path)
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
with path.open("w") as f:
|
|
144
|
+
for run in runs:
|
|
145
|
+
f.write(run.model_dump_json() + "\n")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_jsonl(path: str | Path) -> list[TaskRun]:
|
|
149
|
+
with Path(path).open() as f:
|
|
150
|
+
return [TaskRun.model_validate_json(line) for line in f if line.strip()]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def default_store_path(base_dir: str | Path = ".") -> Path:
|
|
154
|
+
"""Project-local default: ./.whetkit/traces.sqlite3 (gitignored)."""
|
|
155
|
+
return Path(base_dir) / ".whetkit" / "traces.sqlite3"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _summary_row(run: TaskRun) -> dict:
|
|
159
|
+
usage = run.total_usage
|
|
160
|
+
return {
|
|
161
|
+
"task_id": run.task_id,
|
|
162
|
+
"status": str(run.status),
|
|
163
|
+
"tools": run.called_tool_names,
|
|
164
|
+
"turns": len(run.turns),
|
|
165
|
+
"input_tokens": usage.input_tokens,
|
|
166
|
+
"output_tokens": usage.output_tokens,
|
|
167
|
+
"latency_ms": round(run.total_latency_ms, 1),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def summarize_runs(runs: list[TaskRun]) -> str:
|
|
172
|
+
return json.dumps([_summary_row(run) for run in runs], indent=2)
|