mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
test(engine): add routing and planner evals for the AI pipeline
This commit is contained in:
@@ -46,3 +46,7 @@ logs/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Eval run outputs - measurements, not source.
|
||||
evals/*/results/
|
||||
evals/*/results-*/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Planner eval
|
||||
|
||||
Measures the second decision in an edit request: once the orchestrator has routed to
|
||||
`pdf_edit`, which of the 73 operations gets chosen.
|
||||
|
||||
It exists to quantify one specific thing. The production planner prompt is roughly 6,800
|
||||
tokens; a default Ollama accepts about 2,050 and silently drops the head. This eval
|
||||
measures what that costs, rather than assuming it costs something.
|
||||
|
||||
```bash
|
||||
uv run --group engine python evals/planner/runner.py
|
||||
```
|
||||
|
||||
## The three strategies
|
||||
|
||||
| Strategy | Prompt shape |
|
||||
| --- | --- |
|
||||
| `prod` | Production order - request first, then the full catalogue. Overruns the context. |
|
||||
| `request_last` | Identical content, request moved to the tail, which survives truncation. |
|
||||
| `shortlist` | Top-N operations by embedding similarity (`nomic-embed-text`), request last. Fits comfortably. |
|
||||
|
||||
`--shortlist N` sets the candidate count; the runner prints recall@N first, so a low score
|
||||
there is visible before the accuracy numbers are read.
|
||||
|
||||
## Why position is reported
|
||||
|
||||
`dataset.py` records each expected operation's index in `OPERATIONS`, and the menu is
|
||||
rendered in that order. Conversions occupy 0-25 and lose their descriptions first;
|
||||
security operations at 63-72 always survive. Accuracy is therefore broken out by catalogue
|
||||
band, and `mean_picked_index` shows whether a strategy's answers are being dragged toward
|
||||
the end of the list.
|
||||
|
||||
The response schema enumerates all 73 endpoints regardless of what the prompt contains -
|
||||
exactly as production does, since `ToolEndpoint` is a `StrEnum`. A truncated prompt does
|
||||
not stop the model naming an operation; it only stops it reading what that operation does.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Cases are single-operation. Production can chain, and chained plans are not measured here.
|
||||
- `also_ok` marks operations that genuinely satisfy a request too (CSV for a spreadsheet ask).
|
||||
- Latency is measured under `--concurrency`, so it is throughput rather than user-facing.
|
||||
- **`prompt_tokens_reported` is not a truncation measure.** Ollama counts only
|
||||
newly-evaluated tokens, so cases sharing a long prefix report far fewer than they sent.
|
||||
Measure the ceiling with a single controlled request instead: send one oversized prompt
|
||||
and read `usage.prompt_tokens` off it alone.
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Labelled tool-selection cases for the pdf_edit planner.
|
||||
|
||||
Each case is a request that one operation answers. ``expected`` is that operation's
|
||||
``ToolEndpoint`` name; ``also_ok`` holds operations that genuinely satisfy the request too.
|
||||
|
||||
Cases carry the expected operation's index in ``OPERATIONS`` so accuracy can be reported by
|
||||
catalogue position. Ollama drops the head of an over-long prompt, and the menu is rendered
|
||||
in ``OPERATIONS`` order, so an early operation loses its description while a late one keeps
|
||||
it. Position is therefore a variable under test, not a curiosity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlannerCase:
|
||||
id: str
|
||||
message: str
|
||||
expected: str
|
||||
also_ok: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _p(id: str, message: str, expected: str, also_ok: frozenset[str] = frozenset()) -> PlannerCase:
|
||||
return PlannerCase(id, message, expected, also_ok)
|
||||
|
||||
|
||||
# Conversions - indexes 0-25, the front of the catalogue and the first thing truncation eats.
|
||||
_CONVERSIONS = [
|
||||
_p("to-word", "Turn this into an editable Word document", "PDF_TO_WORD"),
|
||||
_p("to-excel", "I need this as a spreadsheet I can sort", "PDF_TO_XLSX", frozenset({"PDF_TO_CSV"})),
|
||||
_p("to-images", "Save every page as a separate PNG", "PDF_TO_IMG"),
|
||||
_p("to-text", "Give me the plain text out of this", "PDF_TO_TEXT"),
|
||||
_p("to-pdfa", "Make this archival standard compliant for our records", "PDF_TO_PDFA"),
|
||||
_p("to-html", "Convert this into a web page", "PDF_TO_HTML"),
|
||||
_p("to-markdown", "I want this as markdown for our docs repo", "PDF_TO_MARKDOWN"),
|
||||
_p("to-epub", "Turn this into an ebook I can read on my Kindle", "PDF_TO_EPUB"),
|
||||
_p("to-slides", "Make this into a slide deck", "PDF_TO_PRESENTATION"),
|
||||
_p("from-images", "Combine these photos into one PDF", "IMG_TO_PDF"),
|
||||
_p("from-url", "Save this web address as a PDF", "URL_TO_PDF"),
|
||||
_p("to-accessible", "Make this compliant for screen reader users", "PDF_TO_UA"),
|
||||
]
|
||||
|
||||
# Page-level structure - indexes 26-42, the middle of the catalogue.
|
||||
_STRUCTURE = [
|
||||
_p("merge", "Join these two files into one", "MERGE_PDFS"),
|
||||
_p("split-pages", "Break this into one file per page", "SPLIT_PAGES"),
|
||||
_p("split-size", "Split this so no file is bigger than 5MB", "SPLIT_BY_SIZE_OR_COUNT"),
|
||||
_p("split-chapters", "Separate this book out by chapter", "SPLIT_PDF_BY_CHAPTERS"),
|
||||
_p("rotate", "Turn every page ninety degrees clockwise", "ROTATE_PDF"),
|
||||
_p("remove-pages", "Get rid of pages 4 through 9", "REMOVE_PAGES"),
|
||||
_p("rearrange", "Put the pages in reverse order", "REARRANGE_PAGES"),
|
||||
_p("crop", "Trim the white margins off every page", "CROP"),
|
||||
_p("nup", "Print this four pages to a sheet", "MULTI_PAGE_LAYOUT"),
|
||||
_p("booklet", "Lay this out as a folded booklet for printing", "BOOKLET_IMPOSITION"),
|
||||
_p("edit-text", "Change the word Draft to Final throughout", "EDIT_TEXT"),
|
||||
_p("scale", "Resize every page to A4", "SCALE_PAGES"),
|
||||
]
|
||||
|
||||
# Content operations - indexes 43-62.
|
||||
_CONTENT = [
|
||||
_p("compress", "Shrink this down, it is too big to email", "COMPRESS_PDF"),
|
||||
_p("ocr", "Make this scan searchable", "OCR_PDF"),
|
||||
_p("page-numbers", "Number the pages at the bottom", "ADD_PAGE_NUMBERS"),
|
||||
_p("stamp", "Put our logo in the top corner of each page", "ADD_STAMP"),
|
||||
_p("flatten", "Make the form fields non-editable", "FLATTEN"),
|
||||
_p("remove-blanks", "Strip out the empty pages", "REMOVE_BLANKS"),
|
||||
_p("extract-images", "Pull out all the pictures in this", "EXTRACT_IMAGES"),
|
||||
_p("attachments", "Get the files attached to this PDF", "EXTRACT_ATTACHMENTS"),
|
||||
_p("metadata", "Change the author name in the document properties", "UPDATE_METADATA"),
|
||||
_p("repair", "This file is corrupted, can you fix it", "REPAIR"),
|
||||
_p("auto-rotate", "Some pages are sideways, straighten them out", "AUTO_ROTATE_PDF"),
|
||||
_p("scanner", "Make this look like it came off a scanner", "SCANNER_EFFECT"),
|
||||
]
|
||||
|
||||
# Security and reporting - indexes 63-72, the tail that always survives truncation.
|
||||
_SECURITY = [
|
||||
_p("password", "Lock this with the password hunter2", "ADD_PASSWORD"),
|
||||
_p("remove-password", "Take the password off this file", "REMOVE_PASSWORD"),
|
||||
_p("watermark", "Put DRAFT across every page", "ADD_WATERMARK"),
|
||||
_p("redact", "Black out every mention of the client name", "REDACT_EXECUTE"),
|
||||
_p("sanitize", "Strip any embedded scripts out of this", "SANITIZE_PDF"),
|
||||
_p("unsign", "Remove the digital signature from this", "REMOVE_CERT_SIGN"),
|
||||
_p("timestamp", "Add a trusted timestamp to this document", "TIMESTAMP_PDF"),
|
||||
_p("a11y-report", "Check this against accessibility rules and report back", "ACCESSIBILITY_REPORT"),
|
||||
]
|
||||
|
||||
CASES: list[PlannerCase] = [*_CONVERSIONS, *_STRUCTURE, *_CONTENT, *_SECURITY]
|
||||
|
||||
|
||||
# Catalogue bands, by position in OPERATIONS. Named rather than computed so a reordering of
|
||||
# the enum shows up as a failing assertion instead of silently changing what is measured.
|
||||
BANDS: dict[str, range] = {
|
||||
"front (0-25, conversions)": range(0, 26),
|
||||
"middle (26-42, structure)": range(26, 43),
|
||||
"back (43-62, content)": range(43, 63),
|
||||
"tail (63-72, security)": range(63, 73),
|
||||
}
|
||||
|
||||
|
||||
def band_for(index: int) -> str:
|
||||
for name, span in BANDS.items():
|
||||
if index in span:
|
||||
return name
|
||||
raise ValueError(f"index {index} is outside every band")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Sweep operation-retrieval settings for the planner shortlist.
|
||||
|
||||
Recall is the planner's ceiling: the model was correct on every case whose answer reached
|
||||
the candidate list, so an operation that retrieval misses is an operation the planner
|
||||
cannot pick. Recall needs embeddings only - no generation - so a full sweep costs seconds
|
||||
and can be run before spending anything on the model.
|
||||
|
||||
uv run --group engine python evals/planner/recall.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
_EVALS_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EVALS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EVALS_ROOT))
|
||||
_SRC = _EVALS_ROOT.parent / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from planner.dataset import CASES # noqa: E402
|
||||
from planner.runner import ( # noqa: E402
|
||||
ALL_OPS,
|
||||
EMBED_MODEL,
|
||||
EMBED_URL,
|
||||
INDEX_OF,
|
||||
bm25_scores,
|
||||
cosine,
|
||||
rank_fusion,
|
||||
retrieval_text,
|
||||
tokenize,
|
||||
)
|
||||
|
||||
from stirling.models import OPERATIONS, ToolEndpoint # noqa: E402
|
||||
|
||||
|
||||
def op_text(op: ToolEndpoint, *, variant: str) -> str:
|
||||
"""How an operation is described to the retriever."""
|
||||
schema = OPERATIONS[op].model_json_schema()
|
||||
description = (schema.get("description") or "").strip()
|
||||
name = op.name.replace("_", " ").lower()
|
||||
if variant == "name":
|
||||
return name
|
||||
if variant == "name_desc":
|
||||
return f"{name}. {description}"
|
||||
return retrieval_text(op)
|
||||
|
||||
|
||||
async def embed_all(client: httpx.AsyncClient, texts: list[str]) -> list[list[float]]:
|
||||
response = await client.post(EMBED_URL, json={"model": EMBED_MODEL, "input": texts}, timeout=600.0)
|
||||
return response.json()["embeddings"]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
variants = ["name", "name_desc", "name_desc_params"]
|
||||
cutoffs = [5, 10, 12, 15, 20, 30]
|
||||
results: dict[str, dict[str, dict[int, float]]] = {}
|
||||
best_rankings: dict[str, dict[str, list[list[str]]]] = {}
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
case_vectors = await embed_all(http, [c.message for c in CASES])
|
||||
|
||||
for variant in variants:
|
||||
texts = [op_text(op, variant=variant) for op in ALL_OPS]
|
||||
op_vectors = await embed_all(http, texts)
|
||||
corpus = [tokenize(t) for t in texts]
|
||||
results[variant] = {"vector": {}, "bm25": {}, "hybrid": {}}
|
||||
|
||||
per_case: dict[str, list[list[str]]] = {"vector": [], "bm25": [], "hybrid": []}
|
||||
for case, vector in zip(CASES, case_vectors, strict=True):
|
||||
vec_rank = sorted(ALL_OPS, key=lambda op: -cosine(vector, op_vectors[INDEX_OF[op.name]]))
|
||||
vec_names = [op.name for op in vec_rank]
|
||||
lex = bm25_scores(case.message, corpus)
|
||||
lex_names = [op.name for op in sorted(ALL_OPS, key=lambda op: -lex[INDEX_OF[op.name]])]
|
||||
per_case["vector"].append(vec_names)
|
||||
per_case["bm25"].append(lex_names)
|
||||
per_case["hybrid"].append(rank_fusion(vec_names, lex_names))
|
||||
|
||||
best_rankings[variant] = per_case
|
||||
for method, rankings in per_case.items():
|
||||
for cutoff in cutoffs:
|
||||
hits = sum(
|
||||
1 for case, ranking in zip(CASES, rankings, strict=True) if case.expected in ranking[:cutoff]
|
||||
)
|
||||
results[variant][method][cutoff] = round(hits / len(CASES), 4)
|
||||
|
||||
header = " ".join(f"@{c:<5}" for c in cutoffs)
|
||||
print(f"{'embedding text':<20} {'method':<8} {header}")
|
||||
for variant, methods in results.items():
|
||||
for method, by_cutoff in methods.items():
|
||||
row = " ".join(f"{by_cutoff[c]:<6.1%}" for c in cutoffs)
|
||||
print(f"{variant:<20} {method:<8} {row}")
|
||||
|
||||
best = max(
|
||||
((v, m, c, s) for v, ms in results.items() for m, cs in ms.items() for c, s in cs.items()),
|
||||
key=lambda row: (row[3], -row[2]),
|
||||
)
|
||||
print(f"\nBest: {best[0]} + {best[1]} @{best[2]} = {best[3]:.1%}")
|
||||
|
||||
# What the winner still cannot reach, so the next change has somewhere to aim.
|
||||
best_variant, best_method, best_cutoff, _ = best
|
||||
misses = [
|
||||
{"case": case.id, "expected": case.expected, "rank": ranking.index(case.expected) + 1}
|
||||
for case, ranking in zip(CASES, best_rankings[best_variant][best_method], strict=True)
|
||||
if case.expected not in ranking[:best_cutoff]
|
||||
]
|
||||
if misses:
|
||||
print(f"\nStill missed at @{best_cutoff} ({len(misses)} of {len(CASES)}) - true rank in brackets:")
|
||||
for miss in sorted(misses, key=lambda m: m["rank"]):
|
||||
print(f" {miss['case']:<16} {miss['expected']:<24} [{miss['rank']}]")
|
||||
|
||||
out = Path("evals/planner/results/recall.json")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(
|
||||
json.dumps({"cutoffs": cutoffs, "results": results, "misses_at_best": misses}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"\nWrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Measure pdf_edit tool selection, and how much of it the context ceiling costs.
|
||||
|
||||
uv run --group engine python evals/planner/runner.py
|
||||
|
||||
Strategies share one decision - pick the operation that answers the request - and differ
|
||||
only in how the 73-operation catalogue is presented. The production prompt overruns what
|
||||
Ollama will accept, so this quantifies the damage rather than assuming it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_EVALS_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EVALS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EVALS_ROOT))
|
||||
_SRC = _EVALS_ROOT.parent / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from planner.dataset import CASES, PlannerCase, band_for # noqa: E402
|
||||
from routing.client import OllamaRouter # noqa: E402
|
||||
|
||||
from stirling.agents.pdf_edit import PdfEditAgent # noqa: E402
|
||||
from stirling.models import OPERATIONS, ToolEndpoint # noqa: E402
|
||||
|
||||
ALL_OPS: list[ToolEndpoint] = list(OPERATIONS.keys())
|
||||
INDEX_OF: dict[str, int] = {op.name: i for i, op in enumerate(ALL_OPS)}
|
||||
BY_VALUE: dict[str, ToolEndpoint] = {op.value: op for op in ALL_OPS}
|
||||
|
||||
EMBED_URL = "http://localhost:11434/api/embed"
|
||||
EMBED_MODEL = "nomic-embed-text"
|
||||
|
||||
_SYSTEM = (
|
||||
"Plan PDF edit requests. Choose the single operation that best answers the request. "
|
||||
"Each operation is listed with its description. Treat that list as authoritative: an "
|
||||
"operation can only do what its description allows."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlannerObservation:
|
||||
strategy: str
|
||||
case_id: str
|
||||
expected: str
|
||||
predicted: str
|
||||
correct: bool
|
||||
expected_index: int
|
||||
predicted_index: int
|
||||
band: str
|
||||
prompt_tokens_sent: int
|
||||
# Ollama reports only newly-evaluated tokens, so a shared prefix across cases makes
|
||||
# this read low. Use the controlled probes, not this, to measure truncation.
|
||||
prompt_tokens_reported: int
|
||||
latency_s: float
|
||||
|
||||
|
||||
_WORD = re.compile(r"[a-z0-9]+")
|
||||
# Splits SCREAMING_SNAKE endpoint names into their parts so PDF_TO_WORD matches "word".
|
||||
_STOPWORDS = frozenset({"pdf", "the", "this", "a", "an", "of", "to", "and", "for", "it", "into", "out"})
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return [w for w in _WORD.findall(text.lower().replace("_", " ")) if w not in _STOPWORDS]
|
||||
|
||||
|
||||
def bm25_scores(query: str, corpus: list[list[str]]) -> list[float]:
|
||||
"""Plain BM25 over the operation texts, as the lexical half of hybrid retrieval."""
|
||||
k1, b = 1.5, 0.75
|
||||
lengths = [len(doc) for doc in corpus]
|
||||
avg_len = sum(lengths) / len(lengths)
|
||||
doc_freq: Counter[str] = Counter()
|
||||
for doc in corpus:
|
||||
doc_freq.update(set(doc))
|
||||
n = len(corpus)
|
||||
scores = [0.0] * n
|
||||
for term in tokenize(query):
|
||||
df = doc_freq.get(term, 0)
|
||||
if not df:
|
||||
continue
|
||||
idf = math.log(1 + (n - df + 0.5) / (df + 0.5))
|
||||
for i, doc in enumerate(corpus):
|
||||
tf = doc.count(term)
|
||||
if tf:
|
||||
scores[i] += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * lengths[i] / avg_len))
|
||||
return scores
|
||||
|
||||
|
||||
def rank_fusion(*rankings: list[str], k: int = 60) -> list[str]:
|
||||
"""Reciprocal rank fusion - combines rankings without needing comparable scores."""
|
||||
fused: dict[str, float] = {}
|
||||
for ranking in rankings:
|
||||
for position, name in enumerate(ranking):
|
||||
fused[name] = fused.get(name, 0.0) + 1.0 / (k + position + 1)
|
||||
return sorted(fused, key=lambda name: -fused[name])
|
||||
|
||||
|
||||
def op_line(op: ToolEndpoint) -> str:
|
||||
schema = OPERATIONS[op].model_json_schema()
|
||||
description = (schema.get("description") or "").strip()
|
||||
return f"- {op.name} ({op.value}): {description}" if description else f"- {op.name} ({op.value})"
|
||||
|
||||
|
||||
def retrieval_text(op: ToolEndpoint) -> str:
|
||||
"""What the retriever indexes - richer than what the prompt shows.
|
||||
|
||||
Parameter descriptions carry the words users actually type ("watermark opacity",
|
||||
"OCR language"), and including them lifted recall@12 from 77.3% to 88.6%. They stay out
|
||||
of the prompt itself, which only needs enough to tell the candidates apart.
|
||||
"""
|
||||
model = OPERATIONS[op]
|
||||
description = (model.model_json_schema().get("description") or "").strip()
|
||||
# Field metadata, not the JSON schema: enum-typed fields render as a bare "$ref" and
|
||||
# lose their description there.
|
||||
params = [(f.description or "").strip() for f in model.model_fields.values() if (f.description or "").strip()]
|
||||
return f"{op.name.replace('_', ' ').lower()}. {description} {' '.join(params)}".strip()
|
||||
|
||||
|
||||
def endpoint_schema(ops: list[ToolEndpoint]) -> dict[str, Any]:
|
||||
"""Mirrors production: ToolEndpoint is a StrEnum, so the schema carries the path values.
|
||||
|
||||
The names stay selectable even when the prompt describing them is truncated away, which
|
||||
is exactly the production failure - the model can still name an operation it can no
|
||||
longer read about.
|
||||
"""
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "plan",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"operation": {"type": "string", "enum": [op.value for op in ops]}},
|
||||
"required": ["operation"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def embed(client: httpx.AsyncClient, texts: list[str]) -> list[list[float]]:
|
||||
response = await client.post(EMBED_URL, json={"model": EMBED_MODEL, "input": texts}, timeout=600.0)
|
||||
return response.json()["embeddings"]
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
na = sum(x * x for x in a) ** 0.5
|
||||
nb = sum(y * y for y in b) ** 0.5
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
def prod_prompt(case: PlannerCase) -> str:
|
||||
"""The production shape: request first, then the full catalogue."""
|
||||
menu = PdfEditAgent._get_supported_operations_prompt(ALL_OPS)
|
||||
return (
|
||||
f"Conversation history:\nNone\n"
|
||||
f"User request: {case.message}\n"
|
||||
f"Files: document.pdf\n"
|
||||
f"Supported operations:\n{menu}\n"
|
||||
f"Extracted page text:\nNone"
|
||||
)
|
||||
|
||||
|
||||
def request_last_prompt(case: PlannerCase) -> str:
|
||||
"""Same content, request moved to the tail - the part that survives truncation."""
|
||||
menu = PdfEditAgent._get_supported_operations_prompt(ALL_OPS)
|
||||
return (
|
||||
f"Supported operations:\n{menu}\n"
|
||||
f"Conversation history:\nNone\n"
|
||||
f"Files: document.pdf\n"
|
||||
f"Extracted page text:\nNone\n"
|
||||
f"User request: {case.message}"
|
||||
)
|
||||
|
||||
|
||||
def shortlist_prompt(case: PlannerCase, ops: list[ToolEndpoint], *, rich: bool = False) -> str:
|
||||
"""Candidates only. ``rich`` spends the space a short list frees on describing them.
|
||||
|
||||
The plain line carries the operation's own description; ``rich`` adds the parameter text
|
||||
on top. With only 12-20 candidates there is room for both and the prompt still sits far
|
||||
inside the context ceiling.
|
||||
"""
|
||||
render = (lambda op: f"- {op.name} ({op.value}): {retrieval_text(op)}") if rich else op_line
|
||||
menu = "\n".join(render(op) for op in ops)
|
||||
return (
|
||||
f"Candidate operations:\n{menu}\nConversation history:\nNone\nFiles: document.pdf\nUser request: {case.message}"
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="pdf_edit tool-selection eval.")
|
||||
parser.add_argument("--out", default="evals/planner/results")
|
||||
parser.add_argument("--model", default="qwen3:8b")
|
||||
parser.add_argument("--concurrency", type=int, default=3)
|
||||
parser.add_argument("--shortlist", type=int, default=12)
|
||||
parser.add_argument("--strategies", default="prod,request_last,shortlist")
|
||||
parser.add_argument("--retrieval", default="hybrid", choices=["vector", "hybrid"])
|
||||
args = parser.parse_args()
|
||||
|
||||
import tiktoken
|
||||
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
semaphore = asyncio.Semaphore(args.concurrency)
|
||||
observations: list[PlannerObservation] = []
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
router = OllamaRouter(http, model=args.model)
|
||||
|
||||
print("Embedding the operation catalogue...", flush=True)
|
||||
texts = [retrieval_text(op) for op in ALL_OPS]
|
||||
op_vectors = await embed(http, texts)
|
||||
corpus = [tokenize(t) for t in texts]
|
||||
case_vectors = await embed(http, [c.message for c in CASES])
|
||||
shortlists: dict[str, list[ToolEndpoint]] = {}
|
||||
by_name = {op.name: op for op in ALL_OPS}
|
||||
for case, vector in zip(CASES, case_vectors, strict=True):
|
||||
vector_rank = [
|
||||
op.name for op in sorted(ALL_OPS, key=lambda op: -cosine(vector, op_vectors[INDEX_OF[op.name]]))
|
||||
]
|
||||
if args.retrieval == "vector":
|
||||
ranked_names = vector_rank
|
||||
else:
|
||||
lexical = bm25_scores(case.message, corpus)
|
||||
lexical_rank = [op.name for op in sorted(ALL_OPS, key=lambda op: -lexical[INDEX_OF[op.name]])]
|
||||
ranked_names = rank_fusion(vector_rank, lexical_rank)
|
||||
shortlists[case.id] = [by_name[n] for n in ranked_names[: args.shortlist]]
|
||||
recall = sum(1 for c in CASES if c.expected in {op.name for op in shortlists[c.id]}) / len(CASES)
|
||||
print(f"Shortlist recall@{args.shortlist} ({args.retrieval}): {recall:.1%}", flush=True)
|
||||
|
||||
async def run(strategy: str, case: PlannerCase) -> PlannerObservation:
|
||||
if strategy == "prod":
|
||||
prompt, ops = prod_prompt(case), ALL_OPS
|
||||
elif strategy == "request_last":
|
||||
prompt, ops = request_last_prompt(case), ALL_OPS
|
||||
else:
|
||||
ops = shortlists[case.id]
|
||||
prompt = shortlist_prompt(case, ops, rich=strategy == "shortlist_rich")
|
||||
sent = len(encoding.encode(_SYSTEM)) + len(encoding.encode(prompt))
|
||||
async with semaphore:
|
||||
result = await router.call(
|
||||
_SYSTEM,
|
||||
prompt,
|
||||
response_format=endpoint_schema(ops),
|
||||
max_tokens=4096,
|
||||
temperature=0.0,
|
||||
thinking=True,
|
||||
)
|
||||
predicted = "__failed__"
|
||||
if not result.error and result.finish_reason != "length":
|
||||
try:
|
||||
value = json.loads(result.content).get("operation")
|
||||
predicted = BY_VALUE[value].name if value in BY_VALUE else "__offmenu__"
|
||||
except ValueError:
|
||||
predicted = "__unparsable__"
|
||||
correct = predicted == case.expected or predicted in case.also_ok
|
||||
return PlannerObservation(
|
||||
strategy=strategy,
|
||||
case_id=case.id,
|
||||
expected=case.expected,
|
||||
predicted=predicted,
|
||||
correct=correct,
|
||||
expected_index=INDEX_OF[case.expected],
|
||||
predicted_index=INDEX_OF.get(predicted, -1),
|
||||
band=band_for(INDEX_OF[case.expected]),
|
||||
prompt_tokens_sent=sent,
|
||||
prompt_tokens_reported=result.input_tokens,
|
||||
latency_s=round(result.latency_s, 3),
|
||||
)
|
||||
|
||||
for strategy in [s.strip() for s in args.strategies.split(",")]:
|
||||
started = time.monotonic()
|
||||
rows = await asyncio.gather(*[run(strategy, case) for case in CASES])
|
||||
observations.extend(rows)
|
||||
print(
|
||||
f"{strategy:14s} acc={sum(r.correct for r in rows) / len(rows):6.1%} "
|
||||
f"sent={rows[0].prompt_tokens_sent:5d} "
|
||||
f"wall={time.monotonic() - started:6.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
summary: dict[str, Any] = {}
|
||||
grouped: dict[str, list[PlannerObservation]] = defaultdict(list)
|
||||
for obs in observations:
|
||||
grouped[obs.strategy].append(obs)
|
||||
for name, rows in grouped.items():
|
||||
bands = {}
|
||||
for band in {r.band for r in rows}:
|
||||
band_rows = [r for r in rows if r.band == band]
|
||||
bands[band] = {
|
||||
"n": len(band_rows),
|
||||
"accuracy": round(sum(r.correct for r in band_rows) / len(band_rows), 4),
|
||||
}
|
||||
picked = [r.predicted_index for r in rows if r.predicted_index >= 0]
|
||||
summary[name] = {
|
||||
"accuracy": round(sum(r.correct for r in rows) / len(rows), 4),
|
||||
"prompt_tokens_sent": rows[0].prompt_tokens_sent,
|
||||
"prompt_tokens_reported_avg": round(sum(r.prompt_tokens_reported for r in rows) / len(rows), 1),
|
||||
"avg_latency_s": round(sum(r.latency_s for r in rows) / len(rows), 2),
|
||||
# Where in the catalogue its answers come from: truncation should drag this late.
|
||||
"mean_picked_index": round(sum(picked) / len(picked), 1) if picked else None,
|
||||
"by_band": dict(sorted(bands.items())),
|
||||
"wrong": [
|
||||
{"case": r.case_id, "expected": r.expected, "picked": r.predicted} for r in rows if not r.correct
|
||||
][:15],
|
||||
"top_wrong_picks": Counter(r.predicted for r in rows if not r.correct).most_common(5),
|
||||
}
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "summary.json").write_text(
|
||||
json.dumps({"model": args.model, "case_count": len(CASES), "summary": summary}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (out_dir / "observations.jsonl").open("w", encoding="utf-8") as handle:
|
||||
for obs in observations:
|
||||
handle.write(json.dumps(asdict(obs)) + "\n")
|
||||
expected_mean = sum(INDEX_OF[c.expected] for c in CASES) / len(CASES)
|
||||
print(f"\nMean catalogue index of the chosen operation (expected mean {expected_mean:.1f}):")
|
||||
for name, stats in summary.items():
|
||||
print(f" {name:14s} {stats['mean_picked_index']}")
|
||||
print(f"\nWrote {out_dir / 'summary.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
# Routing eval
|
||||
|
||||
Measures the orchestrator's top-level capability decision - the one that sends a turn to
|
||||
`pdf_edit`, `pdf_question`, `pdf_review`, `pdf_create`, `user_spec`, or `unsupported`.
|
||||
|
||||
It exists because the reported failure ("asking a question and it runs a tool instead") is
|
||||
a routing failure, and prompt changes aimed at it were otherwise unmeasurable.
|
||||
|
||||
## Running it
|
||||
|
||||
Needs a reachable Ollama with the model pulled. Nothing else - no engine server, no database.
|
||||
|
||||
```bash
|
||||
uv run --group engine python evals/routing/runner.py
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `--strategies thinking_off,fewshot_thinking_off` | Run a subset. |
|
||||
| `--repeats 5` | Run each case N times; feeds the `unstable_cases` count. |
|
||||
| `--concurrency 3` | Parallel in-flight requests. |
|
||||
| `--model qwen2.5:7b` | Compare models on the same cases. |
|
||||
| `--limit 8` | Smoke test. |
|
||||
|
||||
Then turn the results into the cost table:
|
||||
|
||||
```bash
|
||||
uv run --group engine python evals/routing/cost.py
|
||||
```
|
||||
|
||||
Outputs land in `evals/routing/results/`: `summary.json` (per-strategy scores),
|
||||
`observations.jsonl` (one row per case per strategy, for re-scoring without re-spending),
|
||||
and `cost.json`.
|
||||
|
||||
## What is scored
|
||||
|
||||
`dataset.py` holds 82 labelled turns in four bands:
|
||||
|
||||
- **clear** - unambiguous either way.
|
||||
- **boundary** - question and edit phrased almost identically ("Are there any blank pages?"
|
||||
vs "Remove the blank pages"). This is the reported failure mode.
|
||||
- **contextual** - only resolvable from conversation history ("yes do that", "now rotate it").
|
||||
- **adversarial** - a tool's name appears in a turn that is not asking for that tool
|
||||
("Which section talks about the merger?").
|
||||
|
||||
Beyond plain accuracy the runner reports:
|
||||
|
||||
- **destructive misroutes** - a read-only turn sent to a mutating capability. Weighted
|
||||
separately because that direction changes the user's file; the reverse only answers.
|
||||
- **hard failures** - truncated, unparsable, or off-menu model output.
|
||||
- **unstable cases** - with `--repeats > 1`, cases where the same input routed differently
|
||||
across runs.
|
||||
|
||||
`tolerated` on a case marks a genuinely defensible second answer, scored as a near-miss
|
||||
rather than a failure, so the boundary cases do not punish reasonable disagreement.
|
||||
|
||||
## Adding a strategy
|
||||
|
||||
Implement the `Strategy` protocol in `strategies.py` (a `name`, a `description`, and an
|
||||
async `route`) and add it to `build_strategies()`. The prompts come from
|
||||
`stirling.agents.orchestrator`, so the baseline tracks production rather than a copy of it.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Latency is measured under `--concurrency`, so it reflects throughput, not the latency a
|
||||
single user sees. Re-run with `--concurrency 1` for user-facing latency.
|
||||
- `reasoning_effort: "none"` is the only thinking switch Ollama honours on the
|
||||
OpenAI-compatible path for qwen3; `chat_template_kwargs={"enable_thinking": false}` and a
|
||||
`/no_think` suffix are both ignored.
|
||||
- The eval sends prompts directly rather than through pydantic-ai, so it does not exercise
|
||||
pydantic-ai's output-validation retries. A `__truncated__` result here is what production
|
||||
spends a retry on.
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Thin Ollama client for the routing eval.
|
||||
|
||||
The eval talks to ``/v1/chat/completions`` directly rather than through pydantic-ai so it
|
||||
can drive knobs pydantic-ai does not surface (``reasoning_effort``) and can measure the
|
||||
reasoning tokens Ollama reports outside ``completion_tokens``. The prompts themselves are
|
||||
imported from the production orchestrator, so what is measured is the real prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:11434/v1/chat/completions"
|
||||
DEFAULT_MODEL = "qwen3:8b"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallResult:
|
||||
"""One model round trip."""
|
||||
|
||||
content: str
|
||||
latency_s: float
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
# Ollama returns qwen3's chain of thought in a separate `reasoning` field and (except
|
||||
# when the cap truncates) leaves it out of completion_tokens, so it is counted here.
|
||||
thinking_chars: int
|
||||
finish_reason: str
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Budget:
|
||||
"""Running totals for one strategy run over one case."""
|
||||
|
||||
calls: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
thinking_chars: int = 0
|
||||
latency_s: float = 0.0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def add(self, result: CallResult) -> None:
|
||||
self.calls += 1
|
||||
self.input_tokens += result.input_tokens
|
||||
self.output_tokens += result.output_tokens
|
||||
self.thinking_chars += result.thinking_chars
|
||||
self.latency_s += result.latency_s
|
||||
if result.error:
|
||||
self.errors.append(result.error)
|
||||
|
||||
|
||||
def enum_schema(name: str, values: list[str], *, with_message: bool = False) -> dict[str, Any]:
|
||||
"""A json-schema response format holding a single enum choice.
|
||||
|
||||
Mirrors what pydantic-ai's NativeOutput sends for the router's ``_RouteDecision``.
|
||||
"""
|
||||
properties: dict[str, Any] = {"capability": {"type": "string", "enum": values}}
|
||||
required = ["capability"]
|
||||
if with_message:
|
||||
properties["message"] = {"type": "string"}
|
||||
required.append("message")
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": name,
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class OllamaRouter:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
model: str = DEFAULT_MODEL,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._model = model
|
||||
self._base_url = base_url
|
||||
|
||||
async def call(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
response_format: dict[str, Any] | None,
|
||||
max_tokens: int,
|
||||
temperature: float | None,
|
||||
thinking: bool,
|
||||
) -> CallResult:
|
||||
body: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if response_format is not None:
|
||||
body["response_format"] = response_format
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
if not thinking:
|
||||
# The only knob Ollama honours on the OpenAI-compatible path for qwen3.
|
||||
# chat_template_kwargs{enable_thinking} and a /no_think suffix are both ignored.
|
||||
body["reasoning_effort"] = "none"
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = await self._client.post(self._base_url, json=body, timeout=600.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return CallResult("", time.monotonic() - started, 0, 0, 0, "transport", f"{type(exc).__name__}: {exc}")
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return CallResult("", elapsed, 0, 0, 0, "bad-json", f"HTTP {response.status_code}: non-JSON body")
|
||||
if "choices" not in payload:
|
||||
return CallResult("", elapsed, 0, 0, 0, "no-choices", f"HTTP {response.status_code}: {payload}"[:400])
|
||||
|
||||
choice = payload["choices"][0]
|
||||
message = choice.get("message") or {}
|
||||
usage = payload.get("usage") or {}
|
||||
reasoning = message.get("reasoning") or message.get("reasoning_content") or ""
|
||||
return CallResult(
|
||||
content=message.get("content") or "",
|
||||
latency_s=elapsed,
|
||||
input_tokens=int(usage.get("prompt_tokens") or 0),
|
||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
||||
thinking_chars=len(reasoning),
|
||||
finish_reason=str(choice.get("finish_reason") or ""),
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Cost model for the routing decision.
|
||||
|
||||
Two questions the eval alone does not answer:
|
||||
|
||||
1. What does each routing strategy cost per conversation, in tokens, in local GPU
|
||||
seconds, and in dollars if the same orchestrator runs on the hosted default
|
||||
(engine/.env ships ``anthropic:claude-haiku-4-5``)?
|
||||
2. How big is that cost next to the rest of the request it is deciding for?
|
||||
|
||||
Run after runner.py:
|
||||
|
||||
uv run --group engine python evals/routing/cost.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_EVALS_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EVALS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EVALS_ROOT))
|
||||
_SRC = _EVALS_ROOT.parent / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
|
||||
# Claude Haiku 4.5 list price, the hosted default in engine/.env. USD per million tokens.
|
||||
HAIKU_INPUT_PER_MTOK = 1.00
|
||||
HAIKU_OUTPUT_PER_MTOK = 5.00
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageProfile:
|
||||
"""One model call in a request, sized from the real rendered prompts."""
|
||||
|
||||
name: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
note: str
|
||||
|
||||
|
||||
def measure_pipeline_stages() -> list[StageProfile]:
|
||||
"""Token profile of a single-operation pdf_edit request, measured not guessed."""
|
||||
import tiktoken
|
||||
|
||||
from stirling.agents.orchestrator import _ROUTER_SYSTEM_PROMPT
|
||||
from stirling.agents.pdf_edit import PdfEditAgent
|
||||
from stirling.models import OPERATIONS
|
||||
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
operations = list(OPERATIONS.keys())
|
||||
|
||||
def count(text: str) -> int:
|
||||
return len(encoding.encode(text))
|
||||
|
||||
menu = PdfEditAgent._get_supported_operations_prompt(operations)
|
||||
flat_names = PdfEditAgent._get_operations_prompt(operations)
|
||||
|
||||
# A representative turn: short message, one file, no history.
|
||||
turn_overhead = count("Conversation history:\nNone\nUser message: Rotate all pages 90 degrees\nFiles: a.pdf\n")
|
||||
|
||||
router_in = count(_ROUTER_SYSTEM_PROMPT) + turn_overhead + count("Available artifacts:\n- none")
|
||||
# The planner's system prompt carries the flat name+path list; the user prompt carries
|
||||
# the full menu with every parameter description.
|
||||
planner_in = count(flat_names) + count(menu) + turn_overhead + 200
|
||||
|
||||
# One parameter call per planned operation. It re-sends the same turn plus the
|
||||
# selected operation's schema, and pydantic-ai passes the model the full schema.
|
||||
param_schema_tokens = max(count(json.dumps(OPERATIONS[op].model_json_schema())) for op in operations)
|
||||
median_schema = sorted(count(json.dumps(OPERATIONS[op].model_json_schema())) for op in operations)[
|
||||
len(operations) // 2
|
||||
]
|
||||
|
||||
return [
|
||||
StageProfile("router", router_in, 8, "6-way enum decision"),
|
||||
StageProfile("edit planner", planner_in, 60, f"73-operation menu ({count(menu)} tok) + flat list"),
|
||||
StageProfile("parameter call", median_schema + turn_overhead + 100, 60, "one per planned operation"),
|
||||
StageProfile(
|
||||
"parameter call (worst schema)",
|
||||
param_schema_tokens + turn_overhead + 100,
|
||||
60,
|
||||
"largest operation schema",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def dollars(input_tokens: float, output_tokens: float) -> float:
|
||||
return input_tokens / 1e6 * HAIKU_INPUT_PER_MTOK + output_tokens / 1e6 * HAIKU_OUTPUT_PER_MTOK
|
||||
|
||||
|
||||
def build_cost_table(summary_path: Path) -> dict:
|
||||
payload = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
summary = payload["summary"]
|
||||
|
||||
rows = []
|
||||
for name, stats in summary.items():
|
||||
per_conv_in = stats["avg_input_tokens"]
|
||||
per_conv_out = stats["avg_output_tokens"]
|
||||
accuracy = stats["accuracy"]
|
||||
usd_1k = dollars(per_conv_in * 1000, per_conv_out * 1000)
|
||||
# A wrong route is wasted spend plus a wasted downstream pipeline, so the honest
|
||||
# unit is cost per correctly-routed conversation.
|
||||
usd_1k_correct = usd_1k / accuracy if accuracy else float("inf")
|
||||
rows.append(
|
||||
{
|
||||
"strategy": name,
|
||||
"description": payload["strategy_descriptions"].get(name, ""),
|
||||
"accuracy": accuracy,
|
||||
"accuracy_with_tolerated": stats["accuracy_with_tolerated"],
|
||||
"destructive_misroutes": stats["destructive_misroutes"],
|
||||
"hard_failures": stats["hard_failures"],
|
||||
"avg_calls": stats["avg_calls"],
|
||||
"avg_input_tokens": per_conv_in,
|
||||
"avg_output_tokens": per_conv_out,
|
||||
"avg_thinking_chars": stats["avg_thinking_chars"],
|
||||
"avg_latency_s": stats["avg_latency_s"],
|
||||
"p90_latency_s": stats["p90_latency_s"],
|
||||
"usd_per_1k_conversations": round(usd_1k, 4),
|
||||
"usd_per_1k_correct": round(usd_1k_correct, 4),
|
||||
"by_band": stats["by_band"],
|
||||
"top_confusions": stats["top_confusions"],
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: -r["accuracy"])
|
||||
return {"meta": payload, "rows": rows}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
results = Path("evals/routing/results/summary.json")
|
||||
stages = measure_pipeline_stages()
|
||||
|
||||
print("=== Measured pipeline token profile (single-operation pdf_edit request) ===")
|
||||
total_in = 0
|
||||
for stage in stages:
|
||||
if "worst" in stage.name:
|
||||
continue
|
||||
total_in += stage.input_tokens
|
||||
print(f" {stage.name:24s} in={stage.input_tokens:6d} out={stage.output_tokens:4d} {stage.note}")
|
||||
print(f" {'TOTAL (3 calls)':24s} in={total_in:6d}")
|
||||
router_share = stages[0].input_tokens / total_in
|
||||
print(f" router share of input tokens: {router_share:.1%}")
|
||||
|
||||
if not results.exists():
|
||||
print("\nNo eval results yet - run runner.py first.")
|
||||
return
|
||||
|
||||
table = build_cost_table(results)
|
||||
print("\n=== Per-strategy cost (measured) ===")
|
||||
print(
|
||||
f"{'strategy':20s} {'acc':>6s} {'dstr':>5s} {'calls':>6s} {'in':>7s} "
|
||||
f"{'out':>6s} {'lat_s':>7s} {'$/1k':>8s} {'$/1k ok':>8s}"
|
||||
)
|
||||
for row in table["rows"]:
|
||||
print(
|
||||
f"{row['strategy']:20s} {row['accuracy']:6.1%} {row['destructive_misroutes']:5d} "
|
||||
f"{row['avg_calls']:6.2f} {row['avg_input_tokens']:7.0f} {row['avg_output_tokens']:6.0f} "
|
||||
f"{row['avg_latency_s']:7.2f} {row['usd_per_1k_conversations']:8.3f} {row['usd_per_1k_correct']:8.3f}"
|
||||
)
|
||||
|
||||
out = Path("evals/routing/results/cost.json")
|
||||
out.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pricing": {
|
||||
"model": "claude-haiku-4-5",
|
||||
"input_per_mtok": HAIKU_INPUT_PER_MTOK,
|
||||
"output_per_mtok": HAIKU_OUTPUT_PER_MTOK,
|
||||
},
|
||||
"pipeline_stages": [vars(s) for s in stages],
|
||||
"strategies": table["rows"],
|
||||
"meta": {k: v for k, v in table["meta"].items() if k != "summary"},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"\nWrote {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Labelled routing cases for the orchestrator's top-level capability decision.
|
||||
|
||||
Each case is one user turn as the orchestrator sees it. ``expected`` is the correct
|
||||
capability; ``tolerated`` lists routes that are defensible for genuinely ambiguous
|
||||
turns and are scored as near-misses rather than failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
# Mirrors orchestrator._RouteCapability. Kept as plain strings so the eval can score a
|
||||
# model that returns something off-menu without blowing up.
|
||||
CAPABILITIES = ("pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported")
|
||||
|
||||
|
||||
class Band(StrEnum):
|
||||
"""Difficulty band, so accuracy is reported per band rather than as one blurred number."""
|
||||
|
||||
CLEAR = "clear"
|
||||
BOUNDARY = "boundary"
|
||||
CONTEXTUAL = "contextual"
|
||||
ADVERSARIAL = "adversarial"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingCase:
|
||||
id: str
|
||||
message: str
|
||||
expected: str
|
||||
band: Band
|
||||
files: tuple[str, ...] = ("document.pdf",)
|
||||
history: tuple[tuple[str, str], ...] = ()
|
||||
tolerated: frozenset[str] = frozenset()
|
||||
note: str = ""
|
||||
|
||||
|
||||
def _c(
|
||||
id: str,
|
||||
message: str,
|
||||
expected: str,
|
||||
band: Band,
|
||||
*,
|
||||
files: tuple[str, ...] = ("document.pdf",),
|
||||
history: tuple[tuple[str, str], ...] = (),
|
||||
tolerated: frozenset[str] = frozenset(),
|
||||
note: str = "",
|
||||
) -> RoutingCase:
|
||||
return RoutingCase(id, message, expected, band, files, history, tolerated, note)
|
||||
|
||||
|
||||
# --- Clear questions: reading the document, nothing mutated -------------------------
|
||||
_CLEAR_QUESTIONS = [
|
||||
_c("q-what-about", "What is this document about?", "pdf_question", Band.CLEAR),
|
||||
_c("q-who-signed", "Who signed this contract?", "pdf_question", Band.CLEAR),
|
||||
_c("q-total", "What is the total amount on this invoice?", "pdf_question", Band.CLEAR),
|
||||
_c("q-summarise", "Summarise this for me", "pdf_question", Band.CLEAR),
|
||||
_c("q-deadline", "When is the payment deadline?", "pdf_question", Band.CLEAR),
|
||||
_c("q-parties", "List the parties named in this agreement", "pdf_question", Band.CLEAR),
|
||||
_c("q-explain", "Explain clause 7 in plain English", "pdf_question", Band.CLEAR),
|
||||
_c("q-howmany-pages", "How many pages does this have?", "pdf_question", Band.CLEAR),
|
||||
_c("q-mention", "Does this mention anything about termination?", "pdf_question", Band.CLEAR),
|
||||
_c("q-language", "What language is this written in?", "pdf_question", Band.CLEAR),
|
||||
]
|
||||
|
||||
# --- Clear edits: the user wants a changed file back --------------------------------
|
||||
_CLEAR_EDITS = [
|
||||
_c("e-rotate", "Rotate all pages 90 degrees", "pdf_edit", Band.CLEAR),
|
||||
_c("e-to-word", "Convert this to Word", "pdf_edit", Band.CLEAR),
|
||||
_c("e-compress", "Compress this file, it is too big to email", "pdf_edit", Band.CLEAR),
|
||||
_c("e-split", "Split this into one file per page", "pdf_edit", Band.CLEAR),
|
||||
_c("e-merge", "Merge these two files together", "pdf_edit", Band.CLEAR, files=("a.pdf", "b.pdf")),
|
||||
_c("e-watermark", "Add a DRAFT watermark to every page", "pdf_edit", Band.CLEAR),
|
||||
_c("e-password", "Password protect this with hunter2", "pdf_edit", Band.CLEAR),
|
||||
_c("e-delete-p3", "Delete page 3", "pdf_edit", Band.CLEAR),
|
||||
_c("e-ocr", "Run OCR on this so I can search it", "pdf_edit", Band.CLEAR),
|
||||
_c("e-to-images", "Export every page as a PNG", "pdf_edit", Band.CLEAR),
|
||||
]
|
||||
|
||||
# --- The reported failure mode: question vs edit on near-identical surface forms -----
|
||||
_BOUNDARY = [
|
||||
_c(
|
||||
"b-blank-ask",
|
||||
"Are there any blank pages in this?",
|
||||
"pdf_question",
|
||||
Band.BOUNDARY,
|
||||
note="Asks whether, does not ask to remove.",
|
||||
),
|
||||
_c("b-blank-do", "Remove the blank pages", "pdf_edit", Band.BOUNDARY),
|
||||
_c("b-signed-ask", "Is this document signed?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-signed-do", "Sign this document for me", "pdf_edit", Band.BOUNDARY),
|
||||
_c("b-pw-ask", "Is this file password protected?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-pw-do", "Remove the password from this file", "pdf_edit", Band.BOUNDARY),
|
||||
_c("b-pii-ask", "Does this contain any personal data?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-pii-do", "Redact all the personal data", "pdf_edit", Band.BOUNDARY),
|
||||
_c(
|
||||
"b-can-you-compress",
|
||||
"Can you compress this?",
|
||||
"pdf_edit",
|
||||
Band.BOUNDARY,
|
||||
note="Polite imperative, not a capability question.",
|
||||
),
|
||||
_c(
|
||||
"b-how-much-smaller",
|
||||
"How much smaller could this file get?",
|
||||
"pdf_question",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"unsupported"}),
|
||||
),
|
||||
_c("b-which-pages", "Which pages mention the budget?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-extract-pages", "Extract the pages that mention the budget", "pdf_edit", Band.BOUNDARY),
|
||||
_c("b-count-forms", "How many form fields are in this?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-flatten-forms", "Flatten the form fields", "pdf_edit", Band.BOUNDARY),
|
||||
_c("b-orientation-ask", "Are any pages upside down?", "pdf_question", Band.BOUNDARY),
|
||||
_c("b-orientation-do", "Fix the pages that are upside down", "pdf_edit", Band.BOUNDARY),
|
||||
_c(
|
||||
"b-should-i-split",
|
||||
"Should I split this into separate documents?",
|
||||
"pdf_question",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"unsupported"}),
|
||||
note="Advice, not an instruction.",
|
||||
),
|
||||
_c("b-what-format", "What format is this file in?", "pdf_question", Band.BOUNDARY),
|
||||
]
|
||||
|
||||
# --- Create a brand-new document, usually with no input file ------------------------
|
||||
_CREATE = [
|
||||
_c("c-invoice", "Write me an invoice for 500 pounds of consulting work", "pdf_create", Band.CLEAR, files=()),
|
||||
_c("c-letter", "Draft a resignation letter", "pdf_create", Band.CLEAR, files=()),
|
||||
_c("c-report", "Create a one page status report about our Q3 launch", "pdf_create", Band.CLEAR, files=()),
|
||||
_c("c-contract", "Make me a simple freelance contract template", "pdf_create", Band.CLEAR, files=()),
|
||||
_c("c-nda", "Generate an NDA between Acme Ltd and Beta Inc", "pdf_create", Band.CLEAR, files=()),
|
||||
_c(
|
||||
"c-invoice-withfile",
|
||||
"Create an invoice like this one but for 900 pounds",
|
||||
"pdf_create",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"pdf_edit", "pdf_question"}),
|
||||
note="File present but the output is a new doc.",
|
||||
),
|
||||
_c("c-cover", "Write a cover letter for a product manager role", "pdf_create", Band.CLEAR, files=()),
|
||||
_c("c-agenda", "Put together a meeting agenda for Thursday", "pdf_create", Band.CLEAR, files=()),
|
||||
]
|
||||
|
||||
# --- Review: return the PDF with comments/annotations attached ----------------------
|
||||
_REVIEW = [
|
||||
_c("r-review", "Review this and leave comments", "pdf_review", Band.CLEAR),
|
||||
_c("r-annotate", "Annotate anything unclear with sticky notes", "pdf_review", Band.CLEAR),
|
||||
_c("r-feedback", "Give me feedback on this draft as comments on the document", "pdf_review", Band.CLEAR),
|
||||
_c("r-flag", "Flag any risky clauses directly in the PDF", "pdf_review", Band.CLEAR),
|
||||
_c(
|
||||
"r-markup",
|
||||
"Mark up the sections that need work",
|
||||
"pdf_review",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"pdf_edit"}),
|
||||
),
|
||||
_c(
|
||||
"r-whats-wrong",
|
||||
"What is wrong with this contract?",
|
||||
"pdf_question",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"pdf_review"}),
|
||||
note="Wants an answer, not an annotated file.",
|
||||
),
|
||||
_c("r-proofread", "Proofread this and put your corrections in the margin", "pdf_review", Band.BOUNDARY),
|
||||
]
|
||||
|
||||
# --- Agent spec authoring -----------------------------------------------------------
|
||||
_SPEC = [
|
||||
_c("s-agent", "Create an agent that watermarks every file I upload", "user_spec", Band.CLEAR, files=()),
|
||||
_c("s-automation", "Set up an automation to compress incoming invoices", "user_spec", Band.CLEAR, files=()),
|
||||
_c("s-define", "Define a new agent for redacting client names", "user_spec", Band.CLEAR, files=()),
|
||||
_c(
|
||||
"s-workflow",
|
||||
"I want a workflow that OCRs and then splits by chapter",
|
||||
"user_spec",
|
||||
Band.BOUNDARY,
|
||||
tolerated=frozenset({"pdf_edit"}),
|
||||
),
|
||||
_c("s-edit-spec", "Change my watermark agent to use red text", "user_spec", Band.CLEAR, files=()),
|
||||
_c("s-once", "Watermark this one file", "pdf_edit", Band.BOUNDARY, note="One-off action, not a reusable spec."),
|
||||
]
|
||||
|
||||
# --- About the assistant, or out of scope -------------------------------------------
|
||||
_UNSUPPORTED = [
|
||||
_c("u-model", "What model are you running on?", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-who", "Who made you?", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-capabilities", "What can you do?", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-weather", "What is the weather in London tomorrow?", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-code", "Write me a Python script to sort a list", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-hello", "hey", "unsupported", Band.CLEAR, files=()),
|
||||
_c("u-thanks", "thanks, that worked", "unsupported", Band.CLEAR, tolerated=frozenset({"pdf_question"})),
|
||||
]
|
||||
|
||||
# --- Turns that only make sense against the conversation history ---------------------
|
||||
_CONTEXTUAL = [
|
||||
_c(
|
||||
"x-do-it",
|
||||
"yes do that",
|
||||
"pdf_edit",
|
||||
Band.CONTEXTUAL,
|
||||
history=(
|
||||
("user", "Can you compress this file?"),
|
||||
("assistant", "I can compress it to about 40% of the current size. Shall I go ahead?"),
|
||||
),
|
||||
),
|
||||
_c(
|
||||
"x-and-page2",
|
||||
"and what about page 2?",
|
||||
"pdf_question",
|
||||
Band.CONTEXTUAL,
|
||||
history=(
|
||||
("user", "What does page 1 say?"),
|
||||
("assistant", "Page 1 is the cover sheet for the Acme service agreement."),
|
||||
),
|
||||
),
|
||||
_c(
|
||||
"x-now-rotate",
|
||||
"now rotate it",
|
||||
"pdf_edit",
|
||||
Band.CONTEXTUAL,
|
||||
history=(("user", "Split this into single pages"), ("assistant", "Done, I split it into 12 files.")),
|
||||
),
|
||||
_c(
|
||||
"x-why",
|
||||
"why did you pick that one?",
|
||||
"unsupported",
|
||||
Band.CONTEXTUAL,
|
||||
tolerated=frozenset({"pdf_question"}),
|
||||
history=(("user", "Compress this"), ("assistant", "I used the lossless compression profile.")),
|
||||
),
|
||||
_c(
|
||||
"x-same-again",
|
||||
"do the same to this one",
|
||||
"pdf_edit",
|
||||
Band.CONTEXTUAL,
|
||||
files=("second.pdf",),
|
||||
history=(("user", "Add a watermark saying CONFIDENTIAL"), ("assistant", "Watermark added.")),
|
||||
),
|
||||
_c(
|
||||
"x-more-detail",
|
||||
"can you give me more detail on that?",
|
||||
"pdf_question",
|
||||
Band.CONTEXTUAL,
|
||||
history=(("user", "What are the payment terms?"), ("assistant", "Net 30 from invoice date.")),
|
||||
),
|
||||
]
|
||||
|
||||
# --- Phrasings built to trip a keyword-ish router -----------------------------------
|
||||
_ADVERSARIAL = [
|
||||
_c(
|
||||
"a-word-question",
|
||||
"Does this document explain how to convert a PDF to Word?",
|
||||
"pdf_question",
|
||||
Band.ADVERSARIAL,
|
||||
note="Contains 'convert to Word' but is a content question.",
|
||||
),
|
||||
_c(
|
||||
"a-merge-mention",
|
||||
"Which section talks about the merger?",
|
||||
"pdf_question",
|
||||
Band.ADVERSARIAL,
|
||||
note="'merger' is not the merge tool.",
|
||||
),
|
||||
_c("a-split-mention", "What does it say about splitting the estate?", "pdf_question", Band.ADVERSARIAL),
|
||||
_c("a-rotate-mention", "Is there anything in here about staff rotation?", "pdf_question", Band.ADVERSARIAL),
|
||||
_c(
|
||||
"a-sign-mention", "What are the signature requirements described in clause 4?", "pdf_question", Band.ADVERSARIAL
|
||||
),
|
||||
_c("a-compress-mention", "Does the report discuss compression algorithms?", "pdf_question", Band.ADVERSARIAL),
|
||||
_c(
|
||||
"a-polite-delete",
|
||||
"Could you please delete the last page?",
|
||||
"pdf_edit",
|
||||
Band.ADVERSARIAL,
|
||||
note="Question mark, but an instruction.",
|
||||
),
|
||||
_c("a-polite-landscape", "I would really appreciate it if this were in landscape", "pdf_edit", Band.ADVERSARIAL),
|
||||
_c(
|
||||
"a-declarative-q",
|
||||
"I need to know who approved this",
|
||||
"pdf_question",
|
||||
Band.ADVERSARIAL,
|
||||
note="Declarative, but a question.",
|
||||
),
|
||||
_c("a-redact-mention", "Explain the redaction policy described on page 6", "pdf_question", Band.ADVERSARIAL),
|
||||
]
|
||||
|
||||
CASES: list[RoutingCase] = [
|
||||
*_CLEAR_QUESTIONS,
|
||||
*_CLEAR_EDITS,
|
||||
*_BOUNDARY,
|
||||
*_CREATE,
|
||||
*_REVIEW,
|
||||
*_SPEC,
|
||||
*_UNSUPPORTED,
|
||||
*_CONTEXTUAL,
|
||||
*_ADVERSARIAL,
|
||||
]
|
||||
|
||||
|
||||
def by_band() -> dict[Band, list[RoutingCase]]:
|
||||
grouped: dict[Band, list[RoutingCase]] = {band: [] for band in Band}
|
||||
for case in CASES:
|
||||
grouped[case.band].append(case)
|
||||
return grouped
|
||||
|
||||
|
||||
# Misrouting a read-only turn into a mutating capability changes the user's file; the
|
||||
# reverse merely answers. Scored separately so a strategy cannot win on raw accuracy
|
||||
# while getting the dangerous direction wrong.
|
||||
MUTATING = frozenset({"pdf_edit", "pdf_create", "pdf_review"})
|
||||
|
||||
|
||||
def is_destructive_miss(case: RoutingCase, predicted: str) -> bool:
|
||||
return case.expected not in MUTATING and predicted in MUTATING and predicted not in case.tolerated
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Run the routing eval and write results to JSON.
|
||||
|
||||
uv run --group engine python evals/routing/runner.py --out evals/routing/results
|
||||
|
||||
Every strategy sees every case. Results are written per call so a run can be re-scored or
|
||||
re-charted without paying for the model calls again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
_EVALS_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EVALS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EVALS_ROOT))
|
||||
_SRC = _EVALS_ROOT.parent / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from routing.client import DEFAULT_BASE_URL, DEFAULT_MODEL, OllamaRouter # noqa: E402
|
||||
from routing.dataset import CASES, Band, RoutingCase, is_destructive_miss # noqa: E402
|
||||
from routing.strategies import Strategy, build_strategies # noqa: E402
|
||||
|
||||
|
||||
@dataclass
|
||||
class Observation:
|
||||
strategy: str
|
||||
case_id: str
|
||||
band: str
|
||||
expected: str
|
||||
predicted: str
|
||||
correct: bool
|
||||
tolerated: bool
|
||||
destructive: bool
|
||||
failed: bool
|
||||
calls: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
thinking_chars: int
|
||||
latency_s: float
|
||||
repeat: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
def score(case: RoutingCase, predicted: str) -> tuple[bool, bool, bool, bool]:
|
||||
"""(correct, tolerated, destructive_miss, hard_failure)."""
|
||||
failed = predicted.startswith("__")
|
||||
correct = predicted == case.expected
|
||||
tolerated = (not correct) and predicted in case.tolerated
|
||||
destructive = (not failed) and is_destructive_miss(case, predicted)
|
||||
return correct, tolerated, destructive, failed
|
||||
|
||||
|
||||
async def run_one(
|
||||
router: OllamaRouter,
|
||||
strategy: Strategy,
|
||||
case: RoutingCase,
|
||||
repeat: int,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> Observation:
|
||||
async with semaphore:
|
||||
result = await strategy.route(router, case)
|
||||
correct, tolerated, destructive, failed = score(case, result.predicted)
|
||||
return Observation(
|
||||
strategy=strategy.name,
|
||||
case_id=case.id,
|
||||
band=str(case.band),
|
||||
expected=case.expected,
|
||||
predicted=result.predicted,
|
||||
correct=correct,
|
||||
tolerated=tolerated,
|
||||
destructive=destructive,
|
||||
failed=failed,
|
||||
calls=result.budget.calls,
|
||||
input_tokens=result.budget.input_tokens,
|
||||
output_tokens=result.budget.output_tokens,
|
||||
thinking_chars=result.budget.thinking_chars,
|
||||
latency_s=round(result.budget.latency_s, 3),
|
||||
repeat=repeat,
|
||||
errors=result.budget.errors,
|
||||
)
|
||||
|
||||
|
||||
def summarise(observations: list[Observation]) -> dict[str, Any]:
|
||||
by_strategy: dict[str, list[Observation]] = defaultdict(list)
|
||||
for obs in observations:
|
||||
by_strategy[obs.strategy].append(obs)
|
||||
|
||||
summary: dict[str, Any] = {}
|
||||
for name, rows in by_strategy.items():
|
||||
total = len(rows)
|
||||
bands: dict[str, dict[str, float]] = {}
|
||||
for band in Band:
|
||||
band_rows = [r for r in rows if r.band == str(band)]
|
||||
if band_rows:
|
||||
bands[str(band)] = {
|
||||
"n": len(band_rows),
|
||||
"accuracy": round(sum(r.correct for r in band_rows) / len(band_rows), 4),
|
||||
}
|
||||
confusion = Counter((r.expected, r.predicted) for r in rows if not r.correct)
|
||||
# Stability: how often repeats of the same case disagree with each other.
|
||||
grouped: dict[str, set[str]] = defaultdict(set)
|
||||
for row in rows:
|
||||
grouped[row.case_id].add(row.predicted)
|
||||
unstable = sum(1 for preds in grouped.values() if len(preds) > 1)
|
||||
|
||||
summary[name] = {
|
||||
"n": total,
|
||||
"accuracy": round(sum(r.correct for r in rows) / total, 4),
|
||||
"accuracy_with_tolerated": round(sum(r.correct or r.tolerated for r in rows) / total, 4),
|
||||
"destructive_misroutes": sum(r.destructive for r in rows),
|
||||
"hard_failures": sum(r.failed for r in rows),
|
||||
"unstable_cases": unstable,
|
||||
"cases_seen": len(grouped),
|
||||
"avg_calls": round(sum(r.calls for r in rows) / total, 3),
|
||||
"avg_input_tokens": round(sum(r.input_tokens for r in rows) / total, 1),
|
||||
"avg_output_tokens": round(sum(r.output_tokens for r in rows) / total, 1),
|
||||
"avg_thinking_chars": round(sum(r.thinking_chars for r in rows) / total, 1),
|
||||
"avg_latency_s": round(sum(r.latency_s for r in rows) / total, 2),
|
||||
"p90_latency_s": round(sorted(r.latency_s for r in rows)[int(0.9 * (total - 1))], 2),
|
||||
"total_latency_s": round(sum(r.latency_s for r in rows), 1),
|
||||
"by_band": bands,
|
||||
"top_confusions": [{"expected": e, "predicted": p, "count": c} for (e, p), c in confusion.most_common(8)],
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Routing eval for the orchestrator's capability decision.")
|
||||
parser.add_argument("--out", default="evals/routing/results", help="Output directory.")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL)
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--repeats", type=int, default=1, help="Runs per case, for stability measurement.")
|
||||
parser.add_argument("--concurrency", type=int, default=3)
|
||||
parser.add_argument("--strategies", default="", help="Comma-separated subset of strategy names.")
|
||||
parser.add_argument("--limit", type=int, default=0, help="Cap the number of cases (smoke tests).")
|
||||
args = parser.parse_args()
|
||||
|
||||
strategies = build_strategies()
|
||||
if args.strategies:
|
||||
wanted = {s.strip() for s in args.strategies.split(",")}
|
||||
strategies = [s for s in strategies if s.name in wanted]
|
||||
missing = wanted - {s.name for s in strategies}
|
||||
if missing:
|
||||
parser.error(f"Unknown strategies: {sorted(missing)}")
|
||||
cases = CASES[: args.limit] if args.limit else CASES
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
semaphore = asyncio.Semaphore(args.concurrency)
|
||||
started = time.monotonic()
|
||||
observations: list[Observation] = []
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
router = OllamaRouter(http, model=args.model, base_url=args.base_url)
|
||||
for strategy in strategies:
|
||||
strategy_started = time.monotonic()
|
||||
tasks = [
|
||||
run_one(router, strategy, case, repeat, semaphore) for repeat in range(args.repeats) for case in cases
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
observations.extend(results)
|
||||
accuracy = sum(r.correct for r in results) / len(results)
|
||||
print(
|
||||
f"{strategy.name:20s} acc={accuracy:6.1%} "
|
||||
f"destructive={sum(r.destructive for r in results):3d} "
|
||||
f"fail={sum(r.failed for r in results):3d} "
|
||||
f"wall={time.monotonic() - strategy_started:6.1f}s",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": args.model,
|
||||
"repeats": args.repeats,
|
||||
"concurrency": args.concurrency,
|
||||
"case_count": len(cases),
|
||||
"wall_clock_s": round(time.monotonic() - started, 1),
|
||||
"strategy_descriptions": {s.name: s.description for s in strategies},
|
||||
"summary": summarise(observations),
|
||||
}
|
||||
(out_dir / "summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
with (out_dir / "observations.jsonl").open("w", encoding="utf-8") as handle:
|
||||
for obs in observations:
|
||||
handle.write(json.dumps(asdict(obs)) + "\n")
|
||||
print(f"\nWrote {out_dir / 'summary.json'} and observations.jsonl ({len(observations)} rows)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Routing strategies under test.
|
||||
|
||||
``prod_baseline`` reproduces what ships today. Every other strategy is a candidate fix,
|
||||
implemented so the eval measures the actual change rather than a description of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from routing.client import Budget, CallResult, OllamaRouter, enum_schema
|
||||
from routing.dataset import CAPABILITIES, RoutingCase
|
||||
from stirling.agents.orchestrator import _ROUTER_SYSTEM_PROMPT
|
||||
from stirling.contracts import AiFile, ConversationMessage, format_conversation_history, format_file_names
|
||||
|
||||
# Production ceiling for the router's tier (engine/.env STIRLING_FAST_MODEL_MAX_TOKENS).
|
||||
PROD_MAX_TOKENS = 2048
|
||||
# Headroom for the strategies that deliberately let qwen3 think: measured chains run
|
||||
# 1,400-2,200 characters, and the cap covers reasoning plus content together.
|
||||
THINK_MAX_TOKENS = 4096
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyResult:
|
||||
predicted: str
|
||||
budget: Budget
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class Strategy(Protocol):
|
||||
name: str
|
||||
description: str
|
||||
|
||||
async def route(self, router: OllamaRouter, case: RoutingCase) -> StrategyResult: ...
|
||||
|
||||
|
||||
def build_user_prompt(case: RoutingCase) -> str:
|
||||
"""The orchestrator's own prompt shape (OrchestratorAgent._build_prompt)."""
|
||||
history = format_conversation_history([ConversationMessage(role=r, content=c) for r, c in case.history])
|
||||
files = format_file_names([AiFile(id=f"id-{name}", name=name) for name in case.files])
|
||||
return (
|
||||
f"Conversation history:\n{history}\nUser message: {case.message}\nFiles: {files}\nAvailable artifacts:\n- none"
|
||||
)
|
||||
|
||||
|
||||
def _parse_capability(result: CallResult, allowed: list[str]) -> str:
|
||||
"""Pull the capability out of a constrained-decode response, or report why not."""
|
||||
if result.error:
|
||||
return "__error__"
|
||||
if result.finish_reason == "length":
|
||||
# The cap fired mid-generation: pydantic-ai sees invalid output and burns a retry.
|
||||
return "__truncated__"
|
||||
try:
|
||||
parsed = json.loads(result.content)
|
||||
except ValueError:
|
||||
return "__unparsable__"
|
||||
value = parsed.get("capability")
|
||||
return value if value in allowed else "__offmenu__"
|
||||
|
||||
|
||||
# Worded differently from every eval case, so the measured gain is generalisation
|
||||
# rather than memorisation of the test set.
|
||||
_FEWSHOT = """
|
||||
Decide by what the user wants BACK, not by which PDF words appear in the message.
|
||||
- They want an ANSWER, or information read out of the document -> pdf_question.
|
||||
- They want a CHANGED FILE handed back -> pdf_edit.
|
||||
- A document merely being mentioned is not a request to act on it.
|
||||
|
||||
Examples:
|
||||
"How long is the notice period?" -> pdf_question
|
||||
"Shorten the notice period to 30 days" -> pdf_edit
|
||||
"Is there a table of contents?" -> pdf_question
|
||||
"Add a table of contents" -> pdf_edit
|
||||
"Does it say anything about encryption?" -> pdf_question
|
||||
"Encrypt this file" -> pdf_edit
|
||||
"What sections cover the merger?" -> pdf_question
|
||||
"Combine these into one file" -> pdf_edit
|
||||
"Tell me if the numbers add up" -> pdf_question
|
||||
"Put a note on every paragraph that needs work" -> pdf_review
|
||||
"Build me a purchase order from scratch" -> pdf_create
|
||||
"Make a rule that stamps every upload" -> user_spec
|
||||
"Which version of the app is this?" -> unsupported
|
||||
""".strip()
|
||||
|
||||
|
||||
class _EnumRouter:
|
||||
"""Single-call enum routing. The knobs are what distinguishes the strategies."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
*,
|
||||
thinking: bool,
|
||||
temperature: float | None,
|
||||
max_tokens: int,
|
||||
fewshot: bool = False,
|
||||
narrow_by_files: bool = False,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self._thinking = thinking
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._fewshot = fewshot
|
||||
self._narrow = narrow_by_files
|
||||
|
||||
def _allowed(self, case: RoutingCase) -> list[str]:
|
||||
if self._narrow and not case.files:
|
||||
# Nothing is attached, so the three capabilities that operate on an input file
|
||||
# are impossible. Dropping them from the enum makes them undecodable rather
|
||||
# than merely discouraged.
|
||||
return ["user_spec", "pdf_create", "unsupported"]
|
||||
return list(CAPABILITIES)
|
||||
|
||||
def _system_prompt(self) -> str:
|
||||
return f"{_ROUTER_SYSTEM_PROMPT}\n\n{_FEWSHOT}" if self._fewshot else _ROUTER_SYSTEM_PROMPT
|
||||
|
||||
async def route(self, router: OllamaRouter, case: RoutingCase) -> StrategyResult:
|
||||
allowed = self._allowed(case)
|
||||
budget = Budget()
|
||||
result = await router.call(
|
||||
self._system_prompt(),
|
||||
build_user_prompt(case),
|
||||
response_format=enum_schema("route", allowed, with_message=False),
|
||||
max_tokens=self._max_tokens,
|
||||
temperature=self._temperature,
|
||||
thinking=self._thinking,
|
||||
)
|
||||
budget.add(result)
|
||||
return StrategyResult(_parse_capability(result, allowed), budget)
|
||||
|
||||
|
||||
_STAGE1_SYSTEM = (
|
||||
"Decide one thing only: after this turn, does the user expect a FILE back that they did "
|
||||
"not have before (a changed document, a new document, or the document with annotations "
|
||||
"added), or do they expect an ANSWER in the chat?\n"
|
||||
'Answer "file" or "answer".\n'
|
||||
"A document being mentioned or described is not a request to produce a file. "
|
||||
"Questions about what a document contains, means, or whether something is present are "
|
||||
'"answer". Instructions to change, convert, protect, split, combine, annotate, or '
|
||||
'author a document are "file".'
|
||||
)
|
||||
|
||||
_STAGE2_FILE_SYSTEM = (
|
||||
"The user wants a file back. Choose which one:\n"
|
||||
"- pdf_edit: change or convert the attached document(s).\n"
|
||||
"- pdf_create: author a brand new document from scratch.\n"
|
||||
"- pdf_review: hand the document back with review comments or sticky notes on it.\n"
|
||||
"- user_spec: define a reusable agent or automation rule, rather than acting once now."
|
||||
)
|
||||
|
||||
_STAGE2_ANSWER_SYSTEM = (
|
||||
"The user wants an answer in chat. Choose which one:\n"
|
||||
"- pdf_question: the answer comes from reading the attached document(s).\n"
|
||||
"- unsupported: the question is about the assistant itself, or is unrelated to the "
|
||||
"attached documents; put a short helpful reply in 'message'."
|
||||
)
|
||||
|
||||
|
||||
class BinaryChain:
|
||||
"""Two narrow decisions instead of one six-way one.
|
||||
|
||||
Costs a second call, but each decision has a much smaller space, and the first one is
|
||||
exactly the distinction that is being got wrong today.
|
||||
"""
|
||||
|
||||
name = "binary_chain"
|
||||
description = "Stage 1: file-or-answer. Stage 2: pick within that half."
|
||||
|
||||
def __init__(self, *, thinking: bool = False, fewshot: bool = True) -> None:
|
||||
self._thinking = thinking
|
||||
self._fewshot = fewshot
|
||||
|
||||
async def route(self, router: OllamaRouter, case: RoutingCase) -> StrategyResult:
|
||||
budget = Budget()
|
||||
prompt = build_user_prompt(case)
|
||||
stage1_system = f"{_STAGE1_SYSTEM}\n\n{_FEWSHOT}" if self._fewshot else _STAGE1_SYSTEM
|
||||
stage1 = await router.call(
|
||||
stage1_system,
|
||||
prompt,
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "intent",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"expects": {"type": "string", "enum": ["file", "answer"]}},
|
||||
"required": ["expects"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
max_tokens=THINK_MAX_TOKENS if self._thinking else PROD_MAX_TOKENS,
|
||||
temperature=0.0,
|
||||
thinking=self._thinking,
|
||||
)
|
||||
budget.add(stage1)
|
||||
if stage1.error or stage1.finish_reason == "length":
|
||||
return StrategyResult("__error__" if stage1.error else "__truncated__", budget, "stage1")
|
||||
try:
|
||||
expects = json.loads(stage1.content).get("expects")
|
||||
except ValueError:
|
||||
return StrategyResult("__unparsable__", budget, "stage1")
|
||||
|
||||
if expects == "file":
|
||||
allowed = ["pdf_edit", "pdf_create", "pdf_review", "user_spec"]
|
||||
if not case.files:
|
||||
allowed = ["pdf_create", "user_spec"]
|
||||
system = _STAGE2_FILE_SYSTEM
|
||||
else:
|
||||
allowed = ["pdf_question", "unsupported"]
|
||||
if not case.files:
|
||||
allowed = ["unsupported"]
|
||||
system = _STAGE2_ANSWER_SYSTEM
|
||||
|
||||
if len(allowed) == 1:
|
||||
return StrategyResult(allowed[0], budget, f"stage1={expects}, stage2 skipped")
|
||||
|
||||
stage2 = await router.call(
|
||||
system,
|
||||
prompt,
|
||||
response_format=enum_schema("route", allowed),
|
||||
max_tokens=THINK_MAX_TOKENS if self._thinking else PROD_MAX_TOKENS,
|
||||
temperature=0.0,
|
||||
thinking=self._thinking,
|
||||
)
|
||||
budget.add(stage2)
|
||||
return StrategyResult(_parse_capability(stage2, allowed), budget, f"stage1={expects}")
|
||||
|
||||
|
||||
class SelfConsistency:
|
||||
"""Sample the same decision N times and take the majority."""
|
||||
|
||||
def __init__(self, n: int = 3, *, temperature: float = 0.6, thinking: bool = False, fewshot: bool = True) -> None:
|
||||
self.name = f"vote{n}"
|
||||
self.description = f"{n} samples at temperature {temperature}, majority wins."
|
||||
self._n = n
|
||||
self._temperature = temperature
|
||||
self._inner = _EnumRouter(
|
||||
f"vote{n}-inner",
|
||||
"",
|
||||
thinking=thinking,
|
||||
temperature=temperature,
|
||||
max_tokens=THINK_MAX_TOKENS if thinking else PROD_MAX_TOKENS,
|
||||
fewshot=fewshot,
|
||||
)
|
||||
|
||||
async def route(self, router: OllamaRouter, case: RoutingCase) -> StrategyResult:
|
||||
budget = Budget()
|
||||
votes: list[str] = []
|
||||
for _ in range(self._n):
|
||||
single = await self._inner.route(router, case)
|
||||
budget.calls += single.budget.calls
|
||||
budget.input_tokens += single.budget.input_tokens
|
||||
budget.output_tokens += single.budget.output_tokens
|
||||
budget.thinking_chars += single.budget.thinking_chars
|
||||
budget.latency_s += single.budget.latency_s
|
||||
budget.errors.extend(single.budget.errors)
|
||||
votes.append(single.predicted)
|
||||
valid = [v for v in votes if v in CAPABILITIES]
|
||||
if not valid:
|
||||
return StrategyResult(votes[0] if votes else "__error__", budget, f"votes={votes}")
|
||||
winner, _ = Counter(valid).most_common(1)[0]
|
||||
return StrategyResult(winner, budget, f"votes={votes}")
|
||||
|
||||
|
||||
def build_strategies() -> list[Strategy]:
|
||||
return [
|
||||
_EnumRouter(
|
||||
"prod_baseline",
|
||||
"Exactly what ships today: 6-way enum, no temperature set, 2048 cap, thinking left on.",
|
||||
thinking=True,
|
||||
temperature=None,
|
||||
max_tokens=PROD_MAX_TOKENS,
|
||||
),
|
||||
_EnumRouter(
|
||||
"temp0",
|
||||
"Production prompt, temperature pinned to 0.",
|
||||
thinking=True,
|
||||
temperature=0.0,
|
||||
max_tokens=PROD_MAX_TOKENS,
|
||||
),
|
||||
_EnumRouter(
|
||||
"thinking_off",
|
||||
"Temperature 0 and reasoning_effort=none.",
|
||||
thinking=False,
|
||||
temperature=0.0,
|
||||
max_tokens=PROD_MAX_TOKENS,
|
||||
),
|
||||
_EnumRouter(
|
||||
"think_budget",
|
||||
"Thinking on, temperature 0, cap raised to 4096 so the chain cannot be truncated.",
|
||||
thinking=True,
|
||||
temperature=0.0,
|
||||
max_tokens=THINK_MAX_TOKENS,
|
||||
),
|
||||
_EnumRouter(
|
||||
"fewshot_thinking_off",
|
||||
"Few-shot boundary examples, thinking off.",
|
||||
thinking=False,
|
||||
temperature=0.0,
|
||||
max_tokens=PROD_MAX_TOKENS,
|
||||
fewshot=True,
|
||||
),
|
||||
_EnumRouter(
|
||||
"fewshot_think",
|
||||
"Few-shot boundary examples, thinking on with a 4096 cap.",
|
||||
thinking=True,
|
||||
temperature=0.0,
|
||||
max_tokens=THINK_MAX_TOKENS,
|
||||
fewshot=True,
|
||||
),
|
||||
_EnumRouter(
|
||||
"fewshot_narrowed",
|
||||
"Few-shot, thinking off, plus enum narrowed by whether a file is attached.",
|
||||
thinking=False,
|
||||
temperature=0.0,
|
||||
max_tokens=PROD_MAX_TOKENS,
|
||||
fewshot=True,
|
||||
narrow_by_files=True,
|
||||
),
|
||||
BinaryChain(thinking=False, fewshot=True),
|
||||
SelfConsistency(3, temperature=0.6, thinking=False, fewshot=True),
|
||||
]
|
||||
Reference in New Issue
Block a user