Add evaluation harness for memory/context-management experiments
Browse filesCompare memory presets (full_history, prod, summarization/editing-only,
aggressive, profile_memory) on quality, memory retention, retrieval,
tokens, cost, and latency:
- app: memory presets (app/memory_presets.py), per-turn telemetry
independent of LangSmith (app/telemetry.py, context_stats event +
data-context-stats SSE part), long-term student-profile memory
(StudentProfileMiddleware + store), memoryPreset/studentId API fields
- evals/: battery runner (resume-safe, per-turn timeout, trace bundles),
offline grader (code checks + blinded hand-grading workbook), report
(side-by-side preset tables + token curves), trigger-verification gate
- docs: evals.md (datasets, metrics, Part B results, findings F1-F9),
evals_background.md (research notes), data/eval/README.md (schemas)
- data/scraping_scripts/annotate_discussion_posts.py: discussion-post
annotation used to build the eval batteries
Eval datasets and run results contain real student text and ship via the
private HF dataset (towardsai-tutors/ai-tutor-data), never git; gitignore
now covers review_batches/ and review_log explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- .gitignore +6 -0
- app/api.py +42 -0
- app/chat_service.py +224 -22
- app/chat_types.py +6 -0
- app/memory_presets.py +69 -0
- app/telemetry.py +148 -0
- data/eval/README.md +122 -0
- data/scraping_scripts/annotate_discussion_posts.py +433 -0
- evals.md +133 -0
- evals/__init__.py +10 -0
- evals/check_triggers.py +127 -0
- evals/common.py +68 -0
- evals/grade.py +342 -0
- evals/handgrade_workbook.py +191 -0
- evals/report.py +276 -0
- evals/run_battery.py +454 -0
- evals_background.md +233 -0
- tests/test_api.py +60 -0
- tests/test_evals_grade.py +151 -0
- tests/test_memory_presets.py +146 -0
- tests/test_telemetry.py +143 -0
|
@@ -206,3 +206,9 @@ frontend/out/
|
|
| 206 |
!frontend/app/apple-icon.png
|
| 207 |
|
| 208 |
.claude/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
!frontend/app/apple-icon.png
|
| 207 |
|
| 208 |
.claude/
|
| 209 |
+
runs/
|
| 210 |
+
# Eval data with real student text: ships via the private HF dataset
|
| 211 |
+
# (towardsai-tutors/ai-tutor-data), never via git — main gets force-pushed
|
| 212 |
+
# to the public prod Space on deploys. (*.jsonl is already ignored globally.)
|
| 213 |
+
data/eval/review_batches/
|
| 214 |
+
data/eval/review_log_v1.md
|
|
@@ -25,6 +25,7 @@ from .chat_service import (
|
|
| 25 |
)
|
| 26 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn
|
| 27 |
from .kb_manifest import load_manifest_entries
|
|
|
|
| 28 |
from .config import (
|
| 29 |
AVAILABLE_MODELS,
|
| 30 |
AVAILABLE_SOURCES,
|
|
@@ -71,6 +72,11 @@ class ApiChatRequest(BaseModel):
|
|
| 71 |
model: str | None = Field(default=None, max_length=200)
|
| 72 |
includeReasoning: bool = True
|
| 73 |
threadId: str = Field(default="", max_length=128)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
@field_validator("messages")
|
| 76 |
@classmethod
|
|
@@ -233,6 +239,9 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
|
|
| 233 |
model_name = (payload.model or DEFAULT_MODEL_NAME).strip() or DEFAULT_MODEL_NAME
|
| 234 |
if model_name not in {model["id"] for model in AVAILABLE_MODELS}:
|
| 235 |
raise HTTPException(status_code=422, detail="Unknown model")
|
|
|
|
|
|
|
|
|
|
| 236 |
if payload.enabledTools is None:
|
| 237 |
enabled_tools = tuple(
|
| 238 |
tool["key"]
|
|
@@ -258,6 +267,8 @@ def build_chat_request(payload: ApiChatRequest) -> ChatRequest:
|
|
| 258 |
include_reasoning=bool(payload.includeReasoning),
|
| 259 |
thread_id=(payload.threadId or "").strip(),
|
| 260 |
enabled_tools=enabled_tools,
|
|
|
|
|
|
|
| 261 |
)
|
| 262 |
|
| 263 |
|
|
@@ -472,6 +483,37 @@ class UIMessageStreamEncoder:
|
|
| 472 |
)
|
| 473 |
return parts
|
| 474 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
if event.type == "message_completed":
|
| 476 |
parts.extend(self.close_reasoning_block())
|
| 477 |
answer = str(event.data.get("answer", "")).strip()
|
|
|
|
| 25 |
)
|
| 26 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn
|
| 27 |
from .kb_manifest import load_manifest_entries
|
| 28 |
+
from .memory_presets import MEMORY_PRESETS
|
| 29 |
from .config import (
|
| 30 |
AVAILABLE_MODELS,
|
| 31 |
AVAILABLE_SOURCES,
|
|
|
|
| 72 |
model: str | None = Field(default=None, max_length=200)
|
| 73 |
includeReasoning: bool = True
|
| 74 |
threadId: str = Field(default="", max_length=128)
|
| 75 |
+
# Memory/context-management preset (experiments + workshop toggles).
|
| 76 |
+
# Omitted means the server-side default resolution order.
|
| 77 |
+
memoryPreset: str | None = Field(default=None, max_length=64)
|
| 78 |
+
# Long-term memory key for profile-memory presets.
|
| 79 |
+
studentId: str = Field(default="", max_length=128)
|
| 80 |
|
| 81 |
@field_validator("messages")
|
| 82 |
@classmethod
|
|
|
|
| 239 |
model_name = (payload.model or DEFAULT_MODEL_NAME).strip() or DEFAULT_MODEL_NAME
|
| 240 |
if model_name not in {model["id"] for model in AVAILABLE_MODELS}:
|
| 241 |
raise HTTPException(status_code=422, detail="Unknown model")
|
| 242 |
+
memory_preset = (payload.memoryPreset or "").strip()
|
| 243 |
+
if memory_preset and memory_preset not in MEMORY_PRESETS:
|
| 244 |
+
raise HTTPException(status_code=422, detail="Unknown memory preset")
|
| 245 |
if payload.enabledTools is None:
|
| 246 |
enabled_tools = tuple(
|
| 247 |
tool["key"]
|
|
|
|
| 267 |
include_reasoning=bool(payload.includeReasoning),
|
| 268 |
thread_id=(payload.threadId or "").strip(),
|
| 269 |
enabled_tools=enabled_tools,
|
| 270 |
+
memory_preset=memory_preset,
|
| 271 |
+
student_id=payload.studentId.strip(),
|
| 272 |
)
|
| 273 |
|
| 274 |
|
|
|
|
| 483 |
)
|
| 484 |
return parts
|
| 485 |
|
| 486 |
+
if event.type == "context_stats":
|
| 487 |
+
data = event.data
|
| 488 |
+
parts.append(
|
| 489 |
+
{
|
| 490 |
+
"type": "data-context-stats",
|
| 491 |
+
"data": {
|
| 492 |
+
"messageId": str(data.get("message_id", "")),
|
| 493 |
+
"memoryPreset": str(data.get("memory_preset", "")),
|
| 494 |
+
"llmCalls": data.get("llm_calls"),
|
| 495 |
+
"inputTokens": data.get("input_tokens"),
|
| 496 |
+
"outputTokens": data.get("output_tokens"),
|
| 497 |
+
"totalTokens": data.get("total_tokens"),
|
| 498 |
+
"cacheReadTokens": data.get("cache_read_tokens"),
|
| 499 |
+
"cacheCreationTokens": data.get("cache_creation_tokens"),
|
| 500 |
+
# None when a used model has no price-table entry;
|
| 501 |
+
# the client must render that as unknown, not $0.
|
| 502 |
+
"estCostUsd": data.get("est_cost_usd"),
|
| 503 |
+
"ttftMs": data.get("ttft_ms"),
|
| 504 |
+
"totalMs": data.get("total_ms"),
|
| 505 |
+
"contextMessages": data.get("context_messages"),
|
| 506 |
+
"contextTokensApprox": data.get("context_tokens_approx"),
|
| 507 |
+
"summaryMessages": data.get("summary_messages"),
|
| 508 |
+
"clearedToolOutputs": data.get("cleared_tool_outputs"),
|
| 509 |
+
},
|
| 510 |
+
# Delivered to onData for a live meter; not stored in
|
| 511 |
+
# message.parts (nothing renders it as message content).
|
| 512 |
+
"transient": True,
|
| 513 |
+
}
|
| 514 |
+
)
|
| 515 |
+
return parts
|
| 516 |
+
|
| 517 |
if event.type == "message_completed":
|
| 518 |
parts.extend(self.close_reasoning_block())
|
| 519 |
answer = str(event.data.get("answer", "")).strip()
|
|
@@ -30,8 +30,21 @@ from langchain_core.messages import (
|
|
| 30 |
)
|
| 31 |
from langchain_openai import ChatOpenAI
|
| 32 |
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
| 33 |
|
| 34 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
|
| 36 |
from .kb_shell import (
|
| 37 |
KbCommandError,
|
|
@@ -66,6 +79,12 @@ from .config import (
|
|
| 66 |
logger = logging.getLogger(__name__)
|
| 67 |
|
| 68 |
CHECKPOINTER = InMemorySaver()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# Visible transcript per thread, recorded as each turn finishes streaming.
|
| 70 |
# The checkpointed messages stop mirroring the visible transcript once
|
| 71 |
# SummarizationMiddleware rewrites thread state, so thread reuse is decided
|
|
@@ -98,6 +117,24 @@ class AppContext:
|
|
| 98 |
allowed_sources: tuple[str, ...]
|
| 99 |
kb_session_id: str = ""
|
| 100 |
kb_command_limit: int = DEFAULT_KB_COMMAND_LIMIT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
|
| 103 |
def _claim_kb_command_budget(session_id: str, limit: int) -> tuple[bool, int]:
|
|
@@ -812,6 +849,82 @@ class SourcePreferenceMiddleware(AgentMiddleware):
|
|
| 812 |
return await handler(self._inject(request))
|
| 813 |
|
| 814 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
@lru_cache(maxsize=32)
|
| 816 |
def build_agent(
|
| 817 |
model_name: str,
|
|
@@ -819,36 +932,19 @@ def build_agent(
|
|
| 819 |
include_thoughts: bool = False,
|
| 820 |
kb_agents_instructions: str | None = None,
|
| 821 |
include_local_tools: bool = True,
|
|
|
|
| 822 |
):
|
| 823 |
# kb_agents_instructions is part of the cache key on purpose: an agent
|
| 824 |
# built before data/kb/AGENTS.md existed must not pin its degraded
|
| 825 |
-
# system prompt for the process lifetime.
|
|
|
|
| 826 |
model = build_chat_model(model_name, include_thoughts=include_thoughts)
|
| 827 |
# An explicit empty source selection turns the knowledge base off: no
|
| 828 |
# retrieval, no KB browsing, and a system prompt that says so.
|
| 829 |
tools: list[Any] = (
|
| 830 |
[retrieve_tutor_context, run_kb_command] if include_local_tools else []
|
| 831 |
)
|
| 832 |
-
middleware
|
| 833 |
-
ContextEditingMiddleware(
|
| 834 |
-
edits=[
|
| 835 |
-
ClearToolUsesEdit(
|
| 836 |
-
trigger=5_000,
|
| 837 |
-
keep=5,
|
| 838 |
-
# Retrieval results stay; only shell outputs get cleared.
|
| 839 |
-
exclude_tools=("retrieve_tutor_context",),
|
| 840 |
-
placeholder="[tool output cleared to save context]",
|
| 841 |
-
)
|
| 842 |
-
],
|
| 843 |
-
token_count_method="approximate",
|
| 844 |
-
),
|
| 845 |
-
SummarizationMiddleware(
|
| 846 |
-
model=model,
|
| 847 |
-
trigger=("tokens", 30_000),
|
| 848 |
-
keep=("messages", 20),
|
| 849 |
-
),
|
| 850 |
-
SourcePreferenceMiddleware(),
|
| 851 |
-
]
|
| 852 |
enabled = set(enabled_tools)
|
| 853 |
if is_google_genai_model(model_name):
|
| 854 |
if "web_search" in enabled:
|
|
@@ -885,6 +981,7 @@ def build_agent(
|
|
| 885 |
),
|
| 886 |
context_schema=AppContext,
|
| 887 |
checkpointer=CHECKPOINTER,
|
|
|
|
| 888 |
middleware=middleware,
|
| 889 |
)
|
| 890 |
|
|
@@ -954,6 +1051,7 @@ def agent_run_config(
|
|
| 954 |
request: ChatRequest,
|
| 955 |
active_thread_id: str,
|
| 956 |
message_id: str,
|
|
|
|
| 957 |
) -> dict[str, Any]:
|
| 958 |
provider, actual_model = model_provider_and_name(request.model_name)
|
| 959 |
tools = effective_tool_names(
|
|
@@ -965,6 +1063,7 @@ def agent_run_config(
|
|
| 965 |
SOURCE_KEY_TO_LABEL.get(source_key, source_key)
|
| 966 |
for source_key in request.source_keys
|
| 967 |
]
|
|
|
|
| 968 |
config = thread_config(active_thread_id)
|
| 969 |
config.update(
|
| 970 |
{
|
|
@@ -974,6 +1073,7 @@ def agent_run_config(
|
|
| 974 |
"knowledge-base-chatbot",
|
| 975 |
f"provider:{provider}",
|
| 976 |
f"model:{actual_model}",
|
|
|
|
| 977 |
*(f"tool:{tool_name}" for tool_name in tools),
|
| 978 |
],
|
| 979 |
"metadata": {
|
|
@@ -989,13 +1089,68 @@ def agent_run_config(
|
|
| 989 |
"source_keys": list(request.source_keys),
|
| 990 |
"source_labels": source_labels,
|
| 991 |
"include_reasoning": bool(request.include_reasoning),
|
|
|
|
|
|
|
| 992 |
},
|
| 993 |
}
|
| 994 |
)
|
| 995 |
return config
|
| 996 |
|
| 997 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
normalized_history = normalize_history(request.history)
|
| 1000 |
retrieval_evidence: dict[str, SourceMatch] = {}
|
| 1001 |
shell_evidence: dict[str, SourceMatch] = {}
|
|
@@ -1036,6 +1191,7 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 1036 |
ensure_kb_agents_instructions() if include_local_tools else None
|
| 1037 |
),
|
| 1038 |
include_local_tools=include_local_tools,
|
|
|
|
| 1039 |
)
|
| 1040 |
|
| 1041 |
agent = await asyncio.to_thread(_build_agent_for_request)
|
|
@@ -1047,7 +1203,10 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 1047 |
normalized_history,
|
| 1048 |
)
|
| 1049 |
_touch_thread(active_thread_id)
|
| 1050 |
-
run_config = agent_run_config(
|
|
|
|
|
|
|
|
|
|
| 1051 |
if fork_checkpoint_id:
|
| 1052 |
# Time travel: run from the checkpoint matching the history the
|
| 1053 |
# client kept; the turns after it become an abandoned branch.
|
|
@@ -1064,6 +1223,7 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 1064 |
allowed_sources=request.source_keys,
|
| 1065 |
kb_session_id=message_id,
|
| 1066 |
kb_command_limit=DEFAULT_KB_COMMAND_LIMIT,
|
|
|
|
| 1067 |
),
|
| 1068 |
stream_mode=["messages", "updates"],
|
| 1069 |
version="v2",
|
|
@@ -1116,6 +1276,8 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 1116 |
if not text_delta and token.content:
|
| 1117 |
text_delta = message_content_to_text(token.content)
|
| 1118 |
if text_delta:
|
|
|
|
|
|
|
| 1119 |
answer_chunks.append(text_delta)
|
| 1120 |
yield ChatEvent(
|
| 1121 |
"text_delta",
|
|
@@ -1324,6 +1486,46 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
|
| 1324 |
"source_match",
|
| 1325 |
source_match_payload(source_match, message_id=message_id),
|
| 1326 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1327 |
yield ChatEvent(
|
| 1328 |
"message_completed",
|
| 1329 |
{
|
|
|
|
| 30 |
)
|
| 31 |
from langchain_openai import ChatOpenAI
|
| 32 |
from langgraph.checkpoint.memory import InMemorySaver
|
| 33 |
+
from langgraph.store.memory import InMemoryStore
|
| 34 |
|
| 35 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn, SourceMatch
|
| 36 |
+
from .memory_presets import (
|
| 37 |
+
DEFAULT_MEMORY_PRESET,
|
| 38 |
+
MEMORY_PRESETS,
|
| 39 |
+
MemoryConfig,
|
| 40 |
+
resolve_memory_preset,
|
| 41 |
+
)
|
| 42 |
+
from .telemetry import (
|
| 43 |
+
TurnUsageHandler,
|
| 44 |
+
context_window_stats,
|
| 45 |
+
estimate_cost_usd,
|
| 46 |
+
usage_totals,
|
| 47 |
+
)
|
| 48 |
from .chroma_rag import LocalChromaRetriever, format_tool_payload, parse_tool_payload
|
| 49 |
from .kb_shell import (
|
| 50 |
KbCommandError,
|
|
|
|
| 79 |
logger = logging.getLogger(__name__)
|
| 80 |
|
| 81 |
CHECKPOINTER = InMemorySaver()
|
| 82 |
+
# Long-term memory (student profiles), keyed by namespace ("student", <id>).
|
| 83 |
+
# In-process like the checkpointer: profiles survive across threads/sessions
|
| 84 |
+
# within one server lifetime, which is what the profile-memory experiments
|
| 85 |
+
# need. Swap for a persistent LangGraph store to survive restarts.
|
| 86 |
+
STORE = InMemoryStore()
|
| 87 |
+
CLEARED_TOOL_OUTPUT_PLACEHOLDER = "[tool output cleared to save context]"
|
| 88 |
# Visible transcript per thread, recorded as each turn finishes streaming.
|
| 89 |
# The checkpointed messages stop mirroring the visible transcript once
|
| 90 |
# SummarizationMiddleware rewrites thread state, so thread reuse is decided
|
|
|
|
| 117 |
allowed_sources: tuple[str, ...]
|
| 118 |
kb_session_id: str = ""
|
| 119 |
kb_command_limit: int = DEFAULT_KB_COMMAND_LIMIT
|
| 120 |
+
student_id: str = ""
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def get_student_profile(student_id: str) -> str:
|
| 124 |
+
if not student_id:
|
| 125 |
+
return ""
|
| 126 |
+
item = STORE.get(("student", student_id), "profile")
|
| 127 |
+
if item is None:
|
| 128 |
+
return ""
|
| 129 |
+
return str((item.value or {}).get("profile", "")).strip()
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def set_student_profile(student_id: str, profile: str) -> None:
|
| 133 |
+
"""Write a student profile directly; eval runners use this to seed
|
| 134 |
+
personas without spending a turn on the on-the-fly write path."""
|
| 135 |
+
if not student_id:
|
| 136 |
+
return
|
| 137 |
+
STORE.put(("student", student_id), "profile", {"profile": profile.strip()})
|
| 138 |
|
| 139 |
|
| 140 |
def _claim_kb_command_budget(session_id: str, limit: int) -> tuple[bool, int]:
|
|
|
|
| 849 |
return await handler(self._inject(request))
|
| 850 |
|
| 851 |
|
| 852 |
+
class StudentProfileMiddleware(AgentMiddleware):
|
| 853 |
+
"""Append the stored student profile to the system prompt.
|
| 854 |
+
|
| 855 |
+
Long-term semantic memory: the profile lives in the LangGraph store under
|
| 856 |
+
``("student", <student_id>)`` and is updated after each turn by
|
| 857 |
+
``stream_chat`` (see ``_update_student_profile``). InMemoryStore reads are
|
| 858 |
+
dict lookups, so the sync ``store.get`` is fine on the async path too.
|
| 859 |
+
"""
|
| 860 |
+
|
| 861 |
+
def _inject(self, request):
|
| 862 |
+
runtime = getattr(request, "runtime", None)
|
| 863 |
+
ctx = getattr(runtime, "context", None) if runtime else None
|
| 864 |
+
student_id = getattr(ctx, "student_id", "") if ctx else ""
|
| 865 |
+
if not student_id:
|
| 866 |
+
return request
|
| 867 |
+
store = getattr(runtime, "store", None) or STORE
|
| 868 |
+
item = store.get(("student", student_id), "profile")
|
| 869 |
+
profile = ""
|
| 870 |
+
if item is not None:
|
| 871 |
+
profile = str((item.value or {}).get("profile", "")).strip()
|
| 872 |
+
if not profile:
|
| 873 |
+
return request
|
| 874 |
+
note = (
|
| 875 |
+
"## Student profile (long-term memory)\n\n"
|
| 876 |
+
f"{profile}\n\n"
|
| 877 |
+
"Use this profile to calibrate level, language, and examples. "
|
| 878 |
+
"Do not re-ask for information it already answers."
|
| 879 |
+
)
|
| 880 |
+
sys_msg = request.system_message
|
| 881 |
+
if sys_msg is None:
|
| 882 |
+
new_sys = SystemMessage(content=note)
|
| 883 |
+
else:
|
| 884 |
+
new_sys = SystemMessage(content=f"{sys_msg.content}\n\n{note}")
|
| 885 |
+
return request.override(system_message=new_sys)
|
| 886 |
+
|
| 887 |
+
def wrap_model_call(self, request, handler):
|
| 888 |
+
return handler(self._inject(request))
|
| 889 |
+
|
| 890 |
+
async def awrap_model_call(self, request, handler):
|
| 891 |
+
return await handler(self._inject(request))
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
def build_agent_middleware(
|
| 895 |
+
model: Any, memory_config: MemoryConfig
|
| 896 |
+
) -> list[AgentMiddleware]:
|
| 897 |
+
"""Assemble the compaction/memory middleware stack for one preset."""
|
| 898 |
+
middleware: list[AgentMiddleware] = []
|
| 899 |
+
if memory_config.context_editing:
|
| 900 |
+
middleware.append(
|
| 901 |
+
ContextEditingMiddleware(
|
| 902 |
+
edits=[
|
| 903 |
+
ClearToolUsesEdit(
|
| 904 |
+
trigger=memory_config.context_editing_trigger_tokens,
|
| 905 |
+
keep=memory_config.context_editing_keep,
|
| 906 |
+
# Retrieval results stay; only shell outputs get cleared.
|
| 907 |
+
exclude_tools=("retrieve_tutor_context",),
|
| 908 |
+
placeholder=CLEARED_TOOL_OUTPUT_PLACEHOLDER,
|
| 909 |
+
)
|
| 910 |
+
],
|
| 911 |
+
token_count_method="approximate",
|
| 912 |
+
)
|
| 913 |
+
)
|
| 914 |
+
if memory_config.summarization:
|
| 915 |
+
middleware.append(
|
| 916 |
+
SummarizationMiddleware(
|
| 917 |
+
model=model,
|
| 918 |
+
trigger=("tokens", memory_config.summarization_trigger_tokens),
|
| 919 |
+
keep=("messages", memory_config.summarization_keep_messages),
|
| 920 |
+
)
|
| 921 |
+
)
|
| 922 |
+
if memory_config.longterm_memory:
|
| 923 |
+
middleware.append(StudentProfileMiddleware())
|
| 924 |
+
middleware.append(SourcePreferenceMiddleware())
|
| 925 |
+
return middleware
|
| 926 |
+
|
| 927 |
+
|
| 928 |
@lru_cache(maxsize=32)
|
| 929 |
def build_agent(
|
| 930 |
model_name: str,
|
|
|
|
| 932 |
include_thoughts: bool = False,
|
| 933 |
kb_agents_instructions: str | None = None,
|
| 934 |
include_local_tools: bool = True,
|
| 935 |
+
memory_config: MemoryConfig = MEMORY_PRESETS[DEFAULT_MEMORY_PRESET],
|
| 936 |
):
|
| 937 |
# kb_agents_instructions is part of the cache key on purpose: an agent
|
| 938 |
# built before data/kb/AGENTS.md existed must not pin its degraded
|
| 939 |
+
# system prompt for the process lifetime. memory_config (frozen, hashable)
|
| 940 |
+
# is too: each preset gets its own agent.
|
| 941 |
model = build_chat_model(model_name, include_thoughts=include_thoughts)
|
| 942 |
# An explicit empty source selection turns the knowledge base off: no
|
| 943 |
# retrieval, no KB browsing, and a system prompt that says so.
|
| 944 |
tools: list[Any] = (
|
| 945 |
[retrieve_tutor_context, run_kb_command] if include_local_tools else []
|
| 946 |
)
|
| 947 |
+
middleware = build_agent_middleware(model, memory_config)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
enabled = set(enabled_tools)
|
| 949 |
if is_google_genai_model(model_name):
|
| 950 |
if "web_search" in enabled:
|
|
|
|
| 981 |
),
|
| 982 |
context_schema=AppContext,
|
| 983 |
checkpointer=CHECKPOINTER,
|
| 984 |
+
store=STORE,
|
| 985 |
middleware=middleware,
|
| 986 |
)
|
| 987 |
|
|
|
|
| 1051 |
request: ChatRequest,
|
| 1052 |
active_thread_id: str,
|
| 1053 |
message_id: str,
|
| 1054 |
+
memory_preset: str = "",
|
| 1055 |
) -> dict[str, Any]:
|
| 1056 |
provider, actual_model = model_provider_and_name(request.model_name)
|
| 1057 |
tools = effective_tool_names(
|
|
|
|
| 1063 |
SOURCE_KEY_TO_LABEL.get(source_key, source_key)
|
| 1064 |
for source_key in request.source_keys
|
| 1065 |
]
|
| 1066 |
+
preset = memory_preset or DEFAULT_MEMORY_PRESET
|
| 1067 |
config = thread_config(active_thread_id)
|
| 1068 |
config.update(
|
| 1069 |
{
|
|
|
|
| 1073 |
"knowledge-base-chatbot",
|
| 1074 |
f"provider:{provider}",
|
| 1075 |
f"model:{actual_model}",
|
| 1076 |
+
f"memory:{preset}",
|
| 1077 |
*(f"tool:{tool_name}" for tool_name in tools),
|
| 1078 |
],
|
| 1079 |
"metadata": {
|
|
|
|
| 1089 |
"source_keys": list(request.source_keys),
|
| 1090 |
"source_labels": source_labels,
|
| 1091 |
"include_reasoning": bool(request.include_reasoning),
|
| 1092 |
+
"memory_preset": preset,
|
| 1093 |
+
"student_id": request.student_id,
|
| 1094 |
},
|
| 1095 |
}
|
| 1096 |
)
|
| 1097 |
return config
|
| 1098 |
|
| 1099 |
|
| 1100 |
+
_PROFILE_UPDATE_PROMPT = """\
|
| 1101 |
+
You maintain a short profile of a student for an AI tutor.
|
| 1102 |
+
|
| 1103 |
+
Current profile (may be empty):
|
| 1104 |
+
{profile}
|
| 1105 |
+
|
| 1106 |
+
Latest exchange:
|
| 1107 |
+
Student: {query}
|
| 1108 |
+
Tutor: {answer}
|
| 1109 |
+
|
| 1110 |
+
Rewrite the profile in at most 5 short lines. Keep only durable facts useful
|
| 1111 |
+
for future tutoring: skill level, goals, preferred language/tools, weak
|
| 1112 |
+
topics, current course/lesson. Drop one-off details. Return only the profile
|
| 1113 |
+
text; return NONE if nothing durable is known yet."""
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
async def _update_student_profile(
|
| 1117 |
+
request: ChatRequest, answer: str, usage_handler: TurnUsageHandler
|
| 1118 |
+
) -> None:
|
| 1119 |
+
"""Refresh the stored profile from this turn (one small extra LLM call).
|
| 1120 |
+
|
| 1121 |
+
The call reports into ``usage_handler`` so profile-memory presets pay for
|
| 1122 |
+
their own upkeep in the turn's token/cost numbers.
|
| 1123 |
+
"""
|
| 1124 |
+
model = build_chat_model(request.model_name)
|
| 1125 |
+
prompt = _PROFILE_UPDATE_PROMPT.format(
|
| 1126 |
+
profile=get_student_profile(request.student_id) or "(empty)",
|
| 1127 |
+
query=request.query[:1500],
|
| 1128 |
+
answer=answer[:3000],
|
| 1129 |
+
)
|
| 1130 |
+
response = await model.ainvoke(
|
| 1131 |
+
[HumanMessage(content=prompt)],
|
| 1132 |
+
config={
|
| 1133 |
+
"run_name": "student-profile-update",
|
| 1134 |
+
"callbacks": [usage_handler],
|
| 1135 |
+
"metadata": {"lc_source": "student_profile_update"},
|
| 1136 |
+
},
|
| 1137 |
+
)
|
| 1138 |
+
text = message_content_to_text(response.content).strip()
|
| 1139 |
+
if not text or text.upper() == "NONE":
|
| 1140 |
+
return
|
| 1141 |
+
set_student_profile(request.student_id, text[:1200])
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
|
| 1145 |
+
turn_started = time.monotonic()
|
| 1146 |
+
# Raises on unknown preset names: a mislabeled experiment run must fail,
|
| 1147 |
+
# not silently fall back to prod. The API layer pre-validates to a 422.
|
| 1148 |
+
memory_config = resolve_memory_preset(request.memory_preset)
|
| 1149 |
+
# Token usage and call counts come from the model calls themselves
|
| 1150 |
+
# (middleware summarization and profile updates included), so eval runs
|
| 1151 |
+
# never depend on LangSmith being enabled or within plan limits.
|
| 1152 |
+
usage_handler = TurnUsageHandler()
|
| 1153 |
+
first_text_at: float | None = None
|
| 1154 |
normalized_history = normalize_history(request.history)
|
| 1155 |
retrieval_evidence: dict[str, SourceMatch] = {}
|
| 1156 |
shell_evidence: dict[str, SourceMatch] = {}
|
|
|
|
| 1191 |
ensure_kb_agents_instructions() if include_local_tools else None
|
| 1192 |
),
|
| 1193 |
include_local_tools=include_local_tools,
|
| 1194 |
+
memory_config=memory_config,
|
| 1195 |
)
|
| 1196 |
|
| 1197 |
agent = await asyncio.to_thread(_build_agent_for_request)
|
|
|
|
| 1203 |
normalized_history,
|
| 1204 |
)
|
| 1205 |
_touch_thread(active_thread_id)
|
| 1206 |
+
run_config = agent_run_config(
|
| 1207 |
+
request, active_thread_id, message_id, memory_config.name
|
| 1208 |
+
)
|
| 1209 |
+
run_config["callbacks"] = [usage_handler]
|
| 1210 |
if fork_checkpoint_id:
|
| 1211 |
# Time travel: run from the checkpoint matching the history the
|
| 1212 |
# client kept; the turns after it become an abandoned branch.
|
|
|
|
| 1223 |
allowed_sources=request.source_keys,
|
| 1224 |
kb_session_id=message_id,
|
| 1225 |
kb_command_limit=DEFAULT_KB_COMMAND_LIMIT,
|
| 1226 |
+
student_id=request.student_id,
|
| 1227 |
),
|
| 1228 |
stream_mode=["messages", "updates"],
|
| 1229 |
version="v2",
|
|
|
|
| 1276 |
if not text_delta and token.content:
|
| 1277 |
text_delta = message_content_to_text(token.content)
|
| 1278 |
if text_delta:
|
| 1279 |
+
if first_text_at is None:
|
| 1280 |
+
first_text_at = time.monotonic()
|
| 1281 |
answer_chunks.append(text_delta)
|
| 1282 |
yield ChatEvent(
|
| 1283 |
"text_delta",
|
|
|
|
| 1486 |
"source_match",
|
| 1487 |
source_match_payload(source_match, message_id=message_id),
|
| 1488 |
)
|
| 1489 |
+
|
| 1490 |
+
# Long-term memory upkeep happens before the stats event so the profile
|
| 1491 |
+
# update's tokens land in this turn's bill.
|
| 1492 |
+
if memory_config.longterm_memory and request.student_id and answer:
|
| 1493 |
+
try:
|
| 1494 |
+
await _update_student_profile(request, answer, usage_handler)
|
| 1495 |
+
except Exception as exc:
|
| 1496 |
+
logger.warning("Student profile update failed. error=%s", exc)
|
| 1497 |
+
|
| 1498 |
+
# Aborted streams never reach this event; eval runners consume the full
|
| 1499 |
+
# stream, and the UI treats the meter as best-effort.
|
| 1500 |
+
state_messages = agent.get_state(thread_config(active_thread_id)).values.get(
|
| 1501 |
+
"messages", []
|
| 1502 |
+
)
|
| 1503 |
+
totals = usage_totals(usage_handler.usage_metadata)
|
| 1504 |
+
total_ms = int((time.monotonic() - turn_started) * 1000)
|
| 1505 |
+
yield ChatEvent(
|
| 1506 |
+
"context_stats",
|
| 1507 |
+
{
|
| 1508 |
+
"message_id": message_id,
|
| 1509 |
+
"thread_id": active_thread_id,
|
| 1510 |
+
"memory_preset": memory_config.name,
|
| 1511 |
+
"llm_calls": usage_handler.llm_calls,
|
| 1512 |
+
# Per-model breakdown for trace bundles; the totals below are
|
| 1513 |
+
# what the UI meter renders.
|
| 1514 |
+
"usage_by_model": {
|
| 1515 |
+
model_key: dict(usage)
|
| 1516 |
+
for model_key, usage in usage_handler.usage_metadata.items()
|
| 1517 |
+
},
|
| 1518 |
+
**totals,
|
| 1519 |
+
"est_cost_usd": estimate_cost_usd(usage_handler.usage_metadata),
|
| 1520 |
+
"ttft_ms": (
|
| 1521 |
+
int((first_text_at - turn_started) * 1000)
|
| 1522 |
+
if first_text_at is not None
|
| 1523 |
+
else None
|
| 1524 |
+
),
|
| 1525 |
+
"total_ms": total_ms,
|
| 1526 |
+
**context_window_stats(state_messages, CLEARED_TOOL_OUTPUT_PLACEHOLDER),
|
| 1527 |
+
},
|
| 1528 |
+
)
|
| 1529 |
yield ChatEvent(
|
| 1530 |
"message_completed",
|
| 1531 |
{
|
|
@@ -33,6 +33,12 @@ class ChatRequest:
|
|
| 33 |
include_reasoning: bool = False
|
| 34 |
thread_id: str = ""
|
| 35 |
enabled_tools: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
@dataclass(frozen=True, slots=True)
|
|
|
|
| 33 |
include_reasoning: bool = False
|
| 34 |
thread_id: str = ""
|
| 35 |
enabled_tools: tuple[str, ...] = ()
|
| 36 |
+
# Memory/context-management preset name (see app/memory_presets.py).
|
| 37 |
+
# Empty means the env-var/default resolution order.
|
| 38 |
+
memory_preset: str = ""
|
| 39 |
+
# Long-term memory key: profile-memory presets read and update the stored
|
| 40 |
+
# student profile under this id. Empty disables profile memory I/O.
|
| 41 |
+
student_id: str = ""
|
| 42 |
|
| 43 |
|
| 44 |
@dataclass(frozen=True, slots=True)
|
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Memory/context-management presets.
|
| 2 |
+
|
| 3 |
+
A preset bundles every knob that changes how the agent manages conversation
|
| 4 |
+
context: the compaction middlewares (summarization, tool-output clearing) and
|
| 5 |
+
long-term student-profile memory. ``build_agent()`` assembles its middleware
|
| 6 |
+
stack from the active preset, so experiment runs can compare configurations by
|
| 7 |
+
name while the API default stays on ``prod``.
|
| 8 |
+
|
| 9 |
+
Selection order: explicit request value > ``AI_TUTOR_MEMORY_PRESET`` env var >
|
| 10 |
+
``DEFAULT_MEMORY_PRESET``. Unknown names raise instead of falling back; a
|
| 11 |
+
mislabeled experiment run is worse than a failed one.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True, slots=True)
|
| 21 |
+
class MemoryConfig:
|
| 22 |
+
name: str
|
| 23 |
+
summarization: bool = True
|
| 24 |
+
summarization_trigger_tokens: int = 30_000
|
| 25 |
+
summarization_keep_messages: int = 20
|
| 26 |
+
context_editing: bool = True
|
| 27 |
+
context_editing_trigger_tokens: int = 5_000
|
| 28 |
+
context_editing_keep: int = 5
|
| 29 |
+
longterm_memory: bool = False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
MEMORY_PRESETS: dict[str, MemoryConfig] = {
|
| 33 |
+
# No compaction at all: the quality/memory upper bound and the
|
| 34 |
+
# token-cost worst case.
|
| 35 |
+
"full_history": MemoryConfig(
|
| 36 |
+
name="full_history", summarization=False, context_editing=False
|
| 37 |
+
),
|
| 38 |
+
# What production runs today.
|
| 39 |
+
"prod": MemoryConfig(name="prod"),
|
| 40 |
+
"summarization_only": MemoryConfig(
|
| 41 |
+
name="summarization_only", context_editing=False
|
| 42 |
+
),
|
| 43 |
+
"editing_only": MemoryConfig(name="editing_only", summarization=False),
|
| 44 |
+
# How bad can cheap get: compaction fires early and keeps little.
|
| 45 |
+
"aggressive": MemoryConfig(
|
| 46 |
+
name="aggressive",
|
| 47 |
+
summarization_trigger_tokens=8_000,
|
| 48 |
+
summarization_keep_messages=8,
|
| 49 |
+
context_editing_trigger_tokens=2_000,
|
| 50 |
+
context_editing_keep=2,
|
| 51 |
+
),
|
| 52 |
+
# prod compaction + long-term semantic memory (student profile store).
|
| 53 |
+
"profile_memory": MemoryConfig(name="profile_memory", longterm_memory=True),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
DEFAULT_MEMORY_PRESET = "prod"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def resolve_memory_preset(name: str | None = None) -> MemoryConfig:
|
| 60 |
+
requested = (
|
| 61 |
+
(name or "").strip()
|
| 62 |
+
or os.environ.get("AI_TUTOR_MEMORY_PRESET", "").strip()
|
| 63 |
+
or DEFAULT_MEMORY_PRESET
|
| 64 |
+
)
|
| 65 |
+
config = MEMORY_PRESETS.get(requested)
|
| 66 |
+
if config is None:
|
| 67 |
+
known = ", ".join(sorted(MEMORY_PRESETS))
|
| 68 |
+
raise ValueError(f"Unknown memory preset {requested!r}. Known presets: {known}")
|
| 69 |
+
return config
|
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-turn token/cost/latency telemetry, independent of LangSmith.
|
| 2 |
+
|
| 3 |
+
Token counts come from the ``usage_metadata`` every chat-model call reports,
|
| 4 |
+
collected by a callback handler attached to the agent run. That includes
|
| 5 |
+
SummarizationMiddleware's internal summary calls and the student-profile
|
| 6 |
+
update call, so a preset's overhead is part of its own bill. Nothing here
|
| 7 |
+
talks to LangSmith: eval runs can stream thousands of turns with tracing
|
| 8 |
+
disabled and still get complete numbers.
|
| 9 |
+
|
| 10 |
+
Costs are estimates from the local price table below. Raw token counts are
|
| 11 |
+
always emitted alongside, so costs can be recomputed offline whenever prices
|
| 12 |
+
change; an unknown model yields ``est_cost_usd=None`` rather than a wrong
|
| 13 |
+
number.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from langchain_core.callbacks.usage import UsageMetadataCallbackHandler
|
| 22 |
+
from langchain_core.messages import BaseMessage
|
| 23 |
+
from langchain_core.messages.utils import count_tokens_approximately
|
| 24 |
+
from langchain_core.outputs import LLMResult
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TurnUsageHandler(UsageMetadataCallbackHandler):
|
| 28 |
+
"""Aggregate per-model usage for one turn and count chat-model calls."""
|
| 29 |
+
|
| 30 |
+
def __init__(self) -> None:
|
| 31 |
+
super().__init__()
|
| 32 |
+
self.llm_calls = 0
|
| 33 |
+
|
| 34 |
+
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
| 35 |
+
with self._lock:
|
| 36 |
+
self.llm_calls += 1
|
| 37 |
+
super().on_llm_end(response, **kwargs)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True, slots=True)
|
| 41 |
+
class ModelPricing:
|
| 42 |
+
"""USD per million tokens. ``cache_write`` of None bills cache-creation
|
| 43 |
+
tokens at the plain input rate (providers without a write surcharge)."""
|
| 44 |
+
|
| 45 |
+
input: float
|
| 46 |
+
output: float
|
| 47 |
+
cache_read: float
|
| 48 |
+
cache_write: float | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# Verify against the provider price sheets before quoting cost numbers
|
| 52 |
+
# anywhere public; prices move. Keys match by longest prefix so dated
|
| 53 |
+
# variants ("claude-haiku-4-5-20251001") hit their family entry.
|
| 54 |
+
MODEL_PRICING: dict[str, ModelPricing] = {
|
| 55 |
+
# TODO verify: seeded from the gemini-2.5-flash sheet.
|
| 56 |
+
"gemini-3.5-flash": ModelPricing(input=0.30, output=2.50, cache_read=0.075),
|
| 57 |
+
"claude-haiku-4-5": ModelPricing(
|
| 58 |
+
input=1.00, output=5.00, cache_read=0.10, cache_write=1.25
|
| 59 |
+
),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def pricing_for_model(model_key: str) -> ModelPricing | None:
|
| 64 |
+
best: tuple[int, ModelPricing] | None = None
|
| 65 |
+
for prefix, pricing in MODEL_PRICING.items():
|
| 66 |
+
if model_key.startswith(prefix) and (best is None or len(prefix) > best[0]):
|
| 67 |
+
best = (len(prefix), pricing)
|
| 68 |
+
return best[1] if best else None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def usage_totals(usage_by_model: dict[str, Any]) -> dict[str, int]:
|
| 72 |
+
"""Sum usage across models into the fields the stats event reports."""
|
| 73 |
+
totals = {
|
| 74 |
+
"input_tokens": 0,
|
| 75 |
+
"output_tokens": 0,
|
| 76 |
+
"total_tokens": 0,
|
| 77 |
+
"cache_read_tokens": 0,
|
| 78 |
+
"cache_creation_tokens": 0,
|
| 79 |
+
}
|
| 80 |
+
for usage in usage_by_model.values():
|
| 81 |
+
totals["input_tokens"] += int(usage.get("input_tokens", 0) or 0)
|
| 82 |
+
totals["output_tokens"] += int(usage.get("output_tokens", 0) or 0)
|
| 83 |
+
totals["total_tokens"] += int(usage.get("total_tokens", 0) or 0)
|
| 84 |
+
details = usage.get("input_token_details") or {}
|
| 85 |
+
totals["cache_read_tokens"] += int(details.get("cache_read", 0) or 0)
|
| 86 |
+
totals["cache_creation_tokens"] += int(details.get("cache_creation", 0) or 0)
|
| 87 |
+
return totals
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def estimate_cost_usd(usage_by_model: dict[str, Any]) -> float | None:
|
| 91 |
+
"""Estimated turn cost, or None when any used model has no price entry.
|
| 92 |
+
|
| 93 |
+
Cached input tokens are billed at the cache-read rate; ``input_tokens``
|
| 94 |
+
includes them (LangChain's UsageMetadata convention), so they are carved
|
| 95 |
+
out of the plain-input bucket rather than added on top.
|
| 96 |
+
"""
|
| 97 |
+
total = 0.0
|
| 98 |
+
for model_key, usage in usage_by_model.items():
|
| 99 |
+
pricing = pricing_for_model(model_key)
|
| 100 |
+
if pricing is None:
|
| 101 |
+
return None
|
| 102 |
+
input_tokens = int(usage.get("input_tokens", 0) or 0)
|
| 103 |
+
output_tokens = int(usage.get("output_tokens", 0) or 0)
|
| 104 |
+
details = usage.get("input_token_details") or {}
|
| 105 |
+
cache_read = int(details.get("cache_read", 0) or 0)
|
| 106 |
+
cache_creation = int(details.get("cache_creation", 0) or 0)
|
| 107 |
+
plain_input = max(0, input_tokens - cache_read - cache_creation)
|
| 108 |
+
write_rate = (
|
| 109 |
+
pricing.cache_write if pricing.cache_write is not None else pricing.input
|
| 110 |
+
)
|
| 111 |
+
total += (
|
| 112 |
+
plain_input * pricing.input
|
| 113 |
+
+ cache_read * pricing.cache_read
|
| 114 |
+
+ cache_creation * write_rate
|
| 115 |
+
+ output_tokens * pricing.output
|
| 116 |
+
) / 1_000_000
|
| 117 |
+
return total
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def context_window_stats(
|
| 121 |
+
messages: list[BaseMessage], cleared_placeholder: str
|
| 122 |
+
) -> dict[str, int]:
|
| 123 |
+
"""Describe the checkpointed context after a turn.
|
| 124 |
+
|
| 125 |
+
``summary_messages`` counts SummarizationMiddleware's summary insertions
|
| 126 |
+
(tagged ``lc_source: summarization``); ``cleared_tool_outputs`` counts
|
| 127 |
+
tool results ContextEditingMiddleware replaced with the placeholder. A
|
| 128 |
+
runner diffs these across turns to verify compaction actually fired.
|
| 129 |
+
"""
|
| 130 |
+
summary_messages = 0
|
| 131 |
+
cleared_tool_outputs = 0
|
| 132 |
+
for message in messages:
|
| 133 |
+
additional = getattr(message, "additional_kwargs", None) or {}
|
| 134 |
+
if additional.get("lc_source") == "summarization":
|
| 135 |
+
summary_messages += 1
|
| 136 |
+
if getattr(message, "type", "") == "tool":
|
| 137 |
+
content = message.content
|
| 138 |
+
text = content if isinstance(content, str) else str(content)
|
| 139 |
+
if text.startswith(cleared_placeholder):
|
| 140 |
+
cleared_tool_outputs += 1
|
| 141 |
+
return {
|
| 142 |
+
"context_messages": len(messages),
|
| 143 |
+
"context_tokens_approx": (
|
| 144 |
+
int(count_tokens_approximately(messages)) if messages else 0
|
| 145 |
+
),
|
| 146 |
+
"summary_messages": summary_messages,
|
| 147 |
+
"cleared_tool_outputs": cleared_tool_outputs,
|
| 148 |
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Evaluation batteries (v1, frozen 2026-06-11)
|
| 2 |
+
|
| 3 |
+
The datasets for evaluating the AI tutor (see `evals.md` at the repo root for the overall effort and results). **This README is the single reference for what every file, field, and term means.**
|
| 4 |
+
|
| 5 |
+
Built from `data/academy_discussion_eval.jsonl` — real student posts from the academy discussion boards with real staff answers — plus authored content (sessions, personas).
|
| 6 |
+
|
| 7 |
+
## Ground rules
|
| 8 |
+
|
| 9 |
+
- **Never edit a battery in place.** To change cases: edit the verdict/source files, rerun assembly, write `_v2`. Results are only comparable within one battery version.
|
| 10 |
+
- **All `*.jsonl` here is gitignored** (repo-wide rule) and contains real student text. Treat like the private HF dataset; never publish or paste into slides.
|
| 11 |
+
- **Inputs only.** No file contains LLM-generated answers to grade against. Ground truth is either real staff replies (distilled into key points) or facts we authored ourselves (sessions, personas).
|
| 12 |
+
|
| 13 |
+
## What each battery is for
|
| 14 |
+
|
| 15 |
+
| File | Tests | Main metrics it feeds | Graded by |
|
| 16 |
+
|---|---|---|---|
|
| 17 |
+
| `battery_singleturn_v1.jsonl` | One-shot answer quality and behavior routing. Memory presets should score ~equal here (sanity tier); differences indicate bugs, not memory effects. | key-point coverage · behavior accuracy · retrieval recall@k · citation validity · all ops metrics (tokens/cost/latency) | code + judge/hand-grade |
|
| 18 |
+
| `battery_sessions_v1.jsonl` | **Where memory presets separate.** Long sessions inflate context past compression triggers, then probes test what survived. | memory probe accuracy (per probe type) · cumulative tokens per session · trigger firings · late-turn retrieval recall | code (string checks) + judge for nuanced probes |
|
| 19 |
+
| `battery_personas_v1.jsonl` | The `profile_memory` preset specifically: does a stored student profile actually shape answers across sessions? | personalization pass rate | mostly code (regex), few judge checks |
|
| 20 |
+
| `replay_n1_v1.jsonl` | Multi-turn answering on *real* conversation prefixes (no synthetic anything). Secondary battery. | reply quality vs the real staff reply | judge or hand-grade |
|
| 21 |
+
|
| 22 |
+
"Metrics" here map to the metric layers defined in `evals.md` (ops, trajectory, retrieval, behavior, quality, memory).
|
| 23 |
+
|
| 24 |
+
## Glossary
|
| 25 |
+
|
| 26 |
+
- **Case / session / probe**: one gradeable unit. A *case* is a single question (single-turn, persona, replay). A *session* is an ordered multi-turn conversation run on one `thread_id`. A *probe* is a turn inside a session that gets graded (other turns are ungraded context).
|
| 27 |
+
- **eval_quality — `gold` / `usable` / `weak` / `exclude`**: reviewer confidence that the case is a fair, gradeable test. `gold` = clear question, verifiable expectation, safe to treat a failure as the system's fault. `usable` = gradeable but with a caveat (thin reference answer, fragile specifics) — failures deserve a second look before blaming the system. `weak`/`exclude` never enter a battery. "60 cases: gold 49 / usable 11" means 49 high-confidence + 11 caveated cases.
|
| 28 |
+
- **key_points**: 1–5 atomic, binary-checkable claims that a good answer SHOULD contain, distilled from the real staff answer (only claims still true today). Each is graded independently pass/fail ("does the answer correctly convey this point?") → the **key-point coverage** metric (fraction of points passed). This decomposition is what makes grading reliable: small binary checks instead of a holistic 1–5 score. Empty list is intentional for redirect/feedback cases — those are graded on behavior, not content.
|
| 29 |
+
- **expected_behavior**: which of the tutor's four correct moves this case demands. `answer_from_corpus` = retrieve and ground the answer in course/docs content. `answer_general` = legitimate AI/programming question outside the corpus; answer from general knowledge without fake citations. `redirect_to_support` = platform/billing/submission issue; empathize and point to human support, never invent platform answers. `acknowledge_feedback` = course feedback; thank and acknowledge, don't promise fixes. Graded as **behavior accuracy** (did the tutor do the right *kind* of thing), separate from answer content.
|
| 30 |
+
- **standalone question** (`question` field): the student's post rewritten so it works without seeing the discussion page — names the course/lesson, keeps code and error text, strips student names. This is the text actually sent to the tutor.
|
| 31 |
+
- **reference_answer / reference_reply / reference_links**: the real staff reply. **Grader context only — never shown to the tutor.** Key points are derived from it; graders read it to resolve ambiguity.
|
| 32 |
+
- **time_bound**: the staff answer depended on a point in time (broken link since fixed, "we'll add this soon", old library version). Time-bound cases were excluded unless durable behavior can still be graded — the 9 included `time_bound: true` cases are all platform issues where the *redirect behavior* is what's graded, not the stale facts.
|
| 33 |
+
- **requires_notebook**: fully answering needs the Colab notebook contents (cells/outputs/files), which our KB does not contain — verified against lesson markdown with file/line evidence (`notebook_evidence`), not guessed. All 60 included cases are `false`; the one true case in review was excluded. If retrieval misses on a flagged-adjacent case, check `notebook_evidence` before blaming the retriever.
|
| 34 |
+
- **requires_media**: the post referenced a screenshot we don't have. The 2 included cases remain gradeable without it.
|
| 35 |
+
- **planted facts**: personal facts (environment, goal, level, weak topic, preference/constraint) a session's student states in turn 0, which later probes test. The session-level analog of a profile.
|
| 36 |
+
- **Probe types** (`probe_type`): `fact_recall` — answer must use a planted fact from many turns ago. `preference_compliance` — answer must honor a stated preference (OS, tooling, language, style). `anaphora` — question refers to "the X you explained earlier"; unanswerable without resolving it to an earlier turn. `anaphora_consistency` — answer must stay consistent with the tutor's own earlier recommendation. `fact_update` — the student CHANGED a fact mid-session; using the old value = fail (catches summaries that freeze stale state — the signature compaction failure). `behavior_routing` — a platform issue dropped mid-session; the tutor must still redirect properly under context pressure.
|
| 37 |
+
- **expected_facts / check_note**: per probe, the specific facts a correct answer must use, and a one-line pass/fail rule for the grader. Grade probes binary → **memory probe accuracy**, reported overall and per probe type per memory preset.
|
| 38 |
+
- **profile_seed / facts**: the persona's stored profile. Seed it with `app.chat_service.set_student_profile(student_id, profile_seed)` before running that persona's questions; `facts` is the same content structured, for slicing results by fact type.
|
| 39 |
+
- **Self-grading checks** (`checks` / `anti_patterns`): persona questions grade themselves because we authored the ground truth. `{"type": "regex_any", "patterns": [...]}` = case-insensitive regex over the answer; any match passes that check. `{"type": "llm", "instruction": ...}` = a judge call with that instruction (use a strong Anthropic model; binary verdict). `anti_patterns` = case-insensitive regexes whose match FAILS the question (e.g. bash `export` advice for a Windows persona). A question passes when every check passes and no anti-pattern matches.
|
| 40 |
+
- **N-1 replay**: take a real thread, feed turns 1..N-1 as history, have the tutor produce turn N, grade against what the staff actually replied. Tests multi-turn behavior on fully real data (Hamel's recommended alternative to simulated users).
|
| 41 |
+
- **Retrieval ground truth**: every discussion-derived case carries `source_key` + `lesson_url` — the course/lesson the question came from. Computing whether retrieval surfaced that source/lesson gives **recall@k / MRR** with zero extra labeling.
|
| 42 |
+
- **review / verdict / review log**: every single-turn case carries the reviewer's `changes` (diff vs the original Gemini annotation) and `notes`. `review_log_v1.md` is the human-readable audit: systematic findings, the 11 judgment-call cases, all 92 verdicts, all 32 exclusions. Machine-readable verdicts: `review_batches/verdicts_batch_*.jsonl`.
|
| 43 |
+
- **Memory preset**: a named memory/context configuration (`full_history`, `prod`, `aggressive`, `profile_memory`, ...) — see `app/memory_presets.py`. The experiment variable these batteries measure.
|
| 44 |
+
|
| 45 |
+
## File schemas
|
| 46 |
+
|
| 47 |
+
### `battery_singleturn_v1.jsonl` — 60 cases, one JSON object per line
|
| 48 |
+
|
| 49 |
+
| Field | Meaning |
|
| 50 |
+
|---|---|
|
| 51 |
+
| `case_id` | Stable id (`st_<post_id>`). Use in run logs/bundles. |
|
| 52 |
+
| `post_id` | Provenance: the original academy discussion post (`academy.towardsai.net/manage/discussion/posts/<post_id>`). |
|
| 53 |
+
| `course`, `source_key`, `lesson_name`, `lesson_url` | Where the question came from; `source_key`+`lesson_url` double as retrieval ground truth. |
|
| 54 |
+
| `asked_at` | Original post date (for staleness debugging). |
|
| 55 |
+
| `question` | The standalone question — the prompt sent to the tutor. |
|
| 56 |
+
| `category` | `conceptual` / `debugging` / `platform_issue` / `course_feedback` / `other` — for slicing results. |
|
| 57 |
+
| `expected_behavior` | See glossary. Drives the behavior-accuracy check. |
|
| 58 |
+
| `key_points` | See glossary. Empty for behavior-only cases (13 of 60). |
|
| 59 |
+
| `eval_quality` | `gold` (49) or `usable` (11) — see glossary. |
|
| 60 |
+
| `time_bound` | 9 true: grade behavior only, ignore stale specifics. |
|
| 61 |
+
| `requires_notebook`, `notebook_evidence` | All false; evidence records what was checked in the KB. |
|
| 62 |
+
| `requires_media` | 2 true; still gradeable. |
|
| 63 |
+
| `reference_answer`, `reference_links` | Real staff reply — grader-only. |
|
| 64 |
+
| `review.{changes,notes,batch}` | Reviewer audit trail per case. |
|
| 65 |
+
|
| 66 |
+
Distribution to keep in mind when reporting: behaviors 37 corpus / 10 redirect / 7 general / 6 feedback; courses 41 full-stack / 16 agentic / 3 beginner-python (skewed — say so on slides).
|
| 67 |
+
|
| 68 |
+
### `battery_sessions_v1.jsonl` — 32 sessions (337 turns, 113 probes)
|
| 69 |
+
|
| 70 |
+
| Field | Meaning |
|
| 71 |
+
|---|---|
|
| 72 |
+
| `session_id` | e.g. `s08_fullstack_gamedev_update_12t` (s01–s05 hand-written, s06–s32 generated + spot-checked). |
|
| 73 |
+
| `course`, `source_key` | Course context; pass `source_keys=(source_key,)` to the tutor. |
|
| 74 |
+
| `persona` | One-line description of the student (for readers, not the tutor). |
|
| 75 |
+
| `planted_facts` | The facts turn 0 establishes (and mid-session updates, marked "UPDATED at turn k"). |
|
| 76 |
+
| `turns` | Ordered user messages. Run ALL sequentially on ONE `thread_id`. Middle turns are real corpus questions (post_ids in `notes`) — ungraded context whose job is to inflate tokens past compression triggers. Some reference since-changed content; that's realistic, leave it. |
|
| 77 |
+
| `probes` | The graded turns: `turn_index` (0-based into `turns` — the probe IS that turn), `probe_type` (glossary), `expected_facts`, `check_note`. |
|
| 78 |
+
| `notes` | Provenance: which post_ids / lesson names the turns use. |
|
| 79 |
+
|
| 80 |
+
Probe mix: fact_recall 55 · preference_compliance 27 · anaphora 12 · anaphora_consistency 9 · fact_update 7 (sessions s08, s11, s18, s21, s24, s31) · behavior_routing 3.
|
| 81 |
+
**Before the bake-off**: smoke-run a few sessions and confirm compression actually fired before the probe turns (`context_stats.summary_messages` / `cleared_tool_outputs` > 0); lengthen sessions if not — a memory eval where compaction never triggered measures nothing.
|
| 82 |
+
|
| 83 |
+
### `battery_personas_v1.jsonl` — 10 personas × 4 questions
|
| 84 |
+
|
| 85 |
+
| Field | Meaning |
|
| 86 |
+
|---|---|
|
| 87 |
+
| `persona_id` | Also use as the `student_id` for seeding/runs. |
|
| 88 |
+
| `course`, `source_key` | Course context. |
|
| 89 |
+
| `profile_seed` | Text to write into the store via `set_student_profile()` before the run. |
|
| 90 |
+
| `facts` | Same profile, structured (level/goal/environment/weak_topic/preference) for slicing. |
|
| 91 |
+
| `questions[].question_id` | e.g. `p01_q3`. |
|
| 92 |
+
| `questions[].question` | Sent on a FRESH `thread_id` per question — isolates long-term memory from working memory. Deliberately unanswerable-correctly without the profile. |
|
| 93 |
+
| `questions[].expected_facts_used` | Which profile facts a correct answer must draw on. |
|
| 94 |
+
| `questions[].checks`, `anti_patterns`, `check_note` | Self-grading rules — see glossary. |
|
| 95 |
+
|
| 96 |
+
Run each persona under `profile_memory` AND under a no-memory preset: the gap between the two pass rates is the personalization effect.
|
| 97 |
+
|
| 98 |
+
### `replay_n1_v1.jsonl` — 30 cases from 24 real threads
|
| 99 |
+
|
| 100 |
+
| Field | Meaning |
|
| 101 |
+
|---|---|
|
| 102 |
+
| `replay_id` | `replay_<post_id>_t<k>`: replays up to staff turn k. |
|
| 103 |
+
| `post_id`, `course`, `source_key`, `lesson_url`, `category` | Provenance + retrieval ground truth. |
|
| 104 |
+
| `eval_quality`, `time_bound` | 17 gold / 13 usable; 8 time_bound — filter those for most uses. |
|
| 105 |
+
| `history` | Real conversation prefix: original question + thread turns, mapped student→`user`, staff→`assistant`. Feed as the request history; the last entry is always a user message. |
|
| 106 |
+
| `reference_reply` | The real staff reply to grade against (grader-only). |
|
| 107 |
+
|
| 108 |
+
Contains real names inside message text (inherent to replay) — private.
|
| 109 |
+
|
| 110 |
+
### Provenance files
|
| 111 |
+
|
| 112 |
+
- `review_batches/batch_*.json` — the 92 raw cases as sent to reviewers; `review_batches/verdicts_batch_*.jsonl` — machine-readable verdicts. To overturn a verdict: edit the line, rerun assembly, bump the battery to v2.
|
| 113 |
+
- `review_log_v1.md` — the human audit document for the single-turn battery.
|
| 114 |
+
- `sessions_handwritten.jsonl`, `sessions_generated_{a,b,c}.jsonl` — raw session inputs merged into `battery_sessions_v1.jsonl`.
|
| 115 |
+
|
| 116 |
+
## How grading will work (Part A3/B of the experiments plan)
|
| 117 |
+
|
| 118 |
+
- **Single-turn**: behavior routing + citation validity as code assertions; key-point coverage hand-graded for the workshop (≈60–80 binary readings), later automated by a judge validated against those hand labels (product plan P4). Judges: strong Anthropic model via API, binary verdict + one-line critique per check.
|
| 119 |
+
- **Sessions**: probe `expected_facts` checked by string match where unambiguous, judge with `check_note` as the rubric otherwise; trigger-firing verified from `context_stats` telemetry.
|
| 120 |
+
- **Personas**: fully programmatic except `type: "llm"` checks.
|
| 121 |
+
- **Replay**: judge or hand-grade against `reference_reply`.
|
| 122 |
+
- Every reported number should name its battery version (e.g. "key-point coverage, singleturn_v1") and, for judge-graded metrics, the judge's measured agreement with human labels (TPR/TNR).
|
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Annotate academy discussion posts into an eval-ready dataset.
|
| 2 |
+
|
| 3 |
+
Reads ``data/academy_discussion_posts.jsonl`` (scraped course discussion
|
| 4 |
+
threads) and emits ``data/academy_discussion_eval.jsonl``, one annotated
|
| 5 |
+
record per thread, combining:
|
| 6 |
+
|
| 7 |
+
- deterministic fields extracted in code: the cleaned question, the earliest
|
| 8 |
+
reply written by course staff (the reference answer), the full thread in
|
| 9 |
+
chronological order, and the corpus source key for the course; and
|
| 10 |
+
- LLM annotations (Gemini structured output): category, expected tutor
|
| 11 |
+
behavior, a self-contained rewrite of the question, evergreen key points
|
| 12 |
+
distilled from the staff answer, and an overall eval-quality grade.
|
| 13 |
+
|
| 14 |
+
The output is the raw material for eval datasets: filter on
|
| 15 |
+
``annotation.eval_quality`` / ``annotation.category`` to build the Q&A set
|
| 16 |
+
(judge against ``key_points``), the out-of-scope behavior set
|
| 17 |
+
(``expected_behavior == "redirect_to_support"``), and retrieval ground truth
|
| 18 |
+
(``source_key`` + ``lesson_url``).
|
| 19 |
+
|
| 20 |
+
Resumable: post_ids already present in the output file are skipped unless
|
| 21 |
+
``--force`` is passed.
|
| 22 |
+
|
| 23 |
+
Usage:
|
| 24 |
+
uv run -m data.scraping_scripts.annotate_discussion_posts [--limit N] [--force]
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import asyncio
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import re
|
| 32 |
+
from datetime import UTC, datetime
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Literal
|
| 35 |
+
|
| 36 |
+
from dotenv import load_dotenv
|
| 37 |
+
from google import genai
|
| 38 |
+
from google.genai import types
|
| 39 |
+
from google.genai.errors import APIError
|
| 40 |
+
from pydantic import BaseModel, Field
|
| 41 |
+
from tenacity import (
|
| 42 |
+
retry,
|
| 43 |
+
retry_if_exception,
|
| 44 |
+
stop_after_attempt,
|
| 45 |
+
wait_random_exponential,
|
| 46 |
+
)
|
| 47 |
+
from tqdm.asyncio import tqdm
|
| 48 |
+
|
| 49 |
+
load_dotenv(".env")
|
| 50 |
+
|
| 51 |
+
DEFAULT_INPUT = Path("data/academy_discussion_posts.jsonl")
|
| 52 |
+
DEFAULT_OUTPUT = Path("data/academy_discussion_eval.jsonl")
|
| 53 |
+
ANNOTATION_MODEL = os.getenv("ANNOTATION_MODEL", "gemini-3.5-flash")
|
| 54 |
+
ANNOTATION_CONCURRENCY = int(os.getenv("ANNOTATION_CONCURRENCY", "8"))
|
| 55 |
+
RETRYABLE_GENAI_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
| 56 |
+
|
| 57 |
+
# People who answer threads in an official capacity. Replies from anyone else
|
| 58 |
+
# (students helping each other, the original poster following up) are kept in
|
| 59 |
+
# the thread but never used as the reference answer. Derived from responders
|
| 60 |
+
# who reply in threads they did not start; edit as the team changes.
|
| 61 |
+
STAFF_RESPONDERS = {
|
| 62 |
+
"Jaiganesan N",
|
| 63 |
+
"Louis-François Bouchard",
|
| 64 |
+
"Paul Iusztin",
|
| 65 |
+
"Samridhi",
|
| 66 |
+
"Louie Peters",
|
| 67 |
+
"Omar Solano",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Dataset course_name -> source key in source_registry.SOURCE_CONFIGS.
|
| 71 |
+
# Courses absent from the corpus map to None (the tutor has no material for
|
| 72 |
+
# them, which the annotator is told about).
|
| 73 |
+
COURSE_TO_SOURCE_KEY: dict[str, str | None] = {
|
| 74 |
+
"Full Stack AI Engineering": "full_stack_ai_engineering",
|
| 75 |
+
"Agent Engineering: Building Multi-Agent Systems": "agentic_ai_engineering",
|
| 76 |
+
"Beginner Python for AI Engineering": "beginner_python_for_ai_engineering",
|
| 77 |
+
"Master AI For Work": "master_ai_for_work",
|
| 78 |
+
"8-hour Generative AI Primer": None,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class PostAnnotation(BaseModel):
|
| 83 |
+
"""LLM-produced annotation for one discussion thread."""
|
| 84 |
+
|
| 85 |
+
category: Literal[
|
| 86 |
+
"conceptual", "debugging", "course_feedback", "platform_issue", "other"
|
| 87 |
+
] = Field(
|
| 88 |
+
description=(
|
| 89 |
+
"conceptual: technical/theory question about course material. "
|
| 90 |
+
"debugging: an error or unexpected result in the student's code, "
|
| 91 |
+
"notebook, or environment. course_feedback: typo/errata reports or "
|
| 92 |
+
"suggestions about course content. platform_issue: academy website, "
|
| 93 |
+
"videos, quizzes, certificates, accounts, billing, community access. "
|
| 94 |
+
"other: greetings, kudos, or unintelligible posts."
|
| 95 |
+
)
|
| 96 |
+
)
|
| 97 |
+
expected_behavior: Literal[
|
| 98 |
+
"answer_from_corpus",
|
| 99 |
+
"answer_general",
|
| 100 |
+
"redirect_to_support",
|
| 101 |
+
"acknowledge_feedback",
|
| 102 |
+
] = Field(
|
| 103 |
+
description=(
|
| 104 |
+
"What an ideal tutor response does. answer_from_corpus: answer "
|
| 105 |
+
"grounded in course/docs material. answer_general: sound technical "
|
| 106 |
+
"answer that likely goes beyond the corpus. redirect_to_support: "
|
| 107 |
+
"the tutor cannot act on this; it should say so briefly and point "
|
| 108 |
+
"to the support team, without inventing troubleshooting steps. "
|
| 109 |
+
"acknowledge_feedback: thank the student and suggest reporting the "
|
| 110 |
+
"issue to the team."
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
self_contained: bool = Field(
|
| 114 |
+
description=(
|
| 115 |
+
"True if the question can be fully understood without seeing the "
|
| 116 |
+
"lesson page, a screenshot, a quiz, or other context the tutor "
|
| 117 |
+
"does not have."
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
standalone_question: str | None = Field(
|
| 121 |
+
description=(
|
| 122 |
+
"A minimally rewritten, self-contained version of the question "
|
| 123 |
+
"suitable to send to the tutor: resolve references like 'this "
|
| 124 |
+
"lesson', 'Image 8', or 'the notebook' using the course and "
|
| 125 |
+
"lesson names; keep the student's wording and code verbatim "
|
| 126 |
+
"otherwise. Null if the question cannot be made self-contained."
|
| 127 |
+
)
|
| 128 |
+
)
|
| 129 |
+
key_points: list[str] = Field(
|
| 130 |
+
description=(
|
| 131 |
+
"1-4 evergreen technical claims distilled from STAFF replies, "
|
| 132 |
+
"each independently checkable against a candidate answer (the "
|
| 133 |
+
"diagnosis, the fix, the concept). Exclude insider or time-bound "
|
| 134 |
+
"content: notebook-update promises, platform actions, version "
|
| 135 |
+
"numbers true only at the time. Empty if there is no staff reply "
|
| 136 |
+
"or it contains nothing technical."
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
excluded_from_key_points: str | None = Field(
|
| 140 |
+
description=(
|
| 141 |
+
"One short note on staff-reply content deliberately left out of "
|
| 142 |
+
"key_points (e.g. 'promise to update the notebook'). Null if "
|
| 143 |
+
"nothing was excluded."
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
time_bound: bool = Field(
|
| 147 |
+
description=(
|
| 148 |
+
"True if the correct answer depends on a point-in-time state "
|
| 149 |
+
"(package version, notebook revision, platform status) that may "
|
| 150 |
+
"have changed since."
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
eval_quality: Literal["gold", "usable", "weak", "exclude"] = Field(
|
| 154 |
+
description=(
|
| 155 |
+
"Suitability as an eval case for the eval its expected_behavior "
|
| 156 |
+
"implies. gold: clear self-contained question with a solid staff "
|
| 157 |
+
"answer yielding key points, or an unambiguous redirect case. "
|
| 158 |
+
"usable: good question with a partial/thin reference or needing "
|
| 159 |
+
"the rewrite. weak: marginal; heavily time-bound or barely "
|
| 160 |
+
"intelligible. exclude: no eval value (greetings, kudos, empty)."
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
notes: str = Field(description="One-line rationale for the grades above.")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
ANNOTATION_PROMPT = """\
|
| 167 |
+
You are annotating a student discussion thread from an online AI course so it \
|
| 168 |
+
can be turned into an evaluation case for an AI tutor chatbot.
|
| 169 |
+
|
| 170 |
+
About the tutor being evaluated:
|
| 171 |
+
- It answers questions grounded in a corpus of course materials and AI library \
|
| 172 |
+
docs (Transformers, PEFT, TRL, LlamaIndex, LangChain, LangGraph, OpenAI docs, \
|
| 173 |
+
and the courses themselves), and can optionally search the web.
|
| 174 |
+
- It CANNOT: access the academy platform (videos, quizzes, assignments, \
|
| 175 |
+
certificates, accounts, billing, Slack/Discord), see screenshots or images, \
|
| 176 |
+
run or update course notebooks, or change course content.
|
| 177 |
+
|
| 178 |
+
Thread metadata:
|
| 179 |
+
- Course: {course_name} ({corpus_note})
|
| 180 |
+
- Lesson: {lesson_name}
|
| 181 |
+
- The original post {media_note}.
|
| 182 |
+
|
| 183 |
+
Thread (chronological; STAFF = official course staff, STUDENT = learner):
|
| 184 |
+
|
| 185 |
+
{thread_text}
|
| 186 |
+
|
| 187 |
+
Annotate the thread per the response schema. Additional rules:
|
| 188 |
+
- key_points must come only from STAFF replies. If there is no staff reply, \
|
| 189 |
+
key_points must be empty and eval_quality is at most "usable".
|
| 190 |
+
- standalone_question preserves the student's voice and any code verbatim; it \
|
| 191 |
+
only resolves references the tutor could not see. If the post fundamentally \
|
| 192 |
+
depends on unseen content (a screenshot, quiz option text, a specific page), \
|
| 193 |
+
set self_contained to false and standalone_question to null.
|
| 194 |
+
- A clear platform_issue is still a valuable eval case (the tutor should \
|
| 195 |
+
redirect gracefully), so grade it on that basis rather than excluding it.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
_genai_client: genai.Client | None = None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def get_genai_client() -> genai.Client:
|
| 202 |
+
global _genai_client
|
| 203 |
+
if _genai_client is None:
|
| 204 |
+
api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
| 205 |
+
if not api_key:
|
| 206 |
+
raise RuntimeError("GEMINI_API_KEY or GOOGLE_API_KEY must be set.")
|
| 207 |
+
_genai_client = genai.Client(api_key=api_key)
|
| 208 |
+
return _genai_client
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def is_retryable_genai_error(exc: BaseException) -> bool:
|
| 212 |
+
return isinstance(exc, APIError) and exc.code in RETRYABLE_GENAI_STATUS_CODES
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def parse_post_date(text: str | None) -> str | None:
|
| 216 |
+
if not text:
|
| 217 |
+
return None
|
| 218 |
+
cleaned = " ".join(text.split())
|
| 219 |
+
for fmt in ("%B %d, %Y", "%b %d, %Y"):
|
| 220 |
+
try:
|
| 221 |
+
return datetime.strptime(cleaned, fmt).date().isoformat()
|
| 222 |
+
except ValueError:
|
| 223 |
+
continue
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def parse_byline(byline: str | None) -> tuple[str | None, str | None]:
|
| 228 |
+
"""Split 'Posted by NAME on\\nDATE' into (name, ISO date)."""
|
| 229 |
+
if not byline:
|
| 230 |
+
return None, None
|
| 231 |
+
match = re.search(r"Posted by\s+(.+?)\s+on\b\s*(.*)$", byline, re.DOTALL)
|
| 232 |
+
if not match:
|
| 233 |
+
return " ".join(byline.split()) or None, None
|
| 234 |
+
name = " ".join(match.group(1).split())
|
| 235 |
+
return name or None, parse_post_date(match.group(2))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def build_thread(record: dict) -> list[dict]:
|
| 239 |
+
"""Replies in chronological order (the source lists newest first)."""
|
| 240 |
+
thread = []
|
| 241 |
+
for resp in reversed(record.get("responses") or []):
|
| 242 |
+
author = (resp.get("responded_by") or "").strip()
|
| 243 |
+
content = (resp.get("content") or "").strip()
|
| 244 |
+
if not content:
|
| 245 |
+
continue
|
| 246 |
+
thread.append(
|
| 247 |
+
{
|
| 248 |
+
"role": "staff" if author in STAFF_RESPONDERS else "student",
|
| 249 |
+
"author": author,
|
| 250 |
+
"date": parse_post_date(resp.get("responded_at")),
|
| 251 |
+
"content": content,
|
| 252 |
+
"links": resp.get("links") or [],
|
| 253 |
+
}
|
| 254 |
+
)
|
| 255 |
+
return thread
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def first_staff_reply(thread: list[dict]) -> dict | None:
|
| 259 |
+
return next((r for r in thread if r["role"] == "staff"), None)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def render_thread(
|
| 263 |
+
question: str, asked_by: str | None, asked_at: str | None, thread: list[dict]
|
| 264 |
+
) -> str:
|
| 265 |
+
parts = [
|
| 266 |
+
f"[1] STUDENT {asked_by or 'Unknown'} ({asked_at or 'unknown date'}) — original post:\n{question}"
|
| 267 |
+
]
|
| 268 |
+
for i, reply in enumerate(thread, start=2):
|
| 269 |
+
parts.append(
|
| 270 |
+
f"[{i}] {reply['role'].upper()} {reply['author']} "
|
| 271 |
+
f"({reply['date'] or 'unknown date'}):\n{reply['content']}"
|
| 272 |
+
)
|
| 273 |
+
return "\n\n".join(parts)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def build_record(raw: dict) -> dict:
|
| 277 |
+
"""Deterministic part of the output record (everything but `annotation`)."""
|
| 278 |
+
asked_by, asked_at = parse_byline((raw.get("original_post") or {}).get("byline"))
|
| 279 |
+
title = (raw.get("discussion_title") or "").strip()
|
| 280 |
+
body = ((raw.get("original_post") or {}).get("content") or "").strip()
|
| 281 |
+
question = f"{title}\n\n{body}".strip() if title else body
|
| 282 |
+
thread = build_thread(raw)
|
| 283 |
+
reference = first_staff_reply(thread)
|
| 284 |
+
course_name = raw.get("course_name") or ""
|
| 285 |
+
|
| 286 |
+
return {
|
| 287 |
+
"post_id": raw.get("post_id"),
|
| 288 |
+
"discussion_url": raw.get("discussion_url"),
|
| 289 |
+
"discussion_title": title,
|
| 290 |
+
"course_name": course_name,
|
| 291 |
+
"source_key": COURSE_TO_SOURCE_KEY.get(course_name),
|
| 292 |
+
"lesson_name": raw.get("lesson_name"),
|
| 293 |
+
"lesson_url": raw.get("lesson_url"),
|
| 294 |
+
"asked_by": asked_by,
|
| 295 |
+
"asked_at": asked_at,
|
| 296 |
+
"question": question,
|
| 297 |
+
"question_has_media": bool((raw.get("original_post") or {}).get("media")),
|
| 298 |
+
"reference_answer": reference["content"] if reference else None,
|
| 299 |
+
"reference_answer_by": reference["author"] if reference else None,
|
| 300 |
+
"reference_answer_at": reference["date"] if reference else None,
|
| 301 |
+
"reference_links": reference["links"] if reference else [],
|
| 302 |
+
"thread": thread,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@retry(
|
| 307 |
+
retry=retry_if_exception(is_retryable_genai_error),
|
| 308 |
+
stop=stop_after_attempt(6),
|
| 309 |
+
wait=wait_random_exponential(multiplier=1, max=60),
|
| 310 |
+
reraise=True,
|
| 311 |
+
)
|
| 312 |
+
async def annotate_record(record: dict, model: str) -> PostAnnotation:
|
| 313 |
+
source_key = record["source_key"]
|
| 314 |
+
corpus_note = (
|
| 315 |
+
f"in the tutor's corpus as source '{source_key}'"
|
| 316 |
+
if source_key
|
| 317 |
+
else "NOT in the tutor's corpus"
|
| 318 |
+
)
|
| 319 |
+
media_note = (
|
| 320 |
+
"includes an image/screenshot the tutor cannot see"
|
| 321 |
+
if record["question_has_media"]
|
| 322 |
+
else "has no attached media"
|
| 323 |
+
)
|
| 324 |
+
prompt = ANNOTATION_PROMPT.format(
|
| 325 |
+
course_name=record["course_name"],
|
| 326 |
+
corpus_note=corpus_note,
|
| 327 |
+
lesson_name=record["lesson_name"] or "unknown",
|
| 328 |
+
media_note=media_note,
|
| 329 |
+
thread_text=render_thread(
|
| 330 |
+
record["question"],
|
| 331 |
+
record["asked_by"],
|
| 332 |
+
record["asked_at"],
|
| 333 |
+
record["thread"],
|
| 334 |
+
),
|
| 335 |
+
)
|
| 336 |
+
response = await get_genai_client().aio.models.generate_content(
|
| 337 |
+
model=model,
|
| 338 |
+
contents=prompt,
|
| 339 |
+
config=types.GenerateContentConfig(
|
| 340 |
+
temperature=0.0,
|
| 341 |
+
response_mime_type="application/json",
|
| 342 |
+
response_schema=PostAnnotation,
|
| 343 |
+
),
|
| 344 |
+
)
|
| 345 |
+
if isinstance(response.parsed, PostAnnotation):
|
| 346 |
+
return response.parsed
|
| 347 |
+
if isinstance(response.parsed, dict):
|
| 348 |
+
return PostAnnotation.model_validate(response.parsed)
|
| 349 |
+
if response.text:
|
| 350 |
+
return PostAnnotation.model_validate_json(response.text)
|
| 351 |
+
raise ValueError(f"Gemini returned no annotation for post {record['post_id']}.")
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def load_jsonl(path: Path) -> list[dict]:
|
| 355 |
+
if not path.exists():
|
| 356 |
+
return []
|
| 357 |
+
with path.open() as fh:
|
| 358 |
+
return [json.loads(line) for line in fh if line.strip()]
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def write_jsonl(path: Path, records: list[dict]) -> None:
|
| 362 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 363 |
+
with path.open("w") as fh:
|
| 364 |
+
for record in records:
|
| 365 |
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
async def annotate_all(records: list[dict], model: str) -> list[dict]:
|
| 369 |
+
semaphore = asyncio.Semaphore(ANNOTATION_CONCURRENCY)
|
| 370 |
+
annotated_at = datetime.now(UTC).isoformat(timespec="seconds")
|
| 371 |
+
|
| 372 |
+
async def worker(record: dict) -> dict:
|
| 373 |
+
async with semaphore:
|
| 374 |
+
annotation = await annotate_record(record, model)
|
| 375 |
+
return {
|
| 376 |
+
**record,
|
| 377 |
+
"annotation": annotation.model_dump(),
|
| 378 |
+
"annotation_model": model,
|
| 379 |
+
"annotated_at": annotated_at,
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
return await tqdm.gather(*(worker(r) for r in records), desc="Annotating")
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def main() -> None:
|
| 386 |
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
| 387 |
+
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
|
| 388 |
+
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
| 389 |
+
parser.add_argument("--model", default=ANNOTATION_MODEL)
|
| 390 |
+
parser.add_argument(
|
| 391 |
+
"--limit", type=int, default=None, help="Annotate at most N pending posts."
|
| 392 |
+
)
|
| 393 |
+
parser.add_argument(
|
| 394 |
+
"--force",
|
| 395 |
+
action="store_true",
|
| 396 |
+
help="Re-annotate posts already present in the output file.",
|
| 397 |
+
)
|
| 398 |
+
args = parser.parse_args()
|
| 399 |
+
|
| 400 |
+
raw_records = load_jsonl(args.input)
|
| 401 |
+
if not raw_records:
|
| 402 |
+
raise SystemExit(f"No records found in {args.input}.")
|
| 403 |
+
|
| 404 |
+
existing = {r["post_id"]: r for r in load_jsonl(args.output)}
|
| 405 |
+
records = [build_record(raw) for raw in raw_records]
|
| 406 |
+
pending = [r for r in records if args.force or r["post_id"] not in existing]
|
| 407 |
+
if args.limit is not None:
|
| 408 |
+
pending = pending[: args.limit]
|
| 409 |
+
|
| 410 |
+
print(
|
| 411 |
+
f"{len(records)} threads in input, {len(existing)} already annotated, "
|
| 412 |
+
f"{len(pending)} to annotate with {args.model}."
|
| 413 |
+
)
|
| 414 |
+
if pending:
|
| 415 |
+
for record in asyncio.run(annotate_all(pending, args.model)):
|
| 416 |
+
existing[record["post_id"]] = record
|
| 417 |
+
|
| 418 |
+
merged = sorted(existing.values(), key=lambda r: int(r["post_id"]))
|
| 419 |
+
write_jsonl(args.output, merged)
|
| 420 |
+
|
| 421 |
+
counts: dict[str, int] = {}
|
| 422 |
+
for record in merged:
|
| 423 |
+
quality = record["annotation"]["eval_quality"]
|
| 424 |
+
category = record["annotation"]["category"]
|
| 425 |
+
counts[f"quality={quality}"] = counts.get(f"quality={quality}", 0) + 1
|
| 426 |
+
counts[f"category={category}"] = counts.get(f"category={category}", 0) + 1
|
| 427 |
+
print(f"Wrote {len(merged)} annotated threads to {args.output}.")
|
| 428 |
+
for key in sorted(counts):
|
| 429 |
+
print(f" {key}: {counts[key]}")
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
if __name__ == "__main__":
|
| 433 |
+
main()
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Evaluating the AI tutor
|
| 2 |
+
|
| 3 |
+
We compare memory and context-management techniques on the tutor with measured numbers — answer quality, memory retention, retrieval accuracy, tokens, cost, latency — for the June 2026 workshop, and keep the same harness as the ongoing quality program afterwards. Research sources and design rationale: `evals_background.md`.
|
| 4 |
+
|
| 5 |
+
## What we evaluate, and what varies
|
| 6 |
+
|
| 7 |
+
The system under test is the production tutor (`app/`): a LangGraph agent over the course corpus with hybrid retrieval (`retrieve_tutor_context`), a sandboxed KB shell (`run_kb_command`), and per-thread conversation memory. One variable changes between experiment arms — the **memory preset** (`app/memory_presets.py`):
|
| 8 |
+
|
| 9 |
+
| Preset | Configuration |
|
| 10 |
+
|---|---|
|
| 11 |
+
| `full_history` | no compaction (baseline: quality ceiling, token worst case) |
|
| 12 |
+
| `prod` | production settings: summarization @30k tokens keep 20 msgs + tool-output clearing @5k keep 5 |
|
| 13 |
+
| `summarization_only` / `editing_only` | each compaction technique isolated |
|
| 14 |
+
| `aggressive` | summarize @8k keep 8, clear @2k keep 2 |
|
| 15 |
+
| `profile_memory` | prod + long-term memory: stored student profile injected into the system prompt, updated after each turn (`StudentProfileMiddleware`) |
|
| 16 |
+
|
| 17 |
+
Held constant per experiment: model (`gemini-3.5-flash`), system prompt, retrieval configuration, source selection, web tools off, temperature. Presets are selectable per request (`ChatRequest.memory_preset`, API `memoryPreset`) and are part of the agent cache key.
|
| 18 |
+
|
| 19 |
+
## What we measure
|
| 20 |
+
|
| 21 |
+
| Layer | Metrics | Source |
|
| 22 |
+
|---|---|---|
|
| 23 |
+
| Ops | TTFT, turn latency, input/output tokens, cache-read/creation tokens, est. cost, LLM calls, compaction-trigger firings | `context_stats` event each turn (`app/telemetry.py`) — self-contained, no LangSmith dependency |
|
| 24 |
+
| Trajectory | tool calls per turn by tool, redundant calls, KB-budget use | tool-call events (code) |
|
| 25 |
+
| Retrieval | recall@shown / MRR of the case's true `source_key` + `lesson_url` in retrieval results | ground truth carried by every discussion-derived case (code) |
|
| 26 |
+
| Behavior | called retrieval on corpus questions, citations present and resolving, redirect/feedback routing | code assertions + heuristics; authoritative number from hand grades |
|
| 27 |
+
| Quality | key-point coverage, behavior correctness, probe pass/fail | **hand-graded binary** (blinded workbook); an LLM judge only after validation against those labels (TPR/TNR, target >90%) |
|
| 28 |
+
| Memory | probe accuracy by type (fact recall, preference, anaphora, fact-update, behavior-under-pressure), personalization pass rate | session probes + persona checks |
|
| 29 |
+
|
| 30 |
+
Cost is reported both as tokens and as cached dollars — they rank presets differently (finding F2).
|
| 31 |
+
|
| 32 |
+
## The dataset
|
| 33 |
+
|
| 34 |
+
Source: `data/academy_discussion_eval.jsonl` — 151 real student posts from the academy discussion boards with real staff answers, annotated by gemini-3.5-flash. All eval inputs are real or hand-authored; **we never generate reference answers** — ground truth is staff replies (distilled to `key_points`) or facts we wrote ourselves.
|
| 35 |
+
|
| 36 |
+
**Review.** All 62 gold + 30 not-time-bound usable annotations (92) were re-reviewed against the live KB with file/line evidence. 60 kept, 32 excluded. Main corrections: the annotator badly under-flagged staleness (course content was updated after many posts — ~14 exclusions had premises no longer true in today's corpus), 5 near-duplicates, 1 fabricated URL, 1 misaligned question/answer pair, and key points rewritten to claims that are atomic, still true, and binary-checkable. Colab-notebook dependency was checked per case: **1 of 92** truly required the notebook (excluded); lessons embed the cells everywhere else. Full audit trail: `data/eval/review_log_v1.md` (11 judgment-call cases flagged for human review).
|
| 37 |
+
|
| 38 |
+
**Batteries** (`data/eval/`, schemas and glossary in its `README.md`; files are gitignored — real student text):
|
| 39 |
+
|
| 40 |
+
| Battery | Contents | Tests |
|
| 41 |
+
|---|---|---|
|
| 42 |
+
| `battery_singleturn_v1` | 60 reviewed real questions (37 corpus / 10 redirect / 7 general / 6 feedback; 49 gold) | one-shot answer quality + behavior routing; presets should roughly tie here |
|
| 43 |
+
| `battery_sessions_v1` | 32 authored study sessions, 337 turns, 113 probes (5 hand-written, 27 generated and spot-checked; middle turns are verbatim real questions) | where memory methods separate: facts planted early, context inflated past compression triggers, probes late. Probe types: fact_recall (55), preference_compliance (27), anaphora (12), anaphora_consistency (9), **fact_update** (7 — the student changes a fact mid-session; answering with the stale value fails), behavior_routing (3) |
|
| 44 |
+
| `battery_personas_v1` | 10 authored student profiles × 4 questions each, unanswerable correctly without the profile; self-grading regex/anti-pattern checks | long-term profile memory |
|
| 45 |
+
| `replay_n1_v1` | 30 prefixes of real multi-turn threads, graded against the real next staff reply | multi-turn behavior on fully real data (secondary) |
|
| 46 |
+
|
| 47 |
+
## How it runs
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
uv run -m evals.run_battery --battery data/eval/battery_singleturn_v1.jsonl --preset prod --trials 2 --out runs/exp
|
| 51 |
+
uv run -m evals.grade --run runs/exp # code checks + handgrade_sheet.csv
|
| 52 |
+
uv run -m evals.check_triggers --runs runs/exp # gate: compaction fired where probes assume
|
| 53 |
+
uv run -m evals.report --runs runs/expA runs/expB # side-by-side tables + token curves
|
| 54 |
+
uv run -m evals.handgrade_workbook build|merge ... # blinded human-grading workbook
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Every turn persists a JSON trace bundle, so grading and reporting re-run offline forever without touching the API. Runs are resume-safe (re-run the same command after any interruption), have a 10-minute per-turn timeout, and keep LangSmith off by default. Needs `GEMINI_API_KEY` + `COHERE_API_KEY`; the full 4-preset bake-off below cost $73.
|
| 58 |
+
|
| 59 |
+
**Setup for collaborators.** Code and docs are in git; the datasets and run results contain real student text and live only in the private HF dataset (`towardsai-tutors/ai-tutor-data` — git is force-pushed to the public prod Space on deploys, so student data never enters git). With an `HF_TOKEN` that can read it:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
uv run python -c "from huggingface_hub import snapshot_download as d; d(repo_id='towardsai-tutors/ai-tutor-data', repo_type='dataset', allow_patterns=['eval/**','eval_runs/**'], local_dir='.')"
|
| 63 |
+
mv eval/* data/eval/ && mv eval_runs/part_b/* runs/ # restore working paths
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## Results — Part B bake-off, 2026-06-12
|
| 67 |
+
|
| 68 |
+
4 presets × 2 trials over the full single-turn battery, full persona battery, and 3 sessions (s01 13-turn, s02 agentic, s08 fact-update). 1,232 turns, zero API errors. Tables: `runs/b_report/report.md`; token curves: `tokens_by_turn.csv`. **Auto-graded only — quality and probe-accuracy columns land after the hand-grading pass.**
|
| 69 |
+
|
| 70 |
+
| | full_history | prod | profile_memory | aggressive |
|
| 71 |
+
|---|---|---|---|---|
|
| 72 |
+
| personalization pass (personas, n=70) | 63% | 67% | **94%** | 56% |
|
| 73 |
+
| sessions: est. cost/turn | **$0.034** | $0.051 | $0.047 | $0.066 |
|
| 74 |
+
| sessions: TTFT p50 | **17s** | 21s | 22s | 43s |
|
| 75 |
+
| sessions: tool calls/turn | **2.8** | 3.5 | 3.9 | 8.3 |
|
| 76 |
+
| sessions: cumulative input tokens | 2.9M | 1.7M | 1.6M | 1.75M |
|
| 77 |
+
| single-turn: behavior heuristic (n=106) | **88%** | 86% | 86% | 78% |
|
| 78 |
+
| single-turn: retrieval recall@shown source | 51% | 50% | 49% | 47% |
|
| 79 |
+
|
| 80 |
+
Row definitions:
|
| 81 |
+
- **personalization pass** — % of persona question-runs whose answer passed every authored check (expected regex matched, e.g. `conda` for the conda persona) with no anti-pattern hit (e.g. bash `export` for a Windows persona). n=70: 40 questions × 2 trials, minus 10 runs whose checks need human judgment.
|
| 82 |
+
- **est. cost/turn** — mean estimated $ per conversation turn: token counts × `MODEL_PRICING`, cached input billed at the cache-read discount (our table, not the invoice).
|
| 83 |
+
- **TTFT p50** — median seconds from user message to first answer text, including all tool calls and internal LLM rounds before the answer starts (perceived wait).
|
| 84 |
+
- **tool calls/turn** — mean retrieval + KB-command invocations per turn; here it's the re-work signal (compaction presets re-search for evidence their compressed history lost).
|
| 85 |
+
- **cumulative input tokens** — all input tokens billed across an entire session (every turn, every internal call), mean over the 6 session-runs per preset.
|
| 86 |
+
- **behavior heuristic** — % of single-turn case-runs where a programmatic proxy confirms the right *kind* of response (corpus → called retrieval/KB; support issue → answer points to support; feedback → acknowledges). n=106 of 120: `answer_general` has no heuristic. Proxy only — authoritative behavior accuracy comes from the hand grades.
|
| 87 |
+
- **retrieval recall@shown source** — % of case-runs where the reranked retrieval results the agent saw contained at least one chunk from the correct course. Stricter `recall@lesson` (right lesson page, 30–36%) and MRR are in the full report.
|
| 88 |
+
|
| 89 |
+
### Observations
|
| 90 |
+
|
| 91 |
+
Numbered findings; each states what it was tested on. Convention: entries are never edited, only superseded.
|
| 92 |
+
|
| 93 |
+
- **F1 — Retrieval payloads dominate input tokens, not conversation history.** Each retrieval call may return up to `DEFAULT_CONTEXT_TOKEN_BUDGET = 100_000` tokens; turns average ~200k input. (All runs; high confidence.) → retrieval budget became a Part C variant dimension.
|
| 94 |
+
- **F2 — Compaction saves tokens but not necessarily dollars.** Summarization rewrites the prompt prefix and invalidates Gemini's implicit cache: on the same session, `full_history` had 86.8% of input billed at the ~4x cache discount; `aggressive` used 44% fewer tokens yet cost 68% more. (s03 × 3 presets, then confirmed n=24 session-runs; provider-specific — Anthropic caching is explicit.)
|
| 95 |
+
- **F3 — Context editing never fires on this workload.** `ClearToolUsesEdit` excludes retrieval results — where the tokens are; 0 clears across all session runs. → `editing_only` dropped from Part B; "clear retrieval results too" variant queued for Part C.
|
| 96 |
+
- **F4 — Aggressive compaction degrades even single-turn behavior.** 18.0 vs 9.6 LLM calls/turn, 57s vs 39s median, behavior heuristic −10pts vs full_history (60 cases × 2 trials). Mid-turn compaction churn changes agent behavior, it doesn't just trim.
|
| 97 |
+
- **F5 — Gemini reasoning tokens are ~90%+ of output even with reasoning display off.** Billed as output; dominates latency; recorded per model in `usage_by_model`.
|
| 98 |
+
- **F6 — Long context costs latency, not correctness.** Zero API errors in 1,018+ turns at any size (max 6.06M tokens across one turn's calls; largest single context 274k). Median TTFT scales 22s → 76s from <100k to >800k input tokens/turn. Compaction's value here is responsiveness and spend, not keeping the model functional.
|
| 99 |
+
- **F7 — ~0.8% of turns produce no answer text** despite tool calls and billed reasoning tokens; preset- and size-independent. Candidate golden-case assertion ("answer non-empty").
|
| 100 |
+
- **F8 — Profile memory wins on quality AND cost.** 94% personalization vs 56–67% without, while the cheapest persona preset ($0.049 vs $0.081–0.095/turn, 7.6 vs 10–11.2 tool calls): the stored profile saves the agent from re-searching for user context. (40 questions × 2 trials × 4 presets, auto checks; LLM-check rows pending hand grades.)
|
| 101 |
+
- **F9 — Full history is cheapest AND fastest up to 13 turns; compaction causes re-work.** Sessions: $0.034/turn, TTFT 17s, 2.8 tool calls for `full_history` vs $0.051–0.066, 21–43s, 3.5–8.3 elsewhere — despite ~2x the tokens (F2's cache mechanism plus a second one: raw history lets the agent re-use earlier retrieval evidence; summaries force re-retrieval). (3 sessions × 4 presets × 2 trials, n=24, zero errors.) The conventional pitch inverts: under modern prompt caching, the naive baseline wins short-to-medium sessions; compaction must justify itself on quality (pending hand grades) and long horizons. Where the crossover actually sits is a Part C question.
|
| 102 |
+
|
| 103 |
+
Harness corrections (bugs in our measurement, not findings): the overnight 06-12 stall was machine sleep hanging API streams (all four pipelines stopped the same minute; fixed with the per-turn timeout); battery lesson-URLs carried a `/discussions/` suffix that silently zeroed recall@lesson until normalized (caught by the A3 smoke, re-graded from bundles without re-running).
|
| 104 |
+
|
| 105 |
+
Talk-outline correction: the planned "skills / just-in-time KB instructions" change (Change 4) was never built, and its premise is off — the KB instructions block measures **458 tokens** (not ~4k) inside a 1,655-token system prompt; max saving ~2% of a turn, mostly cache-discounted. Recommended reframe: demo progressive disclosure on tool *outputs* (F1), where our numbers actually are.
|
| 106 |
+
|
| 107 |
+
## Remaining work
|
| 108 |
+
|
| 109 |
+
**Omar**
|
| 110 |
+
- [ ] Grade `runs/b_report/workbook.csv` — 284 blinded rows, priority-ordered (96 session probes ≈ 25 min is the critical tier). Don't open `workbook_keymap.csv` until done. Then: `uv run -m evals.handgrade_workbook merge --workbook <filled.csv>` and re-run grade/report for the final table.
|
| 111 |
+
- [ ] Audit `data/eval/review_log_v1.md` (the 11 flagged judgment calls first).
|
| 112 |
+
- [ ] Verify the `gemini-3.5-flash` entry in `app/telemetry.py:MODEL_PRICING` against the current price sheet.
|
| 113 |
+
|
| 114 |
+
**Workshop**
|
| 115 |
+
- [ ] Replace placeholder slide numbers with measured ones; pick 2–3 failure traces from the graded probes for the failure→fix→number beats (a `fact_update` failure under summarization is the target demo).
|
| 116 |
+
- [ ] Token-vs-turn plot: CSV is ready; add matplotlib (or plot elsewhere) for the PNG.
|
| 117 |
+
- [ ] Next.js meter component over the `data-context-stats` SSE part, if the live demo needs it.
|
| 118 |
+
- [ ] Skills section: reframe per the 458-token measurement above.
|
| 119 |
+
|
| 120 |
+
**Part C (post-workshop, ~$300–400, after the judge is validated)**
|
| 121 |
+
- [ ] Variants: `summarization_only`, `aggressive`, clear-retrieval-results editing, retrieval budget 100k/30k/10k, 1–2 SOTA methods (retrieval-over-history, structured note-taking, LangMem-style consolidation), optionally lazy KB instructions.
|
| 122 |
+
- [ ] Full batteries (60 single-turn + 32 sessions + 30 replay) × 3 trials; paired stats (McNemar/bootstrap), pass^3 consistency; failure-taxonomy diffs per variant.
|
| 123 |
+
|
| 124 |
+
**Product quality track (post-workshop)**
|
| 125 |
+
Golden cases as a CI gate (5–10 critical-path cases, deterministic assertions, incl. the F7 non-empty check) → error-analysis cycles (expert reads 50–100 traces in a small viewer, binary + first-failure note, stop at saturation) → failure taxonomy → code assertions for recurring modes → LLM judges built from the hand-grade critiques and validated to >90% TPR/TNR on held-out labels before any judge-graded number is reported → nightly battery + weekly trace sampling. Full methodology behind each step: `evals_background.md`.
|
| 126 |
+
|
| 127 |
+
## Files
|
| 128 |
+
|
| 129 |
+
- `evals.md` — this file: the what, the data, the results, the queue.
|
| 130 |
+
- `evals_background.md` — research sources (Hamel, howtoeval, OpenAI macro-evals) and design rationale.
|
| 131 |
+
- `data/eval/README.md` — battery schemas + glossary of terms; `review_log_v1.md` — dataset audit trail.
|
| 132 |
+
- `evals/` — the harness code; `app/memory_presets.py`, `app/telemetry.py` — the app-side hooks.
|
| 133 |
+
- `runs/b_report/` — generated: final tables, token curves, the blinded grading workbook.
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eval harness: run batteries against the tutor, grade bundles, report.
|
| 2 |
+
|
| 3 |
+
Pipeline (see evals.md):
|
| 4 |
+
uv run -m evals.run_battery -> runs/<exp>/bundles.jsonl (talks to the app)
|
| 5 |
+
uv run -m evals.grade -> grades_auto.jsonl + handgrade_sheet.csv (pure)
|
| 6 |
+
uv run -m evals.report -> report.md + tokens_by_turn.csv (pure)
|
| 7 |
+
|
| 8 |
+
Only run_battery imports app code; grade/report operate on JSON alone so they
|
| 9 |
+
can re-grade old bundles forever without a running system.
|
| 10 |
+
"""
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify compression triggers fired where the session battery assumes.
|
| 2 |
+
|
| 3 |
+
uv run -m evals.check_triggers --runs runs/a4_sessions_prod
|
| 4 |
+
uv run -m evals.check_triggers --runs runs/a4_s03_fullhist --expect-none
|
| 5 |
+
|
| 6 |
+
A memory eval where compaction never activated measures nothing (see
|
| 7 |
+
data/eval/README.md), so this is the gate before any bake-off:
|
| 8 |
+
- default: every probe turn must have compaction active (summary_messages or
|
| 9 |
+
cleared_tool_outputs > 0 in context_stats); exit 1 otherwise.
|
| 10 |
+
- --expect-none (for full_history): NO turn may show compaction; exit 1 if
|
| 11 |
+
the baseline ever compressed — that would invalidate the comparison.
|
| 12 |
+
|
| 13 |
+
Also prints per-session cost/token totals for Part B/C budget projection.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import sys
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from .common import load_jsonl
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def session_table(bundles: list[dict[str, Any]]) -> dict[tuple[str, int], list[dict]]:
|
| 28 |
+
sessions: dict[tuple[str, int], list[dict]] = defaultdict(list)
|
| 29 |
+
for bundle in bundles:
|
| 30 |
+
if bundle.get("turn_index") is None:
|
| 31 |
+
continue
|
| 32 |
+
sessions[(bundle["unit_id"], bundle["trial"])].append(bundle)
|
| 33 |
+
for rows in sessions.values():
|
| 34 |
+
rows.sort(key=lambda b: b["turn_index"])
|
| 35 |
+
return sessions
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def probe_indices(battery_path: str, session_id: str) -> list[int]:
|
| 39 |
+
for record in load_jsonl(battery_path):
|
| 40 |
+
if record.get("session_id") == session_id:
|
| 41 |
+
return [probe["turn_index"] for probe in record.get("probes", [])]
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def compaction_active(bundle: dict[str, Any]) -> bool:
|
| 46 |
+
stats = bundle.get("context_stats") or {}
|
| 47 |
+
return bool(
|
| 48 |
+
(stats.get("summary_messages") or 0) or (stats.get("cleared_tool_outputs") or 0)
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def check_run(run_dir: Path, expect_none: bool) -> bool:
|
| 53 |
+
bundles = load_jsonl(run_dir / "bundles.jsonl")
|
| 54 |
+
ok = True
|
| 55 |
+
for (session_id, trial), rows in sorted(session_table(bundles).items()):
|
| 56 |
+
preset = rows[0]["preset"]
|
| 57 |
+
probes = set(probe_indices(rows[0]["battery_path"], session_id))
|
| 58 |
+
first_summary = next(
|
| 59 |
+
(
|
| 60 |
+
b["turn_index"]
|
| 61 |
+
for b in rows
|
| 62 |
+
if (b.get("context_stats") or {}).get("summary_messages")
|
| 63 |
+
),
|
| 64 |
+
None,
|
| 65 |
+
)
|
| 66 |
+
first_clear = next(
|
| 67 |
+
(
|
| 68 |
+
b["turn_index"]
|
| 69 |
+
for b in rows
|
| 70 |
+
if (b.get("context_stats") or {}).get("cleared_tool_outputs")
|
| 71 |
+
),
|
| 72 |
+
None,
|
| 73 |
+
)
|
| 74 |
+
tokens = sum(
|
| 75 |
+
(b.get("context_stats") or {}).get("input_tokens") or 0 for b in rows
|
| 76 |
+
)
|
| 77 |
+
cost = sum(
|
| 78 |
+
(b.get("context_stats") or {}).get("est_cost_usd") or 0 for b in rows
|
| 79 |
+
)
|
| 80 |
+
errors = [b["turn_index"] for b in rows if b.get("error")]
|
| 81 |
+
bad_probes = []
|
| 82 |
+
compressed_turns = [b["turn_index"] for b in rows if compaction_active(b)]
|
| 83 |
+
if expect_none:
|
| 84 |
+
if compressed_turns:
|
| 85 |
+
ok = False
|
| 86 |
+
verdict = f"FAIL compaction at turns {compressed_turns} (expected none)"
|
| 87 |
+
else:
|
| 88 |
+
verdict = "OK no compaction"
|
| 89 |
+
else:
|
| 90 |
+
bad_probes = [
|
| 91 |
+
b["turn_index"]
|
| 92 |
+
for b in rows
|
| 93 |
+
if b["turn_index"] in probes and not compaction_active(b)
|
| 94 |
+
]
|
| 95 |
+
if bad_probes:
|
| 96 |
+
ok = False
|
| 97 |
+
verdict = f"FAIL probes without compaction: {bad_probes}"
|
| 98 |
+
elif not probes:
|
| 99 |
+
verdict = "OK (no probes found?)"
|
| 100 |
+
else:
|
| 101 |
+
verdict = "OK all probes under compaction"
|
| 102 |
+
if errors:
|
| 103 |
+
ok = False
|
| 104 |
+
verdict += f" | ERRORS at turns {errors}"
|
| 105 |
+
print(
|
| 106 |
+
f"{session_id} t{trial} [{preset}] turns={len(rows)} "
|
| 107 |
+
f"first_summary@{first_summary} first_clear@{first_clear} "
|
| 108 |
+
f"input_tok={tokens:,} est_cost=${cost:.2f} -> {verdict}"
|
| 109 |
+
)
|
| 110 |
+
return ok
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def main() -> None:
|
| 114 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 115 |
+
parser.add_argument("--runs", nargs="+", required=True)
|
| 116 |
+
parser.add_argument(
|
| 117 |
+
"--expect-none",
|
| 118 |
+
action="store_true",
|
| 119 |
+
help="Assert NO compaction anywhere (full_history baseline).",
|
| 120 |
+
)
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
ok = all(check_run(Path(r), args.expect_none) for r in args.runs)
|
| 123 |
+
sys.exit(0 if ok else 1)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
main()
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared helpers for the eval runner/grader/report (no app imports)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Iterable
|
| 8 |
+
|
| 9 |
+
BATTERY_TYPES = ("singleturn", "sessions", "personas", "replay")
|
| 10 |
+
# Identifying key per battery type (see data/eval/README.md schemas).
|
| 11 |
+
_TYPE_KEYS = {
|
| 12 |
+
"case_id": "singleturn",
|
| 13 |
+
"session_id": "sessions",
|
| 14 |
+
"persona_id": "personas",
|
| 15 |
+
"replay_id": "replay",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
| 20 |
+
with open(path, encoding="utf-8") as f:
|
| 21 |
+
return [json.loads(line) for line in f if line.strip()]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def append_jsonl(path: str | Path, rows: Iterable[dict[str, Any]]) -> None:
|
| 25 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 26 |
+
for row in rows:
|
| 27 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def write_jsonl(path: str | Path, rows: Iterable[dict[str, Any]]) -> None:
|
| 31 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 32 |
+
for row in rows:
|
| 33 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def detect_battery_type(records: list[dict[str, Any]]) -> str:
|
| 37 |
+
if not records:
|
| 38 |
+
raise ValueError("Battery file is empty.")
|
| 39 |
+
for key, battery_type in _TYPE_KEYS.items():
|
| 40 |
+
if key in records[0]:
|
| 41 |
+
return battery_type
|
| 42 |
+
raise ValueError(
|
| 43 |
+
f"Unrecognized battery schema; expected one of {sorted(_TYPE_KEYS)} "
|
| 44 |
+
f"in the first record, got {sorted(records[0])}"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def normalize_url(url: str | None) -> str:
|
| 49 |
+
"""Comparable form for lesson-URL matching (recall ground truth).
|
| 50 |
+
|
| 51 |
+
Battery `lesson_url`s point at the discussion on the lesson page
|
| 52 |
+
(`.../<lesson>/discussions/<post_id>`); retrieval matches carry the bare
|
| 53 |
+
lesson URL, so the discussion suffix is stripped before comparing.
|
| 54 |
+
"""
|
| 55 |
+
if not url:
|
| 56 |
+
return ""
|
| 57 |
+
url = url.strip().lower().split("#", 1)[0].split("?", 1)[0]
|
| 58 |
+
url = url.split("/discussions/", 1)[0]
|
| 59 |
+
return url.rstrip("/")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def percentile(values: list[float], pct: float) -> float | None:
|
| 63 |
+
"""Nearest-rank percentile; None on empty input."""
|
| 64 |
+
if not values:
|
| 65 |
+
return None
|
| 66 |
+
ordered = sorted(values)
|
| 67 |
+
rank = max(0, min(len(ordered) - 1, round(pct / 100 * (len(ordered) - 1))))
|
| 68 |
+
return ordered[rank]
|
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Grade trace bundles: code checks now, a hand-grade sheet for the rest.
|
| 2 |
+
|
| 3 |
+
uv run -m evals.grade --run runs/<exp> # auto grades + sheet
|
| 4 |
+
uv run -m evals.grade --run runs/<exp> --handgrades runs/<exp>/handgrade_filled.csv
|
| 5 |
+
|
| 6 |
+
Pure JSON/CSV in and out — no app imports — so old bundles re-grade forever.
|
| 7 |
+
|
| 8 |
+
Outputs in the run dir:
|
| 9 |
+
- grades_auto.jsonl — one row per bundle row with every code-computable check
|
| 10 |
+
(retrieval recall, behavior heuristics, citation presence, persona regex
|
| 11 |
+
checks, probe trigger context). See data/eval/README.md for term definitions.
|
| 12 |
+
- handgrade_sheet.csv — one row per pending human judgment (key points, session
|
| 13 |
+
probes, replay replies, persona llm-checks). Fill `grade` with pass/fail
|
| 14 |
+
(optionally a note), save as handgrade_filled.csv, re-run with --handgrades.
|
| 15 |
+
- grades_merged.jsonl — auto + human grades joined, ready for evals.report.
|
| 16 |
+
|
| 17 |
+
Behavior checks here are HEURISTIC proxies (regex/trajectory); the reportable
|
| 18 |
+
behavior-accuracy number comes from the hand grades.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import csv
|
| 25 |
+
import re
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Any
|
| 28 |
+
|
| 29 |
+
from .common import load_jsonl, normalize_url, write_jsonl
|
| 30 |
+
|
| 31 |
+
BEHAVIOR_HEURISTICS = {
|
| 32 |
+
"redirect_to_support": r"support|academy team|reach out|contact",
|
| 33 |
+
"acknowledge_feedback": r"thank|feedback|appreciate|noted|passed (this|it) (on|along)",
|
| 34 |
+
}
|
| 35 |
+
RETRIEVAL_TOOLS = {"retrieve_tutor_context", "run_kb_command"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def index_battery(battery: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
| 39 |
+
index: dict[str, dict[str, Any]] = {}
|
| 40 |
+
for record in battery:
|
| 41 |
+
for key in ("case_id", "session_id", "replay_id"):
|
| 42 |
+
if key in record:
|
| 43 |
+
index[record[key]] = record
|
| 44 |
+
if "persona_id" in record:
|
| 45 |
+
for question in record["questions"]:
|
| 46 |
+
index[question["question_id"]] = {**question, "_persona": record}
|
| 47 |
+
return index
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def retrieval_metrics(
|
| 51 |
+
bundle: dict[str, Any], source_key: str | None, lesson_url: str | None
|
| 52 |
+
) -> dict[str, Any]:
|
| 53 |
+
"""Recall/MRR over the (post-rerank) matches of retrieval tool calls.
|
| 54 |
+
|
| 55 |
+
Matches are the reranked top-k the agent actually saw, so 'recall' here is
|
| 56 |
+
recall@top-k-shown. MRR ranks across calls in order.
|
| 57 |
+
"""
|
| 58 |
+
matches: list[dict[str, Any]] = []
|
| 59 |
+
called_retrieval = False
|
| 60 |
+
for call in bundle.get("tool_calls") or []:
|
| 61 |
+
if call.get("tool_name") in RETRIEVAL_TOOLS:
|
| 62 |
+
called_retrieval = True
|
| 63 |
+
if call.get("tool_name") == "retrieve_tutor_context":
|
| 64 |
+
matches.extend(call.get("matches") or [])
|
| 65 |
+
lesson = normalize_url(lesson_url)
|
| 66 |
+
source_hit = any(m.get("source_key") == source_key for m in matches)
|
| 67 |
+
lesson_rank = next(
|
| 68 |
+
(
|
| 69 |
+
rank
|
| 70 |
+
for rank, m in enumerate(matches, start=1)
|
| 71 |
+
if lesson and normalize_url(m.get("url")) == lesson
|
| 72 |
+
),
|
| 73 |
+
0,
|
| 74 |
+
)
|
| 75 |
+
return {
|
| 76 |
+
"called_retrieval": called_retrieval,
|
| 77 |
+
"retrieved_matches": len(matches),
|
| 78 |
+
"recall_source": source_hit if matches or called_retrieval else None,
|
| 79 |
+
"recall_lesson": bool(lesson_rank) if lesson else None,
|
| 80 |
+
"mrr_lesson": (1.0 / lesson_rank) if lesson_rank else 0.0 if lesson else None,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def behavior_heuristic(expected: str, bundle: dict[str, Any]) -> bool | None:
|
| 85 |
+
"""Cheap proxy for behavior routing; None = no heuristic for this class."""
|
| 86 |
+
answer = bundle.get("answer") or ""
|
| 87 |
+
if expected == "answer_from_corpus":
|
| 88 |
+
return any(
|
| 89 |
+
call.get("tool_name") in RETRIEVAL_TOOLS
|
| 90 |
+
for call in bundle.get("tool_calls") or []
|
| 91 |
+
)
|
| 92 |
+
pattern = BEHAVIOR_HEURISTICS.get(expected)
|
| 93 |
+
if pattern is None:
|
| 94 |
+
return None
|
| 95 |
+
return re.search(pattern, answer, re.IGNORECASE) is not None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def grade_persona_question(question: dict[str, Any], answer: str) -> dict[str, Any]:
|
| 99 |
+
"""Apply the battery's self-grading checks (see data/eval/README.md)."""
|
| 100 |
+
results, needs_judgment = [], False
|
| 101 |
+
for check in question.get("checks") or []:
|
| 102 |
+
if check["type"] == "regex_any":
|
| 103 |
+
passed = any(
|
| 104 |
+
re.search(pattern, answer, re.IGNORECASE)
|
| 105 |
+
for pattern in check["patterns"]
|
| 106 |
+
)
|
| 107 |
+
results.append({"type": "regex_any", "passed": passed})
|
| 108 |
+
elif check["type"] == "llm":
|
| 109 |
+
needs_judgment = True
|
| 110 |
+
results.append({"type": "llm", "passed": None})
|
| 111 |
+
anti_hits = [
|
| 112 |
+
pattern
|
| 113 |
+
for pattern in question.get("anti_patterns") or []
|
| 114 |
+
if re.search(pattern, answer, re.IGNORECASE)
|
| 115 |
+
]
|
| 116 |
+
decided = [r["passed"] for r in results if r["passed"] is not None]
|
| 117 |
+
auto_pass: bool | None
|
| 118 |
+
if anti_hits:
|
| 119 |
+
auto_pass = False
|
| 120 |
+
elif needs_judgment:
|
| 121 |
+
auto_pass = None # resolved by the hand/judge grade
|
| 122 |
+
else:
|
| 123 |
+
auto_pass = all(decided) if decided else None
|
| 124 |
+
return {
|
| 125 |
+
"checks": results,
|
| 126 |
+
"anti_pattern_hits": anti_hits,
|
| 127 |
+
"needs_judgment": needs_judgment,
|
| 128 |
+
"auto_pass": auto_pass,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def sheet_row(
|
| 133 |
+
*,
|
| 134 |
+
bundle: dict[str, Any],
|
| 135 |
+
item_type: str,
|
| 136 |
+
criterion: str,
|
| 137 |
+
reference: str = "",
|
| 138 |
+
) -> dict[str, str]:
|
| 139 |
+
return {
|
| 140 |
+
"sheet_row_id": f"{bundle['run_id']}|{item_type}|{abs(hash(criterion)) % 10**8}",
|
| 141 |
+
"run_id": bundle["run_id"],
|
| 142 |
+
"battery_type": bundle["battery_type"],
|
| 143 |
+
"preset": bundle["preset"],
|
| 144 |
+
"item_type": item_type,
|
| 145 |
+
"question": bundle["query"][:600],
|
| 146 |
+
"answer": (bundle.get("answer") or "")[:4000],
|
| 147 |
+
"criterion": criterion,
|
| 148 |
+
"reference": reference[:2000],
|
| 149 |
+
"grade": "",
|
| 150 |
+
"note": "",
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
| 155 |
+
bundles = load_jsonl(run_dir / "bundles.jsonl")
|
| 156 |
+
if not bundles:
|
| 157 |
+
raise SystemExit(f"No bundles in {run_dir}")
|
| 158 |
+
battery = load_jsonl(bundles[0]["battery_path"])
|
| 159 |
+
by_id = index_battery(battery)
|
| 160 |
+
|
| 161 |
+
grades: list[dict[str, Any]] = []
|
| 162 |
+
sheet: list[dict[str, str]] = []
|
| 163 |
+
for bundle in bundles:
|
| 164 |
+
record = by_id.get(bundle["unit_id"], {})
|
| 165 |
+
stats = bundle.get("context_stats") or {}
|
| 166 |
+
row: dict[str, Any] = {
|
| 167 |
+
"run_id": bundle["run_id"],
|
| 168 |
+
"unit_id": bundle["unit_id"],
|
| 169 |
+
"battery_type": bundle["battery_type"],
|
| 170 |
+
"preset": bundle["preset"],
|
| 171 |
+
"model": bundle["model"],
|
| 172 |
+
"trial": bundle["trial"],
|
| 173 |
+
"turn_index": bundle.get("turn_index"),
|
| 174 |
+
"error": bundle.get("error"),
|
| 175 |
+
"answer_chars": len(bundle.get("answer") or ""),
|
| 176 |
+
"tool_call_count": len(bundle.get("tool_calls") or []),
|
| 177 |
+
"has_citation": bool(bundle.get("resolved_sources")),
|
| 178 |
+
"ttft_ms": stats.get("ttft_ms"),
|
| 179 |
+
"total_ms": stats.get("total_ms"),
|
| 180 |
+
"input_tokens": stats.get("input_tokens"),
|
| 181 |
+
"output_tokens": stats.get("output_tokens"),
|
| 182 |
+
"est_cost_usd": stats.get("est_cost_usd"),
|
| 183 |
+
"llm_calls": stats.get("llm_calls"),
|
| 184 |
+
"context_tokens_approx": stats.get("context_tokens_approx"),
|
| 185 |
+
"summary_messages": stats.get("summary_messages"),
|
| 186 |
+
"cleared_tool_outputs": stats.get("cleared_tool_outputs"),
|
| 187 |
+
}
|
| 188 |
+
if bundle.get("error"):
|
| 189 |
+
grades.append(row)
|
| 190 |
+
continue
|
| 191 |
+
|
| 192 |
+
if bundle["battery_type"] == "singleturn":
|
| 193 |
+
row.update(
|
| 194 |
+
retrieval_metrics(
|
| 195 |
+
bundle, record.get("source_key"), record.get("lesson_url")
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
expected = record.get("expected_behavior", "")
|
| 199 |
+
row["expected_behavior"] = expected
|
| 200 |
+
row["behavior_heuristic"] = behavior_heuristic(expected, bundle)
|
| 201 |
+
for point in record.get("key_points") or []:
|
| 202 |
+
sheet.append(
|
| 203 |
+
sheet_row(
|
| 204 |
+
bundle=bundle,
|
| 205 |
+
item_type="key_point",
|
| 206 |
+
criterion=point,
|
| 207 |
+
reference=record.get("reference_answer") or "",
|
| 208 |
+
)
|
| 209 |
+
)
|
| 210 |
+
if expected:
|
| 211 |
+
sheet.append(
|
| 212 |
+
sheet_row(
|
| 213 |
+
bundle=bundle,
|
| 214 |
+
item_type="behavior",
|
| 215 |
+
criterion=f"Did the tutor do the right thing for "
|
| 216 |
+
f"'{expected}'? (see README definitions)",
|
| 217 |
+
reference=record.get("reference_answer") or "",
|
| 218 |
+
)
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
elif bundle["battery_type"] == "sessions":
|
| 222 |
+
probe = next(
|
| 223 |
+
(
|
| 224 |
+
p
|
| 225 |
+
for p in record.get("probes", [])
|
| 226 |
+
if p["turn_index"] == bundle.get("turn_index")
|
| 227 |
+
),
|
| 228 |
+
None,
|
| 229 |
+
)
|
| 230 |
+
row["is_probe"] = probe is not None
|
| 231 |
+
if probe:
|
| 232 |
+
row["probe_type"] = probe["probe_type"]
|
| 233 |
+
# Compression context at probe time: a memory eval where the
|
| 234 |
+
# triggers never fired measures nothing (README warning).
|
| 235 |
+
row["compaction_active"] = bool(
|
| 236 |
+
(stats.get("summary_messages") or 0)
|
| 237 |
+
or (stats.get("cleared_tool_outputs") or 0)
|
| 238 |
+
)
|
| 239 |
+
sheet.append(
|
| 240 |
+
sheet_row(
|
| 241 |
+
bundle=bundle,
|
| 242 |
+
item_type=f"probe:{probe['probe_type']}",
|
| 243 |
+
criterion=f"Expected facts: {probe['expected_facts']} | "
|
| 244 |
+
f"Rule: {probe['check_note']}",
|
| 245 |
+
)
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
elif bundle["battery_type"] == "personas":
|
| 249 |
+
result = grade_persona_question(record, bundle.get("answer") or "")
|
| 250 |
+
row.update(
|
| 251 |
+
{
|
| 252 |
+
"persona_id": (record.get("_persona") or {}).get("persona_id"),
|
| 253 |
+
"auto_pass": result["auto_pass"],
|
| 254 |
+
"anti_pattern_hits": result["anti_pattern_hits"],
|
| 255 |
+
"needs_judgment": result["needs_judgment"],
|
| 256 |
+
}
|
| 257 |
+
)
|
| 258 |
+
if result["needs_judgment"] and not result["anti_pattern_hits"]:
|
| 259 |
+
llm_checks = [
|
| 260 |
+
c["instruction"]
|
| 261 |
+
for c in record.get("checks", [])
|
| 262 |
+
if c["type"] == "llm"
|
| 263 |
+
]
|
| 264 |
+
sheet.append(
|
| 265 |
+
sheet_row(
|
| 266 |
+
bundle=bundle,
|
| 267 |
+
item_type="persona_llm_check",
|
| 268 |
+
criterion=" | ".join(llm_checks),
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
elif bundle["battery_type"] == "replay":
|
| 273 |
+
sheet.append(
|
| 274 |
+
sheet_row(
|
| 275 |
+
bundle=bundle,
|
| 276 |
+
item_type="replay_reply",
|
| 277 |
+
criterion="Is this reply as helpful and correct as the real "
|
| 278 |
+
"staff reply? (binary)",
|
| 279 |
+
reference=record.get("reference_reply") or "",
|
| 280 |
+
)
|
| 281 |
+
)
|
| 282 |
+
grades.append(row)
|
| 283 |
+
return grades, sheet
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def merge_handgrades(
|
| 287 |
+
grades: list[dict[str, Any]], filled_csv: Path
|
| 288 |
+
) -> list[dict[str, Any]]:
|
| 289 |
+
human: dict[str, list[dict[str, str]]] = {}
|
| 290 |
+
with open(filled_csv, encoding="utf-8") as f:
|
| 291 |
+
for row in csv.DictReader(f):
|
| 292 |
+
if row.get("grade", "").strip():
|
| 293 |
+
human.setdefault(row["run_id"], []).append(row)
|
| 294 |
+
for grade in grades:
|
| 295 |
+
rows = human.get(grade["run_id"], [])
|
| 296 |
+
if not rows:
|
| 297 |
+
continue
|
| 298 |
+
key_points = [r for r in rows if r["item_type"] == "key_point"]
|
| 299 |
+
if key_points:
|
| 300 |
+
passed = sum(1 for r in key_points if r["grade"].lower() == "pass")
|
| 301 |
+
grade["key_points_passed"] = passed
|
| 302 |
+
grade["key_points_total"] = len(key_points)
|
| 303 |
+
for row in rows:
|
| 304 |
+
verdict = row["grade"].strip().lower() == "pass"
|
| 305 |
+
if row["item_type"] == "behavior":
|
| 306 |
+
grade["behavior_pass"] = verdict
|
| 307 |
+
elif row["item_type"].startswith("probe:"):
|
| 308 |
+
grade["probe_pass"] = verdict
|
| 309 |
+
elif row["item_type"] == "persona_llm_check":
|
| 310 |
+
# Combines with the regex auto result (both must pass).
|
| 311 |
+
grade["auto_pass"] = verdict and grade.get("auto_pass") is not False
|
| 312 |
+
elif row["item_type"] == "replay_reply":
|
| 313 |
+
grade["replay_pass"] = verdict
|
| 314 |
+
return grades
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def main() -> None:
|
| 318 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 319 |
+
parser.add_argument("--run", required=True, help="Run dir with bundles.jsonl")
|
| 320 |
+
parser.add_argument("--handgrades", default="", help="Filled handgrade CSV")
|
| 321 |
+
args = parser.parse_args()
|
| 322 |
+
run_dir = Path(args.run)
|
| 323 |
+
|
| 324 |
+
grades, sheet = grade_run(run_dir)
|
| 325 |
+
write_jsonl(run_dir / "grades_auto.jsonl", grades)
|
| 326 |
+
if sheet:
|
| 327 |
+
with open(run_dir / "handgrade_sheet.csv", "w", encoding="utf-8") as f:
|
| 328 |
+
writer = csv.DictWriter(f, fieldnames=list(sheet[0]))
|
| 329 |
+
writer.writeheader()
|
| 330 |
+
writer.writerows(sheet)
|
| 331 |
+
print(
|
| 332 |
+
f"{len(grades)} rows auto-graded; {len(sheet)} human judgments pending "
|
| 333 |
+
f"in handgrade_sheet.csv"
|
| 334 |
+
)
|
| 335 |
+
if args.handgrades:
|
| 336 |
+
merged = merge_handgrades(grades, Path(args.handgrades))
|
| 337 |
+
write_jsonl(run_dir / "grades_merged.jsonl", merged)
|
| 338 |
+
print(f"Merged human grades -> {run_dir}/grades_merged.jsonl")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
if __name__ == "__main__":
|
| 342 |
+
main()
|
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build and merge the blinded hand-grading workbook for a bake-off.
|
| 2 |
+
|
| 3 |
+
uv run -m evals.handgrade_workbook build --dirs runs/b_st_* runs/b_pe_* \
|
| 4 |
+
runs/b_se_* --out runs/b_report/workbook.csv
|
| 5 |
+
uv run -m evals.handgrade_workbook merge --workbook runs/b_report/workbook_filled.csv
|
| 6 |
+
|
| 7 |
+
Build collects the human-judgment rows from each run dir's handgrade_sheet.csv
|
| 8 |
+
into ONE shuffled, BLINDED workbook: the grader sees question/answer/criterion
|
| 9 |
+
but never the preset, so grades can't favor a method. A key map (written next
|
| 10 |
+
to the workbook) links each blinded row back to its run dir for the merge.
|
| 11 |
+
|
| 12 |
+
Row selection (keeps the pass to a half-day):
|
| 13 |
+
- priority 1: ALL session probe rows (the memory metric).
|
| 14 |
+
- priority 2: ALL persona llm-check rows.
|
| 15 |
+
- priority 3: single-turn key_point + behavior rows for a stratified subset of
|
| 16 |
+
cases (default 12: 6 corpus / 2 redirect / 2 feedback / 2 general), trial 1.
|
| 17 |
+
|
| 18 |
+
Grade with `pass` or `fail` in the grade column (notes optional). Merge splits
|
| 19 |
+
the filled workbook into per-run-dir handgrade_filled.csv files and prints the
|
| 20 |
+
evals.grade commands that fold them into grades_merged.jsonl.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import csv
|
| 27 |
+
import random
|
| 28 |
+
from collections import defaultdict
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
from .common import load_jsonl
|
| 32 |
+
|
| 33 |
+
VISIBLE_FIELDS = (
|
| 34 |
+
"key",
|
| 35 |
+
"priority",
|
| 36 |
+
"item_type",
|
| 37 |
+
"question",
|
| 38 |
+
"answer",
|
| 39 |
+
"criterion",
|
| 40 |
+
"reference",
|
| 41 |
+
"grade",
|
| 42 |
+
"note",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def read_sheet(run_dir: Path) -> list[dict[str, str]]:
|
| 47 |
+
path = run_dir / "handgrade_sheet.csv"
|
| 48 |
+
if not path.exists():
|
| 49 |
+
return []
|
| 50 |
+
with open(path, encoding="utf-8") as f:
|
| 51 |
+
return [dict(row, _dir=str(run_dir)) for row in csv.DictReader(f)]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def singleturn_subset(battery_path: str, per_behavior: dict[str, int]) -> set[str]:
|
| 55 |
+
by_behavior: dict[str, list[str]] = defaultdict(list)
|
| 56 |
+
for case in load_jsonl(battery_path):
|
| 57 |
+
by_behavior[case["expected_behavior"]].append(case["case_id"])
|
| 58 |
+
subset: set[str] = set()
|
| 59 |
+
for behavior, count in per_behavior.items():
|
| 60 |
+
subset.update(sorted(by_behavior.get(behavior, []))[:count])
|
| 61 |
+
return subset
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build(args: argparse.Namespace) -> None:
|
| 65 |
+
per_behavior = {
|
| 66 |
+
"answer_from_corpus": 6,
|
| 67 |
+
"redirect_to_support": 2,
|
| 68 |
+
"acknowledge_feedback": 2,
|
| 69 |
+
"answer_general": 2,
|
| 70 |
+
}
|
| 71 |
+
selected: list[dict[str, str]] = []
|
| 72 |
+
st_subset: set[str] | None = None
|
| 73 |
+
for run_dir in map(Path, args.dirs):
|
| 74 |
+
for row in read_sheet(run_dir):
|
| 75 |
+
item_type = row["item_type"]
|
| 76 |
+
if item_type.startswith("probe:"):
|
| 77 |
+
row["priority"] = "1"
|
| 78 |
+
elif item_type == "persona_llm_check":
|
| 79 |
+
row["priority"] = "2"
|
| 80 |
+
elif item_type in ("key_point", "behavior"):
|
| 81 |
+
if st_subset is None:
|
| 82 |
+
bundles = load_jsonl(run_dir / "bundles.jsonl")
|
| 83 |
+
st_subset = singleturn_subset(
|
| 84 |
+
bundles[0]["battery_path"], per_behavior
|
| 85 |
+
)
|
| 86 |
+
unit_id, _, rest = row["run_id"].partition("|")
|
| 87 |
+
if unit_id not in st_subset or not rest.endswith("t1"):
|
| 88 |
+
continue
|
| 89 |
+
row["priority"] = "3"
|
| 90 |
+
else: # replay rows etc. — not part of this pass
|
| 91 |
+
continue
|
| 92 |
+
selected.append(row)
|
| 93 |
+
|
| 94 |
+
random.Random(42).shuffle(selected)
|
| 95 |
+
out = Path(args.out)
|
| 96 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
keymap_path = out.with_name(out.stem + "_keymap.csv")
|
| 98 |
+
with (
|
| 99 |
+
open(out, "w", encoding="utf-8") as wf,
|
| 100 |
+
open(keymap_path, "w", encoding="utf-8") as kf,
|
| 101 |
+
):
|
| 102 |
+
writer = csv.DictWriter(wf, fieldnames=list(VISIBLE_FIELDS))
|
| 103 |
+
writer.writeheader()
|
| 104 |
+
key_writer = csv.writer(kf)
|
| 105 |
+
key_writer.writerow(["key", "run_dir", "sheet_row_id"])
|
| 106 |
+
for index, row in enumerate(selected):
|
| 107 |
+
key = f"g{index:04d}"
|
| 108 |
+
writer.writerow(
|
| 109 |
+
{
|
| 110 |
+
"key": key,
|
| 111 |
+
"priority": row["priority"],
|
| 112 |
+
"item_type": row["item_type"],
|
| 113 |
+
"question": row["question"],
|
| 114 |
+
"answer": row["answer"],
|
| 115 |
+
"criterion": row["criterion"],
|
| 116 |
+
"reference": row["reference"],
|
| 117 |
+
"grade": "",
|
| 118 |
+
"note": "",
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
key_writer.writerow([key, row["_dir"], row["sheet_row_id"]])
|
| 122 |
+
counts = defaultdict(int)
|
| 123 |
+
for row in selected:
|
| 124 |
+
counts[row["priority"]] += 1
|
| 125 |
+
print(f"{len(selected)} blinded rows -> {out}")
|
| 126 |
+
print(f" priority 1 (session probes): {counts['1']}")
|
| 127 |
+
print(f" priority 2 (persona llm checks): {counts['2']}")
|
| 128 |
+
print(f" priority 3 (single-turn subset): {counts['3']}")
|
| 129 |
+
print(f"key map (do not open while grading): {keymap_path}")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def merge(args: argparse.Namespace) -> None:
|
| 133 |
+
workbook = Path(args.workbook)
|
| 134 |
+
keymap_path = Path(args.keymap) if args.keymap else None
|
| 135 |
+
if keymap_path is None:
|
| 136 |
+
stem = workbook.stem.replace("_filled", "")
|
| 137 |
+
keymap_path = workbook.with_name(stem + "_keymap.csv")
|
| 138 |
+
with open(keymap_path, encoding="utf-8") as f:
|
| 139 |
+
key_map = {row["key"]: row for row in csv.DictReader(f)}
|
| 140 |
+
|
| 141 |
+
by_dir: dict[str, dict[str, dict[str, str]]] = defaultdict(dict)
|
| 142 |
+
graded = 0
|
| 143 |
+
with open(workbook, encoding="utf-8") as f:
|
| 144 |
+
for row in csv.DictReader(f):
|
| 145 |
+
if not row.get("grade", "").strip():
|
| 146 |
+
continue
|
| 147 |
+
mapping = key_map[row["key"]]
|
| 148 |
+
by_dir[mapping["run_dir"]][mapping["sheet_row_id"]] = row
|
| 149 |
+
graded += 1
|
| 150 |
+
|
| 151 |
+
for run_dir, grades in by_dir.items():
|
| 152 |
+
sheet = read_sheet(Path(run_dir))
|
| 153 |
+
out_rows = []
|
| 154 |
+
for sheet_row in sheet:
|
| 155 |
+
filled = grades.get(sheet_row["sheet_row_id"])
|
| 156 |
+
if filled:
|
| 157 |
+
sheet_row["grade"] = filled["grade"].strip().lower()
|
| 158 |
+
sheet_row["note"] = filled.get("note", "")
|
| 159 |
+
sheet_row.pop("_dir", None)
|
| 160 |
+
out_rows.append(sheet_row)
|
| 161 |
+
out_path = Path(run_dir) / "handgrade_filled.csv"
|
| 162 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 163 |
+
writer = csv.DictWriter(f, fieldnames=list(out_rows[0]))
|
| 164 |
+
writer.writeheader()
|
| 165 |
+
writer.writerows(out_rows)
|
| 166 |
+
print(
|
| 167 |
+
f"{run_dir}: {len(grades)} grade(s) -> {out_path}\n"
|
| 168 |
+
f" next: uv run -m evals.grade --run {run_dir} "
|
| 169 |
+
f"--handgrades {out_path}"
|
| 170 |
+
)
|
| 171 |
+
print(f"total grades merged: {graded}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def main() -> None:
|
| 175 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 176 |
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
| 177 |
+
build_p = sub.add_parser("build")
|
| 178 |
+
build_p.add_argument("--dirs", nargs="+", required=True)
|
| 179 |
+
build_p.add_argument("--out", required=True)
|
| 180 |
+
merge_p = sub.add_parser("merge")
|
| 181 |
+
merge_p.add_argument("--workbook", required=True)
|
| 182 |
+
merge_p.add_argument("--keymap", default="")
|
| 183 |
+
args = parser.parse_args()
|
| 184 |
+
if args.cmd == "build":
|
| 185 |
+
build(args)
|
| 186 |
+
else:
|
| 187 |
+
merge(args)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
main()
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the comparison report from one or more graded runs.
|
| 2 |
+
|
| 3 |
+
uv run -m evals.report --runs runs/bake1_singleturn_prod \
|
| 4 |
+
runs/bake1_singleturn_full_history --out runs/bake1_report
|
| 5 |
+
|
| 6 |
+
Each run dir = one battery x preset x model. Runs sharing a battery_type are
|
| 7 |
+
shown side by side (columns = presets) — the bake-off table. Also emits
|
| 8 |
+
tokens_by_turn.csv (cumulative input tokens per session turn per preset: the
|
| 9 |
+
signature plot) and tokens_by_turn.png when matplotlib is available.
|
| 10 |
+
|
| 11 |
+
Reads grades_merged.jsonl when present (hand grades included), else
|
| 12 |
+
grades_auto.jsonl; quality rows (key-point coverage, probe accuracy, behavior
|
| 13 |
+
accuracy) appear only once hand grades are merged.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import csv
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from statistics import mean
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
from .common import load_jsonl, percentile
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_run(run_dir: Path) -> dict[str, Any]:
|
| 29 |
+
grades_path = run_dir / "grades_merged.jsonl"
|
| 30 |
+
if not grades_path.exists():
|
| 31 |
+
grades_path = run_dir / "grades_auto.jsonl"
|
| 32 |
+
grades = load_jsonl(grades_path)
|
| 33 |
+
if not grades:
|
| 34 |
+
raise SystemExit(f"No grades in {run_dir}; run evals.grade first.")
|
| 35 |
+
return {
|
| 36 |
+
"dir": run_dir,
|
| 37 |
+
"label": f"{grades[0]['preset']}",
|
| 38 |
+
"battery_type": grades[0]["battery_type"],
|
| 39 |
+
"model": grades[0]["model"],
|
| 40 |
+
"grades": grades,
|
| 41 |
+
"merged": grades_path.name == "grades_merged.jsonl",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def rate(values: list[Any]) -> str:
|
| 46 |
+
"""pass-rate over boolean-like values, ignoring None."""
|
| 47 |
+
decided = [v for v in values if v is not None]
|
| 48 |
+
if not decided:
|
| 49 |
+
return "—"
|
| 50 |
+
return f"{sum(1 for v in decided if v) / len(decided):.0%} (n={len(decided)})"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def fmt_ms(values: list[Any]) -> str:
|
| 54 |
+
nums = [v for v in values if isinstance(v, (int, float))]
|
| 55 |
+
if not nums:
|
| 56 |
+
return "—"
|
| 57 |
+
return f"{percentile(nums, 50):.0f} / {percentile(nums, 95):.0f}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def fmt_mean(values: list[Any], spec: str = "{:.0f}") -> str:
|
| 61 |
+
nums = [v for v in values if isinstance(v, (int, float))]
|
| 62 |
+
return spec.format(mean(nums)) if nums else "—"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def col(grades: list[dict[str, Any]], key: str) -> list[Any]:
|
| 66 |
+
return [g.get(key) for g in grades]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]]:
|
| 70 |
+
"""One row per metric, one column per run (preset)."""
|
| 71 |
+
rows: list[list[str]] = []
|
| 72 |
+
|
| 73 |
+
def add(name: str, fn) -> None:
|
| 74 |
+
rows.append([name] + [fn(run["grades"]) for run in runs])
|
| 75 |
+
|
| 76 |
+
add("cases run", lambda g: str(len({x["unit_id"] for x in g})))
|
| 77 |
+
add("errors", lambda g: str(sum(1 for x in g if x.get("error"))))
|
| 78 |
+
add("ttft ms p50/p95", lambda g: fmt_ms(col(g, "ttft_ms")))
|
| 79 |
+
add("turn ms p50/p95", lambda g: fmt_ms(col(g, "total_ms")))
|
| 80 |
+
add("input tok/turn", lambda g: fmt_mean(col(g, "input_tokens")))
|
| 81 |
+
add("output tok/turn", lambda g: fmt_mean(col(g, "output_tokens")))
|
| 82 |
+
add(
|
| 83 |
+
"est cost/turn $",
|
| 84 |
+
lambda g: fmt_mean(col(g, "est_cost_usd"), "{:.4f}"),
|
| 85 |
+
)
|
| 86 |
+
add("llm calls/turn", lambda g: fmt_mean(col(g, "llm_calls"), "{:.1f}"))
|
| 87 |
+
add("tool calls/turn", lambda g: fmt_mean(col(g, "tool_call_count"), "{:.1f}"))
|
| 88 |
+
|
| 89 |
+
if battery_type == "singleturn":
|
| 90 |
+
add(
|
| 91 |
+
"retrieval called (corpus)",
|
| 92 |
+
lambda g: rate(
|
| 93 |
+
[
|
| 94 |
+
x.get("called_retrieval")
|
| 95 |
+
for x in g
|
| 96 |
+
if x.get("expected_behavior") == "answer_from_corpus"
|
| 97 |
+
]
|
| 98 |
+
),
|
| 99 |
+
)
|
| 100 |
+
add("recall@shown source", lambda g: rate(col(g, "recall_source")))
|
| 101 |
+
add("recall@shown lesson", lambda g: rate(col(g, "recall_lesson")))
|
| 102 |
+
add(
|
| 103 |
+
"MRR lesson", lambda g: fmt_mean([x.get("mrr_lesson") for x in g], "{:.2f}")
|
| 104 |
+
)
|
| 105 |
+
add(
|
| 106 |
+
"citation present",
|
| 107 |
+
lambda g: rate(
|
| 108 |
+
[
|
| 109 |
+
x.get("has_citation")
|
| 110 |
+
for x in g
|
| 111 |
+
if x.get("expected_behavior") == "answer_from_corpus"
|
| 112 |
+
]
|
| 113 |
+
),
|
| 114 |
+
)
|
| 115 |
+
add("behavior heuristic", lambda g: rate(col(g, "behavior_heuristic")))
|
| 116 |
+
add("behavior (hand)", lambda g: rate(col(g, "behavior_pass")))
|
| 117 |
+
add("key-point coverage (hand)", lambda g: _kp_coverage(g))
|
| 118 |
+
|
| 119 |
+
if battery_type == "sessions":
|
| 120 |
+
add("cumulative input tok (last turn, mean)", _final_cumulative)
|
| 121 |
+
add(
|
| 122 |
+
"compaction active at probes",
|
| 123 |
+
lambda g: rate(
|
| 124 |
+
[x.get("compaction_active") for x in g if x.get("is_probe")]
|
| 125 |
+
),
|
| 126 |
+
)
|
| 127 |
+
add(
|
| 128 |
+
"probe accuracy (hand)",
|
| 129 |
+
lambda g: rate([x.get("probe_pass") for x in g if x.get("is_probe")]),
|
| 130 |
+
)
|
| 131 |
+
for probe_type in sorted(
|
| 132 |
+
{
|
| 133 |
+
x.get("probe_type")
|
| 134 |
+
for run in runs
|
| 135 |
+
for x in run["grades"]
|
| 136 |
+
if x.get("probe_type")
|
| 137 |
+
}
|
| 138 |
+
):
|
| 139 |
+
rows.append(
|
| 140 |
+
[f" └ {probe_type}"]
|
| 141 |
+
+ [
|
| 142 |
+
rate(
|
| 143 |
+
[
|
| 144 |
+
x.get("probe_pass")
|
| 145 |
+
for x in run["grades"]
|
| 146 |
+
if x.get("probe_type") == probe_type
|
| 147 |
+
]
|
| 148 |
+
)
|
| 149 |
+
for run in runs
|
| 150 |
+
]
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
if battery_type == "personas":
|
| 154 |
+
add("personalization pass (auto)", lambda g: rate(col(g, "auto_pass")))
|
| 155 |
+
add(
|
| 156 |
+
"anti-pattern failures",
|
| 157 |
+
lambda g: str(sum(1 for x in g if x.get("anti_pattern_hits"))),
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if battery_type == "replay":
|
| 161 |
+
add("replay reply pass (hand)", lambda g: rate(col(g, "replay_pass")))
|
| 162 |
+
return rows
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _kp_coverage(grades: list[dict[str, Any]]) -> str:
|
| 166 |
+
passed = sum(g.get("key_points_passed", 0) for g in grades)
|
| 167 |
+
total = sum(g.get("key_points_total", 0) for g in grades)
|
| 168 |
+
return f"{passed / total:.0%} ({passed}/{total})" if total else "—"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _final_cumulative(grades: list[dict[str, Any]]) -> str:
|
| 172 |
+
finals: dict[tuple[str, int], int] = {}
|
| 173 |
+
for g in grades:
|
| 174 |
+
tokens = g.get("input_tokens")
|
| 175 |
+
if g.get("turn_index") is None or not isinstance(tokens, (int, float)):
|
| 176 |
+
continue
|
| 177 |
+
key = (g["unit_id"], g["trial"])
|
| 178 |
+
finals[key] = finals.get(key, 0) + int(tokens)
|
| 179 |
+
return fmt_mean(list(finals.values()))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def write_token_curves(runs: list[dict[str, Any]], out_dir: Path) -> None:
|
| 183 |
+
session_runs = [r for r in runs if r["battery_type"] == "sessions"]
|
| 184 |
+
if not session_runs:
|
| 185 |
+
return
|
| 186 |
+
rows = []
|
| 187 |
+
for run in session_runs:
|
| 188 |
+
cumulative: dict[tuple[str, int], int] = defaultdict(int)
|
| 189 |
+
for g in sorted(
|
| 190 |
+
run["grades"],
|
| 191 |
+
key=lambda x: (x["unit_id"], x["trial"], x["turn_index"] or 0),
|
| 192 |
+
):
|
| 193 |
+
if g.get("turn_index") is None:
|
| 194 |
+
continue
|
| 195 |
+
key = (g["unit_id"], g["trial"])
|
| 196 |
+
cumulative[key] += int(g.get("input_tokens") or 0)
|
| 197 |
+
rows.append(
|
| 198 |
+
{
|
| 199 |
+
"preset": run["label"],
|
| 200 |
+
"session_id": g["unit_id"],
|
| 201 |
+
"trial": g["trial"],
|
| 202 |
+
"turn_index": g["turn_index"],
|
| 203 |
+
"turn_input_tokens": g.get("input_tokens") or 0,
|
| 204 |
+
"cumulative_input_tokens": cumulative[key],
|
| 205 |
+
}
|
| 206 |
+
)
|
| 207 |
+
path = out_dir / "tokens_by_turn.csv"
|
| 208 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 209 |
+
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
|
| 210 |
+
writer.writeheader()
|
| 211 |
+
writer.writerows(rows)
|
| 212 |
+
try:
|
| 213 |
+
_plot_token_curves(rows, out_dir)
|
| 214 |
+
except ImportError:
|
| 215 |
+
print("matplotlib not installed; wrote CSV only.")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _plot_token_curves(rows: list[dict[str, Any]], out_dir: Path) -> None:
|
| 219 |
+
import matplotlib
|
| 220 |
+
|
| 221 |
+
matplotlib.use("Agg")
|
| 222 |
+
import matplotlib.pyplot as plt
|
| 223 |
+
|
| 224 |
+
by_preset: dict[str, dict[int, list[int]]] = defaultdict(lambda: defaultdict(list))
|
| 225 |
+
for row in rows:
|
| 226 |
+
by_preset[row["preset"]][row["turn_index"]].append(
|
| 227 |
+
row["cumulative_input_tokens"]
|
| 228 |
+
)
|
| 229 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 230 |
+
for preset, by_turn in sorted(by_preset.items()):
|
| 231 |
+
turns = sorted(by_turn)
|
| 232 |
+
ax.plot(turns, [mean(by_turn[t]) for t in turns], marker="o", label=preset)
|
| 233 |
+
ax.set_xlabel("turn")
|
| 234 |
+
ax.set_ylabel("cumulative input tokens (mean across sessions)")
|
| 235 |
+
ax.set_title("Context cost per turn by memory preset")
|
| 236 |
+
ax.legend()
|
| 237 |
+
fig.tight_layout()
|
| 238 |
+
fig.savefig(out_dir / "tokens_by_turn.png", dpi=150)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def main() -> None:
|
| 242 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 243 |
+
parser.add_argument("--runs", nargs="+", required=True)
|
| 244 |
+
parser.add_argument("--out", default="", help="Report dir (default: first run)")
|
| 245 |
+
args = parser.parse_args()
|
| 246 |
+
runs = [load_run(Path(r)) for r in args.runs]
|
| 247 |
+
out_dir = Path(args.out or runs[0]["dir"])
|
| 248 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 249 |
+
|
| 250 |
+
lines = ["# Eval report", ""]
|
| 251 |
+
models = {run["model"] for run in runs}
|
| 252 |
+
lines.append(
|
| 253 |
+
f"Model(s): {', '.join(sorted(models))}. Runs missing hand grades show "
|
| 254 |
+
"— for quality rows (fill handgrade_sheet.csv, re-run evals.grade "
|
| 255 |
+
"with --handgrades)."
|
| 256 |
+
)
|
| 257 |
+
by_type: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 258 |
+
for run in runs:
|
| 259 |
+
by_type[run["battery_type"]].append(run)
|
| 260 |
+
for battery_type, type_runs in by_type.items():
|
| 261 |
+
lines.append(f"\n## {battery_type}\n")
|
| 262 |
+
header = ["metric"] + [
|
| 263 |
+
run["label"] + ("" if run["merged"] else " (auto only)")
|
| 264 |
+
for run in type_runs
|
| 265 |
+
]
|
| 266 |
+
lines.append("| " + " | ".join(header) + " |")
|
| 267 |
+
lines.append("|" + "---|" * len(header))
|
| 268 |
+
for row in metric_rows(battery_type, type_runs):
|
| 269 |
+
lines.append("| " + " | ".join(row) + " |")
|
| 270 |
+
(out_dir / "report.md").write_text("\n".join(lines) + "\n")
|
| 271 |
+
write_token_curves(runs, out_dir)
|
| 272 |
+
print(f"Report written to {out_dir}/report.md")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if __name__ == "__main__":
|
| 276 |
+
main()
|
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run an eval battery against the tutor and persist one trace bundle per turn.
|
| 2 |
+
|
| 3 |
+
Examples:
|
| 4 |
+
uv run -m evals.run_battery --battery data/eval/battery_singleturn_v1.jsonl \
|
| 5 |
+
--preset prod --out runs/bake1_singleturn_prod
|
| 6 |
+
uv run -m evals.run_battery --battery data/eval/battery_sessions_v1.jsonl \
|
| 7 |
+
--preset full_history --ids s01_fullstack_beginner_13t --trials 2
|
| 8 |
+
|
| 9 |
+
Notes:
|
| 10 |
+
- LangSmith tracing is OFF by default (free-plan quota); --langsmith enables it.
|
| 11 |
+
- Web tools are OFF by default for reproducibility; --enable-tools to add.
|
| 12 |
+
- Re-running with the same --out resumes: completed cases (all trials of all
|
| 13 |
+
turns) are kept; incomplete sessions are re-run whole, because thread state
|
| 14 |
+
lives in process memory and cannot be resumed across runs.
|
| 15 |
+
- Bundles are the durable artifact: grade/report re-run offline against them.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import asyncio
|
| 22 |
+
import datetime
|
| 23 |
+
import json
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import time
|
| 27 |
+
from collections import defaultdict
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any
|
| 30 |
+
|
| 31 |
+
from .common import append_jsonl, detect_battery_type, load_jsonl, write_jsonl
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("evals.run_battery")
|
| 34 |
+
|
| 35 |
+
# Full tool outputs can be 40k chars each; keep bundles browsable. The full
|
| 36 |
+
# size is preserved in output_chars so truncation is visible.
|
| 37 |
+
TOOL_OUTPUT_MAX_CHARS = 6_000
|
| 38 |
+
# Slowest observed turn is ~2.5 min; anything past this is a wedged stream
|
| 39 |
+
# (e.g. laptop sleep killed the connection mid-turn). The turn records a
|
| 40 |
+
# TimeoutError and the unit re-runs on resume instead of hanging forever.
|
| 41 |
+
TURN_TIMEOUT_SECONDS = 600
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_args() -> argparse.Namespace:
|
| 45 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 46 |
+
parser.add_argument("--battery", required=True, help="Path to a battery JSONL.")
|
| 47 |
+
parser.add_argument("--preset", default="prod", help="Memory preset name.")
|
| 48 |
+
parser.add_argument("--model", default="", help="Model id (default: app default).")
|
| 49 |
+
parser.add_argument("--trials", type=int, default=1)
|
| 50 |
+
parser.add_argument("--out", default="", help="Output dir (default: derived).")
|
| 51 |
+
parser.add_argument("--limit", type=int, default=0, help="First N cases only.")
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--ids", nargs="*", default=[], help="Run only these case/session/persona ids."
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument("--concurrency", type=int, default=4)
|
| 56 |
+
parser.add_argument(
|
| 57 |
+
"--scope-sources",
|
| 58 |
+
action="store_true",
|
| 59 |
+
help="Restrict the tutor to the case's own course (default: all sources, "
|
| 60 |
+
"like production).",
|
| 61 |
+
)
|
| 62 |
+
parser.add_argument(
|
| 63 |
+
"--enable-tools", nargs="*", default=[], help="e.g. web_search url_context"
|
| 64 |
+
)
|
| 65 |
+
parser.add_argument("--langsmith", action="store_true", help="Enable tracing.")
|
| 66 |
+
return parser.parse_args()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def record_id(record: dict[str, Any]) -> str:
|
| 70 |
+
for key in ("case_id", "session_id", "persona_id", "replay_id"):
|
| 71 |
+
if key in record:
|
| 72 |
+
return str(record[key])
|
| 73 |
+
raise KeyError("record has no id")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class BundleSink:
|
| 77 |
+
"""Append-only bundle store with resume bookkeeping."""
|
| 78 |
+
|
| 79 |
+
def __init__(self, path: Path):
|
| 80 |
+
self.path = path
|
| 81 |
+
self.lock = asyncio.Lock()
|
| 82 |
+
|
| 83 |
+
async def write(self, rows: list[dict[str, Any]]) -> None:
|
| 84 |
+
async with self.lock:
|
| 85 |
+
await asyncio.to_thread(append_jsonl, self.path, rows)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def prune_incomplete(
|
| 89 |
+
path: Path, battery_type: str, expected_turns: dict[str, int], trials: int
|
| 90 |
+
) -> set[tuple[str, int]]:
|
| 91 |
+
"""Drop partial work units from a previous run; return completed (id, trial).
|
| 92 |
+
|
| 93 |
+
A work unit is one trial of one case (or one whole session). Sessions with
|
| 94 |
+
missing turns are pruned entirely: their thread state died with the old
|
| 95 |
+
process, so they must re-run from turn 0.
|
| 96 |
+
"""
|
| 97 |
+
if not path.exists():
|
| 98 |
+
return set()
|
| 99 |
+
rows = load_jsonl(path)
|
| 100 |
+
by_unit: dict[tuple[str, int], list[dict[str, Any]]] = {}
|
| 101 |
+
for row in rows:
|
| 102 |
+
by_unit.setdefault((row["unit_id"], row["trial"]), []).append(row)
|
| 103 |
+
completed = set()
|
| 104 |
+
for (unit_id, trial), unit_rows in by_unit.items():
|
| 105 |
+
wanted = expected_turns.get(unit_id, 1)
|
| 106 |
+
if len(unit_rows) >= wanted and not any(r.get("error") for r in unit_rows):
|
| 107 |
+
completed.add((unit_id, trial))
|
| 108 |
+
kept = [r for r in rows if (r["unit_id"], r["trial"]) in completed]
|
| 109 |
+
if len(kept) != len(rows):
|
| 110 |
+
write_jsonl(path, kept)
|
| 111 |
+
logger.info(
|
| 112 |
+
"Resume: kept %d completed unit(s), pruned %d partial row(s).",
|
| 113 |
+
len(completed),
|
| 114 |
+
len(rows) - len(kept),
|
| 115 |
+
)
|
| 116 |
+
return completed
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def make_bundle(
|
| 120 |
+
*,
|
| 121 |
+
args: argparse.Namespace,
|
| 122 |
+
battery_type: str,
|
| 123 |
+
unit_id: str,
|
| 124 |
+
trial: int,
|
| 125 |
+
turn_index: int | None,
|
| 126 |
+
query: str,
|
| 127 |
+
result: dict[str, Any],
|
| 128 |
+
) -> dict[str, Any]:
|
| 129 |
+
return {
|
| 130 |
+
"run_id": f"{unit_id}|turn{turn_index if turn_index is not None else 0}"
|
| 131 |
+
f"|t{trial}",
|
| 132 |
+
"unit_id": unit_id,
|
| 133 |
+
"battery_path": args.battery,
|
| 134 |
+
"battery_type": battery_type,
|
| 135 |
+
"preset": args.preset,
|
| 136 |
+
"model": result["model"],
|
| 137 |
+
"trial": trial,
|
| 138 |
+
"turn_index": turn_index,
|
| 139 |
+
"started_at": result["started_at"],
|
| 140 |
+
"duration_ms": result["duration_ms"],
|
| 141 |
+
"query": query,
|
| 142 |
+
"answer": result["answer"],
|
| 143 |
+
"thread_id": result["thread_id"],
|
| 144 |
+
"tool_calls": result["tool_calls"],
|
| 145 |
+
"resolved_sources": result["resolved_sources"],
|
| 146 |
+
"context_stats": result["context_stats"],
|
| 147 |
+
"error": result["error"],
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def run_turn(request: Any) -> dict[str, Any]:
|
| 152 |
+
"""Drive one stream_chat turn and collect everything the graders need."""
|
| 153 |
+
from app.chat_service import stream_chat
|
| 154 |
+
from app.config import DEFAULT_MODEL_NAME
|
| 155 |
+
|
| 156 |
+
started_at = datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
|
| 157 |
+
started = time.monotonic()
|
| 158 |
+
tool_calls: list[dict[str, Any]] = []
|
| 159 |
+
resolved_sources: list[dict[str, Any]] = []
|
| 160 |
+
answer, thread_id, error = "", "", None
|
| 161 |
+
context_stats: dict[str, Any] | None = None
|
| 162 |
+
try:
|
| 163 |
+
async with asyncio.timeout(TURN_TIMEOUT_SECONDS):
|
| 164 |
+
async for event in stream_chat(request):
|
| 165 |
+
if event.type == "thread_started":
|
| 166 |
+
thread_id = str(event.data.get("thread_id", ""))
|
| 167 |
+
elif event.type == "tool_call_completed":
|
| 168 |
+
data = event.data
|
| 169 |
+
output_text = str(data.get("output_text") or "")
|
| 170 |
+
tool_calls.append(
|
| 171 |
+
{
|
| 172 |
+
"tool_name": data.get("tool_name"),
|
| 173 |
+
"args_text": data.get("args_text", ""),
|
| 174 |
+
"output_text": output_text[:TOOL_OUTPUT_MAX_CHARS],
|
| 175 |
+
"output_chars": len(output_text),
|
| 176 |
+
"matches": [
|
| 177 |
+
{
|
| 178 |
+
key: match.get(key)
|
| 179 |
+
for key in ("source_key", "url", "title", "score")
|
| 180 |
+
}
|
| 181 |
+
for match in data.get("matches") or []
|
| 182 |
+
],
|
| 183 |
+
}
|
| 184 |
+
)
|
| 185 |
+
elif event.type == "source_match":
|
| 186 |
+
resolved_sources.append(
|
| 187 |
+
{
|
| 188 |
+
key: event.data.get(key)
|
| 189 |
+
for key in ("source_key", "url", "title", "group")
|
| 190 |
+
}
|
| 191 |
+
)
|
| 192 |
+
elif event.type == "context_stats":
|
| 193 |
+
context_stats = dict(event.data)
|
| 194 |
+
elif event.type == "message_completed":
|
| 195 |
+
answer = str(event.data.get("answer", ""))
|
| 196 |
+
except Exception as exc: # noqa: BLE001 - record and continue the battery
|
| 197 |
+
error = f"{type(exc).__name__}: {exc}"
|
| 198 |
+
logger.warning("Turn failed: %s", error)
|
| 199 |
+
return {
|
| 200 |
+
"model": request.model_name or DEFAULT_MODEL_NAME,
|
| 201 |
+
"started_at": started_at,
|
| 202 |
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
| 203 |
+
"answer": answer,
|
| 204 |
+
"thread_id": thread_id,
|
| 205 |
+
"tool_calls": tool_calls,
|
| 206 |
+
"resolved_sources": resolved_sources,
|
| 207 |
+
"context_stats": context_stats,
|
| 208 |
+
"error": error,
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def build_request(
|
| 213 |
+
args: argparse.Namespace,
|
| 214 |
+
*,
|
| 215 |
+
query: str,
|
| 216 |
+
source_key: str | None,
|
| 217 |
+
history: tuple[Any, ...] = (),
|
| 218 |
+
thread_id: str = "",
|
| 219 |
+
student_id: str = "",
|
| 220 |
+
) -> Any:
|
| 221 |
+
from app.chat_types import ChatRequest
|
| 222 |
+
from app.config import DEFAULT_MODEL_NAME, DEFAULT_SELECTED_SOURCE_KEYS
|
| 223 |
+
|
| 224 |
+
source_keys = (
|
| 225 |
+
(source_key,)
|
| 226 |
+
if (args.scope_sources and source_key)
|
| 227 |
+
else tuple(DEFAULT_SELECTED_SOURCE_KEYS)
|
| 228 |
+
)
|
| 229 |
+
return ChatRequest(
|
| 230 |
+
query=query,
|
| 231 |
+
history=history,
|
| 232 |
+
source_keys=source_keys,
|
| 233 |
+
model_name=args.model or DEFAULT_MODEL_NAME,
|
| 234 |
+
include_reasoning=False,
|
| 235 |
+
thread_id=thread_id,
|
| 236 |
+
enabled_tools=tuple(args.enable_tools),
|
| 237 |
+
memory_preset=args.preset,
|
| 238 |
+
student_id=student_id,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def run_single_case(
|
| 243 |
+
args: argparse.Namespace,
|
| 244 |
+
battery_type: str,
|
| 245 |
+
record: dict[str, Any],
|
| 246 |
+
trial: int,
|
| 247 |
+
sink: BundleSink,
|
| 248 |
+
) -> None:
|
| 249 |
+
"""singleturn / personas-question / replay: one independent turn."""
|
| 250 |
+
from app.chat_types import ChatTurn
|
| 251 |
+
|
| 252 |
+
if record.get("_profile_seed"):
|
| 253 |
+
# Re-seed right before every persona question: under profile_memory
|
| 254 |
+
# the post-turn write-back would otherwise drift the profile between
|
| 255 |
+
# questions (and across concurrent trials), making results
|
| 256 |
+
# order-dependent. Each question grades the canonical seeded profile.
|
| 257 |
+
from app.chat_service import set_student_profile
|
| 258 |
+
|
| 259 |
+
set_student_profile(record["_student_id"], record["_profile_seed"])
|
| 260 |
+
|
| 261 |
+
unit_id = record["_unit_id"]
|
| 262 |
+
query = record["_query"]
|
| 263 |
+
history = tuple(
|
| 264 |
+
ChatTurn(role=turn["role"], content=turn["content"])
|
| 265 |
+
for turn in record.get("_history", [])
|
| 266 |
+
)
|
| 267 |
+
request = build_request(
|
| 268 |
+
args,
|
| 269 |
+
query=query,
|
| 270 |
+
source_key=record.get("source_key"),
|
| 271 |
+
history=history,
|
| 272 |
+
student_id=record.get("_student_id", ""),
|
| 273 |
+
)
|
| 274 |
+
result = await run_turn(request)
|
| 275 |
+
await sink.write(
|
| 276 |
+
[
|
| 277 |
+
make_bundle(
|
| 278 |
+
args=args,
|
| 279 |
+
battery_type=battery_type,
|
| 280 |
+
unit_id=unit_id,
|
| 281 |
+
trial=trial,
|
| 282 |
+
turn_index=None,
|
| 283 |
+
query=query,
|
| 284 |
+
result=result,
|
| 285 |
+
)
|
| 286 |
+
]
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
async def run_session(
|
| 291 |
+
args: argparse.Namespace,
|
| 292 |
+
session: dict[str, Any],
|
| 293 |
+
trial: int,
|
| 294 |
+
sink: BundleSink,
|
| 295 |
+
) -> None:
|
| 296 |
+
"""All turns sequentially on one thread, passing the visible transcript
|
| 297 |
+
back each turn exactly like the real frontend does."""
|
| 298 |
+
from app.chat_types import ChatTurn
|
| 299 |
+
|
| 300 |
+
history: list[ChatTurn] = []
|
| 301 |
+
thread_id = ""
|
| 302 |
+
rows = []
|
| 303 |
+
for turn_index, query in enumerate(session["turns"]):
|
| 304 |
+
request = build_request(
|
| 305 |
+
args,
|
| 306 |
+
query=query,
|
| 307 |
+
source_key=session.get("source_key"),
|
| 308 |
+
history=tuple(history),
|
| 309 |
+
thread_id=thread_id,
|
| 310 |
+
)
|
| 311 |
+
result = await run_turn(request)
|
| 312 |
+
thread_id = result["thread_id"] or thread_id
|
| 313 |
+
rows.append(
|
| 314 |
+
make_bundle(
|
| 315 |
+
args=args,
|
| 316 |
+
battery_type="sessions",
|
| 317 |
+
unit_id=session["session_id"],
|
| 318 |
+
trial=trial,
|
| 319 |
+
turn_index=turn_index,
|
| 320 |
+
query=query,
|
| 321 |
+
result=result,
|
| 322 |
+
)
|
| 323 |
+
)
|
| 324 |
+
if result["error"]:
|
| 325 |
+
logger.warning(
|
| 326 |
+
"Session %s trial %d aborted at turn %d.",
|
| 327 |
+
session["session_id"],
|
| 328 |
+
trial,
|
| 329 |
+
turn_index,
|
| 330 |
+
)
|
| 331 |
+
break
|
| 332 |
+
history.append(ChatTurn("user", query.strip()))
|
| 333 |
+
history.append(ChatTurn("assistant", result["answer"]))
|
| 334 |
+
await sink.write(rows)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def prepare_units(
|
| 338 |
+
battery_type: str, records: list[dict[str, Any]]
|
| 339 |
+
) -> list[dict[str, Any]]:
|
| 340 |
+
"""Flatten batteries into uniform work units with _unit_id/_query fields."""
|
| 341 |
+
if battery_type == "singleturn":
|
| 342 |
+
for record in records:
|
| 343 |
+
record["_unit_id"] = record["case_id"]
|
| 344 |
+
record["_query"] = record["question"]
|
| 345 |
+
return records
|
| 346 |
+
if battery_type == "replay":
|
| 347 |
+
for record in records:
|
| 348 |
+
record["_unit_id"] = record["replay_id"]
|
| 349 |
+
record["_query"] = record["history"][-1]["content"]
|
| 350 |
+
record["_history"] = record["history"][:-1]
|
| 351 |
+
return records
|
| 352 |
+
if battery_type == "personas":
|
| 353 |
+
units = []
|
| 354 |
+
for persona in records:
|
| 355 |
+
for question in persona["questions"]:
|
| 356 |
+
units.append(
|
| 357 |
+
{
|
| 358 |
+
"_unit_id": question["question_id"],
|
| 359 |
+
"_query": question["question"],
|
| 360 |
+
"_student_id": persona["persona_id"],
|
| 361 |
+
"_profile_seed": persona["profile_seed"],
|
| 362 |
+
"source_key": persona.get("source_key"),
|
| 363 |
+
}
|
| 364 |
+
)
|
| 365 |
+
return units
|
| 366 |
+
return records # sessions keep their own shape
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
async def run_all(args: argparse.Namespace) -> Path:
|
| 370 |
+
records = load_jsonl(args.battery)
|
| 371 |
+
battery_type = detect_battery_type(records)
|
| 372 |
+
if args.ids:
|
| 373 |
+
wanted = set(args.ids)
|
| 374 |
+
key = {"personas": "persona_id"}.get(battery_type)
|
| 375 |
+
records = [
|
| 376 |
+
r
|
| 377 |
+
for r in records
|
| 378 |
+
if record_id(r) in wanted or (key and r.get(key) in wanted)
|
| 379 |
+
]
|
| 380 |
+
if args.limit:
|
| 381 |
+
records = records[: args.limit]
|
| 382 |
+
if not records:
|
| 383 |
+
raise SystemExit("No records selected.")
|
| 384 |
+
|
| 385 |
+
units = prepare_units(battery_type, records)
|
| 386 |
+
out_dir = Path(
|
| 387 |
+
args.out or f"runs/{datetime.date.today():%Y%m%d}_{battery_type}_{args.preset}"
|
| 388 |
+
)
|
| 389 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 390 |
+
(out_dir / "run_config.json").write_text(
|
| 391 |
+
json.dumps(vars(args), indent=1, default=str)
|
| 392 |
+
)
|
| 393 |
+
sink = BundleSink(out_dir / "bundles.jsonl")
|
| 394 |
+
|
| 395 |
+
expected_turns = {
|
| 396 |
+
unit["session_id"] if battery_type == "sessions" else unit["_unit_id"]: (
|
| 397 |
+
len(unit["turns"]) if battery_type == "sessions" else 1
|
| 398 |
+
)
|
| 399 |
+
for unit in units
|
| 400 |
+
}
|
| 401 |
+
completed = prune_incomplete(sink.path, battery_type, expected_turns, args.trials)
|
| 402 |
+
|
| 403 |
+
semaphore = asyncio.Semaphore(args.concurrency)
|
| 404 |
+
# Same-persona questions are serialized: each one re-seeds the canonical
|
| 405 |
+
# profile (run_single_case) and profile_memory's post-turn write-back must
|
| 406 |
+
# not land mid-way through a sibling question's turn. Different personas
|
| 407 |
+
# still run concurrently.
|
| 408 |
+
student_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
| 409 |
+
pending = 0
|
| 410 |
+
|
| 411 |
+
async def guarded(unit: dict[str, Any], trial: int) -> None:
|
| 412 |
+
async with semaphore:
|
| 413 |
+
if battery_type == "sessions":
|
| 414 |
+
await run_session(args, unit, trial, sink)
|
| 415 |
+
elif unit.get("_student_id"):
|
| 416 |
+
async with student_locks[unit["_student_id"]]:
|
| 417 |
+
await run_single_case(args, battery_type, unit, trial, sink)
|
| 418 |
+
else:
|
| 419 |
+
await run_single_case(args, battery_type, unit, trial, sink)
|
| 420 |
+
|
| 421 |
+
tasks = []
|
| 422 |
+
for unit in units:
|
| 423 |
+
unit_id = unit["session_id"] if battery_type == "sessions" else unit["_unit_id"]
|
| 424 |
+
for trial in range(1, args.trials + 1):
|
| 425 |
+
if (unit_id, trial) in completed:
|
| 426 |
+
continue
|
| 427 |
+
pending += 1
|
| 428 |
+
tasks.append(asyncio.create_task(guarded(unit, trial)))
|
| 429 |
+
logger.info(
|
| 430 |
+
"Running %d work unit(s) (%d already complete) -> %s",
|
| 431 |
+
pending,
|
| 432 |
+
len(completed),
|
| 433 |
+
out_dir,
|
| 434 |
+
)
|
| 435 |
+
if tasks:
|
| 436 |
+
await asyncio.gather(*tasks)
|
| 437 |
+
return out_dir
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def main() -> None:
|
| 441 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
| 442 |
+
args = parse_args()
|
| 443 |
+
# Must happen before any app import: app.config enables tracing at import
|
| 444 |
+
# time when LANGSMITH_API_KEY is set, and batch runs would eat the
|
| 445 |
+
# free-plan trace quota.
|
| 446 |
+
if not args.langsmith:
|
| 447 |
+
os.environ["LANGSMITH_TRACING"] = "false"
|
| 448 |
+
out_dir = asyncio.run(run_all(args))
|
| 449 |
+
print(f"Bundles written to {out_dir}/bundles.jsonl")
|
| 450 |
+
print(f"Next: uv run -m evals.grade --run {out_dir}")
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
if __name__ == "__main__":
|
| 454 |
+
main()
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Evals background: research notes & rationale
|
| 2 |
+
|
| 3 |
+
Reference material behind `evals.md`: source attributions, metric design rationale, methodology, pitfalls.
|
| 4 |
+
|
| 5 |
+
Goal: build an eval harness that lets us say, with evidence, "memory/context method A got these results, method B regressed quality but cut cost 40%, method C improved both" — across latency, tokens, cost, retrieval accuracy, and answer quality — and turn that into a workshop on comparing these methods.
|
| 6 |
+
|
| 7 |
+
Sources read: Hamel Husain's evals FAQ + related posts, howtoeval.com (Ben Hylak), and the OpenAI cookbook "Macro Evals for Agentic Systems". Codebase and `data/academy_discussion_eval.jsonl` inspected directly.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. The single most important framing: you are doing TWO different things
|
| 12 |
+
|
| 13 |
+
Everything in the sources clicks into place once you separate two activities that look similar but have different methodologies:
|
| 14 |
+
|
| 15 |
+
1. **Product evals (quality assurance).** "Is our tutor good? Where does it fail?" This is Hamel's world: error analysis → failure taxonomy → targeted binary checks and validated LLM judges. The output is a list of failure modes with frequencies, and automated checks that catch regressions.
|
| 16 |
+
|
| 17 |
+
2. **Controlled experiments (benchmarking variants).** "Does summarization middleware beat full history?" This is classic experiment design: freeze everything (model, prompt, dataset, retrieval), vary exactly one component (the memory/context strategy), run the **identical** scenario battery through each variant, compare metrics with paired statistics. This is what the workshop is actually about, and it's closest to the OpenAI cookbook's approach.
|
| 18 |
+
|
| 19 |
+
**Activity 2 depends on activity 1.** You can't compare variants until you have (a) a trustworthy dataset, (b) metrics you believe, and (c) a judge validated against human labels. And when variant B scores worse, only error analysis tells you *why* (the macro-evals point: a regression number without failure-pattern analysis is a dead end). Hamel gives you the machinery for (a)–(c); the cookbook gives you the cross-population analysis; howtoeval gives you the harness philosophy (assert on trajectories, golden cases gate shipping, small high-signal datasets).
|
| 20 |
+
|
| 21 |
+
This two-activity distinction is also a great workshop narrative: *most teams try to jump straight to activity 2 and get numbers they can't trust.*
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 2. What the three sources say (distilled)
|
| 26 |
+
|
| 27 |
+
### Hamel Husain (hamel.dev/blog/posts/evals-faq + evals + llm-judge + field-guide)
|
| 28 |
+
- **Error analysis first, always.** Read 100+ real traces, take free-form notes ("open coding"), group into a failure taxonomy ("axial coding"), count frequencies. Stop when ~20 consecutive traces yield no new failure category ("theoretical saturation"). Re-run on 100+ fresh traces each cycle. This is "the single highest-ROI activity"; expect 60–80% of dev time on evals overall.
|
| 29 |
+
- **Binary pass/fail + written critique, never 1–5 Likert scores.** Decompose nuance into multiple binary checks (e.g., each expected fact = one binary check). One domain-expert "benevolent dictator" labels; don't outsource, don't committee.
|
| 30 |
+
- **LLM-as-judge via "critique shadowing":** expert labels ~30 diverse examples binary + critique → few-shot judge prompt built from the critiques → iterate until >90% agreement with expert on a held-out set → measure **TPR/TNR**, not raw agreement (classes are imbalanced). Use the strongest model you can afford as judge.
|
| 31 |
+
- **Generic metric dashboards (hallucination score, helpfulness 1–5) are an anti-pattern** — "waste time and create false confidence." Build checks for failures you observed, not failures you imagine. Don't do "eval-driven development."
|
| 32 |
+
- **RAG:** evaluate retrieval and generation **separately**. Retrieval = classic IR metrics (Recall@k, Precision@k, MRR) against query→document ground truth. Generation = error analysis + targeted checks (faithfulness to context, answers the question).
|
| 33 |
+
- **Multi-turn:** start with one conversation-level pass/fail ("did the conversation meet the user's goal?"); annotate only the **first** failure (downstream failures cascade). For test generation, **N-1 replay** (feed real turns 1..N-1, evaluate the model's turn N) beats fully simulated users.
|
| 34 |
+
- **Agents:** two phases — (1) end-to-end black-box success rate, (2) step-level diagnostics: tool choice, argument quality, error recovery, context retention across steps, efficiency (steps, seconds, tokens). Build **transition failure matrices** (last good step × first failed step) and compare them **across experiments** — directly applicable to our variant comparison.
|
| 35 |
+
- **CI:** small purpose-built suites (often ~100 examples), prefer cheap code assertions over judges; 100% pass means your evals are too easy (~70% is healthier).
|
| 36 |
+
|
| 37 |
+
### howtoeval.com (Ben Hylak, Raindrop — one long guide, May 2026)
|
| 38 |
+
- Frame: **floor-raising** (eliminate worst-case failures on critical paths) over benchmark-maxxing. For agents, "the path matters as much as the answer" — offline evals should be **code-aware tests** that run the real agent and assert on outputs *and* tool-call sequences (pytest/vitest style), not prompt-scoring.
|
| 39 |
+
- **Golden cases:** start with 5–10 critical-path scenarios; "if your agent starts failing your golden cases, you do not ship." Datasets come from real production failures; "20 high-signal cases beats 200 low-signal ones"; prune cases that haven't failed in 3 months.
|
| 40 |
+
- **Skeptical of LLM-as-judge** (calibration brittleness, Goodhart's law) and of hosted eval dashboards; weights production monitoring + A/B on real traffic for unverifiable hypotheses. Useful tension to present in the workshop against Hamel's pro-judge stance — the resolution is Hamel's validation discipline: a judge is only as good as its measured agreement with a human expert.
|
| 41 |
+
- Budget ~10–20% of dev time (vs Hamel's 60–80% — present both, the truth depends on maturity/stakes).
|
| 42 |
+
|
| 43 |
+
### OpenAI cookbook — "Macro Evals for Agentic Systems" (May 2026, w/ Promptfoo)
|
| 44 |
+
- **Micro evals** grade one run against a rubric; **macro evals** look *across* hundreds of graded runs to find which failure patterns repeat, where they concentrate, and what to inspect first. Rationale: "a final answer is only the last event in a longer workflow" — final-answer inspection alone misses upstream failures.
|
| 45 |
+
- Four labels per run: **case_type** (the scenario setup) → **run_outcome** (how it ended) → **eval_finding** (which rubric check failed) → **behavior_pattern** (cluster discovered across the population). Slice analysis via lift = pattern share in slice ÷ overall share.
|
| 46 |
+
- **Trace bundles**: persist a complete evidence packet per run (events, tool calls, outcomes, judge labels) so you can **re-grade offline without re-running the agent**. "The quality of the trace document is part of the evaluation design."
|
| 47 |
+
- Tracks per-trace counters (tool calls, handoffs, loops, retries) as severity/complexity inputs. Notably it does **not** cover latency/token/cost — none of the three sources do. We have to define that layer ourselves (it's the easy, objective part).
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 3. What we should measure (the metric stack)
|
| 52 |
+
|
| 53 |
+
Five layers, cheapest/most-objective first. Layers 1–3 are code; only layer 4 needs a judge; layer 5 is the one that actually differentiates memory methods.
|
| 54 |
+
|
| 55 |
+
### Layer 1 — Operational telemetry (free, fully objective)
|
| 56 |
+
Per turn and cumulative per conversation:
|
| 57 |
+
- **Latency**: time-to-first-token and total wall-clock per turn (we stream, so TTFT is what users feel).
|
| 58 |
+
- **Tokens**: input/output per LLM call, summed per turn and per conversation. Input tokens are where memory methods differ most — full history grows O(n²) cumulative input tokens over a conversation; summarization/editing flattens that curve. *The cumulative-input-token curve per conversation turn is the workshop's signature plot.*
|
| 59 |
+
- **Cost**: tokens × provider price sheet (account for cache-read pricing if applicable; summarization itself costs extra LLM calls — count them, that's part of the honest comparison).
|
| 60 |
+
- **Trajectory counters**: number of LLM calls, tool calls per turn (retrieve vs kb_command vs web), KB-command budget consumption, summarization/context-edit trigger firings.
|
| 61 |
+
|
| 62 |
+
LangSmith already captures token usage and latency per run (runs are tagged with model + metadata; add a `memory_variant` tag). The `stream_chat` ChatEvent stream gives tool-call-level detail.
|
| 63 |
+
|
| 64 |
+
### Layer 2 — Retrieval accuracy (objective, we have ground truth)
|
| 65 |
+
- The dataset has `source_key` and `lesson_url` per question → **Recall@k / MRR**: did the right source/lesson appear in `retrieve_tutor_context` results? (Hook into the SearchResult list on `tool_call_completed` events; results carry source + URL.)
|
| 66 |
+
- **Tool-routing correctness**: when `expected_behavior == answer_from_corpus`, did the agent call retrieval/KB at all? When `redirect_to_support`, did it correctly *not* go down a retrieval rabbit hole?
|
| 67 |
+
- Why this matters for memory comparisons: compressed/summarized context can degrade the agent's *query formulation* on later turns (it lost the details needed to write a good retrieval query). Retrieval recall on late turns is a sensitive early indicator of memory damage.
|
| 68 |
+
|
| 69 |
+
### Layer 3 — Behavioral correctness (programmatic binary checks)
|
| 70 |
+
- **Expected-behavior match**: dataset annotates `expected_behavior` ∈ {answer_from_corpus, answer_general, redirect_to_support, acknowledge_feedback}. A small classifier-judge or keyword check per behavior → binary pass.
|
| 71 |
+
- **Citation validity**: we already resolve inline citations against current-turn evidence + KB manifest (`app/kb_manifest.py`) — assert citations resolve, and that corpus answers carry ≥1 citation.
|
| 72 |
+
- **Format/safety assertions**: answered in scope, didn't fabricate course logistics, didn't promise human follow-up, etc. (grow this list from error analysis, not imagination).
|
| 73 |
+
|
| 74 |
+
### Layer 4 — Answer quality (LLM-as-judge, validated)
|
| 75 |
+
- **Key-point coverage** (primary): annotations include 1–4 `key_points` extracted from real staff answers. For each key point, one binary judge call: "Does the answer correctly convey this point? pass/fail + one-line critique." Report coverage = fraction of key points passed. This is exactly Hamel's "decompose gradations into separate binary checks" and is far more reliable than holistic scoring.
|
| 76 |
+
- **Faithfulness**: binary — is every substantive claim in the answer supported by the retrieved evidence / KB content in the trace? (Catches hallucination specifically on corpus questions.)
|
| 77 |
+
- **Holistic pass/fail** (secondary): "Would the course staff member have approved sending this answer? pass/fail + critique" — few-shot prompt built from *our own labeled critiques*, validated to >90% TPR/TNR agreement against ~30–50 human-labeled traces before we trust it. The team member who actually answers academy questions is the "benevolent dictator" labeler.
|
| 78 |
+
|
| 79 |
+
### Layer 5 — Memory-specific probes (the differentiator — this is OUR design; no source covers it)
|
| 80 |
+
Memory methods only differ when context pressure exists. Single-turn evals will show **zero difference** between variants (our SummarizationMiddleware triggers at 30k tokens, ContextEditing at 5k tool-tokens — a one-shot question never trips them). So:
|
| 81 |
+
- **Recall-after-compression probes**: plant a fact early (turn 1–2: "I'm on the Agentic AI course, lesson 4, using Python 3.13 on Windows, my API key is set via .env"), drive 5–10 heavy turns (each invoking retrieval/KB to inflate tool tokens past the triggers), then probe: "given my setup, why might X fail?" Binary: does the answer use the planted facts?
|
| 82 |
+
- **Consistency**: does turn N contradict what the tutor said in turn 2? (Judge check on conversation pairs.)
|
| 83 |
+
- **No re-asking**: does the agent ask for information the user already provided? (Strong signal of memory loss; cheap judge check.)
|
| 84 |
+
- **Instruction persistence**: user says "explain everything assuming I'm a beginner / always show code in Python" at turn 1 — is turn 8 still complying?
|
| 85 |
+
- **Anaphora resolution under compression**: late-turn question that says "the second approach you mentioned" — resolvable only if earlier assistant content survived.
|
| 86 |
+
|
| 87 |
+
Score each probe binary, report **memory probe accuracy** per variant. This is the column where full-history wins and aggressive compression loses — the interesting result is *how much* quality each method trades for its token savings.
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 4. Agents vs one-turn chatbots — and tutors vs coding agents
|
| 92 |
+
|
| 93 |
+
How agent evals differ from single-turn chatbot evals (all three sources agree):
|
| 94 |
+
1. **The trajectory is a first-class eval target.** A correct answer reached via 14 redundant KB commands is a different (worse) result than the same answer in 2 tool calls. Assert on tool-call sequences and counts, not just final text.
|
| 95 |
+
2. **Failures cascade.** Annotate the *first* failure per trace; build transition matrices (where in the pipeline do failures start — query formulation? retrieval? synthesis? citation?). Fixing late-stage symptoms of early-stage failures is wasted work.
|
| 96 |
+
3. **Final-answer inspection alone is insufficient** — an answer can look fine while the trace shows the agent ignored retrieved evidence and answered from parametric knowledge (a faithfulness failure that will bite on corpus-specific content).
|
| 97 |
+
4. **Two-phase evaluation**: end-to-end success rate first (cheap, comparable), step-level diagnostics second (explains the deltas).
|
| 98 |
+
5. **Multi-turn adds a conversation-level unit of analysis**: "did the session meet the student's goal" is judged over the whole conversation, with per-turn metrics underneath.
|
| 99 |
+
|
| 100 |
+
How a **tutor** differs from a **coding agent** (your instinct is right):
|
| 101 |
+
- Coding agents have **executable ground truth** (tests pass / build compiles). We don't — there is no compiler for "good pedagogical answer." That pushes us toward (a) human-aligned LLM judges and (b) squeezing every drop out of the *programmatic* ground truth we *do* have: retrieval ground truth (`source_key`/`lesson_url`), citation resolvability, expected-behavior routing, and key-point coverage against real staff answers. We're actually unusually well-positioned: most chatbot teams have no reference answers; we have 135 real staff answers.
|
| 102 |
+
- Tutor-specific quality dimensions worth tracking once error analysis confirms they occur: grounding in *our* course material vs generic internet answers (a student asks about lesson 4's notebook; a generically-correct-but-course-ignorant answer is a failure), level-appropriateness, and scope discipline (redirect platform issues instead of hallucinating refund policies).
|
| 103 |
+
- The agentic surface we *do* share with coding agents: tool selection (retrieve vs browse KB vs web), query formulation quality, and budget efficiency — evaluate those the same way coding-agent evals do (trajectory assertions + efficiency counters).
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## 5. The dataset: what we have, and the gap we must fill
|
| 108 |
+
|
| 109 |
+
`data/academy_discussion_eval.jsonl` — 151 real student questions, LLM-annotated (gemini-3.5-flash, 2026-06-10):
|
| 110 |
+
|
| 111 |
+
| Dimension | Distribution |
|
| 112 |
+
|---|---|
|
| 113 |
+
| eval_quality | gold 62 · usable 61 · weak 9 · exclude 19 |
|
| 114 |
+
| category | debugging 47 · course_feedback 39 · conceptual 31 · platform_issue 20 · other 14 |
|
| 115 |
+
| expected_behavior | answer_general 49 · acknowledge_feedback 41 · answer_from_corpus 40 · redirect_to_support 21 |
|
| 116 |
+
| has reference_answer | 135 yes · 16 no |
|
| 117 |
+
| key_points | 91 cases have ≥1 (60 have none) |
|
| 118 |
+
| time_bound | **52 true** · 99 false |
|
| 119 |
+
| source | full_stack 98 · agentic 37 · python 15 · none 1 |
|
| 120 |
+
|
| 121 |
+
**Strengths**: real user distribution (not synthetic), real staff reference answers, behavior labels that map directly to binary checks, **81 cases that are gold/usable + key_points + reference answer** — that's the core single-turn battery, right in Hamel's "~100-example purpose-built suite" range.
|
| 122 |
+
|
| 123 |
+
**Caveats**:
|
| 124 |
+
- **Verify the annotations.** They're LLM-generated and unreviewed. Before trusting them as ground truth, the domain expert should review at least the 62 gold cases (Hamel: never delegate ground-truth labels). This doubles as your first error-analysis session. Expect to demote some.
|
| 125 |
+
- **52 time_bound cases**: answers reference point-in-time state (broken links, current platform behavior). Exclude from the comparison battery or re-verify; they'll produce noise, not signal.
|
| 126 |
+
- **Skew**: 65% from one course; fine for now, note it when reporting.
|
| 127 |
+
- **It's almost entirely single-turn** (82 threads have 1 reply, 14 have none; the few longer threads are discussion back-and-forth, not chat sessions). **This dataset alone cannot differentiate memory methods** (§3 layer 5).
|
| 128 |
+
|
| 129 |
+
**The gap-filler: a synthetic multi-turn "study session" suite.** Following Hamel's synthetic-inputs guidance (generate *inputs* only; run them through the real system):
|
| 130 |
+
1. Take related gold cases from the same course/lesson; chain 4–8 of them into a plausible study session script (student persona working through a lesson, hitting issues).
|
| 131 |
+
2. Insert planted facts early (persona, environment, constraints) and 1–3 **memory probes** late (§3 layer 5), positioned *after* enough tool-heavy turns that the 5k/30k triggers have demonstrably fired (log trigger events to verify — a memory eval where compression never activated measures nothing).
|
| 132 |
+
3. Hand-write ~5 session scripts first (Hamel: hand-write ~20 seed tuples before LLM-generating more), then LLM-generate variants along dimensions: course × persona (beginner/advanced) × session length × probe type. Target ~30–50 sessions.
|
| 133 |
+
4. Also use **N-1 replay** on the 9 real threads with ≥3 turns — replay real turns, evaluate the next one. Small but real.
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## 6. Error analysis and LLM-as-judge, in plain terms (and exactly what to do)
|
| 138 |
+
|
| 139 |
+
**Error analysis** = systematically reading your system's transcripts and writing down what went wrong, then grouping the notes into a counted taxonomy. No math, no models — structured journaling. It is unanimously (all three sources) the highest-ROI step and the one everyone skips. Concretely for us:
|
| 140 |
+
1. Run the current system (default config) over the ~80-case single-turn battery. Persist full trace bundles (every ChatEvent: tool calls, retrieval results, reasoning, final answer) as JSONL — the cookbook's "grade offline, re-grade cheaply" pattern.
|
| 141 |
+
2. Omar (or whoever answers academy questions) reads every trace next to the staff reference answer, marks binary good/bad, writes a one-to-three-sentence note on the *first* thing that went wrong.
|
| 142 |
+
3. Group notes into a failure taxonomy (LLM can help cluster; human validates) and count. Expect things like: retrieval missed the lesson → answered generically; ignored retrieved evidence; over-eager web search; wrong behavior routing (answered a platform issue instead of redirecting); citation errors.
|
| 143 |
+
4. **Those observed failure modes — not a generic metric list — become the automated checks in layers 3–4.** Hamel's strongest warning is against bolting on prefab "hallucination/helpfulness" scores; build checks for what actually breaks.
|
| 144 |
+
5. A tiny annotation viewer (Streamlit, one screen: trace + reference answer + pass/fail button + notes box) is "the single most impactful investment" — buildable in an afternoon, ~10x labeling speed.
|
| 145 |
+
|
| 146 |
+
**LLM-as-judge** = using a strong LLM to grade outputs at a scale humans can't. The catch: an unvalidated judge is a random-ish number generator with confident prose. The discipline (Hamel's critique-shadowing): human-label 30–50 diverse traces binary+critique → build the judge prompt with those critiques as few-shot examples → run judge on held-out labeled traces → measure TPR/TNR → iterate the prompt until agreement >90% → only then run it at scale, and re-spot-check periodically. Our `key_points` decomposition makes the judge's job nearly mechanical ("does the answer say X?"), which is exactly where judges are most reliable. Hylak's skepticism (Goodhart, calibration drift) is the reason for the validation step, not a reason to skip judges — we have no executable ground truth, so a validated judge is the only scalable quality measure available.
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## 7. The controlled experiment: "method A vs B vs C"
|
| 151 |
+
|
| 152 |
+
### Variants (all parameterizable from our codebase — see §8 for the needed refactor)
|
| 153 |
+
| Variant | Description |
|
| 154 |
+
|---|---|
|
| 155 |
+
| **A. Full history** (baseline) | No summarization, no context editing. Upper bound on quality/memory, worst tokens/cost. |
|
| 156 |
+
| **B. Current prod** | ContextEditing(trigger 5k tool-tokens, keep 5) + Summarization(trigger 30k, keep last 20 msgs). |
|
| 157 |
+
| **C. Summarization only** | Isolate the summarizer's contribution. |
|
| 158 |
+
| **D. Context-editing only** | Isolate tool-result pruning. |
|
| 159 |
+
| **E. Aggressive compression** | e.g. Summarization @ 8k keep 8, editing @ 2k keep 2. The "how bad can cheap get" point. |
|
| 160 |
+
| **F+. SOTA methods (workshop highlights)** | One or more of: **long-term memory store** (LangMem/LangGraph Store: extract salient facts per turn into a store, retrieve-into-context on later turns — MemGPT/Letta lineage); **retrieval-over-history** (embed past turns, RAG over your own conversation instead of carrying it); **structured note-taking / compaction** (agent maintains a running scratchpad of session state, à la Anthropic's context-management work and Claude Code's compaction); **observation offloading** (truncate tool outputs to references, re-fetch on demand). |
|
| 161 |
+
|
| 162 |
+
### Protocol
|
| 163 |
+
- **Hold constant**: model (run the full matrix on one model first; model×memory interaction is a separate, second experiment), system prompt, retrieval config, dataset, tool config, temperature.
|
| 164 |
+
- **Vary**: the memory/context configuration only. Tag every run/trace with `memory_variant` (LangSmith metadata + trace bundle field).
|
| 165 |
+
- **Repeat trials**: LLM nondeterminism is real. 3 trials per case per variant minimum; report mean ± and also **consistency** (pass^3: passed all 3 trials — workshops love seeing that a method is not just better on average but more *reliable*).
|
| 166 |
+
- **Paired comparison**: every variant sees the identical cases, so compare per-case (paired bootstrap for rates, McNemar for binary). With ~80 single-turn cases + ~40 sessions × 3 trials, ~10-point pass-rate differences will be clearly resolvable (none of the sources give significance guidance — this is standard stats we add ourselves).
|
| 167 |
+
- **Two-tier reporting**: single-turn battery (sanity tier: variants should be ~equal here — if a memory method hurts single-turn answers, that's a bug, and it cleanly isolates *memory* effects to the multi-turn tier) + multi-turn session battery (where the methods actually separate).
|
| 168 |
+
|
| 169 |
+
### The results matrix (the workshop money-slide)
|
| 170 |
+
Rows = variants A–F. Columns = answer pass rate · key-point coverage · faithfulness · memory-probe accuracy · retrieval Recall@5 (late-turn) · expected-behavior accuracy · cumulative input tokens/session · cost/session · p50/p95 TTFT · summarizer overhead tokens. Plus the signature plot: **cumulative input tokens vs turn number, one line per variant**, annotated with where each variant's quality started dropping.
|
| 171 |
+
|
| 172 |
+
### Explaining the deltas (macro layer)
|
| 173 |
+
When a variant regresses, don't stop at the number: diff the failure taxonomies between variants (which failure modes did compression *create*?), and use first-failure/transition analysis (did failures move upstream into query formulation?). With a few hundred graded traces per variant, even the cookbook's lightweight version — group failed traces by eval_finding × case_type, look for concentration — is enough; full clustering machinery is optional.
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 8. Implementation notes (grounded in our code)
|
| 178 |
+
|
| 179 |
+
1. **Runner**: a Python script calling `stream_chat(ChatRequest(...))` directly (no HTTP needed) — `app/chat_service.py` is the single entry point. Multi-turn sessions: reuse one `thread_id` across turns (InMemorySaver is in-process, so the runner keeps state naturally). Persist one **trace bundle** JSONL per run: case_id, variant, trial, every ChatEvent, per-call token usage, timings, final answer.
|
| 180 |
+
2. **Parameterize the middlewares**: `build_agent` hard-codes ContextEditing/Summarization params (`app/chat_service.py:832–851`). Add a memory-config parameter (env var or ChatRequest field) selecting variant presets, and include it in the agent cache key. This is the one refactor the harness requires.
|
| 181 |
+
3. **Token/latency capture**: LangChain returns `usage_metadata` per model call; also already in LangSmith runs (tagged with model/thread; add `memory_variant` to the metadata dict at `app/chat_service.py:979–992`). Capture both — bundles for offline analysis, LangSmith for trace browsing during error analysis.
|
| 182 |
+
4. **Retrieval ground truth hook**: `tool_call_completed` events carry SearchResult matches (source, URL, score) — compute Recall@k/MRR in the runner, no retriever changes needed.
|
| 183 |
+
5. **Verify triggers fire**: log summarization/context-edit activations per session; assert the multi-turn suite actually trips them (else lengthen sessions).
|
| 184 |
+
6. **LangSmith vs roll-your-own**: use LangSmith for trace inspection + telemetry; keep the runner/dataset/judging in-repo (plain JSONL + pytest-style checks, per howtoeval) so the workshop materials are self-contained and reproducible without a SaaS dependency.
|
| 185 |
+
|
| 186 |
+
### Phased build (each phase ships something usable alone)
|
| 187 |
+
1. **Curate** (≈1 day): expert-review the 62 gold annotations; freeze a v1 battery (~80 single-turn cases, time_bound excluded); pick 5–10 **golden cases** that gate any future ship.
|
| 188 |
+
2. **Runner + bundles** (≈1–2 days): batch runner, trace persistence, telemetry. Layer-1 metrics work immediately.
|
| 189 |
+
3. **Error analysis** (≈1 day of expert time): run baseline, read all traces in a small Streamlit viewer, build the failure taxonomy. *Do this before writing any judge.*
|
| 190 |
+
4. **Programmatic checks** (≈1 day): retrieval recall, behavior routing, citation validity, + checks derived from step 3.
|
| 191 |
+
5. **Judge** (≈2 days incl. labeling): key-point coverage + faithfulness + holistic, validated to >90% TPR/TNR against expert labels.
|
| 192 |
+
6. **Multi-turn suite** (≈2 days): 5 hand-written sessions → ~30–50 generated; memory probes; N-1 replay of the 9 real multi-turn threads.
|
| 193 |
+
7. **Experiment matrix** (compute-bound): variants × battery × 3 trials; results matrix; failure-taxonomy diff per variant.
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## 9. Workshop framing suggestions
|
| 198 |
+
|
| 199 |
+
- The build order **is** the talk: why generic metrics fail → error analysis on real student questions (show real traces!) → decomposed binary checks → judge validation (show the TPR/TNR table — audiences rarely see *judge* evals) → controlled comparison → results matrix → "why did E fail" trace autopsy.
|
| 200 |
+
- The honest tension between sources is great material: Hamel (judges, 60–80% time) vs Hylak (judge-skeptic, golden cases, 10–20%) vs OpenAI (population-scale macro analysis). Resolution: maturity and stakes determine the dose; validation discipline determines whether judges are trustworthy at all.
|
| 201 |
+
- The memory-probe design (plant facts → inflate context past compression triggers → probe) is the novel, reusable artifact attendees take home — none of the public sources cover it.
|
| 202 |
+
- Have one **counterintuitive result** ready (e.g., "aggressive compression cut cost 70% and memory-probe accuracy only dropped 8 points" or the reverse) — that's what gets shared.
|
| 203 |
+
|
| 204 |
+
## 10. The two-week workshop cut (what to actually do before the talk)
|
| 205 |
+
|
| 206 |
+
The full plan above is research-grade; the talk needs **demo-grade numbers on a meter plus one honest bake-off table**. The cut:
|
| 207 |
+
|
| 208 |
+
**Keep (the talk literally requires these):**
|
| 209 |
+
1. **Layer-1 telemetry = the meter.** Build the `context_stats` instrumentation once so both the UI meter and a headless runner consume it. Capture usage from the raw stream / `aget_state` *before* display filtering (compaction-ON runs undercount otherwise).
|
| 210 |
+
2. **Demo session scripts = the multi-turn suite, miniaturized.** The 13-turn compaction session and the 5-fact memory session ARE recall-after-compression probes. Write them deliberately: plant facts early, verify the 30k/5k triggers actually fire (log trigger events), probe late. 2–3 scripted sessions, rehearsed.
|
| 211 |
+
3. **Rehearsal = error analysis lite.** Read every trace from rehearsal runs. The failures found become the "failure first" demo beats — the talk format (failure → fix → number) *requires* harvesting real failures, which is exactly error analysis.
|
| 212 |
+
4. **Mini bake-off**: 3–4 configs (no-memory RAG baseline · compaction OFF/ON · + profile memory) × ~15–20 expert-verified gold single-turn cases × 2–3 scripted sessions, 1–2 trials. **Hand-grade** answers binary against key_points (≈60–80 readings, a half-day) instead of building a validated judge. Report counts honestly ("20 real student questions, 2 trials") — small-n with real data beats large-n synthetic for a talk.
|
| 213 |
+
5. **Replace every placeholder number before slides freeze** (4k→45k→8k, 0/5→5/5, 4k→800) with measured ones, and cache/pre-record fallback runs for the live demo.
|
| 214 |
+
|
| 215 |
+
**Cut / defer to post-workshop (and pitch as course content):** judge validation to >90% TPR/TNR, the 6-variant × 80-case × 3-trial matrix, N-1 replay, paired statistics, macro failure-pattern analysis, variants D/E/F beyond profile memory.
|
| 216 |
+
|
| 217 |
+
**Three fixes the talk outline needs:**
|
| 218 |
+
- **Stale code paths**: the outline targets `scripts/chat_service.py` + Gradio (`main.py`, `gradio_presenter.py`), but the repo is now `app/chat_service.py` + Next.js (Gradio was removed). The agent-side changes (toggles in `build_agent`, `MemoryMiddleware` via the `SourcePreferenceMiddleware` pattern, `InMemoryStore`, `context_stats` event) port ~1:1; the UI meter must be a Next.js component fed by the SSE stream (or the workshop branch resurrects the old Gradio snapshot — decide which, it changes the estimate).
|
| 219 |
+
- **The bake-off as written can't show a memory effect.** "No-memory RAG vs profile memory on the same questions" yields identical results if the questions are single-turn and profile-independent (§3 layer 5). The bake-off questions must be personalization-sensitive: phrased against the stored profile ("given my level / my project / my language preference…") or run as session turn-N probes. Otherwise the table shows memory ≈ baseline and undermines the demo.
|
| 220 |
+
- **Show compaction's tradeoff, not only the win.** 45k→8k tokens is the headline, but include one probe where summarization *loses* a planted detail (and full history doesn't). That's the credible, memorable beat — and it sets up "this is why you measure" for the course upsell.
|
| 221 |
+
|
| 222 |
+
**Rough schedule (10 working days):** D1–3 workshop-branch code (meter, toggles, memory, skills) · D4–5 curate ~20 gold cases + write session scripts + headless runner · D6–8 bake-off runs, hand-grading, build the table, harvest failure beats, lock real numbers · D9–10 rehearse live paths + cached fallbacks.
|
| 223 |
+
|
| 224 |
+
## 11. Pitfalls checklist
|
| 225 |
+
|
| 226 |
+
- ☐ Don't compare variants on single-turn cases only — memory methods are indistinguishable there.
|
| 227 |
+
- ☐ Don't trust the LLM-generated annotations without expert review (esp. key_points and eval_quality).
|
| 228 |
+
- ☐ Exclude/handle the 52 time_bound cases.
|
| 229 |
+
- ☐ Don't use an unvalidated judge; report its TPR/TNR alongside results.
|
| 230 |
+
- ☐ Count the *full* cost of each method (summarizer LLM calls, memory-store writes) — not just the saved prompt tokens.
|
| 231 |
+
- ☐ One trial per case is noise; pair the comparisons.
|
| 232 |
+
- ☐ No prefab metric dashboards; every automated check traces back to an observed failure or a dataset ground truth.
|
| 233 |
+
- ☐ Verify compression triggers actually fired in the multi-turn suite.
|
|
@@ -710,6 +710,66 @@ class ApiTestCase(unittest.TestCase):
|
|
| 710 |
omitted = build_chat_request(ApiChatRequest(query="What is RAG?"))
|
| 711 |
self.assertEqual(omitted.source_keys, tuple(DEFAULT_SELECTED_SOURCE_KEYS))
|
| 712 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
|
| 714 |
LIVE_API_E2E = pytest.mark.skipif(
|
| 715 |
os.getenv("RUN_LIVE_API_E2E") != "1",
|
|
|
|
| 710 |
omitted = build_chat_request(ApiChatRequest(query="What is RAG?"))
|
| 711 |
self.assertEqual(omitted.source_keys, tuple(DEFAULT_SELECTED_SOURCE_KEYS))
|
| 712 |
|
| 713 |
+
def test_memory_preset_and_student_id_mapping(self) -> None:
|
| 714 |
+
from fastapi import HTTPException
|
| 715 |
+
|
| 716 |
+
from app.api import ApiChatRequest, build_chat_request
|
| 717 |
+
|
| 718 |
+
request = build_chat_request(
|
| 719 |
+
ApiChatRequest(
|
| 720 |
+
query="What is RAG?",
|
| 721 |
+
memoryPreset="full_history",
|
| 722 |
+
studentId=" student-1 ",
|
| 723 |
+
)
|
| 724 |
+
)
|
| 725 |
+
self.assertEqual(request.memory_preset, "full_history")
|
| 726 |
+
self.assertEqual(request.student_id, "student-1")
|
| 727 |
+
|
| 728 |
+
# Omitted preset stays empty so the server-side default resolution
|
| 729 |
+
# (env var, then "prod") applies at stream time.
|
| 730 |
+
omitted = build_chat_request(ApiChatRequest(query="What is RAG?"))
|
| 731 |
+
self.assertEqual(omitted.memory_preset, "")
|
| 732 |
+
|
| 733 |
+
with self.assertRaises(HTTPException) as raised:
|
| 734 |
+
build_chat_request(
|
| 735 |
+
ApiChatRequest(query="What is RAG?", memoryPreset="typo")
|
| 736 |
+
)
|
| 737 |
+
self.assertEqual(raised.exception.status_code, 422)
|
| 738 |
+
|
| 739 |
+
def test_encoder_emits_transient_context_stats_part(self) -> None:
|
| 740 |
+
encoder = UIMessageStreamEncoder()
|
| 741 |
+
parts = encoder.encode(
|
| 742 |
+
ChatEvent(
|
| 743 |
+
"context_stats",
|
| 744 |
+
{
|
| 745 |
+
"message_id": "msg_1",
|
| 746 |
+
"memory_preset": "prod",
|
| 747 |
+
"llm_calls": 3,
|
| 748 |
+
"input_tokens": 1200,
|
| 749 |
+
"output_tokens": 250,
|
| 750 |
+
"total_tokens": 1450,
|
| 751 |
+
"cache_read_tokens": 400,
|
| 752 |
+
"cache_creation_tokens": 0,
|
| 753 |
+
"est_cost_usd": None,
|
| 754 |
+
"ttft_ms": 850,
|
| 755 |
+
"total_ms": 4200,
|
| 756 |
+
"context_messages": 9,
|
| 757 |
+
"context_tokens_approx": 5400,
|
| 758 |
+
"summary_messages": 1,
|
| 759 |
+
"cleared_tool_outputs": 2,
|
| 760 |
+
},
|
| 761 |
+
)
|
| 762 |
+
)
|
| 763 |
+
self.assertEqual(len(parts), 1)
|
| 764 |
+
part = parts[0]
|
| 765 |
+
self.assertEqual(part["type"], "data-context-stats")
|
| 766 |
+
self.assertTrue(part["transient"])
|
| 767 |
+
self.assertEqual(part["data"]["memoryPreset"], "prod")
|
| 768 |
+
self.assertEqual(part["data"]["inputTokens"], 1200)
|
| 769 |
+
self.assertEqual(part["data"]["summaryMessages"], 1)
|
| 770 |
+
# Unknown pricing must surface as null, never $0.
|
| 771 |
+
self.assertIsNone(part["data"]["estCostUsd"])
|
| 772 |
+
|
| 773 |
|
| 774 |
LIVE_API_E2E = pytest.mark.skipif(
|
| 775 |
os.getenv("RUN_LIVE_API_E2E") != "1",
|
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import unittest
|
| 4 |
+
|
| 5 |
+
from evals.common import detect_battery_type, normalize_url, percentile
|
| 6 |
+
from evals.grade import (
|
| 7 |
+
behavior_heuristic,
|
| 8 |
+
grade_persona_question,
|
| 9 |
+
retrieval_metrics,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CommonTests(unittest.TestCase):
|
| 14 |
+
def test_detect_battery_type(self) -> None:
|
| 15 |
+
self.assertEqual(detect_battery_type([{"case_id": "x"}]), "singleturn")
|
| 16 |
+
self.assertEqual(detect_battery_type([{"session_id": "x"}]), "sessions")
|
| 17 |
+
self.assertEqual(detect_battery_type([{"persona_id": "x"}]), "personas")
|
| 18 |
+
self.assertEqual(detect_battery_type([{"replay_id": "x"}]), "replay")
|
| 19 |
+
with self.assertRaises(ValueError):
|
| 20 |
+
detect_battery_type([{"foo": 1}])
|
| 21 |
+
|
| 22 |
+
def test_normalize_url(self) -> None:
|
| 23 |
+
self.assertEqual(
|
| 24 |
+
normalize_url("https://X.com/Lessons/abc/?q=1#frag"),
|
| 25 |
+
"https://x.com/lessons/abc",
|
| 26 |
+
)
|
| 27 |
+
self.assertEqual(normalize_url(None), "")
|
| 28 |
+
|
| 29 |
+
def test_normalize_url_strips_discussion_suffix(self) -> None:
|
| 30 |
+
# Battery lesson_urls point at the discussion; matches carry the bare
|
| 31 |
+
# lesson URL. Both must normalize to the same key.
|
| 32 |
+
discussion = (
|
| 33 |
+
"https://academy.towardsai.net/courses/take/agent-engineering/"
|
| 34 |
+
"multimedia/67469688-lesson-1/discussions/12758677"
|
| 35 |
+
)
|
| 36 |
+
lesson = (
|
| 37 |
+
"https://academy.towardsai.net/courses/take/agent-engineering/"
|
| 38 |
+
"multimedia/67469688-lesson-1"
|
| 39 |
+
)
|
| 40 |
+
self.assertEqual(normalize_url(discussion), normalize_url(lesson))
|
| 41 |
+
|
| 42 |
+
def test_percentile(self) -> None:
|
| 43 |
+
# Nearest-rank: even-length p50 rounds to the upper of the two middles.
|
| 44 |
+
self.assertEqual(percentile([1, 2, 3, 4], 50), 3)
|
| 45 |
+
self.assertEqual(percentile([1, 2, 3], 50), 2)
|
| 46 |
+
self.assertEqual(percentile([5], 95), 5)
|
| 47 |
+
self.assertIsNone(percentile([], 50))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def bundle_with_matches(matches, tool_name="retrieve_tutor_context"):
|
| 51 |
+
return {"tool_calls": [{"tool_name": tool_name, "matches": matches}]}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class RetrievalMetricsTests(unittest.TestCase):
|
| 55 |
+
LESSON = "https://academy.towardsai.net/courses/take/x/lessons/123-foo"
|
| 56 |
+
|
| 57 |
+
def test_hit_source_and_lesson_with_mrr(self) -> None:
|
| 58 |
+
bundle = bundle_with_matches(
|
| 59 |
+
[
|
| 60 |
+
{"source_key": "other", "url": "https://elsewhere"},
|
| 61 |
+
{"source_key": "full_stack_ai_engineering", "url": self.LESSON + "/"},
|
| 62 |
+
]
|
| 63 |
+
)
|
| 64 |
+
metrics = retrieval_metrics(bundle, "full_stack_ai_engineering", self.LESSON)
|
| 65 |
+
self.assertTrue(metrics["called_retrieval"])
|
| 66 |
+
self.assertTrue(metrics["recall_source"])
|
| 67 |
+
self.assertTrue(metrics["recall_lesson"])
|
| 68 |
+
self.assertEqual(metrics["mrr_lesson"], 0.5)
|
| 69 |
+
|
| 70 |
+
def test_miss_lesson(self) -> None:
|
| 71 |
+
bundle = bundle_with_matches(
|
| 72 |
+
[{"source_key": "full_stack_ai_engineering", "url": "https://other"}]
|
| 73 |
+
)
|
| 74 |
+
metrics = retrieval_metrics(bundle, "full_stack_ai_engineering", self.LESSON)
|
| 75 |
+
self.assertFalse(metrics["recall_lesson"])
|
| 76 |
+
self.assertEqual(metrics["mrr_lesson"], 0.0)
|
| 77 |
+
|
| 78 |
+
def test_kb_command_counts_as_retrieval_but_adds_no_matches(self) -> None:
|
| 79 |
+
bundle = bundle_with_matches([], tool_name="run_kb_command")
|
| 80 |
+
metrics = retrieval_metrics(bundle, "x", self.LESSON)
|
| 81 |
+
self.assertTrue(metrics["called_retrieval"])
|
| 82 |
+
self.assertEqual(metrics["retrieved_matches"], 0)
|
| 83 |
+
|
| 84 |
+
def test_no_ground_truth_yields_none(self) -> None:
|
| 85 |
+
bundle = bundle_with_matches([])
|
| 86 |
+
metrics = retrieval_metrics(bundle, None, None)
|
| 87 |
+
self.assertIsNone(metrics["recall_lesson"])
|
| 88 |
+
self.assertIsNone(metrics["mrr_lesson"])
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class BehaviorHeuristicTests(unittest.TestCase):
|
| 92 |
+
def test_corpus_requires_tool_use(self) -> None:
|
| 93 |
+
used = {"answer": "...", "tool_calls": [{"tool_name": "run_kb_command"}]}
|
| 94 |
+
bare = {"answer": "...", "tool_calls": []}
|
| 95 |
+
self.assertTrue(behavior_heuristic("answer_from_corpus", used))
|
| 96 |
+
self.assertFalse(behavior_heuristic("answer_from_corpus", bare))
|
| 97 |
+
|
| 98 |
+
def test_redirect_and_feedback_regexes(self) -> None:
|
| 99 |
+
self.assertTrue(
|
| 100 |
+
behavior_heuristic(
|
| 101 |
+
"redirect_to_support",
|
| 102 |
+
{"answer": "Please reach out to the academy team.", "tool_calls": []},
|
| 103 |
+
)
|
| 104 |
+
)
|
| 105 |
+
self.assertTrue(
|
| 106 |
+
behavior_heuristic(
|
| 107 |
+
"acknowledge_feedback",
|
| 108 |
+
{"answer": "Thank you for the suggestion!", "tool_calls": []},
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
self.assertIsNone(
|
| 112 |
+
behavior_heuristic("answer_general", {"answer": "x", "tool_calls": []})
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class PersonaGradingTests(unittest.TestCase):
|
| 117 |
+
QUESTION = {
|
| 118 |
+
"checks": [{"type": "regex_any", "patterns": ["conda"]}],
|
| 119 |
+
"anti_patterns": ["uv sync", "python -m venv"],
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
def test_pass(self) -> None:
|
| 123 |
+
result = grade_persona_question(self.QUESTION, "Use conda env create.")
|
| 124 |
+
self.assertTrue(result["auto_pass"])
|
| 125 |
+
|
| 126 |
+
def test_anti_pattern_fails_even_when_check_passes(self) -> None:
|
| 127 |
+
result = grade_persona_question(
|
| 128 |
+
self.QUESTION, "conda works, or run `uv sync` instead."
|
| 129 |
+
)
|
| 130 |
+
self.assertFalse(result["auto_pass"])
|
| 131 |
+
self.assertEqual(result["anti_pattern_hits"], ["uv sync"])
|
| 132 |
+
|
| 133 |
+
def test_case_insensitive(self) -> None:
|
| 134 |
+
result = grade_persona_question(self.QUESTION, "CONDA is fine")
|
| 135 |
+
self.assertTrue(result["auto_pass"])
|
| 136 |
+
|
| 137 |
+
def test_llm_check_defers(self) -> None:
|
| 138 |
+
question = {
|
| 139 |
+
"checks": [
|
| 140 |
+
{"type": "regex_any", "patterns": ["conda"]},
|
| 141 |
+
{"type": "llm", "instruction": "is it beginner-level?"},
|
| 142 |
+
],
|
| 143 |
+
"anti_patterns": [],
|
| 144 |
+
}
|
| 145 |
+
result = grade_persona_question(question, "conda activate course")
|
| 146 |
+
self.assertIsNone(result["auto_pass"])
|
| 147 |
+
self.assertTrue(result["needs_judgment"])
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
unittest.main()
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import types
|
| 4 |
+
import unittest
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from app.chat_service import (
|
| 8 |
+
SourcePreferenceMiddleware,
|
| 9 |
+
StudentProfileMiddleware,
|
| 10 |
+
build_agent,
|
| 11 |
+
build_agent_middleware,
|
| 12 |
+
)
|
| 13 |
+
from app.memory_presets import (
|
| 14 |
+
DEFAULT_MEMORY_PRESET,
|
| 15 |
+
MEMORY_PRESETS,
|
| 16 |
+
resolve_memory_preset,
|
| 17 |
+
)
|
| 18 |
+
from langchain.agents.middleware import (
|
| 19 |
+
ContextEditingMiddleware,
|
| 20 |
+
SummarizationMiddleware,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _stub_model():
|
| 25 |
+
# SummarizationMiddleware reads model._llm_type at init.
|
| 26 |
+
return types.SimpleNamespace(_llm_type="fake-chat-model")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class MemoryPresetResolutionTests(unittest.TestCase):
|
| 30 |
+
def test_prod_preset_matches_production_constants(self) -> None:
|
| 31 |
+
prod = MEMORY_PRESETS["prod"]
|
| 32 |
+
self.assertTrue(prod.summarization)
|
| 33 |
+
self.assertEqual(prod.summarization_trigger_tokens, 30_000)
|
| 34 |
+
self.assertEqual(prod.summarization_keep_messages, 20)
|
| 35 |
+
self.assertTrue(prod.context_editing)
|
| 36 |
+
self.assertEqual(prod.context_editing_trigger_tokens, 5_000)
|
| 37 |
+
self.assertEqual(prod.context_editing_keep, 5)
|
| 38 |
+
self.assertFalse(prod.longterm_memory)
|
| 39 |
+
|
| 40 |
+
def test_default_resolution(self) -> None:
|
| 41 |
+
with patch.dict("os.environ", {}, clear=False):
|
| 42 |
+
import os
|
| 43 |
+
|
| 44 |
+
os.environ.pop("AI_TUTOR_MEMORY_PRESET", None)
|
| 45 |
+
self.assertEqual(resolve_memory_preset("").name, DEFAULT_MEMORY_PRESET)
|
| 46 |
+
self.assertEqual(resolve_memory_preset(None).name, DEFAULT_MEMORY_PRESET)
|
| 47 |
+
|
| 48 |
+
def test_env_var_default_and_explicit_override(self) -> None:
|
| 49 |
+
with patch.dict("os.environ", {"AI_TUTOR_MEMORY_PRESET": "full_history"}):
|
| 50 |
+
self.assertEqual(resolve_memory_preset("").name, "full_history")
|
| 51 |
+
# An explicit request value beats the env default.
|
| 52 |
+
self.assertEqual(resolve_memory_preset("aggressive").name, "aggressive")
|
| 53 |
+
|
| 54 |
+
def test_unknown_preset_raises(self) -> None:
|
| 55 |
+
with self.assertRaises(ValueError):
|
| 56 |
+
resolve_memory_preset("does_not_exist")
|
| 57 |
+
with patch.dict("os.environ", {"AI_TUTOR_MEMORY_PRESET": "typo"}):
|
| 58 |
+
with self.assertRaises(ValueError):
|
| 59 |
+
resolve_memory_preset("")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class MiddlewareAssemblyTests(unittest.TestCase):
|
| 63 |
+
def test_full_history_disables_compaction(self) -> None:
|
| 64 |
+
middleware = build_agent_middleware(
|
| 65 |
+
_stub_model(), MEMORY_PRESETS["full_history"]
|
| 66 |
+
)
|
| 67 |
+
self.assertEqual(len(middleware), 1)
|
| 68 |
+
self.assertIsInstance(middleware[0], SourcePreferenceMiddleware)
|
| 69 |
+
|
| 70 |
+
def test_prod_has_editing_then_summarization(self) -> None:
|
| 71 |
+
middleware = build_agent_middleware(_stub_model(), MEMORY_PRESETS["prod"])
|
| 72 |
+
self.assertIsInstance(middleware[0], ContextEditingMiddleware)
|
| 73 |
+
self.assertIsInstance(middleware[1], SummarizationMiddleware)
|
| 74 |
+
self.assertIsInstance(middleware[-1], SourcePreferenceMiddleware)
|
| 75 |
+
self.assertEqual(len(middleware), 3)
|
| 76 |
+
|
| 77 |
+
def test_single_technique_presets(self) -> None:
|
| 78 |
+
summarization_only = build_agent_middleware(
|
| 79 |
+
_stub_model(), MEMORY_PRESETS["summarization_only"]
|
| 80 |
+
)
|
| 81 |
+
self.assertFalse(
|
| 82 |
+
any(isinstance(m, ContextEditingMiddleware) for m in summarization_only)
|
| 83 |
+
)
|
| 84 |
+
self.assertTrue(
|
| 85 |
+
any(isinstance(m, SummarizationMiddleware) for m in summarization_only)
|
| 86 |
+
)
|
| 87 |
+
editing_only = build_agent_middleware(
|
| 88 |
+
_stub_model(), MEMORY_PRESETS["editing_only"]
|
| 89 |
+
)
|
| 90 |
+
self.assertTrue(
|
| 91 |
+
any(isinstance(m, ContextEditingMiddleware) for m in editing_only)
|
| 92 |
+
)
|
| 93 |
+
self.assertFalse(
|
| 94 |
+
any(isinstance(m, SummarizationMiddleware) for m in editing_only)
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def test_profile_memory_adds_student_profile_middleware(self) -> None:
|
| 98 |
+
middleware = build_agent_middleware(
|
| 99 |
+
_stub_model(), MEMORY_PRESETS["profile_memory"]
|
| 100 |
+
)
|
| 101 |
+
self.assertTrue(
|
| 102 |
+
any(isinstance(m, StudentProfileMiddleware) for m in middleware)
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def test_build_agent_cache_keys_include_memory_config(self) -> None:
|
| 106 |
+
build_agent.cache_clear()
|
| 107 |
+
created = []
|
| 108 |
+
|
| 109 |
+
def fake_create_agent(**kwargs):
|
| 110 |
+
agent = types.SimpleNamespace(kwargs=kwargs)
|
| 111 |
+
created.append(agent)
|
| 112 |
+
return agent
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
with (
|
| 116 |
+
patch(
|
| 117 |
+
"app.chat_service.build_chat_model",
|
| 118 |
+
return_value=_stub_model(),
|
| 119 |
+
),
|
| 120 |
+
patch("app.chat_service.build_system_prompt", return_value="prompt"),
|
| 121 |
+
patch("app.chat_service.create_agent", side_effect=fake_create_agent),
|
| 122 |
+
):
|
| 123 |
+
prod = build_agent(
|
| 124 |
+
"google-genai:gemini-3.5-flash",
|
| 125 |
+
memory_config=MEMORY_PRESETS["prod"],
|
| 126 |
+
)
|
| 127 |
+
full_history = build_agent(
|
| 128 |
+
"google-genai:gemini-3.5-flash",
|
| 129 |
+
memory_config=MEMORY_PRESETS["full_history"],
|
| 130 |
+
)
|
| 131 |
+
full_history_again = build_agent(
|
| 132 |
+
"google-genai:gemini-3.5-flash",
|
| 133 |
+
memory_config=MEMORY_PRESETS["full_history"],
|
| 134 |
+
)
|
| 135 |
+
finally:
|
| 136 |
+
build_agent.cache_clear()
|
| 137 |
+
|
| 138 |
+
self.assertIsNot(prod, full_history)
|
| 139 |
+
self.assertIs(full_history, full_history_again)
|
| 140 |
+
self.assertEqual(len(created), 2)
|
| 141 |
+
# Long-term memory needs the store wired into every agent.
|
| 142 |
+
self.assertIn("store", created[0].kwargs)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
unittest.main()
|
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import unittest
|
| 4 |
+
|
| 5 |
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 6 |
+
from langchain_core.outputs import ChatGeneration, LLMResult
|
| 7 |
+
|
| 8 |
+
from app.chat_service import CLEARED_TOOL_OUTPUT_PLACEHOLDER
|
| 9 |
+
from app.telemetry import (
|
| 10 |
+
TurnUsageHandler,
|
| 11 |
+
context_window_stats,
|
| 12 |
+
estimate_cost_usd,
|
| 13 |
+
pricing_for_model,
|
| 14 |
+
usage_totals,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class UsageTotalsTests(unittest.TestCase):
|
| 19 |
+
def test_sums_across_models_including_cache_details(self) -> None:
|
| 20 |
+
usage = {
|
| 21 |
+
"gemini-3.5-flash": {
|
| 22 |
+
"input_tokens": 1_000,
|
| 23 |
+
"output_tokens": 200,
|
| 24 |
+
"total_tokens": 1_200,
|
| 25 |
+
"input_token_details": {"cache_read": 400},
|
| 26 |
+
},
|
| 27 |
+
"claude-haiku-4-5-20251001": {
|
| 28 |
+
"input_tokens": 500,
|
| 29 |
+
"output_tokens": 100,
|
| 30 |
+
"total_tokens": 600,
|
| 31 |
+
"input_token_details": {"cache_read": 50, "cache_creation": 25},
|
| 32 |
+
},
|
| 33 |
+
}
|
| 34 |
+
totals = usage_totals(usage)
|
| 35 |
+
self.assertEqual(totals["input_tokens"], 1_500)
|
| 36 |
+
self.assertEqual(totals["output_tokens"], 300)
|
| 37 |
+
self.assertEqual(totals["total_tokens"], 1_800)
|
| 38 |
+
self.assertEqual(totals["cache_read_tokens"], 450)
|
| 39 |
+
self.assertEqual(totals["cache_creation_tokens"], 25)
|
| 40 |
+
|
| 41 |
+
def test_missing_details_default_to_zero(self) -> None:
|
| 42 |
+
totals = usage_totals({"m": {"input_tokens": 10, "output_tokens": 5}})
|
| 43 |
+
self.assertEqual(totals["cache_read_tokens"], 0)
|
| 44 |
+
self.assertEqual(totals["total_tokens"], 0)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class CostEstimateTests(unittest.TestCase):
|
| 48 |
+
def test_dated_model_name_matches_family_pricing(self) -> None:
|
| 49 |
+
self.assertIsNotNone(pricing_for_model("claude-haiku-4-5-20251001"))
|
| 50 |
+
self.assertIsNone(pricing_for_model("some-unknown-model"))
|
| 51 |
+
|
| 52 |
+
def test_cache_tokens_priced_separately(self) -> None:
|
| 53 |
+
# claude-haiku-4-5: input $1, output $5, cache read $0.10, write $1.25
|
| 54 |
+
# per MTok. input_tokens includes the cached buckets, so the plain
|
| 55 |
+
# bucket is 1M - 400k - 100k = 500k.
|
| 56 |
+
usage = {
|
| 57 |
+
"claude-haiku-4-5-20251001": {
|
| 58 |
+
"input_tokens": 1_000_000,
|
| 59 |
+
"output_tokens": 200_000,
|
| 60 |
+
"input_token_details": {
|
| 61 |
+
"cache_read": 400_000,
|
| 62 |
+
"cache_creation": 100_000,
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
expected = 0.5 * 1.00 + 0.4 * 0.10 + 0.1 * 1.25 + 0.2 * 5.00
|
| 67 |
+
self.assertAlmostEqual(estimate_cost_usd(usage), expected)
|
| 68 |
+
|
| 69 |
+
def test_cache_creation_without_write_rate_bills_as_input(self) -> None:
|
| 70 |
+
# gemini-3.5-flash has no cache_write entry.
|
| 71 |
+
usage = {
|
| 72 |
+
"gemini-3.5-flash": {
|
| 73 |
+
"input_tokens": 1_000_000,
|
| 74 |
+
"output_tokens": 0,
|
| 75 |
+
"input_token_details": {"cache_creation": 200_000},
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
expected = 0.8 * 0.30 + 0.2 * 0.30
|
| 79 |
+
self.assertAlmostEqual(estimate_cost_usd(usage), expected)
|
| 80 |
+
|
| 81 |
+
def test_unknown_model_yields_none_not_zero(self) -> None:
|
| 82 |
+
self.assertIsNone(estimate_cost_usd({"mystery-model": {"input_tokens": 10}}))
|
| 83 |
+
mixed = {
|
| 84 |
+
"gemini-3.5-flash": {"input_tokens": 10, "output_tokens": 1},
|
| 85 |
+
"mystery-model": {"input_tokens": 10, "output_tokens": 1},
|
| 86 |
+
}
|
| 87 |
+
self.assertIsNone(estimate_cost_usd(mixed))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ContextWindowStatsTests(unittest.TestCase):
|
| 91 |
+
def test_counts_summaries_and_cleared_tool_outputs(self) -> None:
|
| 92 |
+
messages = [
|
| 93 |
+
HumanMessage(
|
| 94 |
+
content="Here is a summary of the conversation to date: ...",
|
| 95 |
+
additional_kwargs={"lc_source": "summarization"},
|
| 96 |
+
),
|
| 97 |
+
HumanMessage(content="What is RAG?"),
|
| 98 |
+
AIMessage(content="RAG is retrieval-augmented generation."),
|
| 99 |
+
ToolMessage(
|
| 100 |
+
content=CLEARED_TOOL_OUTPUT_PLACEHOLDER,
|
| 101 |
+
tool_call_id="call_1",
|
| 102 |
+
),
|
| 103 |
+
ToolMessage(content="$ ls\nwiki", tool_call_id="call_2"),
|
| 104 |
+
]
|
| 105 |
+
stats = context_window_stats(messages, CLEARED_TOOL_OUTPUT_PLACEHOLDER)
|
| 106 |
+
self.assertEqual(stats["context_messages"], 5)
|
| 107 |
+
self.assertEqual(stats["summary_messages"], 1)
|
| 108 |
+
self.assertEqual(stats["cleared_tool_outputs"], 1)
|
| 109 |
+
self.assertGreater(stats["context_tokens_approx"], 0)
|
| 110 |
+
|
| 111 |
+
def test_empty_context(self) -> None:
|
| 112 |
+
stats = context_window_stats([], CLEARED_TOOL_OUTPUT_PLACEHOLDER)
|
| 113 |
+
self.assertEqual(stats["context_messages"], 0)
|
| 114 |
+
self.assertEqual(stats["context_tokens_approx"], 0)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class TurnUsageHandlerTests(unittest.TestCase):
|
| 118 |
+
def test_counts_calls_and_aggregates_usage(self) -> None:
|
| 119 |
+
handler = TurnUsageHandler()
|
| 120 |
+
|
| 121 |
+
def result(input_tokens: int, output_tokens: int) -> LLMResult:
|
| 122 |
+
message = AIMessage(
|
| 123 |
+
content="ok",
|
| 124 |
+
usage_metadata={
|
| 125 |
+
"input_tokens": input_tokens,
|
| 126 |
+
"output_tokens": output_tokens,
|
| 127 |
+
"total_tokens": input_tokens + output_tokens,
|
| 128 |
+
},
|
| 129 |
+
response_metadata={"model_name": "gemini-3.5-flash"},
|
| 130 |
+
)
|
| 131 |
+
return LLMResult(generations=[[ChatGeneration(message=message)]])
|
| 132 |
+
|
| 133 |
+
handler.on_llm_end(result(100, 20))
|
| 134 |
+
handler.on_llm_end(result(50, 10))
|
| 135 |
+
|
| 136 |
+
self.assertEqual(handler.llm_calls, 2)
|
| 137 |
+
usage = handler.usage_metadata["gemini-3.5-flash"]
|
| 138 |
+
self.assertEqual(usage["input_tokens"], 150)
|
| 139 |
+
self.assertEqual(usage["output_tokens"], 30)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
unittest.main()
|