omarsol Claude Fable 5 commited on
Commit
a04f9ea
·
1 Parent(s): 839314b

Add evaluation harness for memory/context-management experiments

Browse files

Compare 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 CHANGED
@@ -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
app/api.py CHANGED
@@ -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()
app/chat_service.py CHANGED
@@ -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: list[AgentMiddleware] = [
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(request, active_thread_id, message_id)
 
 
 
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
  {
app/chat_types.py CHANGED
@@ -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)
app/memory_presets.py ADDED
@@ -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
app/telemetry.py ADDED
@@ -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
+ }
data/eval/README.md ADDED
@@ -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).
data/scraping_scripts/annotate_discussion_posts.py ADDED
@@ -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()
evals.md ADDED
@@ -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.
evals/__init__.py ADDED
@@ -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
+ """
evals/check_triggers.py ADDED
@@ -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()
evals/common.py ADDED
@@ -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]
evals/grade.py ADDED
@@ -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()
evals/handgrade_workbook.py ADDED
@@ -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()
evals/report.py ADDED
@@ -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()
evals/run_battery.py ADDED
@@ -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()
evals_background.md ADDED
@@ -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.
tests/test_api.py CHANGED
@@ -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",
tests/test_evals_grade.py ADDED
@@ -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()
tests/test_memory_presets.py ADDED
@@ -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()
tests/test_telemetry.py ADDED
@@ -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()