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.
@@ -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
+ )
@@ -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
+ )
@@ -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)