Omar Solano Claude Opus 4.8 (1M context) commited on
wip: eval harness + public docs bundle; relocate eval docs into evals/ (#10)
Browse filesWork-in-progress checkpoint of the current eval cleanup:
- relocate the 6 evals_*.md docs into evals/ (drop the evals_ prefix);
rewrite every reference (evals.md, AGENTS.md, pyproject.toml,
data/eval/README.md, evals/grade.py, upload_dbs_to_hf.py, cross-links)
- evals/ harness: grade/judge/report/run_battery changes + new
grading_prep.py, grading_merge.py, grade_workflow.js
- data/scraping_scripts: build_public_docs_bundle.py + upload/gitignore wiring
- app/ (api, config, kb_manifest) and tests tweaks
- gitignore local-only working dirs (code_review/, workshop_AIE/)
Claude-Session: https://claude.ai/code/session_01BDhiM239toao3TAmoJ1joD
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .gitignore +7 -0
- AGENTS.md +3 -3
- app/api.py +8 -1
- app/config.py +88 -18
- app/kb_manifest.py +15 -0
- data/eval/README.md +1 -1
- data/scraping_scripts/README.md +31 -0
- data/scraping_scripts/build_public_docs_bundle.py +380 -0
- data/scraping_scripts/upload_dbs_to_hf.py +42 -13
- evals.md +28 -22
- evals_background.md → evals/background.md +0 -0
- evals_compaction.md → evals/compaction.md +0 -0
- evals_contributing.md → evals/contributing.md +0 -0
- evals/grade.py +153 -1
- evals/grade_workflow.js +66 -0
- evals/grading_merge.py +247 -0
- evals/grading_prep.py +171 -0
- evals_graphrag.md → evals/graphrag.md +0 -0
- evals/judge.py +40 -2
- evals_part_c_plan.md → evals/part_c_plan.md +0 -0
- evals/report.py +50 -4
- evals/run_battery.py +9 -3
- evals_slm_compaction.md → evals/slm_compaction.md +2 -2
- pyproject.toml +1 -1
- tests/test_build_public_docs_bundle.py +168 -0
- tests/test_config.py +44 -0
- tests/test_evals_grade.py +205 -0
.gitignore
CHANGED
|
@@ -168,6 +168,8 @@ data/chroma-db**/
|
|
| 168 |
evaluation_data/chroma-db**/
|
| 169 |
# Build artifact; downloaded from HF on first run.
|
| 170 |
data/kb/
|
|
|
|
|
|
|
| 171 |
data/*_md_files/
|
| 172 |
data/full_stack_ai_engineering/
|
| 173 |
data/beginner_python_for_ai_engineering/
|
|
@@ -234,3 +236,8 @@ data/eval/review_log_v1.md
|
|
| 234 |
# so non-.jsonl files (review_log_v1.md, review_batches/*.json) can't slip in.
|
| 235 |
eval/
|
| 236 |
deepseek_eval_results.md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
evaluation_data/chroma-db**/
|
| 169 |
# Build artifact; downloaded from HF on first run.
|
| 170 |
data/kb/
|
| 171 |
+
# Staging dir for the public docs-only bundle (build_public_docs_bundle.py).
|
| 172 |
+
data/public_docs_bundle/
|
| 173 |
data/*_md_files/
|
| 174 |
data/full_stack_ai_engineering/
|
| 175 |
data/beginner_python_for_ai_engineering/
|
|
|
|
| 236 |
# so non-.jsonl files (review_log_v1.md, review_batches/*.json) can't slip in.
|
| 237 |
eval/
|
| 238 |
deepseek_eval_results.md
|
| 239 |
+
|
| 240 |
+
# Local-only working dirs (internal review notes, workshop slide decks).
|
| 241 |
+
# Never meant for the public repo; keep them from an accidental `git add -A`.
|
| 242 |
+
code_review/
|
| 243 |
+
workshop_AIE/
|
AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-confi
|
|
| 12 |
|
| 13 |
- [GitHub repo](https://github.com/towardsai/ai-tutor-app)
|
| 14 |
- [Live demo — prod Space](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) · [Dev Space (private)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)
|
| 15 |
-
- [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
|
| 16 |
|
| 17 |
## Where things live
|
| 18 |
|
|
@@ -91,11 +91,11 @@ uv run -m data.scraping_scripts.retire_source_workflow --sources KEY [--dry-run
|
|
| 91 |
|
| 92 |
## Evaluation
|
| 93 |
|
| 94 |
-
**`evals.md` is the entry point** (what we evaluate, the datasets, results, remaining work); `evals/` is the harness (`run_battery` → `grade` → `report`, plus `check_triggers` and the blinded `handgrade_workbook`). Eval datasets and run results contain **real student text**: they are gitignored and ship via the private `ai-tutor-data` HF dataset (`eval/`, `eval_runs/`) — download snippet in `evals.md`. Runs cost real API money (a 4-preset bake-off ≈ $
|
| 95 |
|
| 96 |
## Environment variables
|
| 97 |
|
| 98 |
-
Chat runtime: `COHERE_API_KEY` (retrieval), one chat-model provider key (`GEMINI_API_KEY`/`GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or — for open-model eval runs — `OPENROUTER_API_KEY` / `DEEPSEEK_API_KEY`), `HF_TOKEN` (first-start download). Optional: `LANGSMITH_*` (tracing), `AI_TUTOR_API_PORT`/`HOST`/`CORS_ALLOW_ORIGINS`, `AI_TUTOR_KB_DIR`, `AI_TUTOR_MEMORY_PRESET` (default memory preset; see `app/memory_presets.py`), `NEXT_PUBLIC_AI_TUTOR_API_BASE_URL`. Data workflows also need `GITHUB_TOKEN` and `GEMINI_API_KEY`/`GOOGLE_API_KEY` (context generation). See `.env.example`.
|
| 99 |
|
| 100 |
## Deployment
|
| 101 |
|
|
|
|
| 12 |
|
| 13 |
- [GitHub repo](https://github.com/towardsai/ai-tutor-app)
|
| 14 |
- [Live demo — prod Space](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) · [Dev Space (private)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)
|
| 15 |
+
- [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) (private; full corpus incl. courses) · [Public docs-only bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db-public) (no token needed; cold-start fallback) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
|
| 16 |
|
| 17 |
## Where things live
|
| 18 |
|
|
|
|
| 91 |
|
| 92 |
## Evaluation
|
| 93 |
|
| 94 |
+
**`evals.md` is the entry point** (what we evaluate, the datasets, results, remaining work); `evals/` is the harness (`run_battery` → `grade` → `report`, plus `check_triggers` and the blinded `handgrade_workbook`). Eval datasets and run results contain **real student text**: they are gitignored and ship via the private `ai-tutor-data` HF dataset (`eval/`, `eval_runs/`) — download snippet in `evals.md`. Runs cost real API money (a 4-preset bake-off ≈ $323; the full eval program ≈ $590 across all Gemini 3.5 Flash runs); grading and reporting re-run offline from saved bundles for free. **Running or contributing an experiment? Follow `evals/contributing.md`** (run → upload data to HF → record an `F<N>` finding → merge; with a definition-of-done gate).
|
| 95 |
|
| 96 |
## Environment variables
|
| 97 |
|
| 98 |
+
Chat runtime: `COHERE_API_KEY` (retrieval), one chat-model provider key (`GEMINI_API_KEY`/`GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or — for open-model eval runs — `OPENROUTER_API_KEY` / `DEEPSEEK_API_KEY`), `HF_TOKEN` (first-start download of the full private bundle; without it, cold start falls back to the public docs-only bundle — documentation sources only, course content hidden). Optional: `LANGSMITH_*` (tracing), `AI_TUTOR_API_PORT`/`HOST`/`CORS_ALLOW_ORIGINS`, `AI_TUTOR_KB_DIR`, `AI_TUTOR_MEMORY_PRESET` (default memory preset; see `app/memory_presets.py`), `NEXT_PUBLIC_AI_TUTOR_API_BASE_URL`. Data workflows also need `GITHUB_TOKEN` and `GEMINI_API_KEY`/`GOOGLE_API_KEY` (context generation). See `.env.example`.
|
| 99 |
|
| 100 |
## Deployment
|
| 101 |
|
app/api.py
CHANGED
|
@@ -24,7 +24,7 @@ from .chat_service import (
|
|
| 24 |
warm_up_retriever,
|
| 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,
|
|
@@ -615,9 +615,16 @@ _SOURCE_VERSIONS: dict[str, dict[str, Any]] = _load_source_versions()
|
|
| 615 |
|
| 616 |
def _source_entries() -> list[dict[str, Any]]:
|
| 617 |
defaults = set(DEFAULT_SELECTED_SOURCES_UI)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
entries: list[dict[str, Any]] = []
|
| 619 |
for label in AVAILABLE_SOURCES_UI:
|
| 620 |
key = SOURCE_UI_TO_KEY[label]
|
|
|
|
|
|
|
| 621 |
group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
|
| 622 |
display = SOURCE_DISPLAY_INFO.get(key, {})
|
| 623 |
entry: dict[str, Any] = {
|
|
|
|
| 24 |
warm_up_retriever,
|
| 25 |
)
|
| 26 |
from .chat_types import ChatEvent, ChatRequest, ChatTurn
|
| 27 |
+
from .kb_manifest import available_source_keys, load_manifest_entries
|
| 28 |
from .memory_presets import MEMORY_PRESETS
|
| 29 |
from .config import (
|
| 30 |
AVAILABLE_MODELS,
|
|
|
|
| 615 |
|
| 616 |
def _source_entries() -> list[dict[str, Any]]:
|
| 617 |
defaults = set(DEFAULT_SELECTED_SOURCES_UI)
|
| 618 |
+
# On the public docs-only bundle the course sources are absent, so hide
|
| 619 |
+
# them from the picker instead of offering sources that return nothing.
|
| 620 |
+
# `None` means the bundle is not loaded yet (advertise everything, the
|
| 621 |
+
# prod default); otherwise only advertise sources present in the bundle.
|
| 622 |
+
available = available_source_keys()
|
| 623 |
entries: list[dict[str, Any]] = []
|
| 624 |
for label in AVAILABLE_SOURCES_UI:
|
| 625 |
key = SOURCE_UI_TO_KEY[label]
|
| 626 |
+
if available is not None and key not in available:
|
| 627 |
+
continue
|
| 628 |
group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
|
| 629 |
display = SOURCE_DISPLAY_INFO.get(key, {})
|
| 630 |
entry: dict[str, Any] = {
|
app/config.py
CHANGED
|
@@ -47,6 +47,14 @@ VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
|
|
| 47 |
DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
|
| 48 |
BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
|
| 49 |
CHROMA_SQLITE_PATH = f"{VECTOR_DB_DIR}/chroma.sqlite3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
KB_DIR = "data/kb"
|
| 51 |
KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
|
| 52 |
KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
|
|
@@ -101,6 +109,85 @@ def ensure_kb_agents_md() -> None:
|
|
| 101 |
os.replace(tmp_path, target)
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def ensure_local_vector_db() -> None:
|
| 105 |
"""Make sure the vector-db/KB bundle and AGENTS.md exist locally.
|
| 106 |
|
|
@@ -119,24 +206,7 @@ def ensure_local_vector_db() -> None:
|
|
| 119 |
"Vector database missing or incomplete locally, downloading "
|
| 120 |
"from Hugging Face"
|
| 121 |
)
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
# Mute httpx's per-file flood during the cold-start download only.
|
| 125 |
-
httpx_logger = logging.getLogger("httpx")
|
| 126 |
-
previous_level = httpx_logger.level
|
| 127 |
-
httpx_logger.setLevel(logging.WARNING)
|
| 128 |
-
try:
|
| 129 |
-
snapshot_download(
|
| 130 |
-
repo_id="towardsai-tutors/ai-tutor-vector-db",
|
| 131 |
-
local_dir="data",
|
| 132 |
-
repo_type="dataset",
|
| 133 |
-
# The GraphRAG experiment index (~150 MB) lives in the same
|
| 134 |
-
# repo for reproducibility but prod does not use it; skip it
|
| 135 |
-
# on cold start. Pull it explicitly to run that eval.
|
| 136 |
-
ignore_patterns=["graphrag/**"],
|
| 137 |
-
)
|
| 138 |
-
finally:
|
| 139 |
-
httpx_logger.setLevel(previous_level)
|
| 140 |
ensure_kb_agents_md()
|
| 141 |
_BUNDLE_READY = _bundle_complete() and os.path.exists(KB_AGENTS_PATH)
|
| 142 |
|
|
|
|
| 47 |
DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
|
| 48 |
BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
|
| 49 |
CHROMA_SQLITE_PATH = f"{VECTOR_DB_DIR}/chroma.sqlite3"
|
| 50 |
+
# The full bundle (all sources, incl. gated course content) lives in the
|
| 51 |
+
# private repo; cold-starting from it needs an HF_TOKEN with read access. When
|
| 52 |
+
# that token is missing or lacks access, we fall back to the public docs-only
|
| 53 |
+
# bundle (the 9 documentation sources, no courses) built by
|
| 54 |
+
# data/scraping_scripts/build_public_docs_bundle.py. Both repos share the same
|
| 55 |
+
# tree layout, so only the download source changes here.
|
| 56 |
+
VECTOR_DB_REPO_ID = "towardsai-tutors/ai-tutor-vector-db"
|
| 57 |
+
PUBLIC_VECTOR_DB_REPO_ID = "towardsai-tutors/ai-tutor-vector-db-public"
|
| 58 |
KB_DIR = "data/kb"
|
| 59 |
KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
|
| 60 |
KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
|
|
|
|
| 109 |
os.replace(tmp_path, target)
|
| 110 |
|
| 111 |
|
| 112 |
+
def _snapshot_bundle(repo_id: str, *, token: str | None) -> None:
|
| 113 |
+
"""Download a vector-db/KB bundle snapshot into ``data/``.
|
| 114 |
+
|
| 115 |
+
Mutes httpx's per-file flood during the cold-start download only. The
|
| 116 |
+
GraphRAG experiment index (~150 MB) lives in the private repo for
|
| 117 |
+
reproducibility but prod does not use it; skip it on cold start. Pull it
|
| 118 |
+
explicitly to run that eval. (The public bundle has no graphrag/ tree, so
|
| 119 |
+
the pattern is simply a no-op there.)
|
| 120 |
+
"""
|
| 121 |
+
from huggingface_hub import snapshot_download
|
| 122 |
+
|
| 123 |
+
httpx_logger = logging.getLogger("httpx")
|
| 124 |
+
previous_level = httpx_logger.level
|
| 125 |
+
httpx_logger.setLevel(logging.WARNING)
|
| 126 |
+
try:
|
| 127 |
+
snapshot_download(
|
| 128 |
+
repo_id=repo_id,
|
| 129 |
+
local_dir="data",
|
| 130 |
+
repo_type="dataset",
|
| 131 |
+
token=token,
|
| 132 |
+
ignore_patterns=["graphrag/**"],
|
| 133 |
+
)
|
| 134 |
+
finally:
|
| 135 |
+
httpx_logger.setLevel(previous_level)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _download_bundle() -> None:
|
| 139 |
+
"""Download the bundle, falling back to the public docs-only one.
|
| 140 |
+
|
| 141 |
+
With a usable ``HF_TOKEN`` we pull the full private bundle (all sources,
|
| 142 |
+
incl. gated course content). When the token is absent, or present but
|
| 143 |
+
without access to the private repo, we fall back to the public docs-only
|
| 144 |
+
bundle so the app can still cold-start (documentation sources only). Any
|
| 145 |
+
non-access error (e.g. a network failure) propagates instead of silently
|
| 146 |
+
degrading to the smaller bundle.
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
from huggingface_hub.errors import (
|
| 150 |
+
GatedRepoError,
|
| 151 |
+
RepositoryNotFoundError,
|
| 152 |
+
)
|
| 153 |
+
except ImportError: # older huggingface_hub
|
| 154 |
+
from huggingface_hub.utils import (
|
| 155 |
+
GatedRepoError,
|
| 156 |
+
RepositoryNotFoundError,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Resolve the token the way huggingface_hub itself does: the HF_TOKEN env
|
| 160 |
+
# var first, then a cached `huggingface-cli login`. Keying off os.getenv
|
| 161 |
+
# alone would skip a cached login that the old implicit-token download honored.
|
| 162 |
+
try:
|
| 163 |
+
from huggingface_hub import get_token
|
| 164 |
+
|
| 165 |
+
token = get_token()
|
| 166 |
+
except Exception: # pragma: no cover - very old hub without get_token
|
| 167 |
+
token = os.getenv("HF_TOKEN") or None
|
| 168 |
+
|
| 169 |
+
if not token:
|
| 170 |
+
logger.warning(
|
| 171 |
+
"No Hugging Face token found; downloading the public docs-only "
|
| 172 |
+
"bundle from %s (documentation sources only, no course content).",
|
| 173 |
+
PUBLIC_VECTOR_DB_REPO_ID,
|
| 174 |
+
)
|
| 175 |
+
_snapshot_bundle(PUBLIC_VECTOR_DB_REPO_ID, token=None)
|
| 176 |
+
return
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
_snapshot_bundle(VECTOR_DB_REPO_ID, token=token)
|
| 180 |
+
except (GatedRepoError, RepositoryNotFoundError) as exc:
|
| 181 |
+
logger.warning(
|
| 182 |
+
"HF_TOKEN cannot access the private bundle %s (%s); falling back to "
|
| 183 |
+
"the public docs-only bundle %s.",
|
| 184 |
+
VECTOR_DB_REPO_ID,
|
| 185 |
+
type(exc).__name__,
|
| 186 |
+
PUBLIC_VECTOR_DB_REPO_ID,
|
| 187 |
+
)
|
| 188 |
+
_snapshot_bundle(PUBLIC_VECTOR_DB_REPO_ID, token=None)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
def ensure_local_vector_db() -> None:
|
| 192 |
"""Make sure the vector-db/KB bundle and AGENTS.md exist locally.
|
| 193 |
|
|
|
|
| 206 |
"Vector database missing or incomplete locally, downloading "
|
| 207 |
"from Hugging Face"
|
| 208 |
)
|
| 209 |
+
_download_bundle()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
ensure_kb_agents_md()
|
| 211 |
_BUNDLE_READY = _bundle_complete() and os.path.exists(KB_AGENTS_PATH)
|
| 212 |
|
app/kb_manifest.py
CHANGED
|
@@ -117,6 +117,21 @@ def load_manifest_entries(kb_dir: str = KB_DIR) -> tuple[KbManifestEntry, ...]:
|
|
| 117 |
return result
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def manifest_indexes(
|
| 121 |
kb_dir: str = KB_DIR,
|
| 122 |
) -> tuple[
|
|
|
|
| 117 |
return result
|
| 118 |
|
| 119 |
|
| 120 |
+
def available_source_keys(kb_dir: str = KB_DIR) -> frozenset[str] | None:
|
| 121 |
+
"""Source keys actually present in the downloaded KB bundle.
|
| 122 |
+
|
| 123 |
+
Returns ``None`` when the manifest is not available yet (the bundle may
|
| 124 |
+
still be downloading on first start), signalling callers to not filter.
|
| 125 |
+
Otherwise it is the distinct set of sources shipped in the bundle. On the
|
| 126 |
+
public docs-only bundle this excludes the course sources, so the UI source
|
| 127 |
+
picker can hide sources that have no data to query.
|
| 128 |
+
"""
|
| 129 |
+
entries = load_manifest_entries(kb_dir)
|
| 130 |
+
if not entries:
|
| 131 |
+
return None
|
| 132 |
+
return frozenset(entry.source for entry in entries if entry.source)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def manifest_indexes(
|
| 136 |
kb_dir: str = KB_DIR,
|
| 137 |
) -> tuple[
|
data/eval/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Built from `data/academy_discussion_eval.jsonl` — real student posts from the
|
|
| 41 |
- **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.
|
| 42 |
- **requires_media**: the post referenced a screenshot we don't have. The 2 included cases remain gradeable without it.
|
| 43 |
- **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.
|
| 44 |
-
- **Probe types** (`probe_type`): `fact_recall` means the answer must use a fact the student gave many turns ago. `preference_compliance` means the tutor must honor a stated preference, such as OS, tooling, language, or style. `anaphora` means the user asks about "that thing from earlier," so the tutor must resolve the reference. `anaphora_consistency` means the answer must stay consistent with the tutor's own earlier recommendation. `fact_update` means the student changed a fact mid-session; using the old value fails. `behavior_routing` means a support/platform issue appears inside a long chat, and the tutor must still redirect appropriately. Battery v2 (planned, not built) will add `contradiction` (a fact changed early and probed only after it would be evicted), `longhorizon_recall`, and `entity_isolation` (two entities in one session, no fact bleed). `cross_session_recall` was an earlier idea for `sleeptime_consolidation` and is deferred with the multi-thread battery; the v1 types here are frozen. See `
|
| 45 |
- **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.
|
| 46 |
- **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.
|
| 47 |
- **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.
|
|
|
|
| 41 |
- **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.
|
| 42 |
- **requires_media**: the post referenced a screenshot we don't have. The 2 included cases remain gradeable without it.
|
| 43 |
- **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.
|
| 44 |
+
- **Probe types** (`probe_type`): `fact_recall` means the answer must use a fact the student gave many turns ago. `preference_compliance` means the tutor must honor a stated preference, such as OS, tooling, language, or style. `anaphora` means the user asks about "that thing from earlier," so the tutor must resolve the reference. `anaphora_consistency` means the answer must stay consistent with the tutor's own earlier recommendation. `fact_update` means the student changed a fact mid-session; using the old value fails. `behavior_routing` means a support/platform issue appears inside a long chat, and the tutor must still redirect appropriately. Battery v2 (planned, not built) will add `contradiction` (a fact changed early and probed only after it would be evicted), `longhorizon_recall`, and `entity_isolation` (two entities in one session, no fact bleed). `cross_session_recall` was an earlier idea for `sleeptime_consolidation` and is deferred with the multi-thread battery; the v1 types here are frozen. See `evals/part_c_plan.md` → "Battery v2" for the build plan.
|
| 45 |
- **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.
|
| 46 |
- **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.
|
| 47 |
- **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.
|
data/scraping_scripts/README.md
CHANGED
|
@@ -270,6 +270,37 @@ uv run -m data.scraping_scripts.retire_source_workflow --sources 8-hour_primer -
|
|
| 270 |
Use `--keep-source-registry` only if you want to remove existing chunks while
|
| 271 |
leaving the source configured as active for a future rebuild.
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
## Tips for New Team Members
|
| 274 |
|
| 275 |
1. To update the AI Tutor with new content:
|
|
|
|
| 270 |
Use `--keep-source-registry` only if you want to remove existing chunks while
|
| 271 |
leaving the source configured as active for a future rebuild.
|
| 272 |
|
| 273 |
+
## Publishing the public docs-only bundle
|
| 274 |
+
|
| 275 |
+
The production bundle in `towardsai-tutors/ai-tutor-vector-db` is **private** and
|
| 276 |
+
holds every source, including gated course content, so cold-starting the app
|
| 277 |
+
needs an `HF_TOKEN` with read access. To let anyone run the chatbot without that
|
| 278 |
+
token, we publish a **public, docs-only** bundle to
|
| 279 |
+
`towardsai-tutors/ai-tutor-vector-db-public` (the 9 documentation/reference
|
| 280 |
+
sources, no courses). The runtime (`app.config.ensure_local_vector_db`) downloads
|
| 281 |
+
the full private bundle when a usable `HF_TOKEN` is present and falls back to the
|
| 282 |
+
public one otherwise; the picker hides course sources when running on it.
|
| 283 |
+
|
| 284 |
+
This is a **standalone, manual** step — it is **not** wired into the docs/course
|
| 285 |
+
workflows. Refresh the public bundle whenever the docs sources change materially:
|
| 286 |
+
|
| 287 |
+
```bash
|
| 288 |
+
# Refresh the prod bundle first (so data/chroma-db-all_sources, data/kb,
|
| 289 |
+
# data/all_sources_data.jsonl are current), then:
|
| 290 |
+
uv run -m data.scraping_scripts.build_public_docs_bundle # build + upload
|
| 291 |
+
uv run -m data.scraping_scripts.build_public_docs_bundle --skip-upload # build only, inspect first
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
It **derives** the docs-only bundle from the already-built prod artifacts (no
|
| 295 |
+
re-embedding, $0 Cohere): it copies the Chroma collection and deletes the course
|
| 296 |
+
chunks, rebuilds the BM25 / document-dict pkls from a docs-only view of
|
| 297 |
+
`all_sources_data.jsonl`, and copies `data/kb` while pruning `raw/courses`,
|
| 298 |
+
`wiki/courses`, and the course rows from the generated indexes. The staged tree
|
| 299 |
+
lives in `data/public_docs_bundle/` (gitignored). Uploading needs an `HF_TOKEN`
|
| 300 |
+
with write access to the `towardsai-tutors` org; the script creates the public
|
| 301 |
+
repo on first run. Pass `--include-contextual` to also ship a docs-only
|
| 302 |
+
`all_sources_contextual_nodes.pkl` (off by default to keep the download small).
|
| 303 |
+
|
| 304 |
## Tips for New Team Members
|
| 305 |
|
| 306 |
1. To update the AI Tutor with new content:
|
data/scraping_scripts/build_public_docs_bundle.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build (and optionally upload) the PUBLIC, docs-only vector-db/KB bundle.
|
| 2 |
+
|
| 3 |
+
The production bundle lives in the **private** dataset
|
| 4 |
+
``towardsai-tutors/ai-tutor-vector-db`` and contains every source, including
|
| 5 |
+
the course content (real student-facing material we keep gated). Anyone
|
| 6 |
+
without an ``HF_TOKEN`` that can read that repo cannot cold-start the app.
|
| 7 |
+
|
| 8 |
+
This script derives a **public** docs-only bundle from the already-built
|
| 9 |
+
production artifacts and pushes it to ``towardsai-tutors/ai-tutor-vector-db-public``
|
| 10 |
+
(public). The runtime (``app.config.ensure_local_vector_db``) falls back to it
|
| 11 |
+
when no usable ``HF_TOKEN`` is present. It contains only the 9 documentation /
|
| 12 |
+
reference sources (``DOC_SOURCE_KEYS``); the 5 course sources are stripped.
|
| 13 |
+
|
| 14 |
+
Why "derive", not "rebuild": the docs-only bundle is exactly what prod would
|
| 15 |
+
produce if the course sources had never existed, so we build each artifact the
|
| 16 |
+
same way prod does, minus the course rows:
|
| 17 |
+
|
| 18 |
+
* **Dense (Chroma)** - copy the prod collection and ``delete`` the course
|
| 19 |
+
chunks. This reuses the existing Cohere embeddings, so the build costs **$0**
|
| 20 |
+
and is identical to prod for every docs chunk.
|
| 21 |
+
* **BM25 + document dict** - rebuilt from a docs-only view of
|
| 22 |
+
``all_sources_data.jsonl`` with the same helpers prod uses
|
| 23 |
+
(``build_document_dict`` / ``BM25Index.build``). Pure, no network.
|
| 24 |
+
* **KB** - copy ``data/kb`` and prune: drop ``raw/courses`` and ``wiki/courses``
|
| 25 |
+
and filter the course rows out of the generated indexes. The rest of the
|
| 26 |
+
docs wiki is kept verbatim.
|
| 27 |
+
|
| 28 |
+
Usage::
|
| 29 |
+
|
| 30 |
+
# build into data/public_docs_bundle/ and upload to the public repo
|
| 31 |
+
uv run -m data.scraping_scripts.build_public_docs_bundle
|
| 32 |
+
|
| 33 |
+
# build only (inspect locally first), skip the upload
|
| 34 |
+
uv run -m data.scraping_scripts.build_public_docs_bundle --skip-upload
|
| 35 |
+
|
| 36 |
+
Prerequisites: a complete local prod bundle (``data/chroma-db-all_sources/``,
|
| 37 |
+
``data/all_sources_data.jsonl``, ``data/kb/``). Build/refresh it with the docs
|
| 38 |
+
workflow first. Uploading additionally needs an ``HF_TOKEN`` with write access
|
| 39 |
+
to the ``towardsai-tutors`` org.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import argparse
|
| 45 |
+
import csv
|
| 46 |
+
import gc
|
| 47 |
+
import json
|
| 48 |
+
import logging
|
| 49 |
+
import shutil
|
| 50 |
+
import sqlite3
|
| 51 |
+
from pathlib import Path
|
| 52 |
+
|
| 53 |
+
import chromadb
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
from data.scraping_scripts.source_registry import (
|
| 57 |
+
ALL_SOURCES_JSONL,
|
| 58 |
+
CONTEXTUAL_NODES_PKL,
|
| 59 |
+
COURSE_SOURCE_KEYS,
|
| 60 |
+
DOC_SOURCE_KEYS,
|
| 61 |
+
)
|
| 62 |
+
from app.chroma_rag import (
|
| 63 |
+
BM25Index,
|
| 64 |
+
build_chunk_records,
|
| 65 |
+
build_document_dict,
|
| 66 |
+
get_chunk_record_source,
|
| 67 |
+
load_jsonl_documents,
|
| 68 |
+
save_bm25_index,
|
| 69 |
+
save_document_dict,
|
| 70 |
+
)
|
| 71 |
+
except ModuleNotFoundError:
|
| 72 |
+
import sys
|
| 73 |
+
|
| 74 |
+
project_root = Path(__file__).resolve().parents[2]
|
| 75 |
+
if str(project_root) not in sys.path:
|
| 76 |
+
sys.path.insert(0, str(project_root))
|
| 77 |
+
from data.scraping_scripts.source_registry import (
|
| 78 |
+
ALL_SOURCES_JSONL,
|
| 79 |
+
CONTEXTUAL_NODES_PKL,
|
| 80 |
+
COURSE_SOURCE_KEYS,
|
| 81 |
+
DOC_SOURCE_KEYS,
|
| 82 |
+
)
|
| 83 |
+
from app.chroma_rag import (
|
| 84 |
+
BM25Index,
|
| 85 |
+
build_chunk_records,
|
| 86 |
+
build_document_dict,
|
| 87 |
+
get_chunk_record_source,
|
| 88 |
+
load_jsonl_documents,
|
| 89 |
+
save_bm25_index,
|
| 90 |
+
save_document_dict,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
logging.basicConfig(level=logging.INFO)
|
| 94 |
+
logger = logging.getLogger(__name__)
|
| 95 |
+
|
| 96 |
+
# Layout mirrors the prod bundle so the runtime needs no path changes: the
|
| 97 |
+
# public repo has the identical tree (chroma-db-all_sources/, kb/), just with
|
| 98 |
+
# the course rows removed. Keep these names in sync with app/config.py.
|
| 99 |
+
VECTOR_DB_DIR = "chroma-db-all_sources"
|
| 100 |
+
VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
|
| 101 |
+
DOCUMENT_DICT_FILE = "document_dict_all_sources.pkl"
|
| 102 |
+
BM25_INDEX_FILE = "bm25_index_all_sources.pkl"
|
| 103 |
+
KB_DIR_NAME = "kb"
|
| 104 |
+
|
| 105 |
+
PUBLIC_REPO_ID = "towardsai-tutors/ai-tutor-vector-db-public"
|
| 106 |
+
DEFAULT_SOURCE_DIR = Path("data")
|
| 107 |
+
DEFAULT_STAGE_DIR = Path("data/public_docs_bundle")
|
| 108 |
+
|
| 109 |
+
_COURSE_KEYS = frozenset(COURSE_SOURCE_KEYS)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _is_course_row(row: dict) -> bool:
|
| 113 |
+
return str(row.get("source") or "") in _COURSE_KEYS
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def stage_chroma(source_dir: Path, stage_dir: Path, *, vacuum: bool = True) -> dict:
|
| 117 |
+
"""Copy the prod Chroma collection and delete the course chunks.
|
| 118 |
+
|
| 119 |
+
Reuses the existing docs embeddings (no Cohere call). Returns counts for
|
| 120 |
+
the build summary.
|
| 121 |
+
"""
|
| 122 |
+
src = source_dir / VECTOR_DB_DIR
|
| 123 |
+
dst = stage_dir / VECTOR_DB_DIR
|
| 124 |
+
if not src.is_dir():
|
| 125 |
+
raise SystemExit(f"Missing prod Chroma collection: {src}")
|
| 126 |
+
|
| 127 |
+
logger.info("Copying Chroma collection %s -> %s", src, dst)
|
| 128 |
+
shutil.copytree(src, dst)
|
| 129 |
+
|
| 130 |
+
client = chromadb.PersistentClient(path=str(dst))
|
| 131 |
+
collection = client.get_collection(name=VECTOR_COLLECTION_NAME)
|
| 132 |
+
before = collection.count()
|
| 133 |
+
course_keys = sorted(_COURSE_KEYS)
|
| 134 |
+
if course_keys:
|
| 135 |
+
collection.delete(where={"source": {"$in": course_keys}})
|
| 136 |
+
after = collection.count()
|
| 137 |
+
logger.info(
|
| 138 |
+
"Chroma: kept %s docs chunks, removed %s course chunks",
|
| 139 |
+
after,
|
| 140 |
+
before - after,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Release the client so the sqlite file is unlocked before VACUUM.
|
| 144 |
+
del collection
|
| 145 |
+
del client
|
| 146 |
+
try:
|
| 147 |
+
chromadb.api.client.SharedSystemClient.clear_system_cache()
|
| 148 |
+
except Exception: # API location varies across chromadb versions
|
| 149 |
+
pass
|
| 150 |
+
gc.collect()
|
| 151 |
+
|
| 152 |
+
if vacuum:
|
| 153 |
+
sqlite_path = dst / "chroma.sqlite3"
|
| 154 |
+
try:
|
| 155 |
+
con = sqlite3.connect(sqlite_path)
|
| 156 |
+
con.execute("VACUUM")
|
| 157 |
+
con.close()
|
| 158 |
+
logger.info("Vacuumed %s to reclaim space from deleted rows", sqlite_path)
|
| 159 |
+
except sqlite3.Error as exc: # best-effort; a larger file is harmless
|
| 160 |
+
logger.warning("VACUUM skipped (%s); bundle will be larger", exc)
|
| 161 |
+
|
| 162 |
+
return {"dense_kept": after, "dense_removed": before - after}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def rebuild_retrieval_pkls(source_dir: Path, stage_dir: Path) -> dict:
|
| 166 |
+
"""Rebuild the BM25 index and document dict from a docs-only JSONL.
|
| 167 |
+
|
| 168 |
+
Mirrors ``create_vector_stores.write_retrieval_artifacts`` so the public
|
| 169 |
+
pkls are byte-for-byte what prod would write without the course rows.
|
| 170 |
+
"""
|
| 171 |
+
jsonl_path = source_dir / Path(ALL_SOURCES_JSONL).name
|
| 172 |
+
if not jsonl_path.exists():
|
| 173 |
+
raise SystemExit(f"Missing aggregate corpus: {jsonl_path}")
|
| 174 |
+
|
| 175 |
+
all_rows = load_jsonl_documents(str(jsonl_path))
|
| 176 |
+
docs_rows = [row for row in all_rows if not _is_course_row(row)]
|
| 177 |
+
logger.info(
|
| 178 |
+
"Corpus: %s docs documents (dropped %s course documents)",
|
| 179 |
+
len(docs_rows),
|
| 180 |
+
len(all_rows) - len(docs_rows),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
dst = stage_dir / VECTOR_DB_DIR
|
| 184 |
+
save_document_dict(build_document_dict(docs_rows), str(dst / DOCUMENT_DICT_FILE))
|
| 185 |
+
bm25_records = build_chunk_records(docs_rows)
|
| 186 |
+
save_bm25_index(BM25Index.build(bm25_records), str(dst / BM25_INDEX_FILE))
|
| 187 |
+
logger.info(
|
| 188 |
+
"Rebuilt document dict (%s docs) and BM25 index (%s chunks)",
|
| 189 |
+
len(docs_rows),
|
| 190 |
+
len(bm25_records),
|
| 191 |
+
)
|
| 192 |
+
return {"documents": len(docs_rows), "bm25_chunks": len(bm25_records)}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def stage_contextual_nodes(source_dir: Path, stage_dir: Path) -> int:
|
| 196 |
+
"""Write a docs-only copy of the contextual-nodes pickle (opt-in).
|
| 197 |
+
|
| 198 |
+
Not needed at runtime; included only when ``--include-contextual`` is
|
| 199 |
+
passed so others can rebuild the docs embeddings from scratch.
|
| 200 |
+
"""
|
| 201 |
+
import pickle
|
| 202 |
+
|
| 203 |
+
src = source_dir / Path(CONTEXTUAL_NODES_PKL).name
|
| 204 |
+
if not src.exists():
|
| 205 |
+
logger.warning("No contextual nodes pickle at %s; skipping", src)
|
| 206 |
+
return 0
|
| 207 |
+
with src.open("rb") as handle:
|
| 208 |
+
records = pickle.load(handle)
|
| 209 |
+
kept = []
|
| 210 |
+
for record in records:
|
| 211 |
+
try:
|
| 212 |
+
record_source = get_chunk_record_source(record)
|
| 213 |
+
except Exception:
|
| 214 |
+
kept.append(record) # keep records we cannot classify
|
| 215 |
+
continue
|
| 216 |
+
if record_source not in _COURSE_KEYS:
|
| 217 |
+
kept.append(record)
|
| 218 |
+
dst = stage_dir / Path(CONTEXTUAL_NODES_PKL).name
|
| 219 |
+
with dst.open("wb") as handle:
|
| 220 |
+
pickle.dump(kept, handle)
|
| 221 |
+
logger.info("Contextual nodes: kept %s docs chunks", len(kept))
|
| 222 |
+
return len(kept)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _filter_jsonl(path: Path) -> int:
|
| 226 |
+
"""Drop course rows from a generated *.jsonl index in place."""
|
| 227 |
+
if not path.exists():
|
| 228 |
+
return 0
|
| 229 |
+
kept = []
|
| 230 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 231 |
+
for line in handle:
|
| 232 |
+
if not line.strip():
|
| 233 |
+
continue
|
| 234 |
+
row = json.loads(line)
|
| 235 |
+
if str(row.get("source") or "") not in _COURSE_KEYS:
|
| 236 |
+
kept.append(line if line.endswith("\n") else line + "\n")
|
| 237 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 238 |
+
handle.writelines(kept)
|
| 239 |
+
return len(kept)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _filter_tsv(path: Path) -> int:
|
| 243 |
+
"""Drop course rows from symbols.tsv in place (keeps the header)."""
|
| 244 |
+
if not path.exists():
|
| 245 |
+
return 0
|
| 246 |
+
with path.open("r", encoding="utf-8", newline="") as handle:
|
| 247 |
+
reader = csv.DictReader(handle, delimiter="\t")
|
| 248 |
+
fieldnames = reader.fieldnames or []
|
| 249 |
+
kept = [
|
| 250 |
+
row for row in reader if str(row.get("source") or "") not in _COURSE_KEYS
|
| 251 |
+
]
|
| 252 |
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
| 253 |
+
writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t")
|
| 254 |
+
writer.writeheader()
|
| 255 |
+
writer.writerows(kept)
|
| 256 |
+
return len(kept)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def stage_kb(source_dir: Path, stage_dir: Path) -> dict:
|
| 260 |
+
"""Copy data/kb and prune course content (raw, wiki, generated indexes)."""
|
| 261 |
+
src = source_dir / KB_DIR_NAME
|
| 262 |
+
dst = stage_dir / KB_DIR_NAME
|
| 263 |
+
if not src.is_dir():
|
| 264 |
+
raise SystemExit(f"Missing KB directory: {src}")
|
| 265 |
+
|
| 266 |
+
logger.info("Copying KB %s -> %s", src, dst)
|
| 267 |
+
shutil.copytree(src, dst)
|
| 268 |
+
|
| 269 |
+
for relative in ("raw/courses", "wiki/courses"):
|
| 270 |
+
target = dst / relative
|
| 271 |
+
if target.exists():
|
| 272 |
+
shutil.rmtree(target)
|
| 273 |
+
logger.info("Removed %s", target)
|
| 274 |
+
|
| 275 |
+
generated = dst / "generated"
|
| 276 |
+
manifest_rows = _filter_jsonl(generated / "corpus_manifest.jsonl")
|
| 277 |
+
heading_rows = _filter_jsonl(generated / "headings.jsonl")
|
| 278 |
+
symbol_rows = _filter_tsv(generated / "symbols.tsv")
|
| 279 |
+
logger.info(
|
| 280 |
+
"KB generated indexes: %s manifest rows, %s heading rows, %s symbol rows",
|
| 281 |
+
manifest_rows,
|
| 282 |
+
heading_rows,
|
| 283 |
+
symbol_rows,
|
| 284 |
+
)
|
| 285 |
+
return {"manifest_rows": manifest_rows}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def build_bundle(
|
| 289 |
+
*,
|
| 290 |
+
source_dir: Path = DEFAULT_SOURCE_DIR,
|
| 291 |
+
stage_dir: Path = DEFAULT_STAGE_DIR,
|
| 292 |
+
include_contextual: bool = False,
|
| 293 |
+
vacuum: bool = True,
|
| 294 |
+
) -> dict:
|
| 295 |
+
if stage_dir.exists():
|
| 296 |
+
logger.info("Clearing existing staging dir %s", stage_dir)
|
| 297 |
+
shutil.rmtree(stage_dir)
|
| 298 |
+
stage_dir.mkdir(parents=True, exist_ok=True)
|
| 299 |
+
|
| 300 |
+
summary: dict = {"sources": sorted(DOC_SOURCE_KEYS)}
|
| 301 |
+
summary.update(stage_chroma(source_dir, stage_dir, vacuum=vacuum))
|
| 302 |
+
summary.update(rebuild_retrieval_pkls(source_dir, stage_dir))
|
| 303 |
+
summary.update(stage_kb(source_dir, stage_dir))
|
| 304 |
+
if include_contextual:
|
| 305 |
+
summary["contextual_chunks"] = stage_contextual_nodes(source_dir, stage_dir)
|
| 306 |
+
return summary
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def public_allow_patterns(*, include_contextual: bool) -> list[str]:
|
| 310 |
+
patterns = [f"{VECTOR_DB_DIR}/**", f"{KB_DIR_NAME}/**"]
|
| 311 |
+
if include_contextual:
|
| 312 |
+
patterns.append(Path(CONTEXTUAL_NODES_PKL).name)
|
| 313 |
+
return patterns
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def main() -> None:
|
| 317 |
+
parser = argparse.ArgumentParser(
|
| 318 |
+
description="Build and upload the public docs-only vector-db/KB bundle."
|
| 319 |
+
)
|
| 320 |
+
parser.add_argument("--source-dir", default=str(DEFAULT_SOURCE_DIR))
|
| 321 |
+
parser.add_argument("--stage-dir", default=str(DEFAULT_STAGE_DIR))
|
| 322 |
+
parser.add_argument("--repo", default=PUBLIC_REPO_ID)
|
| 323 |
+
parser.add_argument(
|
| 324 |
+
"--skip-upload",
|
| 325 |
+
action="store_true",
|
| 326 |
+
help="Build the bundle locally but do not push it to Hugging Face.",
|
| 327 |
+
)
|
| 328 |
+
parser.add_argument(
|
| 329 |
+
"--include-contextual",
|
| 330 |
+
action="store_true",
|
| 331 |
+
help="Also ship a docs-only all_sources_contextual_nodes.pkl so others "
|
| 332 |
+
"can rebuild the docs embeddings (larger download; off by default).",
|
| 333 |
+
)
|
| 334 |
+
parser.add_argument(
|
| 335 |
+
"--no-vacuum",
|
| 336 |
+
action="store_true",
|
| 337 |
+
help="Skip the sqlite VACUUM that reclaims space from deleted rows.",
|
| 338 |
+
)
|
| 339 |
+
args = parser.parse_args()
|
| 340 |
+
|
| 341 |
+
stage_dir = Path(args.stage_dir)
|
| 342 |
+
summary = build_bundle(
|
| 343 |
+
source_dir=Path(args.source_dir),
|
| 344 |
+
stage_dir=stage_dir,
|
| 345 |
+
include_contextual=args.include_contextual,
|
| 346 |
+
vacuum=not args.no_vacuum,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
print("\nBuilt public docs-only bundle:")
|
| 350 |
+
print(f" sources: {', '.join(summary['sources'])}")
|
| 351 |
+
print(f" documents: {summary['documents']}")
|
| 352 |
+
print(
|
| 353 |
+
f" dense chunks: {summary['dense_kept']} (removed {summary['dense_removed']})"
|
| 354 |
+
)
|
| 355 |
+
print(f" bm25 chunks: {summary['bm25_chunks']}")
|
| 356 |
+
print(f" KB manifest: {summary['manifest_rows']} rows")
|
| 357 |
+
if "contextual_chunks" in summary:
|
| 358 |
+
print(f" contextual: {summary['contextual_chunks']} chunks")
|
| 359 |
+
print(f" staged at: {stage_dir}")
|
| 360 |
+
|
| 361 |
+
if args.skip_upload:
|
| 362 |
+
print("\n--skip-upload set; not pushing to Hugging Face.")
|
| 363 |
+
return
|
| 364 |
+
|
| 365 |
+
from data.scraping_scripts.upload_dbs_to_hf import upload_bundle
|
| 366 |
+
|
| 367 |
+
print(f"\nUploading {stage_dir} -> {args.repo} (public) ...")
|
| 368 |
+
upload_bundle(
|
| 369 |
+
args.repo,
|
| 370 |
+
folder_path=str(stage_dir),
|
| 371 |
+
allow_patterns=public_allow_patterns(
|
| 372 |
+
include_contextual=args.include_contextual
|
| 373 |
+
),
|
| 374 |
+
create_public=True,
|
| 375 |
+
)
|
| 376 |
+
print("Done.")
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
if __name__ == "__main__":
|
| 380 |
+
main()
|
data/scraping_scripts/upload_dbs_to_hf.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Upload the Chroma vector database to a Hugging Face dataset repository."""
|
| 2 |
|
| 3 |
import argparse
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
from dotenv import load_dotenv
|
|
@@ -82,21 +83,45 @@ def _prune_stale_remote_files(
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
| 85 |
-
def
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
# Step 1: prune. Restore the auto-cleanup we lost when moving off
|
| 93 |
# `upload_folder(..., delete_patterns=["*"])`. See helper docstring.
|
| 94 |
_prune_stale_remote_files(
|
| 95 |
api,
|
| 96 |
repo_id,
|
| 97 |
-
folder_path=
|
| 98 |
-
allow_patterns=
|
| 99 |
-
ignore_patterns=
|
| 100 |
)
|
| 101 |
|
| 102 |
# Step 2: upload. `upload_large_folder` is the recommended path once the
|
|
@@ -105,14 +130,18 @@ def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
|
|
| 105 |
# `upload_folder` tends to hit at this size. It is resumable: re-running
|
| 106 |
# after a failure skips already-uploaded blobs.
|
| 107 |
api.upload_large_folder(
|
| 108 |
-
folder_path=
|
| 109 |
repo_id=repo_id,
|
| 110 |
repo_type="dataset",
|
| 111 |
-
allow_patterns=
|
| 112 |
-
ignore_patterns=
|
| 113 |
)
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
|
| 117 |
GRAPHRAG_REPO_PATH = "graphrag/output"
|
| 118 |
|
|
@@ -126,7 +155,7 @@ def upload_graphrag_index(repo_id: str = DEFAULT_REPO_ID) -> None:
|
|
| 126 |
it must never delete the production bundle. The runtime cold-start download
|
| 127 |
(`config.ensure_local_vector_db`) ignores `graphrag/**`, so prod Spaces do
|
| 128 |
not pull this ~150 MB experiment artifact; pull it explicitly to run the eval
|
| 129 |
-
(see
|
| 130 |
"""
|
| 131 |
try:
|
| 132 |
api = validate_hf_access(repo_id=repo_id)
|
|
|
|
| 1 |
"""Upload the Chroma vector database to a Hugging Face dataset repository."""
|
| 2 |
|
| 3 |
import argparse
|
| 4 |
+
import os
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from dotenv import load_dotenv
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
| 86 |
+
def upload_bundle(
|
| 87 |
+
repo_id: str = DEFAULT_REPO_ID,
|
| 88 |
+
*,
|
| 89 |
+
folder_path: str = FOLDER_PATH,
|
| 90 |
+
allow_patterns: list[str] | None = None,
|
| 91 |
+
ignore_patterns: list[str] | None = None,
|
| 92 |
+
create_public: bool = False,
|
| 93 |
+
) -> None:
|
| 94 |
+
"""Prune stale remote files, then upload ``folder_path`` to ``repo_id``.
|
| 95 |
+
|
| 96 |
+
The default arguments reproduce the production upload (the private
|
| 97 |
+
all-sources bundle). ``build_public_docs_bundle`` reuses this with a
|
| 98 |
+
different folder/repo/patterns and ``create_public=True`` to publish the
|
| 99 |
+
docs-only bundle to a public dataset repo it may need to create first.
|
| 100 |
+
"""
|
| 101 |
+
allow = allow_patterns if allow_patterns is not None else ALLOW_PATTERNS
|
| 102 |
+
ignore = ignore_patterns if ignore_patterns is not None else IGNORE_PATTERNS
|
| 103 |
+
|
| 104 |
+
if create_public:
|
| 105 |
+
# The public repo may not exist yet, so we cannot validate access to it
|
| 106 |
+
# up front (validate_hf_access requires the repo to exist). Create it as
|
| 107 |
+
# a public dataset (idempotent), which also proves the token can write.
|
| 108 |
+
api = HfApi(token=os.getenv("HF_TOKEN"))
|
| 109 |
+
api.create_repo(repo_id, repo_type="dataset", private=False, exist_ok=True)
|
| 110 |
+
else:
|
| 111 |
+
try:
|
| 112 |
+
api = validate_hf_access(repo_id=repo_id)
|
| 113 |
+
except HuggingFaceAuthError as exc:
|
| 114 |
+
print(exc)
|
| 115 |
+
raise SystemExit(1) from exc
|
| 116 |
|
| 117 |
# Step 1: prune. Restore the auto-cleanup we lost when moving off
|
| 118 |
# `upload_folder(..., delete_patterns=["*"])`. See helper docstring.
|
| 119 |
_prune_stale_remote_files(
|
| 120 |
api,
|
| 121 |
repo_id,
|
| 122 |
+
folder_path=folder_path,
|
| 123 |
+
allow_patterns=allow,
|
| 124 |
+
ignore_patterns=ignore,
|
| 125 |
)
|
| 126 |
|
| 127 |
# Step 2: upload. `upload_large_folder` is the recommended path once the
|
|
|
|
| 130 |
# `upload_folder` tends to hit at this size. It is resumable: re-running
|
| 131 |
# after a failure skips already-uploaded blobs.
|
| 132 |
api.upload_large_folder(
|
| 133 |
+
folder_path=folder_path,
|
| 134 |
repo_id=repo_id,
|
| 135 |
repo_type="dataset",
|
| 136 |
+
allow_patterns=allow,
|
| 137 |
+
ignore_patterns=ignore,
|
| 138 |
)
|
| 139 |
|
| 140 |
|
| 141 |
+
def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
|
| 142 |
+
upload_bundle(repo_id)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
|
| 146 |
GRAPHRAG_REPO_PATH = "graphrag/output"
|
| 147 |
|
|
|
|
| 155 |
it must never delete the production bundle. The runtime cold-start download
|
| 156 |
(`config.ensure_local_vector_db`) ignores `graphrag/**`, so prod Spaces do
|
| 157 |
not pull this ~150 MB experiment artifact; pull it explicitly to run the eval
|
| 158 |
+
(see evals/graphrag.md).
|
| 159 |
"""
|
| 160 |
try:
|
| 161 |
api = validate_hf_access(repo_id=repo_id)
|
evals.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
| 2 |
|
| 3 |
This document explains how we test the AI tutor with repeatable conversations and measured results. The main question is: when the tutor has a long chat history, which memory strategy gives the best answers without wasting too many tokens, dollars, or seconds?
|
| 4 |
|
| 5 |
-
The eval setup compares answer quality, memory retention, retrieval accuracy, tokens, cost, and latency for the June 2026 workshop. The same harness also becomes the ongoing quality program afterwards. Research sources and design rationale live in `
|
| 6 |
|
| 7 |
-
**Running a new experiment?** See `
|
| 8 |
|
| 9 |
Plain-English map:
|
| 10 |
- A **battery** is a dataset of test conversations or test questions.
|
|
@@ -61,7 +61,7 @@ The system under test is the production tutor (`app/`): the same agent, retrieva
|
|
| 61 |
| Tool behavior | Which tools the tutor called, how often, and whether it re-searched for information it had already seen. | Tool-call events saved in each trace bundle. |
|
| 62 |
| Retrieval | Whether the retrieved chunks included the correct course or lesson. `recall@shown` means "was the right source among the results shown to the model?" `MRR` rewards putting the right lesson earlier in the shown results. | Ground truth stored on each real discussion case: `source_key` and `lesson_url`. |
|
| 63 |
| Response behavior | Whether the tutor did the correct kind of thing: answer from course content, answer generally, redirect a platform/support issue, or acknowledge feedback. | Cheap code checks for early signals; final behavior accuracy comes from human grades. |
|
| 64 |
-
| Answer quality | Whether the answer covered the required key points
|
| 65 |
| Memory | Whether the tutor remembered or updated facts from earlier in the session, followed preferences, resolved references like "that thing from earlier," and used stored student profiles. | Session probes and persona checks. |
|
| 66 |
|
| 67 |
Cost is reported both as raw tokens and as estimated dollars after cached-token discounts. These can rank presets differently (finding F2).
|
|
@@ -98,7 +98,7 @@ Command roles:
|
|
| 98 |
- `report` builds side-by-side tables and token curves from graded runs.
|
| 99 |
- `handgrade_workbook` builds and merges a blinded grading workbook, so the grader does not know which preset produced an answer.
|
| 100 |
|
| 101 |
-
Every turn persists a JSON trace bundle, so grading and reporting can re-run offline without touching the API. Runs are resume-safe: re-run the same command after an interruption and completed units are skipped. Each turn has a 10-minute timeout, and LangSmith is off by default. Running the tutor needs `GEMINI_API_KEY` + `COHERE_API_KEY`; the full 4-preset comparison run below cost **≈$
|
| 102 |
|
| 103 |
**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:
|
| 104 |
|
|
@@ -107,7 +107,7 @@ export HF_TOKEN=hf_... # token with READ access; the inline cmd does NOT load
|
|
| 107 |
mkdir -p data/eval runs # runs/ is gitignored, so a fresh clone has no runs/ dir yet
|
| 108 |
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/**'], ignore_patterns=['eval/README.md'], local_dir='.')"
|
| 109 |
mv eval/* data/eval/ && mv eval_runs/part_*/* runs/ # restore working paths (part_b, part_c, ...)
|
| 110 |
-
mv eval_runs/slm_compaction/axis_a/* runs/ 2>/dev/null # SLM Axis-A runs -> runs/axisa_* (F31); Axis-B (F30) stays under eval_runs/slm_compaction/axis_b/, see
|
| 111 |
```
|
| 112 |
|
| 113 |
## Results — Part B comparison run, 2026-06-12
|
|
@@ -153,7 +153,7 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
|
|
| 153 |
|
| 154 |
### Part C screen (2026-06-15)
|
| 155 |
|
| 156 |
-
11 arms (prod + full_history anchors + 9 variants), each on a fixed subset (24 stratified single-turn + 3 sessions s01/s02/s08), 1 trial, ~660 turns, 0 errors, ≈$
|
| 157 |
|
| 158 |
- **F14 — The LLM judge is validated; Part C quality columns are reportable.** A blind subagent re-grade of the 96 human-confirmed probes reproduced them at 98% agreement / TPR 100% / TNR 96% (clears the >90% gate; this measures grader *reproducibility*, since the labels were sign-offs, not blind-from-scratch). The validated grader is the subagent workflow (same per-item-type rubric as `evals/judge.py`), run on subscription at no API cost; all Part C arms are graded that way. Supersedes F13's "judge not yet validated" caveat. (`runs/b_report/judge_val/`.)
|
| 159 |
- **F15 — The screen reproduces F9/F10 on independent arms.** `full_history` is cheapest on sessions ($0.10/turn) AND best memory (100% probe); every compaction arm is both pricier and weaker. `prompt_compression` also reaches 100% memory — it shrinks message text but drops no facts — yet saves little (highest tokens), so "don't drop content" preserves memory but is not a cost win. (Screen; n thin.)
|
|
@@ -177,14 +177,18 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
|
|
| 177 |
|
| 178 |
- **F27 (2026-06-20) — On single-turn, `full_history`'s latency edge is a *tail* effect from `prod` summarizing mid-turn, not a median win; and `total − ttft` cannot de-confound latency.** Re-read of the Part B/C single-turn bundles (no re-run). Paired per question, the `prod` − `full_history` median **TTFT** delta is **+0.43s** (Part B, n=120, 2 trials) / +3.5s (Part C, n=24, 1 trial) and `prod` is slower in only **62/120** cases — a near-tie at the median, *not* the sessions gap (F9's 17s-vs-21–43s is multi-turn and cannot apply with no prior history). The gap lives in the **tail**: `SummarizationMiddleware` fires on **29/120** single-turn `prod` turns (24%) because the agent's *own* retrieval/browse output crosses the 30k trigger **within one turn**; those turns run **52s TTFT / 16 LLM calls** vs **29s / 8** when it doesn't, and on the *same* questions `prod` does 2–3× `full_history`'s calls (e.g. 9→27, 12→32) — the F9/F4 re-work pathology (summary drops raw evidence → re-browse) inside a single turn. `full_history` never summarizes, so it skips the tail; that lifts its *mean* (Part C 35s vs 51s) while the *median* stays tied. **Methodological corollary (extends the time-of-run caveat below):** `total − ttft` does **not** strip API-load noise — it is the answer-streaming tail (**~6.0s for both presets**; +1.5s even when summarization fires), because the entire agentic loop incl. the summarization tax lands in `ttft`, so subtracting it deletes the signal. Latency ≈ work × time-per-work, and the confound smears across ~20 calls, so no algebra on one sequential run removes it; the **unconfounded** read is the work metrics — **LLM calls/turn and tokens/turn** (run-time-independent like cost/recall, already in every bundle) — which are also the upstream cause. Live LangSmith spot-check (3 interleaved pairs, one browse-heavy single-turn Q) reproduced **both** directions: `prod` faster once (summary collapsed 268k→23k ctx, 65s) and slower twice (re-work: 23/32 calls, 116s/124s, $2.27/$1.04 vs `full_history` $0.74/$0.33), and LangSmith's independent latency matched telemetry to the ms (`prod` 65.07s vs 65.16s; `full_history` 103.64s vs 104.28s), trees confirming `full_history` carries no `SummarizationMiddleware`. Caveats: thin n and time-of-run confounded for absolute seconds; the live pairs are n=3 on one non-representative question. Same class as F24 (single-turn *clearing* backfire) on the other compaction half.
|
| 179 |
|
| 180 |
-
- **F28 (2026-06-19) — GraphRAG does not beat classical hybrid RAG on single-turn course Q&A: it ties on grounding accuracy and costs +44% $/turn, empirically confirming the earlier decision to drop it.** A scoped, fair head-to-head where the *only* variable is the retrieval backend behind `retrieve_tutor_context`: production hybrid `LocalChromaRetriever` (dense Cohere `embed-v4` + BM25 → RRF → Cohere rerank → token budget) vs a true Microsoft GraphRAG index (`app/graph_rag.py` `GraphRAGRetriever` — local-search-style context assembly over entities/text-units/community-reports, mapped back to real `source`/`url` via `corpus_manifest`, then the **same Cohere rerank + token budget** as classical; context-provider only, so the agent's **Gemini 3.5 Flash is the sole generation model in both arms**). 41 `full_stack_ai_engineering` single-turn cases (27 `answer_from_corpus`), Gemini 3.5 Flash, `--disable-kb` so the retriever is the only variable, `--scope-sources`, 1 trial, **0 errors/arm**. Both arms **surface and cite the right source 100%** of the time and tie on lesson recall (**76%**) and cited-correct lesson (**85%**); classical is slightly *better* at ranking the right lesson (**MRR 0.70 vs 0.65**). GraphRAG pulls community-report context, so it spends **+61% input tokens (178k vs 110k) and +44% $/turn ($0.212 vs $0.147)** and is a touch slower, with no accuracy payoff (the +3pt behavior proxy is a coarse n=37 code check, not a quality signal). So GraphRAG's community structure does not help typical single-hop course Q&A — this **empirically supports** the prior "dropped on faith" decision (see "Deliberately not pursued" below) rather than asserting it. **Reusable artifact:** the scoped index (90 docs → 15,059 entities / 32,748 relationships / 3,371 community reports, built with Gemini 2.5 Flash for **$44.96**) is published to the private HF dataset (`graphrag/output/`, pulled on demand, *not* by prod cold-start — `ensure_local_vector_db` ignores `graphrag/**`), so the eval re-runs without the ~$45 rebuild. Full methodology, build/run commands, and the indexing gotchas (route Gemini 2.5 via its OpenAI-compatible endpoint; `run_graphrag_index.sh` loops `graphrag index` until `entities.parquet` exists since one exhausted retry aborts the stage) live in **`
|
| 181 |
|
| 182 |
-
- **F29 (2026-06-20) — Keeping a large *static document* in context is a different regime from conversational `full_history`, and it is one boundary where "keep everything" stops winning: for a single long lesson queried repeatedly, retrieving the relevant slice matches keep-it-all at ~1/13 the cost. (Boundary on F9; standalone Gemini-2.5-Flash fleet — absolute numbers not cross-comparable.)** A keep-vs-compact-vs-retrieve study on the corpus's largest lesson (~37.5k tok) loaded once in turn 0, then **15 question-turns with no tools** (the only context is what each method keeps or builds), each answer judged against the full lesson; 11 arms, Gemini 2.5 Flash, 1 trial. `rag` (fetch the relevant chunk per question) is **cheapest *and* tied-best** (60% / 3.2k tok / $0.020); `full_history` (keep the whole lesson in the prompt) is the **priciest in-context arm and not the best** (53% / 43k tok / $0.134); the summarization family cuts ~35% of tokens for a quality drop (40–53%); `incontext_history_retrieval` ties `rag` on quality but at ~13× the tokens; **`hierarchical_summarization` is the worst trade** — priciest ($0.605) and slowest (41.8s p50) for the lowest quality (33%). `graphrag` (53%, $0.045) again does not beat plain `rag` (consistent with **F28**). **Read it as a lesson, not a head-to-head:** this is *not* "RAG beats the `full_history` memory strategy" — here `full_history` only means "the whole lesson sits in the prompt," not its multi-turn-memory role. It says **F9's "keep everything wins" is about *conversation history*; for a big *static document* you could retrieve from, hoarding it in context is the priciest option and buys nothing**, and in the production tutor retrieval (Axis B) and a memory policy (Axis A) **compose**, they don't compete. Companion boundary: F30 (small context window). New reusable presets landed in `app/memory_presets.py`: `delta_summarization`, `hierarchical_summarization`. Full matrix, the F-C1–F-C6 detail, and reproduce steps in **`
|
| 183 |
|
| 184 |
-
- **F30 (2026-06-20) — A small context window is a second boundary where "keep everything" stops winning, and a harder one than F29: when the document physically does not fit, "shove it all" (`full_context`) is forced into truncation and is strictly *dominated*, so retrieving the relevant slice (`rag`) is not just cheaper but necessary. (Small-window companion to F29, Axis B; local-SLM fleet — absolute numbers not cross-comparable.)** "Fit one ~37.7k-token lesson into context to answer 15 questions," 7 methods × 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b-instruct, qwen3:8b no-think) on an M1 Pro/16 GB at a 32k window (`--num-ctx 32768`), no prompt caching; judge stays on Gemini 2.5 Flash (reads the full lesson as ground truth — the SLM cannot hold it, and a model must never grade itself), 1 trial. `full_context` (the whole *document* placed in the prompt, `ctx['full_context'] = lesson`, stateless per question — this is **document-stuffing**, *not* the conversational `full_history` memory policy) is **truncated on 15/15 turns** (37.7k → 32,767) and **dominated**: equal-or-worse quality (67–80%) at **~12–15× the latency** (266–346s vs ~20s) — silent information loss *plus* a huge latency tax (e.g. qwen2.5 `full_context` answered "no model was mentioned" for a fact the truncated intro had named). `rag` (chunk + Cohere-embed + retrieve top-k per question) is **top or tied-top on all three** (qwen2.5 100%, qwen3 100%, llama 80%) at ~2.9k context tokens and ~20–26s. `graphrag` ties `rag` on quality but at ~2.8× tokens and ~2.5× latency — no payoff, **consistent with F28/F29**. Summarization is the floor (llama `summary` **0%**); `trim`/`selective` land mid (47–73%). This marks where F9/F15's precondition — a window big enough to hold everything, cheaply, under caching — simply fails on the hardware a workshop attendee runs. **Distinct from the conversational-memory inversion (F31):** `full_context` here is *document-stuffing* (Axis B), not `full_history`'s multi-turn role, exactly the distinction F29 drew. **Methodology fix:** the first pass scored ~64% of judge verdicts as fails because a long JSON `reason` hit `max_tokens` and truncated the verdict; hardened the judge (25-word reason cap + leading-boolean recovery) and re-graded the saved answers offline (`evals/rejudge_compaction.py`, **0 unparseable** after) — the directly-measured axes (tokens, latency, overflow) were never affected. Caveats: n=15/method, 1 trial, one lesson, one domain — read as a ranking, not rates; **separate fleet (local Ollama SLMs, no caching, Gemini-2.5-Flash judge) — quality/cost not cross-comparable** with the Part B/C 3.5-Flash, the F29 2.5-Flash, or the DeepSeek runs. Full writeup: **`
|
| 185 |
-
- **F31 (2026-06-20) — On an SLM, compacting a *growing conversation history* has no universal best method, and "keep everything" (`full_history`) never wins — and model capability dominates the choice of method. (SLM counterpart to F9/F15 on Axis A; same local fleet — absolute numbers not cross-comparable.)** Same lesson loaded turn 0 of a session, 15 question-turns, **retrieval off**, run through the real app middlewares (the production memory presets) on the 3 SLMs at `num_ctx=32768`, judged on Gemini against the full lesson, 1 trial. The best preset is **model-dependent** — `prompt_compression` on qwen2.5 (60%), `hierarchical_summarization` on llama3.1 (40%), `summarization_only`/`delta_summarization` on qwen3 (73%) — so there is **no single "best compaction" to recommend**. `full_history` (the genuine conversational memory policy — keep *every prior turn*, no summarization or context-editing; `summarization=False, context_editing=False`) **tops no model** (27% / 7% / 67%) and is near-bottom on the two weaker ones; `sliding_window` is reliably among the worst (13% / 13% / 40%). **Model capability dominates the method:** qwen3 (40–73% across *every* preset) >> qwen2.5 (13–60%) >> llama3.1 (0–40%) — choosing a stronger small model buys more than choosing the best strategy. **Mechanism (why keep-all can't win here):** on an SLM "keep everything" (`full_history`) can't even be truncated-and-kept — Ollama's server-side context fitting **evicts the whole oversized turn-0 lesson** from turn 1 on (the checkpoint still holds it; the model receives only ~0.3–1.4k tok of accumulated Q&A), so a method that *summarizes the lesson into a small injected message* is the only way to retain its gist; on Gemini's large window this never happens (F9's `full_history` held ~43k/turn). `hierarchical_summarization` is by far the priciest (≈30-call map-reduce, p50 233–333s, ~25–29k retained tok) for no quality lead except on the weakest model. This **inverts F9/F15's conversational-memory result** (keep-all cheapest *and* best) in the regime where the runtime physically cannot keep it. Caveats: n=15, 1 trial, LLM-judge variance moves individual cells ±1–3/15 (read only the big gaps); the `full_history` numbers are **Ollama-eviction-specific** (a different serving layer could keep a truncated lesson instead); **same separate-fleet comparability caveat as F30**. Writeup: **`
|
| 186 |
- **F32 (2026-06-22) — Synthesis: "keep everything in context" wins only under a specific precondition — a large window that holds the whole history cheaply under prompt caching — and we have now mapped three boundaries where that precondition breaks and keep-all stops winning.** **Two distinct "keep-all" arms — never conflate them:** `full_history` (Axis A — F9/F15/F31) is the *memory policy* that keeps every prior **conversation turn** with no compaction (`summarization=False, context_editing=False` in `app/memory_presets.py`); `full_context` (Axis B — F30) is **document-stuffing** — the whole *source document* placed in the prompt (`ctx['full_context'] = lesson`). Same slogan ("keep everything"), different mechanisms. **F29 is the trap to watch:** its keep-all arm ran the `full_history` *preset* but over a single static lesson parked in turn 0 (`turns = [lesson] + questions`), so what it actually measured is the `full_context` idea (a document held in the prompt), **not** `full_history`'s multi-turn-memory role — which is why F29 sits on the *document* boundary (1) below, alongside F30, and **not** the conversational boundary (3). F9/F15 established the default (raw `full_history` is cheapest *and* best memory on conversational sessions), and F2/F25/F26 explain *why* it holds and even strengthens with horizon (the big **cached** prefix is cheaper than any compaction's cache-breaking rewrite; DeepSeek's ~50× cache pushes the crossover past 36 turns). Remove a piece of that precondition and keep-all loses, along three boundaries: **(1) a large static document you could retrieve from** (F29, Axis B, large cached window) — retrieving the slice *matches* keep-all at ~1/13 the cost, so hoarding the doc is the priciest option and buys nothing; **(2) a context window too small to hold the document** (F30, Axis B, SLM) — keep-all (`full_context`) is forced into truncation and is strictly dominated, so retrieval is *necessary*, not merely cheaper; **(3) a growing history on a small window** (F31, Axis A, SLM) — the runtime *evicts* the oversized turn, so keep-all (`full_history`) never tops the table and some compaction is mandatory, though no single method wins. Two cross-cutting lessons fall out: **model capability dominates the compaction method** on weak local models (F30/F31 — a better small model beats a better strategy), and the **axes compose, they don't compete** (F29) — retrieval (Axis B, *how to fit a document*) and a memory policy (Axis A, *how to compact history*) are orthogonal, so "RAG vs `full_history`" is a category error. **Net guidance:** keep-everything stays the right default in the production regime (large cached cloud window + conversational history; F9/F15/F25/F26); reach for retrieval or compaction exactly when one precondition breaks — a big static doc, a small window, or no caching. **Comparability:** this ties the *lessons* across fleets (3.5-Flash screen, 2.5-Flash F29, DeepSeek F25/F26, local SLMs F30/F31), **not** their absolute numbers, which are not cross-comparable.
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
**Screen winners → promotion candidates:** `incontext_history_retrieval` (needs the long-session test), `retrieval_budget_30k` and `clear_retrieval_kb` (cheap Axis-B wins; clear_retrieval_kb is cheaper than prod with similar memory + better recall/key-points), anchored by `full_history`. **Drop:** `context_reset`, `observation_truncation`, `selective_retention`.
|
| 189 |
|
| 190 |
**Part C — what remains (beyond the workshop).** The screen answered the core question; everything below *hardens* or *extends* it and is **gated on a spend or data decision from Omar** — none is required for the workshop.
|
|
@@ -192,7 +196,7 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
|
|
| 192 |
1. **Promotion (rigor, not new findings).** Re-run the 2-3 winners (+ 1-2 combos) on the full batteries (60 single-turn + 32 sessions + 30 replay) × 3 trials, with paired statistical tests and per-variant failure-taxonomy diffs, to turn the screen's coarse n=12 rankings into confident numbers; optionally an Anthropic (Haiku) re-run of the prefix-rewriting arms (`prompt_compression`, `context_reset`, `aggressive`) to test whether explicit caching flips F2's cost ranking. **~$1,300-1,800 at corrected pricing** (the old "$300-400" was at pre-correction prices; × the 4.4 fix — estimate precisely before running, since the per-turn cost is now ~$0.25).
|
| 193 |
2. **Finish the v2 contradiction tier — the highest-value new dataset (already built and partially run; see the V2 execution note below).** Moderate sessions: plant A, update to A′, then probe after prod compaction has evicted A from the kept-recent window. This is the one correctness regime where `full_history` itself might lose (it sees both A and A′ and must choose the current one) — yet the partial run so far shows the opposite (`full_history` contradiction **2/2** vs `prod` **0/3**), so that regime has not actually appeared. What remains is finishing Tier 1 (the missing `full_history` session, then Tier-1 `profile_memory` and optionally `incontext_history_retrieval`) and adding trials, not building the dataset. **(2026-06-19 — now run in full on DeepSeek-V4-Flash rather than Gemini: F26 confirms `full_history` 3/3 contradiction vs `prod` 0/3, so the regime did not appear on a second model either; the Gemini Tier-1 finish is now optional cross-model confirmation.)**
|
| 194 |
3. **The `incontext_history_retrieval` long-session cost test — principled but less product-critical.** The screen showed `incontext` works (83%, F16) but couldn't show a cost benefit on ≤13-turn sessions. Whether retrieve-old-turns beats `full_history` *once sessions get genuinely long* needs a token-calibrated long-session v2 tier; useful for the workshop argument, but gated by whether that regime matters in real tutor telemetry. **(2026-06-19 — run on DeepSeek (F25) at 36 turns: `full_history` stays cheapest and `incontext` works but does not beat it on cost under first-party caching; whether the answer flips on a 10× cache provider like Gemini/Anthropic is still open.)**
|
| 195 |
-
4. **Conditional builds.** Build `temporal_graph_memory` only if `full_history` fails contradictions (F26: it did **not** on DeepSeek — 3/3 — so this stays unbuilt), `delta_summarization` only if the long-horizon cost tier shows room to beat full history (F25: `full_history` still cheapest at 36t on DeepSeek, so no room there — though a 10× cache provider is untested), and `entity_memory` only if multi-project probes show fact bleed. `collection_memory` is a separate cheap v1/product follow-up for verbatim fact storage (F22 tested the current profile write-back, not that). `hierarchical_summarization` and `sleeptime_consolidation` are deferred. The step-by-step v2 plan is in `
|
| 196 |
|
| 197 |
**Deliberately not pursued.** *Stretch (heavier, likely later):* sub-agent isolation, reframed as a tool-output-token play — keep noisy `run_kb_command` output out of the main context (ties to F1); temporal-graph memory is already the conditional build in item 4 above. *Dropped as low-information given the findings:* the skills / lazy-prompt-loading family (only a ~458-token win — see the talk-outline correction below), procedural memory, GraphRAG (now **empirically tested** rather than dropped on faith — **F28**: ties classical RAG on grounding, costs +44% $/turn, no payoff on single-hop Q&A), and multi-agent / parallel-research agents. Context for the `kb_off` arm (F17/F23): the KB is the dominant grounding tool — ≈89% of Part B turns, ~7.7 KB vs ~0.9 retrieval calls/turn over 1,088 turns — so disabling it forces the 100k-token retrieval fallback, which is why it raised cost/latency.
|
| 198 |
|
|
@@ -200,14 +204,16 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
|
|
| 200 |
|
| 201 |
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).
|
| 202 |
|
| 203 |
-
- **Pricing correction (2026-06-13).** `MODEL_PRICING` under-priced `gemini-3.5-flash` before this date (~$0.30/$2.50 per MTok vs the correct **$1.50 input / $9.00 output / $0.15 cache-read**, verified against Google's price sheet). Every dollar figure generated earlier — the Part B table and the inline costs in findings **F2/F8/F9/F12** — is **~4.4× too low**; token counts are unaffected. Part B bundles were re-costed from their saved token counts (2026-06-15) and `runs/b_report/report.md` regenerated at correct pricing: **relative rankings are unchanged** (F9 still has `full_history` cheapest, $0.11/turn sessions), only absolute dollars move. Real Part A+B spend ≈ **$338**, not ~$73. Use the regenerated report for absolute costs; finding dollars above are pre-correction.
|
| 204 |
- **Stable `sheet_row_id` (2026-06-15).** `evals.grade` built `sheet_row_id` with builtin `hash()`, which is salted per process, so regenerating a `handgrade_sheet.csv` produced ids that no longer matched the frozen workbook keymap — silently emptying `handgrade_workbook merge`. Switched to a `hashlib.md5` hash; the Part B re-merge was rebuilt via the deterministic `run_id` to recover the human grades.
|
| 205 |
- **`cleared_tool_outputs` is blind to library clearing (2026-06-19).** `ContextEditingMiddleware` edits a per-call `deepcopy` and never persists the placeholder to the checkpoint that `context_window_stats` reads, so the signal is 0 for all presets whether or not `ClearToolUsesEdit` fired (full detail + token evidence in **F24**). Detect clearing via input-token deltas; the custom Part C view-mechanisms avoid this by reporting through the `app.telemetry` turn-signal registry.
|
|
|
|
|
|
|
| 206 |
- **Report mislabeled judge grades as human (2026-06-19, fixed).** `evals.report` hardcoded "(human grade)" on the behavior / key-point / probe-accuracy rows, but Part C runs were filled by the LLM judge (`judge_filled.csv`), not a person — so `runs/c_report/report.md` asserted a human label over judge numbers. Fix: `evals.grade` now records a `grade_source` per merged grade (the judge prefixes its note `[judge…]`; humans do not), and `evals.report` tags each column `[judge]`/`[human]`/`[mixed]` and renames the rows to "(graded)". Numbers are unchanged (and judge≈human per F14); only the provenance label is now honest. Same label-vs-measurement class as F23/F24. All Part C arms were re-graded and `c_report` regenerated.
|
| 207 |
- **`input tok/turn` is billed-across-calls, not context size (2026-06-19, clarified).** Telemetry sums `usage_metadata` over every internal model call in a turn, so a high-loop arm (`observation_truncation`, 22.8 calls) bills lots of input from small contexts while a big-payload arm (`kb_off`) bills similar totals from few large ones. Correct for *cost*, but it is not "how big is the context window." The report now shows both: **input tok/turn (billed, all calls)** and a new **context tokens/turn (window size)** row (from `context_tokens_approx`, always computed but previously unsurfaced). On single-turn the two even rank arms differently (e.g. `kb_off` has the *largest* window at 73k from its 100k retrieval payloads, but mid-pack billed input). Read F1/F6's "~200k input" as billed volume, not window size.
|
| 208 |
- **Latency rows are confounded by time-of-run (caveat, not yet fixed).** `run_part_c_screen.sh` runs arms sequentially, and TTFT/total-ms depend on API load at that moment, so cross-arm latency deltas (e.g. F19's 430s p95, F23's 19s-vs-38s) partly reflect *when* each arm ran. Token/cost/recall are run-time-independent and unaffected. Any latency claim that drives a decision should come from arms run interleaved (or repeated trials), not a single sequential screen. **`total − ttft` does *not* remove this** — it is only the answer-streaming tail (~6s, preset-insensitive); the agentic latency incl. the summarization tax is all in `ttft`, so subtracting it deletes the signal. Report the unconfounded work metrics (LLM calls/turn, tokens/turn) as the headline and treat seconds as supporting. See **F27**.
|
| 209 |
- **Latency de-confounding tooling (2026-06-22).** Acting on F27: (1) `runs/run_part_c_screen_interleaved.sh` runs the screen question-outer / arm-inner (writes `runs/ci_*`, not `runs/c_*`) so every question's arms share one API-load window — same total runs as the sequential screen, so interleaving is free. (2) `context_stats` now also emits `time_to_first_token_ms` (first reasoning/tool/text token, always ≤ `ttft_ms`), separating raw model responsiveness from the tool-call loop that `ttft_ms` includes; it flows through `evals/grade.py` to the report and shows `—` on runs recorded before it existed. (3) `evals/report.py` now leads with the run-time-independent work metrics (LLM/tool calls/turn, tokens/turn) and tags every latency row `[confounded]`, with a runtime note. All `runs/*report*` were regenerated (layout/labels only; numbers unchanged). The scripts live in gitignored `runs/` like the rest of the run tooling.
|
| 210 |
-
- **Pattern worth naming — most of our measurement bugs are telemetry/label blind-spots, not wrong math.**
|
| 211 |
|
| 212 |
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.
|
| 213 |
|
|
@@ -224,22 +230,22 @@ Talk-outline correction: the planned "skills / just-in-time KB instructions" cha
|
|
| 224 |
- [ ] Next.js meter component over the `data-context-stats` SSE part, if the live demo needs it.
|
| 225 |
- [ ] Skills section: reframe per the 458-token measurement above.
|
| 226 |
|
| 227 |
-
**Beyond the workshop.** Part C itself is done and reported above (findings F14–F24); what remains only *hardens* or *extends* it and is consolidated in "Part C — what remains" above — promotion, finishing the v2 contradiction tier, the `incontext_history_retrieval` long-session test, the conditional subsystem builds, and the deliberately-not-pursued list. The full execution detail (variant wiring, telemetry signals, the subset run matrix, the cost model, and the v2 battery plan) lives in `
|
| 228 |
|
| 229 |
**Product quality track (post-workshop)**
|
| 230 |
-
Golden cases as a CI gate (5–10 critical-path cases with deterministic assertions, including the F7 non-empty check) → error-analysis cycles (an expert reads 50–100 traces in a small viewer, records pass/fail plus the first failure reason, and stops when new failure types stop appearing) → failure taxonomy → code assertions for recurring modes → LLM judges built from the hand-grade critiques and validated to >90% true-positive and true-negative rates on held-out human labels before any judge-graded number is reported → nightly battery + weekly trace sampling. Full methodology behind each step: `
|
| 231 |
|
| 232 |
## Files
|
| 233 |
|
| 234 |
- `evals.md` — this file: the what, the data, the results, the queue.
|
| 235 |
-
- `
|
| 236 |
-
- `
|
| 237 |
-
- `
|
| 238 |
-
- `
|
| 239 |
-
- `
|
| 240 |
-
- `
|
| 241 |
- `data/eval/README.md` — battery schemas + glossary of terms; `review_log_v1.md` — dataset audit trail.
|
| 242 |
- `evals/` — the harness code; `app/memory_presets.py`, `app/telemetry.py` — the app-side hooks.
|
| 243 |
- `runs/b_report/`, `runs/c_report/` — generated tables, token curves, blinded grading workbook (Part B and the Part C screen). Re-grade / follow-up reports: `runs/kbfair_report/` (F23 KB-fair recall), `runs/d_report_profile_memory_active/` (F22 profile rerun), `runs/e2_v2_partial_report/` (v2 partial).
|
| 244 |
- `runs/ds_v2_t1_report/`, `runs/ds_v2_t2_report/` — **DeepSeek-V4-Flash** v2 matrix (F25/F26: cost + contradiction + long-horizon), graded via the subagent path (`runs/ds_judge/`). Shared on HF as `eval_runs/part_f_deepseek_v2/` (the standard `mv eval_runs/part_*/* runs/` download restores them). Provider added in `app/chat_service.py` (`deepseek` / `openrouter`), pricing in `app/telemetry.py`.
|
| 245 |
-
- `runs/axisa_*_report/`, `data/compaction_slm_*/` — **SLM compaction** study (F30/F31/F32, branch `experiment/slm-compaction`): Axis B fit-a-document and Axis A compact-history on 3 Ollama SLMs at a 32k window. Shared on HF under `eval_runs/slm_compaction/` (`axis_b/<model>/`, `axis_a/axisa_<model>_<preset>/`) + batteries `eval/compaction/`; the snippet above restores Axis-A to `runs/axisa_*`, Axis-B stays under `eval_runs/slm_compaction/axis_b/` — full restore command in `
|
|
|
|
| 2 |
|
| 3 |
This document explains how we test the AI tutor with repeatable conversations and measured results. The main question is: when the tutor has a long chat history, which memory strategy gives the best answers without wasting too many tokens, dollars, or seconds?
|
| 4 |
|
| 5 |
+
The eval setup compares answer quality, memory retention, retrieval accuracy, tokens, cost, and latency for the June 2026 workshop. The same harness also becomes the ongoing quality program afterwards. Research sources and design rationale live in `evals/background.md`.
|
| 6 |
|
| 7 |
+
**Running a new experiment?** See `evals/contributing.md` for the step-by-step (run → upload data to HF → record an `F<N>` finding here → merge) and the definition-of-done checklist, so the next person inherits both your result and the data to reproduce it.
|
| 8 |
|
| 9 |
Plain-English map:
|
| 10 |
- A **battery** is a dataset of test conversations or test questions.
|
|
|
|
| 61 |
| Tool behavior | Which tools the tutor called, how often, and whether it re-searched for information it had already seen. | Tool-call events saved in each trace bundle. |
|
| 62 |
| Retrieval | Whether the retrieved chunks included the correct course or lesson. `recall@shown` means "was the right source among the results shown to the model?" `MRR` rewards putting the right lesson earlier in the shown results. | Ground truth stored on each real discussion case: `source_key` and `lesson_url`. |
|
| 63 |
| Response behavior | Whether the tutor did the correct kind of thing: answer from course content, answer generally, redirect a platform/support issue, or acknowledge feedback. | Cheap code checks for early signals; final behavior accuracy comes from human grades. |
|
| 64 |
+
| Answer quality | Whether the answer covered the required key points, passed graded memory probes, and (holistic) whether course staff would approve sending it as a genuinely helpful, learning-oriented reply. A separate **faithfulness** check (every claim grounded in retrieved evidence) is built but parked — see the cap correction below. | Human pass/fail grading in a blinded workbook, or the validated blind subagent judge (same per-item rubric). LLM judges are used only after they are validated against human labels. Holistic and probe quality are complementary — holistic is graded blind to conversation history, so it cannot see a memory failure (F34); read them together. |
|
| 65 |
| Memory | Whether the tutor remembered or updated facts from earlier in the session, followed preferences, resolved references like "that thing from earlier," and used stored student profiles. | Session probes and persona checks. |
|
| 66 |
|
| 67 |
Cost is reported both as raw tokens and as estimated dollars after cached-token discounts. These can rank presets differently (finding F2).
|
|
|
|
| 98 |
- `report` builds side-by-side tables and token curves from graded runs.
|
| 99 |
- `handgrade_workbook` builds and merges a blinded grading workbook, so the grader does not know which preset produced an answer.
|
| 100 |
|
| 101 |
+
Every turn persists a JSON trace bundle, so grading and reporting can re-run offline without touching the API. Runs are resume-safe: re-run the same command after an interruption and completed units are skipped. Each turn has a 10-minute timeout, and LangSmith is off by default. Running the tutor needs `GEMINI_API_KEY` + `COHERE_API_KEY`; the full 4-preset comparison run below cost **≈$323** at corrected pricing (the pre-2026-06-13 price table under-priced `gemini-3.5-flash` ~4.4×; the often-quoted "$73" is a pre-correction figure — see Harness corrections).
|
| 102 |
|
| 103 |
**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:
|
| 104 |
|
|
|
|
| 107 |
mkdir -p data/eval runs # runs/ is gitignored, so a fresh clone has no runs/ dir yet
|
| 108 |
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/**'], ignore_patterns=['eval/README.md'], local_dir='.')"
|
| 109 |
mv eval/* data/eval/ && mv eval_runs/part_*/* runs/ # restore working paths (part_b, part_c, ...)
|
| 110 |
+
mv eval_runs/slm_compaction/axis_a/* runs/ 2>/dev/null # SLM Axis-A runs -> runs/axisa_* (F31); Axis-B (F30) stays under eval_runs/slm_compaction/axis_b/, see evals/slm_compaction.md
|
| 111 |
```
|
| 112 |
|
| 113 |
## Results — Part B comparison run, 2026-06-12
|
|
|
|
| 153 |
|
| 154 |
### Part C screen (2026-06-15)
|
| 155 |
|
| 156 |
+
11 arms (prod + full_history anchors + 9 variants), each on a fixed subset (24 stratified single-turn + 3 sessions s01/s02/s08), 1 trial, ~660 turns, 0 errors, ≈$163 at correct pricing. Graded by the validated subagent judge. Full table: `runs/c_report/report.md`. Probe-accuracy n=12/arm (1 trial) — treat percentages as coarse rankings, not precise rates; promotion (×3 trials, full batteries) would firm them up.
|
| 157 |
|
| 158 |
- **F14 — The LLM judge is validated; Part C quality columns are reportable.** A blind subagent re-grade of the 96 human-confirmed probes reproduced them at 98% agreement / TPR 100% / TNR 96% (clears the >90% gate; this measures grader *reproducibility*, since the labels were sign-offs, not blind-from-scratch). The validated grader is the subagent workflow (same per-item-type rubric as `evals/judge.py`), run on subscription at no API cost; all Part C arms are graded that way. Supersedes F13's "judge not yet validated" caveat. (`runs/b_report/judge_val/`.)
|
| 159 |
- **F15 — The screen reproduces F9/F10 on independent arms.** `full_history` is cheapest on sessions ($0.10/turn) AND best memory (100% probe); every compaction arm is both pricier and weaker. `prompt_compression` also reaches 100% memory — it shrinks message text but drops no facts — yet saves little (highest tokens), so "don't drop content" preserves memory but is not a cost win. (Screen; n thin.)
|
|
|
|
| 177 |
|
| 178 |
- **F27 (2026-06-20) — On single-turn, `full_history`'s latency edge is a *tail* effect from `prod` summarizing mid-turn, not a median win; and `total − ttft` cannot de-confound latency.** Re-read of the Part B/C single-turn bundles (no re-run). Paired per question, the `prod` − `full_history` median **TTFT** delta is **+0.43s** (Part B, n=120, 2 trials) / +3.5s (Part C, n=24, 1 trial) and `prod` is slower in only **62/120** cases — a near-tie at the median, *not* the sessions gap (F9's 17s-vs-21–43s is multi-turn and cannot apply with no prior history). The gap lives in the **tail**: `SummarizationMiddleware` fires on **29/120** single-turn `prod` turns (24%) because the agent's *own* retrieval/browse output crosses the 30k trigger **within one turn**; those turns run **52s TTFT / 16 LLM calls** vs **29s / 8** when it doesn't, and on the *same* questions `prod` does 2–3× `full_history`'s calls (e.g. 9→27, 12→32) — the F9/F4 re-work pathology (summary drops raw evidence → re-browse) inside a single turn. `full_history` never summarizes, so it skips the tail; that lifts its *mean* (Part C 35s vs 51s) while the *median* stays tied. **Methodological corollary (extends the time-of-run caveat below):** `total − ttft` does **not** strip API-load noise — it is the answer-streaming tail (**~6.0s for both presets**; +1.5s even when summarization fires), because the entire agentic loop incl. the summarization tax lands in `ttft`, so subtracting it deletes the signal. Latency ≈ work × time-per-work, and the confound smears across ~20 calls, so no algebra on one sequential run removes it; the **unconfounded** read is the work metrics — **LLM calls/turn and tokens/turn** (run-time-independent like cost/recall, already in every bundle) — which are also the upstream cause. Live LangSmith spot-check (3 interleaved pairs, one browse-heavy single-turn Q) reproduced **both** directions: `prod` faster once (summary collapsed 268k→23k ctx, 65s) and slower twice (re-work: 23/32 calls, 116s/124s, $2.27/$1.04 vs `full_history` $0.74/$0.33), and LangSmith's independent latency matched telemetry to the ms (`prod` 65.07s vs 65.16s; `full_history` 103.64s vs 104.28s), trees confirming `full_history` carries no `SummarizationMiddleware`. Caveats: thin n and time-of-run confounded for absolute seconds; the live pairs are n=3 on one non-representative question. Same class as F24 (single-turn *clearing* backfire) on the other compaction half.
|
| 179 |
|
| 180 |
+
- **F28 (2026-06-19) — GraphRAG does not beat classical hybrid RAG on single-turn course Q&A: it ties on grounding accuracy and costs +44% $/turn, empirically confirming the earlier decision to drop it.** A scoped, fair head-to-head where the *only* variable is the retrieval backend behind `retrieve_tutor_context`: production hybrid `LocalChromaRetriever` (dense Cohere `embed-v4` + BM25 → RRF → Cohere rerank → token budget) vs a true Microsoft GraphRAG index (`app/graph_rag.py` `GraphRAGRetriever` — local-search-style context assembly over entities/text-units/community-reports, mapped back to real `source`/`url` via `corpus_manifest`, then the **same Cohere rerank + token budget** as classical; context-provider only, so the agent's **Gemini 3.5 Flash is the sole generation model in both arms**). 41 `full_stack_ai_engineering` single-turn cases (27 `answer_from_corpus`), Gemini 3.5 Flash, `--disable-kb` so the retriever is the only variable, `--scope-sources`, 1 trial, **0 errors/arm**. Both arms **surface and cite the right source 100%** of the time and tie on lesson recall (**76%**) and cited-correct lesson (**85%**); classical is slightly *better* at ranking the right lesson (**MRR 0.70 vs 0.65**). GraphRAG pulls community-report context, so it spends **+61% input tokens (178k vs 110k) and +44% $/turn ($0.212 vs $0.147)** and is a touch slower, with no accuracy payoff (the +3pt behavior proxy is a coarse n=37 code check, not a quality signal). So GraphRAG's community structure does not help typical single-hop course Q&A — this **empirically supports** the prior "dropped on faith" decision (see "Deliberately not pursued" below) rather than asserting it. **Reusable artifact:** the scoped index (90 docs → 15,059 entities / 32,748 relationships / 3,371 community reports, built with Gemini 2.5 Flash for **$44.96**) is published to the private HF dataset (`graphrag/output/`, pulled on demand, *not* by prod cold-start — `ensure_local_vector_db` ignores `graphrag/**`), so the eval re-runs without the ~$45 rebuild. Full methodology, build/run commands, and the indexing gotchas (route Gemini 2.5 via its OpenAI-compatible endpoint; `run_graphrag_index.sh` loops `graphrag index` until `entities.parquet` exists since one exhausted retry aborts the stage) live in **`evals/graphrag.md`**. Connects to F17/F23 (the `kb_off` grounding discussion — classical RAG already grounds well). Caveats: scoped to one source, single-turn, n=41 (27 corpus-answer), 1 trial, auto metrics only (key-point/behavior human grades pending); `recall@source` is saturated by `--scope-sources`, so lesson recall / MRR / cited-correct-lesson are the discriminating numbers. GraphRAG's theoretical edge is **multi-hop / cross-document synthesis**, which this single-hop battery barely exercises — so this says it doesn't help on single-hop course Q&A, *not* that it never helps; a fair test of that strength needs a multi-hop `_v2` probe set (out of scope here). (Branch `experiment/graphrag-vs-rag`, PR #2.)
|
| 181 |
|
| 182 |
+
- **F29 (2026-06-20) — Keeping a large *static document* in context is a different regime from conversational `full_history`, and it is one boundary where "keep everything" stops winning: for a single long lesson queried repeatedly, retrieving the relevant slice matches keep-it-all at ~1/13 the cost. (Boundary on F9; standalone Gemini-2.5-Flash fleet — absolute numbers not cross-comparable.)** A keep-vs-compact-vs-retrieve study on the corpus's largest lesson (~37.5k tok) loaded once in turn 0, then **15 question-turns with no tools** (the only context is what each method keeps or builds), each answer judged against the full lesson; 11 arms, Gemini 2.5 Flash, 1 trial. `rag` (fetch the relevant chunk per question) is **cheapest *and* tied-best** (60% / 3.2k tok / $0.020); `full_history` (keep the whole lesson in the prompt) is the **priciest in-context arm and not the best** (53% / 43k tok / $0.134); the summarization family cuts ~35% of tokens for a quality drop (40–53%); `incontext_history_retrieval` ties `rag` on quality but at ~13× the tokens; **`hierarchical_summarization` is the worst trade** — priciest ($0.605) and slowest (41.8s p50) for the lowest quality (33%). `graphrag` (53%, $0.045) again does not beat plain `rag` (consistent with **F28**). **Read it as a lesson, not a head-to-head:** this is *not* "RAG beats the `full_history` memory strategy" — here `full_history` only means "the whole lesson sits in the prompt," not its multi-turn-memory role. It says **F9's "keep everything wins" is about *conversation history*; for a big *static document* you could retrieve from, hoarding it in context is the priciest option and buys nothing**, and in the production tutor retrieval (Axis B) and a memory policy (Axis A) **compose**, they don't compete. Companion boundary: F30 (small context window). New reusable presets landed in `app/memory_presets.py`: `delta_summarization`, `hierarchical_summarization`. Full matrix, the F-C1–F-C6 detail, and reproduce steps in **`evals/compaction.md`**. Caveats: n=15, 1 session, 1 trial (coarse rankings, not precise rates); judge is Gemini 2.5 Flash (same family, reads the full lesson as ground truth); **separate fleet from the Part B/C 3.5-Flash runs — quality is not cross-comparable across models**. (Branch `experiment/context-compaction`, PR #5.)
|
| 183 |
|
| 184 |
+
- **F30 (2026-06-20) — A small context window is a second boundary where "keep everything" stops winning, and a harder one than F29: when the document physically does not fit, "shove it all" (`full_context`) is forced into truncation and is strictly *dominated*, so retrieving the relevant slice (`rag`) is not just cheaper but necessary. (Small-window companion to F29, Axis B; local-SLM fleet — absolute numbers not cross-comparable.)** "Fit one ~37.7k-token lesson into context to answer 15 questions," 7 methods × 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b-instruct, qwen3:8b no-think) on an M1 Pro/16 GB at a 32k window (`--num-ctx 32768`), no prompt caching; judge stays on Gemini 2.5 Flash (reads the full lesson as ground truth — the SLM cannot hold it, and a model must never grade itself), 1 trial. `full_context` (the whole *document* placed in the prompt, `ctx['full_context'] = lesson`, stateless per question — this is **document-stuffing**, *not* the conversational `full_history` memory policy) is **truncated on 15/15 turns** (37.7k → 32,767) and **dominated**: equal-or-worse quality (67–80%) at **~12–15× the latency** (266–346s vs ~20s) — silent information loss *plus* a huge latency tax (e.g. qwen2.5 `full_context` answered "no model was mentioned" for a fact the truncated intro had named). `rag` (chunk + Cohere-embed + retrieve top-k per question) is **top or tied-top on all three** (qwen2.5 100%, qwen3 100%, llama 80%) at ~2.9k context tokens and ~20–26s. `graphrag` ties `rag` on quality but at ~2.8× tokens and ~2.5× latency — no payoff, **consistent with F28/F29**. Summarization is the floor (llama `summary` **0%**); `trim`/`selective` land mid (47–73%). This marks where F9/F15's precondition — a window big enough to hold everything, cheaply, under caching — simply fails on the hardware a workshop attendee runs. **Distinct from the conversational-memory inversion (F31):** `full_context` here is *document-stuffing* (Axis B), not `full_history`'s multi-turn role, exactly the distinction F29 drew. **Methodology fix:** the first pass scored ~64% of judge verdicts as fails because a long JSON `reason` hit `max_tokens` and truncated the verdict; hardened the judge (25-word reason cap + leading-boolean recovery) and re-graded the saved answers offline (`evals/rejudge_compaction.py`, **0 unparseable** after) — the directly-measured axes (tokens, latency, overflow) were never affected. Caveats: n=15/method, 1 trial, one lesson, one domain — read as a ranking, not rates; **separate fleet (local Ollama SLMs, no caching, Gemini-2.5-Flash judge) — quality/cost not cross-comparable** with the Part B/C 3.5-Flash, the F29 2.5-Flash, or the DeepSeek runs. Full writeup: **`evals/slm_compaction.md`** (Experiment 1). (Branch `experiment/slm-compaction`, PR #3.)
|
| 185 |
+
- **F31 (2026-06-20) — On an SLM, compacting a *growing conversation history* has no universal best method, and "keep everything" (`full_history`) never wins — and model capability dominates the choice of method. (SLM counterpart to F9/F15 on Axis A; same local fleet — absolute numbers not cross-comparable.)** Same lesson loaded turn 0 of a session, 15 question-turns, **retrieval off**, run through the real app middlewares (the production memory presets) on the 3 SLMs at `num_ctx=32768`, judged on Gemini against the full lesson, 1 trial. The best preset is **model-dependent** — `prompt_compression` on qwen2.5 (60%), `hierarchical_summarization` on llama3.1 (40%), `summarization_only`/`delta_summarization` on qwen3 (73%) — so there is **no single "best compaction" to recommend**. `full_history` (the genuine conversational memory policy — keep *every prior turn*, no summarization or context-editing; `summarization=False, context_editing=False`) **tops no model** (27% / 7% / 67%) and is near-bottom on the two weaker ones; `sliding_window` is reliably among the worst (13% / 13% / 40%). **Model capability dominates the method:** qwen3 (40–73% across *every* preset) >> qwen2.5 (13–60%) >> llama3.1 (0–40%) — choosing a stronger small model buys more than choosing the best strategy. **Mechanism (why keep-all can't win here):** on an SLM "keep everything" (`full_history`) can't even be truncated-and-kept — Ollama's server-side context fitting **evicts the whole oversized turn-0 lesson** from turn 1 on (the checkpoint still holds it; the model receives only ~0.3–1.4k tok of accumulated Q&A), so a method that *summarizes the lesson into a small injected message* is the only way to retain its gist; on Gemini's large window this never happens (F9's `full_history` held ~43k/turn). `hierarchical_summarization` is by far the priciest (≈30-call map-reduce, p50 233–333s, ~25–29k retained tok) for no quality lead except on the weakest model. This **inverts F9/F15's conversational-memory result** (keep-all cheapest *and* best) in the regime where the runtime physically cannot keep it. Caveats: n=15, 1 trial, LLM-judge variance moves individual cells ±1–3/15 (read only the big gaps); the `full_history` numbers are **Ollama-eviction-specific** (a different serving layer could keep a truncated lesson instead); **same separate-fleet comparability caveat as F30**. Writeup: **`evals/slm_compaction.md`** (Experiment 2). (Branch `experiment/slm-compaction`, PR #3.)
|
| 186 |
- **F32 (2026-06-22) — Synthesis: "keep everything in context" wins only under a specific precondition — a large window that holds the whole history cheaply under prompt caching — and we have now mapped three boundaries where that precondition breaks and keep-all stops winning.** **Two distinct "keep-all" arms — never conflate them:** `full_history` (Axis A — F9/F15/F31) is the *memory policy* that keeps every prior **conversation turn** with no compaction (`summarization=False, context_editing=False` in `app/memory_presets.py`); `full_context` (Axis B — F30) is **document-stuffing** — the whole *source document* placed in the prompt (`ctx['full_context'] = lesson`). Same slogan ("keep everything"), different mechanisms. **F29 is the trap to watch:** its keep-all arm ran the `full_history` *preset* but over a single static lesson parked in turn 0 (`turns = [lesson] + questions`), so what it actually measured is the `full_context` idea (a document held in the prompt), **not** `full_history`'s multi-turn-memory role — which is why F29 sits on the *document* boundary (1) below, alongside F30, and **not** the conversational boundary (3). F9/F15 established the default (raw `full_history` is cheapest *and* best memory on conversational sessions), and F2/F25/F26 explain *why* it holds and even strengthens with horizon (the big **cached** prefix is cheaper than any compaction's cache-breaking rewrite; DeepSeek's ~50× cache pushes the crossover past 36 turns). Remove a piece of that precondition and keep-all loses, along three boundaries: **(1) a large static document you could retrieve from** (F29, Axis B, large cached window) — retrieving the slice *matches* keep-all at ~1/13 the cost, so hoarding the doc is the priciest option and buys nothing; **(2) a context window too small to hold the document** (F30, Axis B, SLM) — keep-all (`full_context`) is forced into truncation and is strictly dominated, so retrieval is *necessary*, not merely cheaper; **(3) a growing history on a small window** (F31, Axis A, SLM) — the runtime *evicts* the oversized turn, so keep-all (`full_history`) never tops the table and some compaction is mandatory, though no single method wins. Two cross-cutting lessons fall out: **model capability dominates the compaction method** on weak local models (F30/F31 — a better small model beats a better strategy), and the **axes compose, they don't compete** (F29) — retrieval (Axis B, *how to fit a document*) and a memory policy (Axis A, *how to compact history*) are orthogonal, so "RAG vs `full_history`" is a category error. **Net guidance:** keep-everything stays the right default in the production regime (large cached cloud window + conversational history; F9/F15/F25/F26); reach for retrieval or compaction exactly when one precondition breaks — a big static doc, a small window, or no caching. **Comparability:** this ties the *lessons* across fleets (3.5-Flash screen, 2.5-Flash F29, DeepSeek F25/F26, local SLMs F30/F31), **not** their absolute numbers, which are not cross-comparable.
|
| 187 |
|
| 188 |
+
- **F33 (2026-06-23) — New quality axis (holistic "would staff send this?"): on single-turn, compaction degrades *answer quality itself*, and the degradation dose-responds to how often summarization fires *within a single turn*. (Independent quality-axis confirmation of F4/F19/F27.)** Added two judge rubrics in `evals/judge.py`: **holistic** (whole-answer staff-approval / does-it-help-the-student-learn gate, emitted for every answered turn) and **faithfulness** (groundedness vs retrieved evidence — *parked*, see the correction below). Graded blind by the validated subagent path (F14) across all single-turn runs; holistic rows added to `evals/report.py`, tagged `[judge]`. The single-turn premise is that memory presets should barely differ ("Memory presets should score about the same here; big differences suggest a bug", §The dataset) — yet holistic spans **57%→100%**, and it is *not* a bug: it tracks within-turn summarization. Dose-response: arms that never summarize mid-turn score 92–100% (`full_history` 92% Part C / 92% Part B n=120; `kb_off` **100%** at just 3.6 LLM calls; `sliding_window`/`incontext_history_retrieval`/`selective_retention`/`retrieval_budget_30k` 96%), while arms whose *own* retrieval/KB output trips the trigger mid-turn sink in trigger order — `observation_truncation` **71%** (22.8 LLM calls/turn, 12/24 turns summarized), `context_reset` **62%** (18/24), `aggressive` **57%** (Part B n=120, **110/120** turns summarized mid-turn). So the F4/F19/F27 within-turn re-work pathology is not merely slower/pricier — it makes the *answers worse*, at a product-relevant magnitude (`aggressive`: ~half its single-turn answers would not be sent). Caveats: Part C n=24 (directional); the firm cell is Part B `aggressive` 57% vs `full_history` 92% at n=120; judge reproducibility-validated on probes (F14), **not** independently on holistic.
|
| 189 |
+
|
| 190 |
+
- **F34 (2026-06-23) — Across a session, standard compaction's damage is *surgical*: it collapses memory while answer quality stays ~100% — but that "quality" is partly an artifact of a history-blind judge, so "looks fine" ≠ "served the student". Only the most aggressive rewrite (`context_reset`) degrades the answers themselves, progressively over the session. (Refines F10/F12 on the holistic axis.)** Holistic graded on every answered turn of `b_se`+`c_se` (683 turns, 15 arms, blind subagent path). **The decoupling:** `prod` probe **58%**(c)/**38%**(b) yet holistic **97/99%**; `kb_off` probe 33% / holistic 100%; `selective_retention` probe 25% / holistic 100%; `profile_memory` probe 38% / holistic 100% — memory and answer-quality are *independent failure axes*. The lone exception is `context_reset` (probe 17%, holistic **69%**, holistic dropping **−24% early→late turn**; `sliding_window` a milder −16% late): broad, progressive quality degradation appears only under the most destructive history rewriting, otherwise quality holds flat (`Δ late−early` ≈ 0 for all standard arms). **But "high holistic" is misleading on exactly the turns that matter** — holistic is graded *blind to the session history* (sessions carry no per-turn gold answer, and the judge sees only question+answer), so on the **memory-failed probe turns** it still rated the (generic, context-ignoring) answer "good" **100% of the time** for every standard arm (`c_se_prod` 5/5, `b_se_prod` 15/15, `kb_off` 8/8, `selective_retention` 9/9; `forgot+looks-bad` = **0**) — only `context_reset` forgot so badly the blind judge caught it (6/10 forgotten-fact turns also looked bad). The failure mode is therefore *confidently generic*: a well-formed answer that silently ignores what the student stated earlier, which only the targeted probe detects. **Lesson:** holistic (context-free quality) and probe (context-aware memory) measure different things and must be read together — "answer quality looks fine" does **not** mean the student was served. This refines F10 (memory dies but general answer quality survives) and F12 (only the *aggressive* rewrites *also* degrade quality). Caveats: `c_se` is 3 sessions × 1 trial (~18 turns/early-late bucket), `b_se` ×2 trials; session holistic graded without a staff reference (the within-arm early/late comparison controls for that); judge reproducibility-validated on probes (F14), not on holistic — the blind-spot above is itself why a held-out human validation of holistic is the next gate.
|
| 191 |
+
|
| 192 |
**Screen winners → promotion candidates:** `incontext_history_retrieval` (needs the long-session test), `retrieval_budget_30k` and `clear_retrieval_kb` (cheap Axis-B wins; clear_retrieval_kb is cheaper than prod with similar memory + better recall/key-points), anchored by `full_history`. **Drop:** `context_reset`, `observation_truncation`, `selective_retention`.
|
| 193 |
|
| 194 |
**Part C — what remains (beyond the workshop).** The screen answered the core question; everything below *hardens* or *extends* it and is **gated on a spend or data decision from Omar** — none is required for the workshop.
|
|
|
|
| 196 |
1. **Promotion (rigor, not new findings).** Re-run the 2-3 winners (+ 1-2 combos) on the full batteries (60 single-turn + 32 sessions + 30 replay) × 3 trials, with paired statistical tests and per-variant failure-taxonomy diffs, to turn the screen's coarse n=12 rankings into confident numbers; optionally an Anthropic (Haiku) re-run of the prefix-rewriting arms (`prompt_compression`, `context_reset`, `aggressive`) to test whether explicit caching flips F2's cost ranking. **~$1,300-1,800 at corrected pricing** (the old "$300-400" was at pre-correction prices; × the 4.4 fix — estimate precisely before running, since the per-turn cost is now ~$0.25).
|
| 197 |
2. **Finish the v2 contradiction tier — the highest-value new dataset (already built and partially run; see the V2 execution note below).** Moderate sessions: plant A, update to A′, then probe after prod compaction has evicted A from the kept-recent window. This is the one correctness regime where `full_history` itself might lose (it sees both A and A′ and must choose the current one) — yet the partial run so far shows the opposite (`full_history` contradiction **2/2** vs `prod` **0/3**), so that regime has not actually appeared. What remains is finishing Tier 1 (the missing `full_history` session, then Tier-1 `profile_memory` and optionally `incontext_history_retrieval`) and adding trials, not building the dataset. **(2026-06-19 — now run in full on DeepSeek-V4-Flash rather than Gemini: F26 confirms `full_history` 3/3 contradiction vs `prod` 0/3, so the regime did not appear on a second model either; the Gemini Tier-1 finish is now optional cross-model confirmation.)**
|
| 198 |
3. **The `incontext_history_retrieval` long-session cost test — principled but less product-critical.** The screen showed `incontext` works (83%, F16) but couldn't show a cost benefit on ≤13-turn sessions. Whether retrieve-old-turns beats `full_history` *once sessions get genuinely long* needs a token-calibrated long-session v2 tier; useful for the workshop argument, but gated by whether that regime matters in real tutor telemetry. **(2026-06-19 — run on DeepSeek (F25) at 36 turns: `full_history` stays cheapest and `incontext` works but does not beat it on cost under first-party caching; whether the answer flips on a 10× cache provider like Gemini/Anthropic is still open.)**
|
| 199 |
+
4. **Conditional builds.** Build `temporal_graph_memory` only if `full_history` fails contradictions (F26: it did **not** on DeepSeek — 3/3 — so this stays unbuilt), `delta_summarization` only if the long-horizon cost tier shows room to beat full history (F25: `full_history` still cheapest at 36t on DeepSeek, so no room there — though a 10× cache provider is untested), and `entity_memory` only if multi-project probes show fact bleed. `collection_memory` is a separate cheap v1/product follow-up for verbatim fact storage (F22 tested the current profile write-back, not that). `hierarchical_summarization` and `sleeptime_consolidation` are deferred. The step-by-step v2 plan is in `evals/part_c_plan.md` → "Battery v2".
|
| 200 |
|
| 201 |
**Deliberately not pursued.** *Stretch (heavier, likely later):* sub-agent isolation, reframed as a tool-output-token play — keep noisy `run_kb_command` output out of the main context (ties to F1); temporal-graph memory is already the conditional build in item 4 above. *Dropped as low-information given the findings:* the skills / lazy-prompt-loading family (only a ~458-token win — see the talk-outline correction below), procedural memory, GraphRAG (now **empirically tested** rather than dropped on faith — **F28**: ties classical RAG on grounding, costs +44% $/turn, no payoff on single-hop Q&A), and multi-agent / parallel-research agents. Context for the `kb_off` arm (F17/F23): the KB is the dominant grounding tool — ≈89% of Part B turns, ~7.7 KB vs ~0.9 retrieval calls/turn over 1,088 turns — so disabling it forces the 100k-token retrieval fallback, which is why it raised cost/latency.
|
| 202 |
|
|
|
|
| 204 |
|
| 205 |
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).
|
| 206 |
|
| 207 |
+
- **Pricing correction (2026-06-13).** `MODEL_PRICING` under-priced `gemini-3.5-flash` before this date (~$0.30/$2.50 per MTok vs the correct **$1.50 input / $9.00 output / $0.15 cache-read**, verified against Google's price sheet). Every dollar figure generated earlier — the Part B table and the inline costs in findings **F2/F8/F9/F12** — is **~4.4× too low**; token counts are unaffected. Part B bundles were re-costed from their saved token counts (2026-06-15) and `runs/b_report/report.md` regenerated at correct pricing: **relative rankings are unchanged** (F9 still has `full_history` cheapest, $0.11/turn sessions), only absolute dollars move. Real Part A+B spend ≈ **$338**, not ~$73; the comprehensive program ≈ **$590** across all Gemini 3.5 Flash runs (Part B ~$323 · Part C ~$163 · rest ~$103). Use the regenerated report for absolute costs; finding dollars above are pre-correction.
|
| 208 |
- **Stable `sheet_row_id` (2026-06-15).** `evals.grade` built `sheet_row_id` with builtin `hash()`, which is salted per process, so regenerating a `handgrade_sheet.csv` produced ids that no longer matched the frozen workbook keymap — silently emptying `handgrade_workbook merge`. Switched to a `hashlib.md5` hash; the Part B re-merge was rebuilt via the deterministic `run_id` to recover the human grades.
|
| 209 |
- **`cleared_tool_outputs` is blind to library clearing (2026-06-19).** `ContextEditingMiddleware` edits a per-call `deepcopy` and never persists the placeholder to the checkpoint that `context_window_stats` reads, so the signal is 0 for all presets whether or not `ClearToolUsesEdit` fired (full detail + token evidence in **F24**). Detect clearing via input-token deltas; the custom Part C view-mechanisms avoid this by reporting through the `app.telemetry` turn-signal registry.
|
| 210 |
+
|
| 211 |
+
- **Faithfulness parked; bundle tool-output cap raised 6k→40k (2026-06-23).** The new **faithfulness** rubric (groundedness vs retrieved evidence, F33) is *not reportable on existing runs*: `evals/run_battery.py` capped each saved tool output at `TOOL_OUTPUT_MAX_CHARS = 6_000` while the KB shell returns up to **40k** (`app/kb_shell.py` `DEFAULT_MAX_OUTPUT_CHARS`), so on KB-heavy arms the judge saw a fraction of what the agent actually grounded on and scored *capture-completeness, not grounding* — faithfulness tracked the KB:retrieval ratio almost monotonically (`b_st_prod` kb:ret 10.3 → **4%**; `c_st_prod` 7.7 → 25%; `kb_off`/`graphrag` kb:ret 0 → 50–76%), the same blind-signal class as **F23/F24**. Raised the cap to **40k** (captures what the agent saw; `output_chars` still records the true length so any overflow stays visible) so *future* re-records make faithfulness measurable; existing bundles cannot be backfilled (content past 6k is gone — needs a re-run). Until then `evals/grade.py` emits faithfulness **only when evidence was captured in full** (`evidence_is_complete`, self-re-enabling after the cap raise), and `evals/report.py` + `evals/grading_merge.py` drop the column (`DROP_ITEM_TYPES`). Holistic (F33/F34) is unaffected — it grades the answer, not the evidence.
|
| 212 |
- **Report mislabeled judge grades as human (2026-06-19, fixed).** `evals.report` hardcoded "(human grade)" on the behavior / key-point / probe-accuracy rows, but Part C runs were filled by the LLM judge (`judge_filled.csv`), not a person — so `runs/c_report/report.md` asserted a human label over judge numbers. Fix: `evals.grade` now records a `grade_source` per merged grade (the judge prefixes its note `[judge…]`; humans do not), and `evals.report` tags each column `[judge]`/`[human]`/`[mixed]` and renames the rows to "(graded)". Numbers are unchanged (and judge≈human per F14); only the provenance label is now honest. Same label-vs-measurement class as F23/F24. All Part C arms were re-graded and `c_report` regenerated.
|
| 213 |
- **`input tok/turn` is billed-across-calls, not context size (2026-06-19, clarified).** Telemetry sums `usage_metadata` over every internal model call in a turn, so a high-loop arm (`observation_truncation`, 22.8 calls) bills lots of input from small contexts while a big-payload arm (`kb_off`) bills similar totals from few large ones. Correct for *cost*, but it is not "how big is the context window." The report now shows both: **input tok/turn (billed, all calls)** and a new **context tokens/turn (window size)** row (from `context_tokens_approx`, always computed but previously unsurfaced). On single-turn the two even rank arms differently (e.g. `kb_off` has the *largest* window at 73k from its 100k retrieval payloads, but mid-pack billed input). Read F1/F6's "~200k input" as billed volume, not window size.
|
| 214 |
- **Latency rows are confounded by time-of-run (caveat, not yet fixed).** `run_part_c_screen.sh` runs arms sequentially, and TTFT/total-ms depend on API load at that moment, so cross-arm latency deltas (e.g. F19's 430s p95, F23's 19s-vs-38s) partly reflect *when* each arm ran. Token/cost/recall are run-time-independent and unaffected. Any latency claim that drives a decision should come from arms run interleaved (or repeated trials), not a single sequential screen. **`total − ttft` does *not* remove this** — it is only the answer-streaming tail (~6s, preset-insensitive); the agentic latency incl. the summarization tax is all in `ttft`, so subtracting it deletes the signal. Report the unconfounded work metrics (LLM calls/turn, tokens/turn) as the headline and treat seconds as supporting. See **F27**.
|
| 215 |
- **Latency de-confounding tooling (2026-06-22).** Acting on F27: (1) `runs/run_part_c_screen_interleaved.sh` runs the screen question-outer / arm-inner (writes `runs/ci_*`, not `runs/c_*`) so every question's arms share one API-load window — same total runs as the sequential screen, so interleaving is free. (2) `context_stats` now also emits `time_to_first_token_ms` (first reasoning/tool/text token, always ≤ `ttft_ms`), separating raw model responsiveness from the tool-call loop that `ttft_ms` includes; it flows through `evals/grade.py` to the report and shows `—` on runs recorded before it existed. (3) `evals/report.py` now leads with the run-time-independent work metrics (LLM/tool calls/turn, tokens/turn) and tags every latency row `[confounded]`, with a runtime note. All `runs/*report*` were regenerated (layout/labels only; numbers unchanged). The scripts live in gitignored `runs/` like the rest of the run tooling.
|
| 216 |
+
- **Pattern worth naming — most of our measurement bugs are telemetry/label blind-spots, not wrong math.** Six now share one shape: a number or label asserted a property the instrumentation could not observe for that config — **F21** (mechanism dormant: `profile_memory` never engaged on sessions), **F23** (recall blind to KB browsing), **F24** (clearing invisible to the checkpoint signal), the judge-as-human label above, **faithfulness graded on truncated evidence** (the 6k cap above — the judge scored what it could *see*, not what the agent *saw*), and **F34's holistic-on-session blind spot** (the quality judge sees no conversation history, so it credits answers that silently ignored the student's earlier-stated facts — "looks good" on 100% of memory-failed turns). `check_triggers` enforces "did the mechanism fire?" for *compaction* only; nothing yet does for retrieval/profile/KB engagement, grade provenance, or whether the *grader* saw what it needed. Standing guard for any new arm or metric: before trusting a per-arm number, confirm the signal/label/grader reflects what the config actually did.
|
| 217 |
|
| 218 |
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.
|
| 219 |
|
|
|
|
| 230 |
- [ ] Next.js meter component over the `data-context-stats` SSE part, if the live demo needs it.
|
| 231 |
- [ ] Skills section: reframe per the 458-token measurement above.
|
| 232 |
|
| 233 |
+
**Beyond the workshop.** Part C itself is done and reported above (findings F14–F24); what remains only *hardens* or *extends* it and is consolidated in "Part C — what remains" above — promotion, finishing the v2 contradiction tier, the `incontext_history_retrieval` long-session test, the conditional subsystem builds, and the deliberately-not-pursued list. The full execution detail (variant wiring, telemetry signals, the subset run matrix, the cost model, and the v2 battery plan) lives in `evals/part_c_plan.md`.
|
| 234 |
|
| 235 |
**Product quality track (post-workshop)**
|
| 236 |
+
Golden cases as a CI gate (5–10 critical-path cases with deterministic assertions, including the F7 non-empty check) → error-analysis cycles (an expert reads 50–100 traces in a small viewer, records pass/fail plus the first failure reason, and stops when new failure types stop appearing) → failure taxonomy → code assertions for recurring modes → LLM judges built from the hand-grade critiques and validated to >90% true-positive and true-negative rates on held-out human labels before any judge-graded number is reported → nightly battery + weekly trace sampling. Full methodology behind each step: `evals/background.md`.
|
| 237 |
|
| 238 |
## Files
|
| 239 |
|
| 240 |
- `evals.md` — this file: the what, the data, the results, the queue.
|
| 241 |
+
- `evals/contributing.md` — contributor playbook: how to run an experiment and land it (run → upload data to HF → record an `F<N>` finding → merge), with a definition-of-done gate.
|
| 242 |
+
- `evals/part_c_plan.md` — Part C execution plan: orchestration (workflow vs direct), the variant catalog with exact wiring + telemetry signals, the subset run matrix, and cost.
|
| 243 |
+
- `evals/background.md` — research sources (Hamel, howtoeval, OpenAI macro-evals) and design rationale.
|
| 244 |
+
- `evals/graphrag.md` — GraphRAG vs classical RAG experiment (branch `experiment/graphrag-vs-rag`): a scoped, true-GraphRAG head-to-head on the single-turn battery. Revisits the dropped GraphRAG idea with a fair test.
|
| 245 |
+
- `evals/compaction.md` — compaction-vs-keep-everything study (branch `experiment/context-compaction`): on the largest lesson, keep-all vs every compaction method vs retrieve-per-question. **Standalone fleet on Gemini 2.5 Flash — not comparable to the 3.5-Flash results above.**
|
| 246 |
+
- `evals/slm_compaction.md` — knowledge-compaction on small local models (branch `experiment/slm-compaction`): two experiments on 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b, qwen3:8b) at a 32k window where the lesson overflows. **Axis B** (fit a document: RAG vs stuff vs summarize, F30) and **Axis A** (compact growing history via the real memory presets, F31). The regime where compaction is *forced*; RAG wins Axis B, no universal winner on Axis A, and "keep everything" never wins on an SLM (synthesis in F32).
|
| 247 |
- `data/eval/README.md` — battery schemas + glossary of terms; `review_log_v1.md` — dataset audit trail.
|
| 248 |
- `evals/` — the harness code; `app/memory_presets.py`, `app/telemetry.py` — the app-side hooks.
|
| 249 |
- `runs/b_report/`, `runs/c_report/` — generated tables, token curves, blinded grading workbook (Part B and the Part C screen). Re-grade / follow-up reports: `runs/kbfair_report/` (F23 KB-fair recall), `runs/d_report_profile_memory_active/` (F22 profile rerun), `runs/e2_v2_partial_report/` (v2 partial).
|
| 250 |
- `runs/ds_v2_t1_report/`, `runs/ds_v2_t2_report/` — **DeepSeek-V4-Flash** v2 matrix (F25/F26: cost + contradiction + long-horizon), graded via the subagent path (`runs/ds_judge/`). Shared on HF as `eval_runs/part_f_deepseek_v2/` (the standard `mv eval_runs/part_*/* runs/` download restores them). Provider added in `app/chat_service.py` (`deepseek` / `openrouter`), pricing in `app/telemetry.py`.
|
| 251 |
+
- `runs/axisa_*_report/`, `data/compaction_slm_*/` — **SLM compaction** study (F30/F31/F32, branch `experiment/slm-compaction`): Axis B fit-a-document and Axis A compact-history on 3 Ollama SLMs at a 32k window. Shared on HF under `eval_runs/slm_compaction/` (`axis_b/<model>/`, `axis_a/axisa_<model>_<preset>/`) + batteries `eval/compaction/`; the snippet above restores Axis-A to `runs/axisa_*`, Axis-B stays under `eval_runs/slm_compaction/axis_b/` — full restore command in `evals/slm_compaction.md`. Provider `ollama` added in `app/chat_service.py`.
|
evals_background.md → evals/background.md
RENAMED
|
File without changes
|
evals_compaction.md → evals/compaction.md
RENAMED
|
File without changes
|
evals_contributing.md → evals/contributing.md
RENAMED
|
File without changes
|
evals/grade.py
CHANGED
|
@@ -50,6 +50,80 @@ RETRIEVAL_TOOLS = {"retrieve_tutor_context", "run_kb_command"}
|
|
| 50 |
# in the command or its output reveal which corpus source the agent touched.
|
| 51 |
KB_RAW_SOURCE_RE = re.compile(r"raw/(?:docs|courses)/([A-Za-z0-9_.\-]+)/")
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def index_battery(battery: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
| 55 |
index: dict[str, dict[str, Any]] = {}
|
|
@@ -207,6 +281,7 @@ def sheet_row(
|
|
| 207 |
item_type: str,
|
| 208 |
criterion: str,
|
| 209 |
reference: str = "",
|
|
|
|
| 210 |
) -> dict[str, str]:
|
| 211 |
return {
|
| 212 |
# Stable hash (NOT builtin hash(), which is salted per process — that
|
|
@@ -221,12 +296,49 @@ def sheet_row(
|
|
| 221 |
"question": bundle["query"][:600],
|
| 222 |
"answer": (bundle.get("answer") or "")[:4000],
|
| 223 |
"criterion": criterion,
|
| 224 |
-
"reference": reference[:
|
| 225 |
"grade": "",
|
| 226 |
"note": "",
|
| 227 |
}
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
| 231 |
bundles = load_jsonl(run_dir / "bundles.jsonl")
|
| 232 |
if not bundles:
|
|
@@ -306,6 +418,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
|
|
| 306 |
reference=record.get("reference_answer") or "",
|
| 307 |
)
|
| 308 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
elif bundle["battery_type"] == "sessions":
|
| 311 |
probe = next(
|
|
@@ -332,6 +449,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
|
|
| 332 |
f"Rule: {probe['check_note']}",
|
| 333 |
)
|
| 334 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
elif bundle["battery_type"] == "personas":
|
| 337 |
result = grade_persona_question(record, bundle.get("answer") or "")
|
|
@@ -356,6 +478,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
|
|
| 356 |
criterion=" | ".join(llm_checks),
|
| 357 |
)
|
| 358 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
elif bundle["battery_type"] == "replay":
|
| 361 |
sheet.append(
|
|
@@ -391,22 +518,47 @@ def merge_handgrades(
|
|
| 391 |
for r in rows
|
| 392 |
}
|
| 393 |
grade["grade_source"] = sources.pop() if len(sources) == 1 else "mixed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
key_points = [r for r in rows if r["item_type"] == "key_point"]
|
| 395 |
if key_points:
|
| 396 |
passed = sum(1 for r in key_points if r["grade"].lower() == "pass")
|
| 397 |
grade["key_points_passed"] = passed
|
| 398 |
grade["key_points_total"] = len(key_points)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
for row in rows:
|
| 400 |
verdict = row["grade"].strip().lower() == "pass"
|
| 401 |
if row["item_type"] == "behavior":
|
| 402 |
grade["behavior_pass"] = verdict
|
|
|
|
| 403 |
elif row["item_type"].startswith("probe:"):
|
| 404 |
grade["probe_pass"] = verdict
|
|
|
|
| 405 |
elif row["item_type"] == "persona_llm_check":
|
| 406 |
# Combines with the regex auto result (both must pass).
|
| 407 |
grade["auto_pass"] = verdict and grade.get("auto_pass") is not False
|
|
|
|
| 408 |
elif row["item_type"] == "replay_reply":
|
| 409 |
grade["replay_pass"] = verdict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
return grades
|
| 411 |
|
| 412 |
|
|
|
|
| 50 |
# in the command or its output reveal which corpus source the agent touched.
|
| 51 |
KB_RAW_SOURCE_RE = re.compile(r"raw/(?:docs|courses)/([A-Za-z0-9_.\-]+)/")
|
| 52 |
|
| 53 |
+
# Per-bundle evidence cap for faithfulness rows. Tool outputs in a bundle are each
|
| 54 |
+
# capped at ~6000 chars; a few calls per turn, so ~12k keeps the gist of every
|
| 55 |
+
# call without bloating the grading prompt.
|
| 56 |
+
FAITHFULNESS_EVIDENCE_MAX = 12_000
|
| 57 |
+
|
| 58 |
+
# Holistic = the "would staff approve sending this?" gate (evals/background.md
|
| 59 |
+
# Layer 4). Emitted for every single-turn answer; the full rubric lives in
|
| 60 |
+
# evals/judge.py RUBRICS["holistic"], this is the at-a-glance criterion.
|
| 61 |
+
HOLISTIC_CRITERION = (
|
| 62 |
+
"Would an experienced course tutor approve sending this answer to the "
|
| 63 |
+
"student? Pass only if it is correct, grounded in the right material, and "
|
| 64 |
+
"genuinely helps them learn (builds understanding), with appropriate scope "
|
| 65 |
+
"and tone. (see README / judge rubric)"
|
| 66 |
+
)
|
| 67 |
+
# Faithfulness = is the answer grounded in the evidence the tutor actually
|
| 68 |
+
# retrieved (catches hallucination on corpus questions). Emitted only when the
|
| 69 |
+
# turn produced retrieval/KB evidence; rubric in judge.py RUBRICS["faithfulness"].
|
| 70 |
+
FAITHFULNESS_CRITERION = (
|
| 71 |
+
"Are the answer's substantive factual/technical claims supported by the "
|
| 72 |
+
"retrieved evidence shown below? Fail on fabricated APIs, parameters, "
|
| 73 |
+
"versions, citations, or course/lesson specifics not in the evidence."
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def faithfulness_evidence(
|
| 78 |
+
bundle: dict[str, Any], max_chars: int = FAITHFULNESS_EVIDENCE_MAX
|
| 79 |
+
) -> str:
|
| 80 |
+
"""Concatenate the retrieval/KB evidence the tutor saw, for grounding grades.
|
| 81 |
+
|
| 82 |
+
Joins each retrieval/KB tool call's command + output (and, for
|
| 83 |
+
`retrieve_tutor_context`, the ranked source titles/URLs) so the judge can
|
| 84 |
+
check the answer's claims against exactly what the tools returned. Empty when
|
| 85 |
+
the turn used no retrieval/KB tool (e.g. answer_general), which is the signal
|
| 86 |
+
to skip the faithfulness row for that case.
|
| 87 |
+
"""
|
| 88 |
+
parts: list[str] = []
|
| 89 |
+
for call in bundle.get("tool_calls") or []:
|
| 90 |
+
name = call.get("tool_name")
|
| 91 |
+
if name not in RETRIEVAL_TOOLS:
|
| 92 |
+
continue
|
| 93 |
+
header = f"[{name}] {(call.get('args_text') or '').strip()}".strip()
|
| 94 |
+
body = (call.get("output_text") or "").strip()
|
| 95 |
+
matches = call.get("matches") or []
|
| 96 |
+
if matches:
|
| 97 |
+
cites = "; ".join(
|
| 98 |
+
f"{m.get('title', '')} <{m.get('url', '')}>" for m in matches[:10]
|
| 99 |
+
)
|
| 100 |
+
body = f"{body}\nSOURCES: {cites}".strip()
|
| 101 |
+
if header or body:
|
| 102 |
+
parts.append(f"{header}\n{body}".strip())
|
| 103 |
+
return "\n\n".join(parts)[:max_chars]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def evidence_is_complete(bundle: dict[str, Any]) -> bool:
|
| 107 |
+
"""True if every retrieval/KB tool output was captured in full.
|
| 108 |
+
|
| 109 |
+
Bundles cap each tool output at ``run_battery.TOOL_OUTPUT_MAX_CHARS`` but
|
| 110 |
+
preserve the true length in ``output_chars``. When a KB-browse output was
|
| 111 |
+
truncated, the judge sees only a slice of what the agent actually read, so a
|
| 112 |
+
faithfulness grade would measure capture-completeness, not grounding (the
|
| 113 |
+
same blind-signal class as evals.md F23/F24). Faithfulness is therefore
|
| 114 |
+
emitted ONLY when nothing the agent grounded on was truncated; after the
|
| 115 |
+
bundle cap is raised and a run is re-recorded, this returns True and the
|
| 116 |
+
column populates automatically.
|
| 117 |
+
"""
|
| 118 |
+
for call in bundle.get("tool_calls") or []:
|
| 119 |
+
if call.get("tool_name") not in RETRIEVAL_TOOLS:
|
| 120 |
+
continue
|
| 121 |
+
full = call.get("output_chars")
|
| 122 |
+
kept = len(call.get("output_text") or "")
|
| 123 |
+
if isinstance(full, int) and full > kept:
|
| 124 |
+
return False
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
|
| 128 |
def index_battery(battery: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
| 129 |
index: dict[str, dict[str, Any]] = {}
|
|
|
|
| 281 |
item_type: str,
|
| 282 |
criterion: str,
|
| 283 |
reference: str = "",
|
| 284 |
+
reference_max: int = 2000,
|
| 285 |
) -> dict[str, str]:
|
| 286 |
return {
|
| 287 |
# Stable hash (NOT builtin hash(), which is salted per process — that
|
|
|
|
| 296 |
"question": bundle["query"][:600],
|
| 297 |
"answer": (bundle.get("answer") or "")[:4000],
|
| 298 |
"criterion": criterion,
|
| 299 |
+
"reference": reference[:reference_max],
|
| 300 |
"grade": "",
|
| 301 |
"note": "",
|
| 302 |
}
|
| 303 |
|
| 304 |
|
| 305 |
+
def quality_sheet_rows(
|
| 306 |
+
bundle: dict[str, Any], reference: str = ""
|
| 307 |
+
) -> list[dict[str, str]]:
|
| 308 |
+
"""Holistic + faithfulness sheet rows for one answered turn.
|
| 309 |
+
|
| 310 |
+
Shared by single-turn and session turns: holistic (would staff approve this
|
| 311 |
+
answer?) is emitted for every answered turn; faithfulness only when the turn
|
| 312 |
+
produced retrieval/KB evidence to ground against. ``reference`` is the staff
|
| 313 |
+
answer for single-turn cases (context for holistic) and empty for session
|
| 314 |
+
turns, which have no per-turn gold answer.
|
| 315 |
+
"""
|
| 316 |
+
rows = [
|
| 317 |
+
sheet_row(
|
| 318 |
+
bundle=bundle,
|
| 319 |
+
item_type="holistic",
|
| 320 |
+
criterion=HOLISTIC_CRITERION,
|
| 321 |
+
reference=reference,
|
| 322 |
+
)
|
| 323 |
+
]
|
| 324 |
+
# Faithfulness only when the agent's grounding was captured in full -- on
|
| 325 |
+
# bundles recorded under the old 6k tool-output cap, KB-browse evidence is
|
| 326 |
+
# truncated, so a faithfulness grade would score capture-completeness, not
|
| 327 |
+
# grounding (evals.md F23/F24 class). Re-record with the raised cap to enable.
|
| 328 |
+
evidence = faithfulness_evidence(bundle)
|
| 329 |
+
if evidence and evidence_is_complete(bundle):
|
| 330 |
+
rows.append(
|
| 331 |
+
sheet_row(
|
| 332 |
+
bundle=bundle,
|
| 333 |
+
item_type="faithfulness",
|
| 334 |
+
criterion=FAITHFULNESS_CRITERION,
|
| 335 |
+
reference=evidence,
|
| 336 |
+
reference_max=FAITHFULNESS_EVIDENCE_MAX,
|
| 337 |
+
)
|
| 338 |
+
)
|
| 339 |
+
return rows
|
| 340 |
+
|
| 341 |
+
|
| 342 |
def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
| 343 |
bundles = load_jsonl(run_dir / "bundles.jsonl")
|
| 344 |
if not bundles:
|
|
|
|
| 418 |
reference=record.get("reference_answer") or "",
|
| 419 |
)
|
| 420 |
)
|
| 421 |
+
# Holistic (whole-answer staff-approval) + faithfulness (grounded in
|
| 422 |
+
# retrieved evidence); staff answer is context for holistic.
|
| 423 |
+
sheet.extend(
|
| 424 |
+
quality_sheet_rows(bundle, record.get("reference_answer") or "")
|
| 425 |
+
)
|
| 426 |
|
| 427 |
elif bundle["battery_type"] == "sessions":
|
| 428 |
probe = next(
|
|
|
|
| 449 |
f"Rule: {probe['check_note']}",
|
| 450 |
)
|
| 451 |
)
|
| 452 |
+
# Holistic + faithfulness on every answered session turn (no per-turn
|
| 453 |
+
# gold answer, so no reference for holistic). Lets us see whether
|
| 454 |
+
# answer quality degrades on late turns under compaction.
|
| 455 |
+
if bundle.get("answer"):
|
| 456 |
+
sheet.extend(quality_sheet_rows(bundle))
|
| 457 |
|
| 458 |
elif bundle["battery_type"] == "personas":
|
| 459 |
result = grade_persona_question(record, bundle.get("answer") or "")
|
|
|
|
| 478 |
criterion=" | ".join(llm_checks),
|
| 479 |
)
|
| 480 |
)
|
| 481 |
+
# Holistic + faithfulness on persona answers too (full quality
|
| 482 |
+
# coverage across batteries); the persona profile is the implicit
|
| 483 |
+
# reference, so no staff reference text here.
|
| 484 |
+
if bundle.get("answer"):
|
| 485 |
+
sheet.extend(quality_sheet_rows(bundle))
|
| 486 |
|
| 487 |
elif bundle["battery_type"] == "replay":
|
| 488 |
sheet.append(
|
|
|
|
| 518 |
for r in rows
|
| 519 |
}
|
| 520 |
grade["grade_source"] = sources.pop() if len(sources) == 1 else "mixed"
|
| 521 |
+
|
| 522 |
+
def _src(row: dict[str, str]) -> str:
|
| 523 |
+
# Per-metric provenance so a run with human key-points but judge-filled
|
| 524 |
+
# holistic/faithfulness still reports each row's true grader (the
|
| 525 |
+
# overall grade_source above goes "mixed" in that case).
|
| 526 |
+
return (
|
| 527 |
+
"judge"
|
| 528 |
+
if (row.get("note") or "").lstrip().startswith("[judge")
|
| 529 |
+
else "human"
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
key_points = [r for r in rows if r["item_type"] == "key_point"]
|
| 533 |
if key_points:
|
| 534 |
passed = sum(1 for r in key_points if r["grade"].lower() == "pass")
|
| 535 |
grade["key_points_passed"] = passed
|
| 536 |
grade["key_points_total"] = len(key_points)
|
| 537 |
+
kp_sources = {_src(r) for r in key_points}
|
| 538 |
+
grade["key_points_source"] = (
|
| 539 |
+
kp_sources.pop() if len(kp_sources) == 1 else "mixed"
|
| 540 |
+
)
|
| 541 |
for row in rows:
|
| 542 |
verdict = row["grade"].strip().lower() == "pass"
|
| 543 |
if row["item_type"] == "behavior":
|
| 544 |
grade["behavior_pass"] = verdict
|
| 545 |
+
grade["behavior_source"] = _src(row)
|
| 546 |
elif row["item_type"].startswith("probe:"):
|
| 547 |
grade["probe_pass"] = verdict
|
| 548 |
+
grade["probe_source"] = _src(row)
|
| 549 |
elif row["item_type"] == "persona_llm_check":
|
| 550 |
# Combines with the regex auto result (both must pass).
|
| 551 |
grade["auto_pass"] = verdict and grade.get("auto_pass") is not False
|
| 552 |
+
grade["persona_source"] = _src(row)
|
| 553 |
elif row["item_type"] == "replay_reply":
|
| 554 |
grade["replay_pass"] = verdict
|
| 555 |
+
grade["replay_source"] = _src(row)
|
| 556 |
+
elif row["item_type"] == "holistic":
|
| 557 |
+
grade["holistic_pass"] = verdict
|
| 558 |
+
grade["holistic_source"] = _src(row)
|
| 559 |
+
elif row["item_type"] == "faithfulness":
|
| 560 |
+
grade["faithfulness_pass"] = verdict
|
| 561 |
+
grade["faithfulness_source"] = _src(row)
|
| 562 |
return grades
|
| 563 |
|
| 564 |
|
evals/grade_workflow.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const meta = {
|
| 2 |
+
name: 'subagent-judge-grading',
|
| 3 |
+
description: 'Blinded subagent judge grades holistic/faithfulness/key-point/behavior chunks',
|
| 4 |
+
phases: [{ title: 'Grade', detail: 'one blinded grader agent per chunk' }],
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
// args: { battery, chunks: <int> } OR { battery, indices: [<int>...] }
|
| 8 |
+
// indices lets us re-run only the missing chunks after a partial run.
|
| 9 |
+
let A = args
|
| 10 |
+
if (typeof A === 'string') {
|
| 11 |
+
try { A = JSON.parse(A) } catch { A = {} }
|
| 12 |
+
}
|
| 13 |
+
const battery = (A && A.battery) || 'singleturn'
|
| 14 |
+
const explicit = A && Array.isArray(A.indices) ? A.indices : null
|
| 15 |
+
const nChunks = (A && A.chunks) || 0
|
| 16 |
+
if (!explicit && !nChunks) throw new Error('pass args.chunks (count) or args.indices (array)')
|
| 17 |
+
|
| 18 |
+
const dir = `runs/_grading/${battery}`
|
| 19 |
+
|
| 20 |
+
const SUMMARY_SCHEMA = {
|
| 21 |
+
type: 'object',
|
| 22 |
+
additionalProperties: false,
|
| 23 |
+
properties: {
|
| 24 |
+
chunk: { type: 'integer' },
|
| 25 |
+
graded: { type: 'integer', description: 'rows graded and written' },
|
| 26 |
+
low_confidence: { type: 'integer', description: 'count of confidence=low verdicts' },
|
| 27 |
+
wrote: { type: 'string', description: 'path of the verdicts json written' },
|
| 28 |
+
},
|
| 29 |
+
required: ['chunk', 'graded', 'low_confidence', 'wrote'],
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function prompt(i) {
|
| 33 |
+
const pad = String(i).padStart(3, '0')
|
| 34 |
+
const chunkPath = `${dir}/chunk_${pad}.csv`
|
| 35 |
+
const outPath = `${dir}/verdicts_${pad}.json`
|
| 36 |
+
return `You are a BLINDED grader for an AI tutor on a course platform (applied AI, LLMs, RAG, Python).
|
| 37 |
+
|
| 38 |
+
INPUT: parse the CSV at ${chunkPath} with Python (use csv with field_size_limit(10_000_000); fields contain newlines). Columns: sheet_row_id, item_type, question, criterion, reference, answer.
|
| 39 |
+
|
| 40 |
+
RUBRICS: read the dict RUBRICS and the string PREAMBLE in evals/judge.py and follow them EXACTLY, selecting the rubric by each row's item_type (for "probe:*" use the "probe" rubric; for "faithfulness" the 'reference' field IS the retrieved evidence to grade grounding against; for unknown types use "key_point"). These are the same rubrics the project's validated judge uses.
|
| 41 |
+
|
| 42 |
+
RULES:
|
| 43 |
+
- Grade EVERY row in the chunk. You see only content; you do NOT know which system/preset produced each answer and must not infer or use it.
|
| 44 |
+
- An EMPTY answer = fail (confidence high).
|
| 45 |
+
- grade is "pass" or "fail"; always decide. confidence is "high" or "low" — use "low" ONLY for genuinely borderline calls a reasonable grader could see either way. reason = one short sentence.
|
| 46 |
+
|
| 47 |
+
OUTPUT: write a JSON file to ${outPath} that is a list of objects, one per row, each: {"sheet_row_id": <str>, "item_type": <str>, "grade": "pass"|"fail", "confidence": "high"|"low", "reason": <str>}. Write it with Python json.dump. Every input sheet_row_id must appear exactly once.
|
| 48 |
+
|
| 49 |
+
Then return the summary (chunk index ${i}, how many you graded, how many were low confidence, and the output path). Your returned text is parsed as data, not shown to a human.`
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
phase('Grade')
|
| 53 |
+
const indices = explicit || Array.from({ length: nChunks }, (_, i) => i)
|
| 54 |
+
const results = await parallel(
|
| 55 |
+
indices.map((i) => () =>
|
| 56 |
+
agent(prompt(i), { schema: SUMMARY_SCHEMA, label: `${battery}:chunk_${i}`, phase: 'Grade' })
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
const ok = results.filter(Boolean)
|
| 61 |
+
const graded = ok.reduce((a, r) => a + (r.graded || 0), 0)
|
| 62 |
+
const low = ok.reduce((a, r) => a + (r.low_confidence || 0), 0)
|
| 63 |
+
const failedChunks = indices.filter((_, k) => !results[k])
|
| 64 |
+
log(`${battery}: ${ok.length}/${indices.length} chunks done, ${graded} rows graded, ${low} low-confidence`)
|
| 65 |
+
if (failedChunks.length) log(`FAILED chunks (rerun): ${failedChunks.join(', ')}`)
|
| 66 |
+
return { battery, chunks_done: ok.length, chunks_total: indices.length, graded, low_confidence: low, failed_chunks: failedChunks }
|
evals/grading_merge.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Merge subagent-judge verdicts back into runs, and collect uncertain calls.
|
| 2 |
+
|
| 3 |
+
Companion to grading_prep.py / grade_workflow.js. For one battery:
|
| 4 |
+
1. load every runs/_grading/<battery>/verdicts_*.json,
|
| 5 |
+
2. per run, write combined_filled.csv = kept existing grades + new judge verdicts,
|
| 6 |
+
then run `evals.grade --handgrades` to regenerate grades_merged.jsonl,
|
| 7 |
+
3. flag any expected rows that never got a verdict (chunk agent failed), and
|
| 8 |
+
4. write uncertain.md + uncertain.csv = every confidence=low verdict with its
|
| 9 |
+
question/answer/criterion (the human-review queue the user asked for).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import collections
|
| 15 |
+
import csv
|
| 16 |
+
import hashlib
|
| 17 |
+
import json
|
| 18 |
+
import subprocess
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
csv.field_size_limit(10_000_000)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _ah(answer: str) -> str:
|
| 26 |
+
"""Short hash of the graded answer text, to disambiguate sheet_row_id.
|
| 27 |
+
|
| 28 |
+
sheet_row_id = run_id|item_type|md5(criterion) is NOT globally unique: run_id
|
| 29 |
+
is per-case, so the same question graded under two presets (different run
|
| 30 |
+
dirs) shares an id while having different answers. Keying verdicts by
|
| 31 |
+
(sheet_row_id, answer-hash) restores uniqueness; identical answers collapse
|
| 32 |
+
correctly (same grade).
|
| 33 |
+
"""
|
| 34 |
+
return hashlib.md5((answer or "").encode("utf-8")).hexdigest()[:12]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
COMBINED_COLS = [
|
| 38 |
+
"sheet_row_id",
|
| 39 |
+
"run_id",
|
| 40 |
+
"battery_type",
|
| 41 |
+
"preset",
|
| 42 |
+
"item_type",
|
| 43 |
+
"criterion",
|
| 44 |
+
"grade",
|
| 45 |
+
"note",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# Item types dropped from the merge + review queue. Faithfulness is parked: on
|
| 49 |
+
# bundles recorded under the old 6k tool-output cap the judge can't see the
|
| 50 |
+
# truncated KB evidence, so it scores capture-completeness not grounding
|
| 51 |
+
# (evals.md F23/F24 class). Re-enable after re-recording runs with the raised cap.
|
| 52 |
+
DROP_ITEM_TYPES = {"faithfulness"}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_verdicts(
|
| 56 |
+
gdir: Path,
|
| 57 |
+
) -> tuple[dict[tuple[str, str], dict], dict[tuple[str, str], dict]]:
|
| 58 |
+
"""Return (verdicts, content) keyed by (sheet_row_id, answer-hash).
|
| 59 |
+
|
| 60 |
+
Recovers the verdict<->row association by POSITION within each chunk (chunk
|
| 61 |
+
row i corresponds to verdict i; order verified identical for all chunks), so
|
| 62 |
+
the per-run answer the verdict actually graded is known and used as the key.
|
| 63 |
+
`content` carries the chunk row (question/criterion/answer) for the report.
|
| 64 |
+
"""
|
| 65 |
+
verdicts: dict[tuple[str, str], dict] = {}
|
| 66 |
+
content: dict[tuple[str, str], dict] = {}
|
| 67 |
+
for cf in sorted(gdir.glob("chunk_*.csv")):
|
| 68 |
+
crows = list(csv.DictReader(cf.open()))
|
| 69 |
+
vf = gdir / cf.name.replace("chunk_", "verdicts_").replace(".csv", ".json")
|
| 70 |
+
if not vf.exists():
|
| 71 |
+
continue
|
| 72 |
+
vrows = json.loads(vf.read_text())
|
| 73 |
+
# Match by sheet_row_id with per-id FIFO queues: robust to a short chunk
|
| 74 |
+
# (agent skipped rows), reordering, or within-chunk duplicate ids (a chunk
|
| 75 |
+
# spanning two runs that share a session/turn). A chunk row with no
|
| 76 |
+
# remaining verdict is left unmatched -> surfaces as "missing" in main().
|
| 77 |
+
queues: dict[str, collections.deque] = collections.defaultdict(
|
| 78 |
+
collections.deque
|
| 79 |
+
)
|
| 80 |
+
for v in vrows:
|
| 81 |
+
queues[v["sheet_row_id"]].append(v)
|
| 82 |
+
for r in crows:
|
| 83 |
+
q = queues.get(r["sheet_row_id"])
|
| 84 |
+
if not q:
|
| 85 |
+
continue
|
| 86 |
+
v = q.popleft()
|
| 87 |
+
key = (r["sheet_row_id"], _ah(r.get("answer") or ""))
|
| 88 |
+
verdicts[key] = v
|
| 89 |
+
content[key] = r
|
| 90 |
+
return verdicts, content
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def expected_keys(gdir: Path) -> set[tuple[str, str]]:
|
| 94 |
+
keys: set[tuple[str, str]] = set()
|
| 95 |
+
for ch in sorted(gdir.glob("chunk_*.csv")):
|
| 96 |
+
for r in csv.DictReader(ch.open()):
|
| 97 |
+
keys.add((r["sheet_row_id"], _ah(r.get("answer") or "")))
|
| 98 |
+
return keys
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def note_for(v: dict) -> str:
|
| 102 |
+
return f"[judge:{v.get('confidence', 'high')}] {v.get('reason', '')}".strip()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def main() -> None:
|
| 106 |
+
battery = sys.argv[1] if len(sys.argv) > 1 else "singleturn"
|
| 107 |
+
gdir = Path("runs/_grading") / battery
|
| 108 |
+
manifest = json.loads((gdir / "manifest.json").read_text())
|
| 109 |
+
verdicts, content = load_verdicts(gdir)
|
| 110 |
+
expected = expected_keys(gdir)
|
| 111 |
+
missing = sorted(expected - set(verdicts))
|
| 112 |
+
print(f"{battery}: {len(verdicts)} verdicts, {len(missing)} expected rows missing")
|
| 113 |
+
|
| 114 |
+
merged_runs = 0
|
| 115 |
+
key_to_run: dict[tuple[str, str], str] = {}
|
| 116 |
+
for name in manifest["runs"]:
|
| 117 |
+
run_dir = Path("runs") / name
|
| 118 |
+
sheet = list(csv.DictReader((run_dir / "handgrade_sheet.csv").open()))
|
| 119 |
+
combined: list[dict] = []
|
| 120 |
+
# kept existing grades (columns may differ across old files; normalize)
|
| 121 |
+
keep_path = gdir / "keep" / f"{name}.csv"
|
| 122 |
+
if keep_path.exists():
|
| 123 |
+
for r in csv.DictReader(keep_path.open()):
|
| 124 |
+
combined.append({c: r.get(c, "") for c in COMBINED_COLS})
|
| 125 |
+
# new judge verdicts for this run's sheet rows (content key disambiguates
|
| 126 |
+
# the cross-run sheet_row_id collision)
|
| 127 |
+
for r in sheet:
|
| 128 |
+
if r["item_type"] in DROP_ITEM_TYPES:
|
| 129 |
+
continue
|
| 130 |
+
key = (r["sheet_row_id"], _ah(r.get("answer") or ""))
|
| 131 |
+
key_to_run[key] = name
|
| 132 |
+
v = verdicts.get(key)
|
| 133 |
+
if not v:
|
| 134 |
+
continue
|
| 135 |
+
combined.append(
|
| 136 |
+
{
|
| 137 |
+
"sheet_row_id": r["sheet_row_id"],
|
| 138 |
+
"run_id": r.get("run_id", ""),
|
| 139 |
+
"battery_type": r.get("battery_type", battery),
|
| 140 |
+
"preset": r.get("preset", ""),
|
| 141 |
+
"item_type": r["item_type"],
|
| 142 |
+
"criterion": r.get("criterion", ""),
|
| 143 |
+
"grade": v["grade"],
|
| 144 |
+
"note": note_for(v),
|
| 145 |
+
}
|
| 146 |
+
)
|
| 147 |
+
cf = run_dir / "combined_filled.csv"
|
| 148 |
+
with cf.open("w", newline="") as f:
|
| 149 |
+
w = csv.DictWriter(f, fieldnames=COMBINED_COLS, extrasaction="ignore")
|
| 150 |
+
w.writeheader()
|
| 151 |
+
w.writerows(combined)
|
| 152 |
+
res = subprocess.run(
|
| 153 |
+
[
|
| 154 |
+
"uv",
|
| 155 |
+
"run",
|
| 156 |
+
"-m",
|
| 157 |
+
"evals.grade",
|
| 158 |
+
"--run",
|
| 159 |
+
str(run_dir),
|
| 160 |
+
"--handgrades",
|
| 161 |
+
str(cf),
|
| 162 |
+
],
|
| 163 |
+
capture_output=True,
|
| 164 |
+
text=True,
|
| 165 |
+
)
|
| 166 |
+
if res.returncode != 0:
|
| 167 |
+
print(f" MERGE FAILED {name}: {res.stderr.strip()[-300:]}")
|
| 168 |
+
else:
|
| 169 |
+
merged_runs += 1
|
| 170 |
+
print(f"merged {merged_runs}/{len(manifest['runs'])} runs")
|
| 171 |
+
|
| 172 |
+
# ---- uncertain queue (the deliverable) --------------------------------
|
| 173 |
+
low = [
|
| 174 |
+
(k, v)
|
| 175 |
+
for k, v in verdicts.items()
|
| 176 |
+
if v.get("confidence") == "low"
|
| 177 |
+
and v.get("item_type", "").split(":")[0] not in DROP_ITEM_TYPES
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
def arm_of(key: tuple[str, str]) -> str:
|
| 181 |
+
return key_to_run.get(key, "?")
|
| 182 |
+
|
| 183 |
+
low.sort(key=lambda kv: (kv[1]["item_type"], arm_of(kv[0])))
|
| 184 |
+
with (gdir / "uncertain.csv").open("w", newline="") as f:
|
| 185 |
+
w = csv.DictWriter(
|
| 186 |
+
f,
|
| 187 |
+
fieldnames=[
|
| 188 |
+
"sheet_row_id",
|
| 189 |
+
"run",
|
| 190 |
+
"item_type",
|
| 191 |
+
"judge_grade",
|
| 192 |
+
"judge_reason",
|
| 193 |
+
"question",
|
| 194 |
+
"criterion",
|
| 195 |
+
"answer",
|
| 196 |
+
"your_grade",
|
| 197 |
+
],
|
| 198 |
+
)
|
| 199 |
+
w.writeheader()
|
| 200 |
+
for key, v in low:
|
| 201 |
+
c = content.get(key, {})
|
| 202 |
+
w.writerow(
|
| 203 |
+
{
|
| 204 |
+
"sheet_row_id": key[0],
|
| 205 |
+
"run": arm_of(key),
|
| 206 |
+
"item_type": v["item_type"],
|
| 207 |
+
"judge_grade": v["grade"],
|
| 208 |
+
"judge_reason": v.get("reason", ""),
|
| 209 |
+
"question": (c.get("question") or "")[:300],
|
| 210 |
+
"criterion": (c.get("criterion") or "")[:300],
|
| 211 |
+
"answer": (c.get("answer") or "")[:1200],
|
| 212 |
+
"your_grade": "",
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
lines = [f"# Uncertain judge calls — {battery}", ""]
|
| 217 |
+
lines.append(
|
| 218 |
+
f"{len(low)} low-confidence verdicts of {len(verdicts)} graded. "
|
| 219 |
+
"Fill `your_grade` in uncertain.csv to override.\n"
|
| 220 |
+
)
|
| 221 |
+
by_type = collections.Counter(v["item_type"].split(":")[0] for _, v in low)
|
| 222 |
+
by_run = collections.Counter(arm_of(k) for k, _ in low)
|
| 223 |
+
lines.append(
|
| 224 |
+
"By item type: " + ", ".join(f"{k}={n}" for k, n in sorted(by_type.items()))
|
| 225 |
+
)
|
| 226 |
+
lines.append(
|
| 227 |
+
"By run: " + ", ".join(f"{k}={n}" for k, n in sorted(by_run.items())) + "\n"
|
| 228 |
+
)
|
| 229 |
+
for key, v in low:
|
| 230 |
+
c = content.get(key, {})
|
| 231 |
+
lines.append(
|
| 232 |
+
f"## [{v['item_type']}] judge={v['grade'].upper()} — run `{arm_of(key)}`"
|
| 233 |
+
)
|
| 234 |
+
lines.append(f"- **id**: `{key[0]}`")
|
| 235 |
+
lines.append(f"- **judge reason**: {v.get('reason', '')}")
|
| 236 |
+
lines.append(f"- **Q**: {(c.get('question') or '')[:300]}")
|
| 237 |
+
lines.append(f"- **criterion**: {(c.get('criterion') or '')[:300]}")
|
| 238 |
+
lines.append(f"- **answer**: {(c.get('answer') or '')[:700]}")
|
| 239 |
+
lines.append("")
|
| 240 |
+
(gdir / "uncertain.md").write_text("\n".join(lines) + "\n")
|
| 241 |
+
print(f"uncertain: {len(low)} low-confidence -> {gdir}/uncertain.md (+ .csv)")
|
| 242 |
+
if missing:
|
| 243 |
+
print("MISSING keys (rerun their chunks):", len(missing))
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
if __name__ == "__main__":
|
| 247 |
+
main()
|
evals/grading_prep.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prep subagent-judge grading inputs from regenerated handgrade sheets.
|
| 2 |
+
|
| 3 |
+
Not part of the shipped harness; a one-off orchestration helper for the
|
| 4 |
+
holistic/faithfulness backfill (kept in-repo so the run is reproducible).
|
| 5 |
+
|
| 6 |
+
Per run, per item_type the column is treated as all-or-nothing:
|
| 7 |
+
- already fully graded (existing judge_filled/handgrade_filled covers every sheet
|
| 8 |
+
row of that type) -> KEEP those grades, do not re-grade;
|
| 9 |
+
- otherwise -> ALL sheet rows of that type go to the judge (a single grader per
|
| 10 |
+
column per run, so no human+judge double-counting and clean provenance).
|
| 11 |
+
|
| 12 |
+
Outputs under runs/_grading/<battery>/:
|
| 13 |
+
- chunk_NNN.csv blinded rows for one grader agent (sheet_row_id + content)
|
| 14 |
+
- keep/<run>.csv existing grades to carry into the final merge
|
| 15 |
+
- manifest.json battery -> {n_chunks, n_rows, runs}
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import csv
|
| 21 |
+
import json
|
| 22 |
+
import sys
|
| 23 |
+
from collections import Counter, defaultdict
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
from .grading_merge import DROP_ITEM_TYPES
|
| 27 |
+
|
| 28 |
+
csv.field_size_limit(10_000_000)
|
| 29 |
+
|
| 30 |
+
SKIP = {
|
| 31 |
+
"a4_s03_aggressive",
|
| 32 |
+
"a4_s03_fullhist",
|
| 33 |
+
"a4_sessions_prod",
|
| 34 |
+
"smoke_personas",
|
| 35 |
+
"smoke_session_prod",
|
| 36 |
+
"smoke_st_fullhist",
|
| 37 |
+
"smoke_st_prod",
|
| 38 |
+
"ds_smoke_st",
|
| 39 |
+
"e_v2_prod",
|
| 40 |
+
}
|
| 41 |
+
MAX_ROWS = 40
|
| 42 |
+
MAX_CHARS = 300_000
|
| 43 |
+
AGENT_COLS = [
|
| 44 |
+
"sheet_row_id",
|
| 45 |
+
"item_type",
|
| 46 |
+
"question",
|
| 47 |
+
"criterion",
|
| 48 |
+
"reference",
|
| 49 |
+
"answer",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def battery_type(run_dir: Path) -> str:
|
| 54 |
+
first = json.loads((run_dir / "bundles.jsonl").open().readline())
|
| 55 |
+
return first.get("battery_type")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def existing_filled(run_dir: Path) -> list[dict[str, str]]:
|
| 59 |
+
out: list[dict[str, str]] = []
|
| 60 |
+
for fn in ("judge_filled.csv", "handgrade_filled.csv"):
|
| 61 |
+
p = run_dir / fn
|
| 62 |
+
if p.exists():
|
| 63 |
+
out += [
|
| 64 |
+
r for r in csv.DictReader(p.open()) if (r.get("grade") or "").strip()
|
| 65 |
+
]
|
| 66 |
+
return out
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def plan_run(run_dir: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
| 70 |
+
"""Return (rows_to_grade, rows_to_keep) for one run."""
|
| 71 |
+
sheet = list(csv.DictReader((run_dir / "handgrade_sheet.csv").open()))
|
| 72 |
+
if not sheet:
|
| 73 |
+
return [], []
|
| 74 |
+
sheet_by_type: dict[str, list] = defaultdict(list)
|
| 75 |
+
for r in sheet:
|
| 76 |
+
sheet_by_type[r["item_type"]].append(r)
|
| 77 |
+
filled = existing_filled(run_dir)
|
| 78 |
+
filled_by_type: dict[str, list] = defaultdict(list)
|
| 79 |
+
for r in filled:
|
| 80 |
+
filled_by_type[r["item_type"]].append(r)
|
| 81 |
+
|
| 82 |
+
to_grade: list[dict[str, str]] = []
|
| 83 |
+
to_keep: list[dict[str, str]] = []
|
| 84 |
+
for itype, rows in sheet_by_type.items():
|
| 85 |
+
graded = filled_by_type.get(itype, [])
|
| 86 |
+
if len(graded) >= len(rows): # column already fully graded -> keep
|
| 87 |
+
to_keep.extend(graded)
|
| 88 |
+
else: # grade the whole column
|
| 89 |
+
to_grade.extend(rows)
|
| 90 |
+
return to_grade, to_keep
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def chunk(rows: list[dict[str, str]]) -> list[list[dict[str, str]]]:
|
| 94 |
+
chunks: list[list[dict[str, str]]] = []
|
| 95 |
+
cur: list[dict[str, str]] = []
|
| 96 |
+
cur_chars = 0
|
| 97 |
+
for r in rows:
|
| 98 |
+
rchars = len(r.get("reference") or "") + len(r.get("answer") or "")
|
| 99 |
+
if cur and (len(cur) >= MAX_ROWS or cur_chars + rchars > MAX_CHARS):
|
| 100 |
+
chunks.append(cur)
|
| 101 |
+
cur, cur_chars = [], 0
|
| 102 |
+
cur.append(r)
|
| 103 |
+
cur_chars += rchars
|
| 104 |
+
if cur:
|
| 105 |
+
chunks.append(cur)
|
| 106 |
+
return chunks
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main() -> None:
|
| 110 |
+
want = sys.argv[1] if len(sys.argv) > 1 else "singleturn"
|
| 111 |
+
# Optional 2nd arg: comma-separated run-name prefixes to include (focus a run
|
| 112 |
+
# on a subset of arms, e.g. "b_se,c_se" for the sessions quality test).
|
| 113 |
+
prefixes = tuple(
|
| 114 |
+
p for p in (sys.argv[2].split(",") if len(sys.argv) > 2 else []) if p
|
| 115 |
+
)
|
| 116 |
+
base = Path("runs")
|
| 117 |
+
out = base / "_grading" / want
|
| 118 |
+
(out / "keep").mkdir(parents=True, exist_ok=True)
|
| 119 |
+
# answers come from bundles (sheet 'answer' is truncated to 4000; fine for grading)
|
| 120 |
+
all_to_grade: list[dict[str, str]] = []
|
| 121 |
+
runs_used: list[str] = []
|
| 122 |
+
by_type = Counter()
|
| 123 |
+
for run_dir in sorted(base.glob("*/")):
|
| 124 |
+
name = run_dir.name
|
| 125 |
+
if name in SKIP or name.startswith("axisa"):
|
| 126 |
+
continue
|
| 127 |
+
if prefixes and not name.startswith(prefixes):
|
| 128 |
+
continue
|
| 129 |
+
if not (run_dir / "handgrade_sheet.csv").exists():
|
| 130 |
+
continue
|
| 131 |
+
if battery_type(run_dir) != want:
|
| 132 |
+
continue
|
| 133 |
+
to_grade, to_keep = plan_run(run_dir)
|
| 134 |
+
# Faithfulness is parked (see grading_merge.DROP_ITEM_TYPES): don't spend
|
| 135 |
+
# grading budget on it while the report/merge drop it.
|
| 136 |
+
to_grade = [r for r in to_grade if r["item_type"] not in DROP_ITEM_TYPES]
|
| 137 |
+
if to_keep:
|
| 138 |
+
with (out / "keep" / f"{name}.csv").open("w", newline="") as f:
|
| 139 |
+
w = csv.DictWriter(f, fieldnames=to_keep[0].keys())
|
| 140 |
+
w.writeheader()
|
| 141 |
+
w.writerows(to_keep)
|
| 142 |
+
if to_grade:
|
| 143 |
+
runs_used.append(name)
|
| 144 |
+
all_to_grade.extend(to_grade)
|
| 145 |
+
for r in to_grade:
|
| 146 |
+
by_type[r["item_type"].split(":")[0]] += 1
|
| 147 |
+
|
| 148 |
+
chunks = chunk(all_to_grade)
|
| 149 |
+
for i, ch in enumerate(chunks):
|
| 150 |
+
with (out / f"chunk_{i:03d}.csv").open("w", newline="") as f:
|
| 151 |
+
w = csv.DictWriter(f, fieldnames=AGENT_COLS, extrasaction="ignore")
|
| 152 |
+
w.writeheader()
|
| 153 |
+
w.writerows(ch)
|
| 154 |
+
(out / "manifest.json").write_text(
|
| 155 |
+
json.dumps(
|
| 156 |
+
{
|
| 157 |
+
"battery": want,
|
| 158 |
+
"n_chunks": len(chunks),
|
| 159 |
+
"n_rows": len(all_to_grade),
|
| 160 |
+
"by_item_type": dict(by_type),
|
| 161 |
+
"runs": runs_used,
|
| 162 |
+
},
|
| 163 |
+
indent=2,
|
| 164 |
+
)
|
| 165 |
+
)
|
| 166 |
+
print(f"{want}: {len(all_to_grade)} rows -> {len(chunks)} chunks {dict(by_type)}")
|
| 167 |
+
print(f" runs: {len(runs_used)} out: {out}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
main()
|
evals_graphrag.md → evals/graphrag.md
RENAMED
|
File without changes
|
evals/judge.py
CHANGED
|
@@ -94,6 +94,37 @@ RUBRICS: dict[str, str] = {
|
|
| 94 |
"The 'reference' shows the gold staff behavior. Pass if the BEHAVIOR "
|
| 95 |
"matches the expected kind even if details differ."
|
| 96 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
"replay_reply": (
|
| 98 |
"ITEM TYPE = replay. The criterion asks whether this reply is about as "
|
| 99 |
"helpful and correct as the real staff reply (shown as 'reference'). Pass "
|
|
@@ -127,9 +158,16 @@ def build_prompt(row: dict[str, str]) -> str:
|
|
| 127 |
f"\nCRITERION:\n{row.get('criterion', '')}",
|
| 128 |
]
|
| 129 |
if (row.get("reference") or "").strip():
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
)
|
|
|
|
| 133 |
parts.append(f"\nANSWER TO GRADE:\n{row.get('answer') or '(EMPTY)'}")
|
| 134 |
return "\n".join(parts)
|
| 135 |
|
|
|
|
| 94 |
"The 'reference' shows the gold staff behavior. Pass if the BEHAVIOR "
|
| 95 |
"matches the expected kind even if details differ."
|
| 96 |
),
|
| 97 |
+
"holistic": (
|
| 98 |
+
"ITEM TYPE = holistic tutor-answer quality (the staff-approval gate). "
|
| 99 |
+
"Decide as the course's expert tutor: would you approve sending this "
|
| 100 |
+
"answer to the student as-is? PASS only if ALL hold: (1) correct and not "
|
| 101 |
+
"misleading; (2) grounded in the right course/library material rather than "
|
| 102 |
+
"vague generic filler, for a course-content question; (3) it actually "
|
| 103 |
+
"helps the student LEARN -- explains or builds understanding at an "
|
| 104 |
+
"appropriate level, not just a bare dump, a wall of unexplained code, or "
|
| 105 |
+
"an unhelpful deflection; (4) appropriate scope and tone -- answers what "
|
| 106 |
+
"was asked, redirects platform/billing issues instead of inventing fixes, "
|
| 107 |
+
"acknowledges feedback without overpromising. FAIL if it is wrong, "
|
| 108 |
+
"ungrounded where grounding was needed, unhelpful for learning, or "
|
| 109 |
+
"off-scope. Judge the WHOLE answer, not a single claim; minor stylistic "
|
| 110 |
+
"nits alone are not a fail. Use confidence=low when it is a genuine "
|
| 111 |
+
"borderline 'I could see staff going either way'."
|
| 112 |
+
),
|
| 113 |
+
"faithfulness": (
|
| 114 |
+
"ITEM TYPE = faithfulness / groundedness. The reference block is the "
|
| 115 |
+
"RETRIEVED EVIDENCE the tutor's own tools returned (corpus retrieval + KB "
|
| 116 |
+
"browse). Grade ONLY whether the answer's substantive factual and "
|
| 117 |
+
"technical claims are supported by that evidence. PASS if every "
|
| 118 |
+
"substantive claim is supported by, or a reasonable paraphrase/synthesis "
|
| 119 |
+
"of, the evidence. FAIL if the answer asserts specifics that are NOT in "
|
| 120 |
+
"the evidence -- fabricated API/function/parameter names, version numbers, "
|
| 121 |
+
"citations or URLs, course/lesson facts, or numeric claims (i.e. "
|
| 122 |
+
"hallucination beyond what the evidence supports). Widely-known general "
|
| 123 |
+
"programming facts an expert would accept need not appear in the evidence; "
|
| 124 |
+
"course-specific and library-specific specifics MUST. If the evidence is "
|
| 125 |
+
"empty or clearly insufficient to support the answer's specific claims, "
|
| 126 |
+
"FAIL (confidence=low if it is borderline)."
|
| 127 |
+
),
|
| 128 |
"replay_reply": (
|
| 129 |
"ITEM TYPE = replay. The criterion asks whether this reply is about as "
|
| 130 |
"helpful and correct as the real staff reply (shown as 'reference'). Pass "
|
|
|
|
| 158 |
f"\nCRITERION:\n{row.get('criterion', '')}",
|
| 159 |
]
|
| 160 |
if (row.get("reference") or "").strip():
|
| 161 |
+
# For faithfulness the reference block IS the retrieved evidence to grade
|
| 162 |
+
# the answer's grounding against; for every other item type it is the
|
| 163 |
+
# staff answer, shown as context only.
|
| 164 |
+
label = (
|
| 165 |
+
"RETRIEVED EVIDENCE the tutor's tools returned (grade the answer's "
|
| 166 |
+
"grounding against THIS)"
|
| 167 |
+
if row["item_type"] == "faithfulness"
|
| 168 |
+
else "STAFF REFERENCE (context only, never shown to the tutor)"
|
| 169 |
)
|
| 170 |
+
parts.append(f"\n{label}:\n{row['reference']}")
|
| 171 |
parts.append(f"\nANSWER TO GRADE:\n{row.get('answer') or '(EMPTY)'}")
|
| 172 |
return "\n".join(parts)
|
| 173 |
|
evals_part_c_plan.md → evals/part_c_plan.md
RENAMED
|
File without changes
|
evals/report.py
CHANGED
|
@@ -97,6 +97,23 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
|
|
| 97 |
def add(name: str, fn) -> None:
|
| 98 |
rows.append([name] + [fn(run["grades"]) for run in runs])
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
add("cases run", lambda g: str(len({x["unit_id"] for x in g})))
|
| 101 |
add("errors", lambda g: str(sum(1 for x in g if x.get("error"))))
|
| 102 |
# --- Work & cost: run-time-INDEPENDENT, the unconfounded headline ----------
|
|
@@ -212,8 +229,24 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
|
|
| 212 |
"behavior proxy from code checks",
|
| 213 |
lambda g: rate(col(g, "behavior_heuristic")),
|
| 214 |
)
|
| 215 |
-
add(
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
if battery_type == "sessions":
|
| 219 |
add("cumulative input tok (last turn, mean)", _final_cumulative)
|
|
@@ -224,9 +257,15 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
|
|
| 224 |
),
|
| 225 |
)
|
| 226 |
add(
|
| 227 |
-
"probe accuracy (graded)",
|
| 228 |
lambda g: rate([x.get("probe_pass") for x in g if x.get("is_probe")]),
|
| 229 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
for probe_type in sorted(
|
| 231 |
{
|
| 232 |
x.get("probe_type")
|
|
@@ -255,9 +294,16 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
|
|
| 255 |
"anti-pattern failures",
|
| 256 |
lambda g: str(sum(1 for x in g if x.get("anti_pattern_hits"))),
|
| 257 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
if battery_type == "replay":
|
| 260 |
-
add(
|
|
|
|
|
|
|
|
|
|
| 261 |
return rows
|
| 262 |
|
| 263 |
|
|
|
|
| 97 |
def add(name: str, fn) -> None:
|
| 98 |
rows.append([name] + [fn(run["grades"]) for run in runs])
|
| 99 |
|
| 100 |
+
def src_tag(source_key: str) -> str:
|
| 101 |
+
"""Per-metric provenance tag across the shown runs: [judge]/[human]/[mixed].
|
| 102 |
+
|
| 103 |
+
Lets a single report mix human-graded rows (e.g. Part B key-points,
|
| 104 |
+
session probes) with judge-graded rows (the new holistic/faithfulness)
|
| 105 |
+
and label each honestly, even when a run's overall grade_source is mixed.
|
| 106 |
+
"""
|
| 107 |
+
seen = {
|
| 108 |
+
g.get(source_key)
|
| 109 |
+
for run in runs
|
| 110 |
+
for g in run["grades"]
|
| 111 |
+
if g.get(source_key)
|
| 112 |
+
}
|
| 113 |
+
if not seen:
|
| 114 |
+
return ""
|
| 115 |
+
return f" [{seen.pop()}]" if len(seen) == 1 else " [mixed]"
|
| 116 |
+
|
| 117 |
add("cases run", lambda g: str(len({x["unit_id"] for x in g})))
|
| 118 |
add("errors", lambda g: str(sum(1 for x in g if x.get("error"))))
|
| 119 |
# --- Work & cost: run-time-INDEPENDENT, the unconfounded headline ----------
|
|
|
|
| 229 |
"behavior proxy from code checks",
|
| 230 |
lambda g: rate(col(g, "behavior_heuristic")),
|
| 231 |
)
|
| 232 |
+
add(
|
| 233 |
+
f"behavior (graded){src_tag('behavior_source')}",
|
| 234 |
+
lambda g: rate(col(g, "behavior_pass")),
|
| 235 |
+
)
|
| 236 |
+
add(
|
| 237 |
+
f"key-point coverage (graded){src_tag('key_points_source')}",
|
| 238 |
+
lambda g: _kp_coverage(g),
|
| 239 |
+
)
|
| 240 |
+
add(
|
| 241 |
+
f"holistic: staff-approval (graded){src_tag('holistic_source')}",
|
| 242 |
+
lambda g: rate(col(g, "holistic_pass")),
|
| 243 |
+
)
|
| 244 |
+
# NOTE: faithfulness-to-evidence is intentionally not reported. On bundles
|
| 245 |
+
# recorded under the old 6k tool-output cap, KB-browse evidence is
|
| 246 |
+
# truncated, so the judge can't see what the agent grounded on and the
|
| 247 |
+
# score tracks capture-completeness, not grounding (evals.md F23/F24
|
| 248 |
+
# class). grade.py only emits it once evidence is captured in full; re-add
|
| 249 |
+
# the row after raising TOOL_OUTPUT_MAX_CHARS and re-recording a run.
|
| 250 |
|
| 251 |
if battery_type == "sessions":
|
| 252 |
add("cumulative input tok (last turn, mean)", _final_cumulative)
|
|
|
|
| 257 |
),
|
| 258 |
)
|
| 259 |
add(
|
| 260 |
+
f"probe accuracy (graded){src_tag('probe_source')}",
|
| 261 |
lambda g: rate([x.get("probe_pass") for x in g if x.get("is_probe")]),
|
| 262 |
)
|
| 263 |
+
# Answer quality across all session turns (not just probes): does holistic
|
| 264 |
+
# quality / faithfulness degrade under compaction over a long session?
|
| 265 |
+
add(
|
| 266 |
+
f"holistic: staff-approval (graded){src_tag('holistic_source')}",
|
| 267 |
+
lambda g: rate(col(g, "holistic_pass")),
|
| 268 |
+
)
|
| 269 |
for probe_type in sorted(
|
| 270 |
{
|
| 271 |
x.get("probe_type")
|
|
|
|
| 294 |
"anti-pattern failures",
|
| 295 |
lambda g: str(sum(1 for x in g if x.get("anti_pattern_hits"))),
|
| 296 |
)
|
| 297 |
+
add(
|
| 298 |
+
f"holistic: staff-approval (graded){src_tag('holistic_source')}",
|
| 299 |
+
lambda g: rate(col(g, "holistic_pass")),
|
| 300 |
+
)
|
| 301 |
|
| 302 |
if battery_type == "replay":
|
| 303 |
+
add(
|
| 304 |
+
f"replay reply pass (graded){src_tag('replay_source')}",
|
| 305 |
+
lambda g: rate(col(g, "replay_pass")),
|
| 306 |
+
)
|
| 307 |
return rows
|
| 308 |
|
| 309 |
|
evals/run_battery.py
CHANGED
|
@@ -38,9 +38,15 @@ from .common import append_jsonl, detect_battery_type, load_jsonl, write_jsonl
|
|
| 38 |
|
| 39 |
logger = logging.getLogger("evals.run_battery")
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
#
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
# Slowest observed turn is ~2.5 min; anything past this is a wedged stream
|
| 45 |
# (e.g. laptop sleep killed the connection mid-turn). The turn records a
|
| 46 |
# TimeoutError and the unit re-runs on resume instead of hanging forever.
|
|
|
|
| 38 |
|
| 39 |
logger = logging.getLogger("evals.run_battery")
|
| 40 |
|
| 41 |
+
# Capture the full tool output the agent saw: the KB shell returns up to
|
| 42 |
+
# DEFAULT_MAX_OUTPUT_CHARS = 40k chars/call (app/kb_shell.py), so 40k preserves
|
| 43 |
+
# everything the model grounded on. This matters for offline faithfulness
|
| 44 |
+
# grading -- under the old 6k cap, KB-browse evidence was truncated, so a
|
| 45 |
+
# faithfulness judge scored capture-completeness, not grounding (evals.md
|
| 46 |
+
# F23/F24 class). output_chars still records the true length, so any rare
|
| 47 |
+
# overflow stays visible. Bundles are bigger (KB-heavy turns ~40k x several
|
| 48 |
+
# calls), which is an acceptable cost for local-only runs.
|
| 49 |
+
TOOL_OUTPUT_MAX_CHARS = 40_000
|
| 50 |
# Slowest observed turn is ~2.5 min; anything past this is a wedged stream
|
| 51 |
# (e.g. laptop sleep killed the connection mid-turn). The turn records a
|
| 52 |
# TimeoutError and the unit re-runs on resume instead of hanging forever.
|
evals_slm_compaction.md → evals/slm_compaction.md
RENAMED
|
@@ -65,7 +65,7 @@ window is itself truncated at build time.
|
|
| 65 |
|
| 66 |
**Naming caveat:** `full_context` here is *document-stuffing* (the whole lesson in
|
| 67 |
the prompt), which is **not** `full_history`'s conversational-memory role. The
|
| 68 |
-
sibling Gemini study (`
|
| 69 |
idea but names that arm `full_history`, because it parks the lesson in turn 0 of a
|
| 70 |
session — so don't read F29's `full_history` as the Axis-A memory policy.
|
| 71 |
|
|
@@ -137,7 +137,7 @@ Run on a **MacBook Pro, Apple M1 Pro, 16 GB**, one model at a time via
|
|
| 137 |
3. **GraphRAG never wins outright — same verdict as the cloud experiment.** It ties
|
| 138 |
`rag` on the qwens (100%) and edges it on llama (87% vs 80%), but at **~2.8× the
|
| 139 |
context tokens (8.2k vs 2.9k) and ~2.5× the latency (~50-78s vs ~20-26s)**.
|
| 140 |
-
No quality gain justifies the cost (cf. `
|
| 141 |
|
| 142 |
4. **Summarization compaction degrades hard, worst on the smallest model.**
|
| 143 |
`summary` and `hierarchical_summary` are the bottom methods (llama `summary`
|
|
|
|
| 65 |
|
| 66 |
**Naming caveat:** `full_context` here is *document-stuffing* (the whole lesson in
|
| 67 |
the prompt), which is **not** `full_history`'s conversational-memory role. The
|
| 68 |
+
sibling Gemini study (`evals/compaction.md`, F29) tests the same document-in-prompt
|
| 69 |
idea but names that arm `full_history`, because it parks the lesson in turn 0 of a
|
| 70 |
session — so don't read F29's `full_history` as the Axis-A memory policy.
|
| 71 |
|
|
|
|
| 137 |
3. **GraphRAG never wins outright — same verdict as the cloud experiment.** It ties
|
| 138 |
`rag` on the qwens (100%) and edges it on llama (87% vs 80%), but at **~2.8× the
|
| 139 |
context tokens (8.2k vs 2.9k) and ~2.5× the latency (~50-78s vs ~20-26s)**.
|
| 140 |
+
No quality gain justifies the cost (cf. `evals/graphrag.md`).
|
| 141 |
|
| 142 |
4. **Summarization compaction degrades hard, worst on the smallest model.**
|
| 143 |
`summary` and `hierarchical_summary` are the bottom methods (llama `summary`
|
pyproject.toml
CHANGED
|
@@ -31,7 +31,7 @@ dependencies = [
|
|
| 31 |
# Eval-only: the GraphRAG-vs-RAG experiment (F28). Pulls graphrag + lancedb/litellm/
|
| 32 |
# pandas, all imported lazily and only by the opt-in `graphrag` retriever — never on
|
| 33 |
# the production path. Kept out of the default install so the Spaces image stays lean.
|
| 34 |
-
# Reproduce the experiment with: uv sync --extra graphrag (see
|
| 35 |
graphrag = ["graphrag>=3.1.0"]
|
| 36 |
|
| 37 |
[dependency-groups]
|
|
|
|
| 31 |
# Eval-only: the GraphRAG-vs-RAG experiment (F28). Pulls graphrag + lancedb/litellm/
|
| 32 |
# pandas, all imported lazily and only by the opt-in `graphrag` retriever — never on
|
| 33 |
# the production path. Kept out of the default install so the Spaces image stays lean.
|
| 34 |
+
# Reproduce the experiment with: uv sync --extra graphrag (see evals/graphrag.md).
|
| 35 |
graphrag = ["graphrag>=3.1.0"]
|
| 36 |
|
| 37 |
[dependency-groups]
|
tests/test_build_public_docs_bundle.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import pickle
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from app import kb_manifest
|
| 8 |
+
from data.scraping_scripts import build_public_docs_bundle as builder
|
| 9 |
+
|
| 10 |
+
# Use real registry keys so the COURSE/_DOC split matches production.
|
| 11 |
+
COURSE_KEY = "master_ai_for_work"
|
| 12 |
+
DOC_KEY = "transformers"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _write_jsonl(path: Path, rows: list[dict]) -> None:
|
| 16 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
with path.open("w", encoding="utf-8") as handle:
|
| 18 |
+
for row in rows:
|
| 19 |
+
handle.write(json.dumps(row) + "\n")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_filter_jsonl_drops_course_rows(tmp_path: Path) -> None:
|
| 23 |
+
path = tmp_path / "headings.jsonl"
|
| 24 |
+
_write_jsonl(
|
| 25 |
+
path,
|
| 26 |
+
[
|
| 27 |
+
{"source": DOC_KEY, "heading": "Install"},
|
| 28 |
+
{"source": COURSE_KEY, "heading": "Lesson 1"},
|
| 29 |
+
{"source": DOC_KEY, "heading": "Usage"},
|
| 30 |
+
],
|
| 31 |
+
)
|
| 32 |
+
kept = builder._filter_jsonl(path)
|
| 33 |
+
assert kept == 2
|
| 34 |
+
rows = [json.loads(line) for line in path.read_text().splitlines()]
|
| 35 |
+
assert {row["source"] for row in rows} == {DOC_KEY}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_filter_tsv_drops_course_rows_keeps_header(tmp_path: Path) -> None:
|
| 39 |
+
path = tmp_path / "symbols.tsv"
|
| 40 |
+
path.write_text(
|
| 41 |
+
"symbol\tsource\ttitle\tpath\theading\tdoc_id\n"
|
| 42 |
+
f"AutoModel\t{DOC_KEY}\tT\tp\th\td1\n"
|
| 43 |
+
f"Lesson\t{COURSE_KEY}\tT\tp\th\td2\n",
|
| 44 |
+
encoding="utf-8",
|
| 45 |
+
)
|
| 46 |
+
kept = builder._filter_tsv(path)
|
| 47 |
+
assert kept == 1
|
| 48 |
+
lines = path.read_text().splitlines()
|
| 49 |
+
assert lines[0].startswith("symbol\tsource") # header preserved
|
| 50 |
+
assert COURSE_KEY not in path.read_text()
|
| 51 |
+
assert DOC_KEY in path.read_text()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_stage_kb_prunes_course_content(tmp_path: Path) -> None:
|
| 55 |
+
source_dir = tmp_path / "data"
|
| 56 |
+
kb = source_dir / "kb"
|
| 57 |
+
(kb / "raw" / "docs" / DOC_KEY).mkdir(parents=True)
|
| 58 |
+
(kb / "raw" / "docs" / DOC_KEY / "a.md").write_text("# A", encoding="utf-8")
|
| 59 |
+
(kb / "raw" / "courses" / COURSE_KEY).mkdir(parents=True)
|
| 60 |
+
(kb / "raw" / "courses" / COURSE_KEY / "l1.md").write_text("# L1", encoding="utf-8")
|
| 61 |
+
(kb / "wiki" / "courses").mkdir(parents=True)
|
| 62 |
+
(kb / "wiki" / "courses" / "c.md").write_text("course", encoding="utf-8")
|
| 63 |
+
(kb / "wiki").joinpath("index.md").write_text("# Index", encoding="utf-8")
|
| 64 |
+
|
| 65 |
+
generated = kb / "generated"
|
| 66 |
+
generated.mkdir(parents=True)
|
| 67 |
+
_write_jsonl(
|
| 68 |
+
generated / "corpus_manifest.jsonl",
|
| 69 |
+
[
|
| 70 |
+
{"source": DOC_KEY, "source_group": "docs", "path": "data/kb/raw/docs/x"},
|
| 71 |
+
{"source": COURSE_KEY, "source_group": "courses", "path": "y"},
|
| 72 |
+
],
|
| 73 |
+
)
|
| 74 |
+
_write_jsonl(
|
| 75 |
+
generated / "headings.jsonl",
|
| 76 |
+
[{"source": DOC_KEY}, {"source": COURSE_KEY}],
|
| 77 |
+
)
|
| 78 |
+
(generated / "symbols.tsv").write_text(
|
| 79 |
+
"symbol\tsource\ttitle\tpath\theading\tdoc_id\n"
|
| 80 |
+
f"X\t{DOC_KEY}\tT\tp\th\td1\n"
|
| 81 |
+
f"Y\t{COURSE_KEY}\tT\tp\th\td2\n",
|
| 82 |
+
encoding="utf-8",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
stage_dir = tmp_path / "stage"
|
| 86 |
+
stage_dir.mkdir()
|
| 87 |
+
summary = builder.stage_kb(source_dir, stage_dir)
|
| 88 |
+
|
| 89 |
+
staged_kb = stage_dir / "kb"
|
| 90 |
+
assert not (staged_kb / "raw" / "courses").exists()
|
| 91 |
+
assert (staged_kb / "raw" / "docs" / DOC_KEY / "a.md").exists()
|
| 92 |
+
assert not (staged_kb / "wiki" / "courses").exists()
|
| 93 |
+
assert (staged_kb / "wiki" / "index.md").exists() # docs wiki kept
|
| 94 |
+
|
| 95 |
+
manifest = [
|
| 96 |
+
json.loads(line)
|
| 97 |
+
for line in (staged_kb / "generated" / "corpus_manifest.jsonl")
|
| 98 |
+
.read_text()
|
| 99 |
+
.splitlines()
|
| 100 |
+
]
|
| 101 |
+
assert {row["source"] for row in manifest} == {DOC_KEY}
|
| 102 |
+
assert summary["manifest_rows"] == 1
|
| 103 |
+
assert COURSE_KEY not in (staged_kb / "generated" / "symbols.tsv").read_text()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_rebuild_retrieval_pkls_drops_course_documents(tmp_path: Path) -> None:
|
| 107 |
+
source_dir = tmp_path / "data"
|
| 108 |
+
rows = [
|
| 109 |
+
{
|
| 110 |
+
"doc_id": "doc-transformers",
|
| 111 |
+
"name": "Transformers",
|
| 112 |
+
"url": "https://example.com/t",
|
| 113 |
+
"source": DOC_KEY,
|
| 114 |
+
"content": "# Heading\n\nUse AutoModel to load weights.",
|
| 115 |
+
"retrieve_doc": True,
|
| 116 |
+
"tokens": 9,
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"doc_id": "doc-course",
|
| 120 |
+
"name": "Course Lesson",
|
| 121 |
+
"url": "https://example.com/c",
|
| 122 |
+
"source": COURSE_KEY,
|
| 123 |
+
"content": "# Lesson\n\nPrivate course material.",
|
| 124 |
+
"retrieve_doc": True,
|
| 125 |
+
"tokens": 7,
|
| 126 |
+
},
|
| 127 |
+
]
|
| 128 |
+
_write_jsonl(source_dir / "all_sources_data.jsonl", rows)
|
| 129 |
+
(tmp_path / "stage" / builder.VECTOR_DB_DIR).mkdir(parents=True)
|
| 130 |
+
|
| 131 |
+
summary = builder.rebuild_retrieval_pkls(source_dir, tmp_path / "stage")
|
| 132 |
+
assert summary["documents"] == 1
|
| 133 |
+
|
| 134 |
+
dict_path = tmp_path / "stage" / builder.VECTOR_DB_DIR / builder.DOCUMENT_DICT_FILE
|
| 135 |
+
with dict_path.open("rb") as handle:
|
| 136 |
+
document_dict = pickle.load(handle)
|
| 137 |
+
assert set(document_dict) == {"doc-transformers"}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_public_allow_patterns_toggles_contextual() -> None:
|
| 141 |
+
base = builder.public_allow_patterns(include_contextual=False)
|
| 142 |
+
assert base == ["chroma-db-all_sources/**", "kb/**"]
|
| 143 |
+
with_ctx = builder.public_allow_patterns(include_contextual=True)
|
| 144 |
+
assert "all_sources_contextual_nodes.pkl" in with_ctx
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_available_source_keys_excludes_absent_sources(tmp_path: Path) -> None:
|
| 148 |
+
kb_dir = tmp_path / "kb"
|
| 149 |
+
generated = kb_dir / "generated"
|
| 150 |
+
generated.mkdir(parents=True)
|
| 151 |
+
# A docs-only manifest (the public bundle): courses are simply not present.
|
| 152 |
+
_write_jsonl(
|
| 153 |
+
generated / "corpus_manifest.jsonl",
|
| 154 |
+
[
|
| 155 |
+
{"doc_id": "1", "source": DOC_KEY, "source_group": "docs"},
|
| 156 |
+
{"doc_id": "2", "source": "langchain", "source_group": "docs"},
|
| 157 |
+
],
|
| 158 |
+
)
|
| 159 |
+
kb_manifest._MANIFEST_CACHE.pop(str(kb_dir), None)
|
| 160 |
+
keys = kb_manifest.available_source_keys(str(kb_dir))
|
| 161 |
+
assert keys == frozenset({DOC_KEY, "langchain"})
|
| 162 |
+
assert COURSE_KEY not in keys
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_available_source_keys_none_when_manifest_missing(tmp_path: Path) -> None:
|
| 166 |
+
missing = tmp_path / "no_kb"
|
| 167 |
+
kb_manifest._MANIFEST_CACHE.pop(str(missing), None)
|
| 168 |
+
assert kb_manifest.available_source_keys(str(missing)) is None
|
tests/test_config.py
CHANGED
|
@@ -77,6 +77,50 @@ def test_complete_bundle_skips_download_and_caches_readiness(tmp_path: Path) ->
|
|
| 77 |
assert snapshot_download.call_count == 0
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def test_ensure_kb_agents_md_writes_once_and_atomically(tmp_path: Path) -> None:
|
| 81 |
_write_bundle_files(tmp_path)
|
| 82 |
|
|
|
|
| 77 |
assert snapshot_download.call_count == 0
|
| 78 |
|
| 79 |
|
| 80 |
+
def _repo_not_found_error():
|
| 81 |
+
try:
|
| 82 |
+
from huggingface_hub.errors import RepositoryNotFoundError
|
| 83 |
+
except ImportError: # older huggingface_hub
|
| 84 |
+
from huggingface_hub.utils import RepositoryNotFoundError
|
| 85 |
+
return RepositoryNotFoundError
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_download_bundle_uses_public_when_no_token() -> None:
|
| 89 |
+
with patch("huggingface_hub.get_token", return_value=None):
|
| 90 |
+
with patch("app.config._snapshot_bundle") as snapshot:
|
| 91 |
+
config._download_bundle()
|
| 92 |
+
snapshot.assert_called_once()
|
| 93 |
+
assert snapshot.call_args.args[0] == config.PUBLIC_VECTOR_DB_REPO_ID
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_download_bundle_uses_private_when_token_present() -> None:
|
| 97 |
+
with patch("huggingface_hub.get_token", return_value="tok"):
|
| 98 |
+
with patch("app.config._snapshot_bundle") as snapshot:
|
| 99 |
+
config._download_bundle()
|
| 100 |
+
snapshot.assert_called_once()
|
| 101 |
+
assert snapshot.call_args.args[0] == config.VECTOR_DB_REPO_ID
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_download_bundle_falls_back_to_public_on_no_access() -> None:
|
| 105 |
+
import requests
|
| 106 |
+
|
| 107 |
+
repo_error = _repo_not_found_error()
|
| 108 |
+
response = requests.Response()
|
| 109 |
+
response.status_code = 401
|
| 110 |
+
calls: list[str] = []
|
| 111 |
+
|
| 112 |
+
def side_effect(repo_id: str, *, token) -> None:
|
| 113 |
+
calls.append(repo_id)
|
| 114 |
+
if repo_id == config.VECTOR_DB_REPO_ID:
|
| 115 |
+
raise repo_error("no access", response=response)
|
| 116 |
+
|
| 117 |
+
with patch("huggingface_hub.get_token", return_value="tok"):
|
| 118 |
+
with patch("app.config._snapshot_bundle", side_effect=side_effect):
|
| 119 |
+
config._download_bundle()
|
| 120 |
+
|
| 121 |
+
assert calls == [config.VECTOR_DB_REPO_ID, config.PUBLIC_VECTOR_DB_REPO_ID]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
def test_ensure_kb_agents_md_writes_once_and_atomically(tmp_path: Path) -> None:
|
| 125 |
_write_bundle_files(tmp_path)
|
| 126 |
|
tests/test_evals_grade.py
CHANGED
|
@@ -1,13 +1,21 @@
|
|
| 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):
|
|
@@ -147,5 +155,202 @@ class PersonaGradingTests(unittest.TestCase):
|
|
| 147 |
self.assertTrue(result["needs_judgment"])
|
| 148 |
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
if __name__ == "__main__":
|
| 151 |
unittest.main()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import csv
|
| 4 |
+
import tempfile
|
| 5 |
import unittest
|
| 6 |
+
from pathlib import Path
|
| 7 |
|
| 8 |
from evals.common import detect_battery_type, normalize_url, percentile
|
| 9 |
from evals.grade import (
|
| 10 |
behavior_heuristic,
|
| 11 |
+
evidence_is_complete,
|
| 12 |
+
faithfulness_evidence,
|
| 13 |
grade_persona_question,
|
| 14 |
+
merge_handgrades,
|
| 15 |
+
quality_sheet_rows,
|
| 16 |
retrieval_metrics,
|
| 17 |
)
|
| 18 |
+
from evals.judge import RUBRICS, build_prompt, rubric_for
|
| 19 |
|
| 20 |
|
| 21 |
class CommonTests(unittest.TestCase):
|
|
|
|
| 155 |
self.assertTrue(result["needs_judgment"])
|
| 156 |
|
| 157 |
|
| 158 |
+
class FaithfulnessEvidenceTests(unittest.TestCase):
|
| 159 |
+
def test_collects_retrieval_and_kb_evidence(self) -> None:
|
| 160 |
+
bundle = {
|
| 161 |
+
"tool_calls": [
|
| 162 |
+
{
|
| 163 |
+
"tool_name": "retrieve_tutor_context",
|
| 164 |
+
"args_text": "context engineering",
|
| 165 |
+
"output_text": "Lesson 3 covers context engineering.",
|
| 166 |
+
"matches": [{"title": "Lesson 3", "url": "https://x/l3"}],
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"tool_name": "run_kb_command",
|
| 170 |
+
"args_text": "rg foo",
|
| 171 |
+
"output_text": "raw/courses/agent/lesson.md: foo bar",
|
| 172 |
+
},
|
| 173 |
+
]
|
| 174 |
+
}
|
| 175 |
+
evidence = faithfulness_evidence(bundle)
|
| 176 |
+
self.assertIn("context engineering", evidence)
|
| 177 |
+
self.assertIn("SOURCES: Lesson 3 <https://x/l3>", evidence)
|
| 178 |
+
self.assertIn("raw/courses/agent/lesson.md", evidence)
|
| 179 |
+
|
| 180 |
+
def test_empty_when_no_retrieval_tool(self) -> None:
|
| 181 |
+
bundle = {"tool_calls": [{"tool_name": "some_other_tool", "output_text": "x"}]}
|
| 182 |
+
self.assertEqual(faithfulness_evidence(bundle), "")
|
| 183 |
+
self.assertEqual(faithfulness_evidence({}), "")
|
| 184 |
+
|
| 185 |
+
def test_respects_max_chars(self) -> None:
|
| 186 |
+
bundle = {
|
| 187 |
+
"tool_calls": [{"tool_name": "run_kb_command", "output_text": "z" * 5000}]
|
| 188 |
+
}
|
| 189 |
+
self.assertEqual(len(faithfulness_evidence(bundle, max_chars=100)), 100)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class EvidenceCompletenessTests(unittest.TestCase):
|
| 193 |
+
def test_complete_when_no_truncation(self) -> None:
|
| 194 |
+
bundle = {
|
| 195 |
+
"tool_calls": [
|
| 196 |
+
{"tool_name": "run_kb_command", "output_text": "abc", "output_chars": 3}
|
| 197 |
+
]
|
| 198 |
+
}
|
| 199 |
+
self.assertTrue(evidence_is_complete(bundle))
|
| 200 |
+
|
| 201 |
+
def test_incomplete_when_kb_output_truncated(self) -> None:
|
| 202 |
+
# output_chars (true length) exceeds captured output_text => truncated.
|
| 203 |
+
bundle = {
|
| 204 |
+
"tool_calls": [
|
| 205 |
+
{
|
| 206 |
+
"tool_name": "run_kb_command",
|
| 207 |
+
"output_text": "x" * 6000,
|
| 208 |
+
"output_chars": 40000,
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
}
|
| 212 |
+
self.assertFalse(evidence_is_complete(bundle))
|
| 213 |
+
|
| 214 |
+
def test_non_retrieval_truncation_is_ignored(self) -> None:
|
| 215 |
+
bundle = {
|
| 216 |
+
"tool_calls": [
|
| 217 |
+
{"tool_name": "some_tool", "output_text": "x", "output_chars": 99999}
|
| 218 |
+
]
|
| 219 |
+
}
|
| 220 |
+
self.assertTrue(evidence_is_complete(bundle))
|
| 221 |
+
|
| 222 |
+
def test_faithfulness_row_gated_on_complete_evidence(self) -> None:
|
| 223 |
+
truncated = {
|
| 224 |
+
"run_id": "r",
|
| 225 |
+
"battery_type": "singleturn",
|
| 226 |
+
"preset": "prod",
|
| 227 |
+
"query": "q",
|
| 228 |
+
"answer": "a",
|
| 229 |
+
"tool_calls": [
|
| 230 |
+
{
|
| 231 |
+
"tool_name": "run_kb_command",
|
| 232 |
+
"args_text": "rg foo",
|
| 233 |
+
"output_text": "y" * 6000,
|
| 234 |
+
"output_chars": 40000,
|
| 235 |
+
}
|
| 236 |
+
],
|
| 237 |
+
}
|
| 238 |
+
types = {r["item_type"] for r in quality_sheet_rows(truncated)}
|
| 239 |
+
self.assertIn("holistic", types)
|
| 240 |
+
self.assertNotIn("faithfulness", types) # truncated -> no faithfulness
|
| 241 |
+
|
| 242 |
+
full = dict(truncated)
|
| 243 |
+
full["tool_calls"] = [
|
| 244 |
+
{
|
| 245 |
+
"tool_name": "run_kb_command",
|
| 246 |
+
"args_text": "rg foo",
|
| 247 |
+
"output_text": "y" * 100,
|
| 248 |
+
"output_chars": 100,
|
| 249 |
+
}
|
| 250 |
+
]
|
| 251 |
+
types_full = {r["item_type"] for r in quality_sheet_rows(full)}
|
| 252 |
+
self.assertIn("faithfulness", types_full) # full evidence -> emitted
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class JudgeRubricTests(unittest.TestCase):
|
| 256 |
+
def test_holistic_and_faithfulness_rubrics_exist(self) -> None:
|
| 257 |
+
self.assertIs(rubric_for("holistic"), RUBRICS["holistic"])
|
| 258 |
+
self.assertIs(rubric_for("faithfulness"), RUBRICS["faithfulness"])
|
| 259 |
+
|
| 260 |
+
def test_unknown_item_type_falls_back_to_key_point(self) -> None:
|
| 261 |
+
self.assertIs(rubric_for("mystery"), RUBRICS["key_point"])
|
| 262 |
+
|
| 263 |
+
def test_faithfulness_reference_labeled_as_evidence(self) -> None:
|
| 264 |
+
row = {
|
| 265 |
+
"item_type": "faithfulness",
|
| 266 |
+
"question": "q",
|
| 267 |
+
"criterion": "grounded?",
|
| 268 |
+
"reference": "RETRIEVED_TEXT_X",
|
| 269 |
+
"answer": "a",
|
| 270 |
+
}
|
| 271 |
+
prompt = build_prompt(row)
|
| 272 |
+
self.assertIn("RETRIEVED EVIDENCE", prompt)
|
| 273 |
+
self.assertNotIn("STAFF REFERENCE", prompt)
|
| 274 |
+
|
| 275 |
+
def test_other_item_types_keep_staff_reference_label(self) -> None:
|
| 276 |
+
row = {
|
| 277 |
+
"item_type": "key_point",
|
| 278 |
+
"question": "q",
|
| 279 |
+
"criterion": "claim",
|
| 280 |
+
"reference": "STAFF_TEXT",
|
| 281 |
+
"answer": "a",
|
| 282 |
+
}
|
| 283 |
+
self.assertIn("STAFF REFERENCE", build_prompt(row))
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class MergeHandgradesTests(unittest.TestCase):
|
| 287 |
+
FIELDS = [
|
| 288 |
+
"sheet_row_id",
|
| 289 |
+
"run_id",
|
| 290 |
+
"item_type",
|
| 291 |
+
"criterion",
|
| 292 |
+
"grade",
|
| 293 |
+
"note",
|
| 294 |
+
]
|
| 295 |
+
|
| 296 |
+
def _merge(self, grades, filled_rows):
|
| 297 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 298 |
+
path = Path(tmp) / "filled.csv"
|
| 299 |
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
| 300 |
+
writer = csv.DictWriter(f, fieldnames=self.FIELDS)
|
| 301 |
+
writer.writeheader()
|
| 302 |
+
writer.writerows(filled_rows)
|
| 303 |
+
return merge_handgrades(grades, path)
|
| 304 |
+
|
| 305 |
+
def test_new_item_types_and_per_metric_source(self) -> None:
|
| 306 |
+
grades = [{"run_id": "r1"}]
|
| 307 |
+
filled = [
|
| 308 |
+
# human-graded key points
|
| 309 |
+
{
|
| 310 |
+
"sheet_row_id": "r1|key_point|a",
|
| 311 |
+
"run_id": "r1",
|
| 312 |
+
"item_type": "key_point",
|
| 313 |
+
"criterion": "k1",
|
| 314 |
+
"grade": "pass",
|
| 315 |
+
"note": "",
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"sheet_row_id": "r1|key_point|b",
|
| 319 |
+
"run_id": "r1",
|
| 320 |
+
"item_type": "key_point",
|
| 321 |
+
"criterion": "k2",
|
| 322 |
+
"grade": "fail",
|
| 323 |
+
"note": "",
|
| 324 |
+
},
|
| 325 |
+
# judge-graded new dimensions
|
| 326 |
+
{
|
| 327 |
+
"sheet_row_id": "r1|holistic|c",
|
| 328 |
+
"run_id": "r1",
|
| 329 |
+
"item_type": "holistic",
|
| 330 |
+
"criterion": "h",
|
| 331 |
+
"grade": "pass",
|
| 332 |
+
"note": "[judge:high] good answer",
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"sheet_row_id": "r1|faithfulness|d",
|
| 336 |
+
"run_id": "r1",
|
| 337 |
+
"item_type": "faithfulness",
|
| 338 |
+
"criterion": "f",
|
| 339 |
+
"grade": "fail",
|
| 340 |
+
"note": "[judge:low] fabricated param",
|
| 341 |
+
},
|
| 342 |
+
]
|
| 343 |
+
merged = self._merge(grades, filled)[0]
|
| 344 |
+
self.assertEqual(merged["key_points_passed"], 1)
|
| 345 |
+
self.assertEqual(merged["key_points_total"], 2)
|
| 346 |
+
self.assertEqual(merged["key_points_source"], "human")
|
| 347 |
+
self.assertTrue(merged["holistic_pass"])
|
| 348 |
+
self.assertEqual(merged["holistic_source"], "judge")
|
| 349 |
+
self.assertFalse(merged["faithfulness_pass"])
|
| 350 |
+
self.assertEqual(merged["faithfulness_source"], "judge")
|
| 351 |
+
# human key-points + judge new dims => overall mixed
|
| 352 |
+
self.assertEqual(merged["grade_source"], "mixed")
|
| 353 |
+
|
| 354 |
+
|
| 355 |
if __name__ == "__main__":
|
| 356 |
unittest.main()
|