Download tests/test_evals_grade.py from towardsai-tutors/ai-tutor-chatbot: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot/resolve/1a2b802acfecc2ab6a1e56d795540fa60acfdd8a/tests/test_evals_grade.py
- Command line
-
hf download hf://spaces/towardsai-tutors/ai-tutor-chatbot@1a2b802acfecc2ab6a1e56d795540fa60acfdd8a/tests/test_evals_grade.py
-
curl -L -o test_evals_grade.py https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot/resolve/1a2b802acfecc2ab6a1e56d795540fa60acfdd8a/tests/test_evals_grade.py
17.8 kB
| from __future__ import annotations | |
| import csv | |
| import sys | |
| import tempfile | |
| import unittest | |
| from pathlib import Path | |
| from unittest import mock | |
| from evals import check_triggers | |
| from evals.common import detect_battery_type, normalize_url, percentile | |
| from evals.grade import ( | |
| FAITHFULNESS_EVIDENCE_MAX, | |
| behavior_heuristic, | |
| evidence_fits, | |
| evidence_is_complete, | |
| faithfulness_evidence, | |
| grade_persona_question, | |
| merge_handgrades, | |
| quality_sheet_rows, | |
| retrieval_metrics, | |
| ) | |
| from evals.judge import RUBRICS, build_prompt, rubric_for | |
| class CommonTests(unittest.TestCase): | |
| def test_detect_battery_type(self) -> None: | |
| self.assertEqual(detect_battery_type([{"case_id": "x"}]), "singleturn") | |
| self.assertEqual(detect_battery_type([{"session_id": "x"}]), "sessions") | |
| self.assertEqual(detect_battery_type([{"persona_id": "x"}]), "personas") | |
| self.assertEqual(detect_battery_type([{"replay_id": "x"}]), "replay") | |
| with self.assertRaises(ValueError): | |
| detect_battery_type([{"foo": 1}]) | |
| def test_normalize_url(self) -> None: | |
| self.assertEqual( | |
| normalize_url("https://X.com/Lessons/abc/?q=1#frag"), | |
| "https://x.com/lessons/abc", | |
| ) | |
| self.assertEqual(normalize_url(None), "") | |
| def test_normalize_url_strips_discussion_suffix(self) -> None: | |
| # Battery lesson_urls point at the discussion; matches carry the bare | |
| # lesson URL. Both must normalize to the same key. | |
| discussion = ( | |
| "https://academy.towardsai.net/courses/take/agent-engineering/" | |
| "multimedia/67469688-lesson-1/discussions/12758677" | |
| ) | |
| lesson = ( | |
| "https://academy.towardsai.net/courses/take/agent-engineering/" | |
| "multimedia/67469688-lesson-1" | |
| ) | |
| self.assertEqual(normalize_url(discussion), normalize_url(lesson)) | |
| def test_percentile(self) -> None: | |
| # Nearest-rank: even-length p50 rounds to the upper of the two middles. | |
| self.assertEqual(percentile([1, 2, 3, 4], 50), 3) | |
| self.assertEqual(percentile([1, 2, 3], 50), 2) | |
| self.assertEqual(percentile([5], 95), 5) | |
| self.assertIsNone(percentile([], 50)) | |
| def bundle_with_matches(matches, tool_name="retrieve_tutor_context"): | |
| return {"tool_calls": [{"tool_name": tool_name, "matches": matches}]} | |
| class RetrievalMetricsTests(unittest.TestCase): | |
| LESSON = "https://academy.towardsai.net/courses/take/x/lessons/123-foo" | |
| def test_hit_source_and_lesson_with_mrr(self) -> None: | |
| bundle = bundle_with_matches( | |
| [ | |
| {"source_key": "other", "url": "https://elsewhere"}, | |
| {"source_key": "full_stack_ai_engineering", "url": self.LESSON + "/"}, | |
| ] | |
| ) | |
| metrics = retrieval_metrics(bundle, "full_stack_ai_engineering", self.LESSON) | |
| self.assertTrue(metrics["called_retrieval"]) | |
| self.assertTrue(metrics["recall_source"]) | |
| self.assertTrue(metrics["recall_lesson"]) | |
| self.assertEqual(metrics["mrr_lesson"], 0.5) | |
| def test_miss_lesson(self) -> None: | |
| bundle = bundle_with_matches( | |
| [{"source_key": "full_stack_ai_engineering", "url": "https://other"}] | |
| ) | |
| metrics = retrieval_metrics(bundle, "full_stack_ai_engineering", self.LESSON) | |
| self.assertFalse(metrics["recall_lesson"]) | |
| self.assertEqual(metrics["mrr_lesson"], 0.0) | |
| def test_kb_command_counts_as_retrieval_but_adds_no_matches(self) -> None: | |
| bundle = bundle_with_matches([], tool_name="run_kb_command") | |
| metrics = retrieval_metrics(bundle, "x", self.LESSON) | |
| self.assertTrue(metrics["called_retrieval"]) | |
| self.assertEqual(metrics["retrieved_matches"], 0) | |
| def test_no_ground_truth_yields_none(self) -> None: | |
| bundle = bundle_with_matches([]) | |
| metrics = retrieval_metrics(bundle, None, None) | |
| self.assertIsNone(metrics["recall_lesson"]) | |
| self.assertIsNone(metrics["mrr_lesson"]) | |
| class BehaviorHeuristicTests(unittest.TestCase): | |
| def test_corpus_requires_tool_use(self) -> None: | |
| used = {"answer": "...", "tool_calls": [{"tool_name": "run_kb_command"}]} | |
| bare = {"answer": "...", "tool_calls": []} | |
| self.assertTrue(behavior_heuristic("answer_from_corpus", used)) | |
| self.assertFalse(behavior_heuristic("answer_from_corpus", bare)) | |
| def test_redirect_and_feedback_regexes(self) -> None: | |
| self.assertTrue( | |
| behavior_heuristic( | |
| "redirect_to_support", | |
| {"answer": "Please reach out to the academy team.", "tool_calls": []}, | |
| ) | |
| ) | |
| self.assertTrue( | |
| behavior_heuristic( | |
| "acknowledge_feedback", | |
| {"answer": "Thank you for the suggestion!", "tool_calls": []}, | |
| ) | |
| ) | |
| self.assertIsNone( | |
| behavior_heuristic("answer_general", {"answer": "x", "tool_calls": []}) | |
| ) | |
| class PersonaGradingTests(unittest.TestCase): | |
| QUESTION = { | |
| "checks": [{"type": "regex_any", "patterns": ["conda"]}], | |
| "anti_patterns": ["uv sync", "python -m venv"], | |
| } | |
| def test_pass(self) -> None: | |
| result = grade_persona_question(self.QUESTION, "Use conda env create.") | |
| self.assertTrue(result["auto_pass"]) | |
| def test_anti_pattern_fails_even_when_check_passes(self) -> None: | |
| result = grade_persona_question( | |
| self.QUESTION, "conda works, or run `uv sync` instead." | |
| ) | |
| self.assertFalse(result["auto_pass"]) | |
| self.assertEqual(result["anti_pattern_hits"], ["uv sync"]) | |
| def test_case_insensitive(self) -> None: | |
| result = grade_persona_question(self.QUESTION, "CONDA is fine") | |
| self.assertTrue(result["auto_pass"]) | |
| def test_llm_check_defers(self) -> None: | |
| question = { | |
| "checks": [ | |
| {"type": "regex_any", "patterns": ["conda"]}, | |
| {"type": "llm", "instruction": "is it beginner-level?"}, | |
| ], | |
| "anti_patterns": [], | |
| } | |
| result = grade_persona_question(question, "conda activate course") | |
| self.assertIsNone(result["auto_pass"]) | |
| self.assertTrue(result["needs_judgment"]) | |
| class FaithfulnessEvidenceTests(unittest.TestCase): | |
| def test_collects_retrieval_and_kb_evidence(self) -> None: | |
| bundle = { | |
| "tool_calls": [ | |
| { | |
| "tool_name": "retrieve_tutor_context", | |
| "args_text": "context engineering", | |
| "output_text": "Lesson 3 covers context engineering.", | |
| "matches": [{"title": "Lesson 3", "url": "https://x/l3"}], | |
| }, | |
| { | |
| "tool_name": "run_kb_command", | |
| "args_text": "rg foo", | |
| "output_text": "raw/courses/agent/lesson.md: foo bar", | |
| }, | |
| ] | |
| } | |
| evidence = faithfulness_evidence(bundle) | |
| self.assertIn("context engineering", evidence) | |
| self.assertIn("SOURCES: Lesson 3 <https://x/l3>", evidence) | |
| self.assertIn("raw/courses/agent/lesson.md", evidence) | |
| def test_empty_when_no_retrieval_tool(self) -> None: | |
| bundle = {"tool_calls": [{"tool_name": "some_other_tool", "output_text": "x"}]} | |
| self.assertEqual(faithfulness_evidence(bundle), "") | |
| self.assertEqual(faithfulness_evidence({}), "") | |
| def test_returns_full_evidence_untruncated(self) -> None: | |
| # One full-size KB capture (run_battery caps each output at 40k). The | |
| # old 12k slice here silently cut the judge's evidence to <1/3 of the | |
| # grounding; evidence must now come back whole -- fitting the judge | |
| # prompt is a separate gate (evidence_fits), not a slice. | |
| bundle = { | |
| "tool_calls": [{"tool_name": "run_kb_command", "output_text": "z" * 40_000}] | |
| } | |
| evidence = faithfulness_evidence(bundle) | |
| self.assertGreaterEqual(len(evidence), 40_000) | |
| class EvidenceCompletenessTests(unittest.TestCase): | |
| def test_complete_when_no_truncation(self) -> None: | |
| bundle = { | |
| "tool_calls": [ | |
| {"tool_name": "run_kb_command", "output_text": "abc", "output_chars": 3} | |
| ] | |
| } | |
| self.assertTrue(evidence_is_complete(bundle)) | |
| def test_incomplete_when_kb_output_truncated(self) -> None: | |
| # output_chars (true length) exceeds captured output_text => truncated. | |
| bundle = { | |
| "tool_calls": [ | |
| { | |
| "tool_name": "run_kb_command", | |
| "output_text": "x" * 6000, | |
| "output_chars": 40000, | |
| } | |
| ] | |
| } | |
| self.assertFalse(evidence_is_complete(bundle)) | |
| def test_non_retrieval_truncation_is_ignored(self) -> None: | |
| bundle = { | |
| "tool_calls": [ | |
| {"tool_name": "some_tool", "output_text": "x", "output_chars": 99999} | |
| ] | |
| } | |
| self.assertTrue(evidence_is_complete(bundle)) | |
| def test_faithfulness_row_gated_on_complete_evidence(self) -> None: | |
| truncated = { | |
| "run_id": "r", | |
| "battery_type": "singleturn", | |
| "preset": "prod", | |
| "query": "q", | |
| "answer": "a", | |
| "tool_calls": [ | |
| { | |
| "tool_name": "run_kb_command", | |
| "args_text": "rg foo", | |
| "output_text": "y" * 6000, | |
| "output_chars": 40000, | |
| } | |
| ], | |
| } | |
| types = {r["item_type"] for r in quality_sheet_rows(truncated)} | |
| self.assertIn("holistic", types) | |
| self.assertNotIn("faithfulness", types) # truncated -> no faithfulness | |
| full = dict(truncated) | |
| full["tool_calls"] = [ | |
| { | |
| "tool_name": "run_kb_command", | |
| "args_text": "rg foo", | |
| "output_text": "y" * 100, | |
| "output_chars": 100, | |
| } | |
| ] | |
| types_full = {r["item_type"] for r in quality_sheet_rows(full)} | |
| self.assertIn("faithfulness", types_full) # full evidence -> emitted | |
| class EvidenceFitsTests(unittest.TestCase): | |
| """The judge-prompt side of the completeness guarantee: a faithfulness row | |
| carries the FULL evidence or is not emitted at all -- never a silent slice | |
| (the F23/F24 blind-judge class the capture gate exists to prevent).""" | |
| def _bundle(output_text: str) -> dict: | |
| return { | |
| "run_id": "r", | |
| "battery_type": "singleturn", | |
| "preset": "prod", | |
| "query": "q", | |
| "answer": "a", | |
| "tool_calls": [ | |
| { | |
| "tool_name": "run_kb_command", | |
| "args_text": "cat raw/docs/x/big.md", | |
| # Fully captured: output_chars == len(output_text), so the | |
| # capture-completeness gate passes. | |
| "output_text": output_text, | |
| "output_chars": len(output_text), | |
| } | |
| ], | |
| } | |
| def test_evidence_fits_predicate(self) -> None: | |
| self.assertTrue(evidence_fits("x" * FAITHFULNESS_EVIDENCE_MAX)) | |
| self.assertFalse(evidence_fits("x" * (FAITHFULNESS_EVIDENCE_MAX + 1))) | |
| self.assertTrue(evidence_fits("abc", max_chars=3)) | |
| self.assertFalse(evidence_fits("abcd", max_chars=3)) | |
| def test_cap_covers_a_realistic_multi_call_kb_turn(self) -> None: | |
| # The gate must not exclude what capture guarantees: a few full 40k | |
| # KB outputs (run_battery.TOOL_OUTPUT_MAX_CHARS) per turn must fit. | |
| self.assertGreaterEqual(FAITHFULNESS_EVIDENCE_MAX, 4 * 40_000) | |
| def test_faithfulness_row_carries_full_evidence(self) -> None: | |
| # One complete 40k KB output: under the old 12k slice the judge saw | |
| # ~30% of the grounding. The row must now carry all of it. | |
| text = ("y" * 39_990) + "END-MARKER" | |
| rows = [ | |
| r | |
| for r in quality_sheet_rows(self._bundle(text)) | |
| if r["item_type"] == "faithfulness" | |
| ] | |
| self.assertEqual(len(rows), 1) | |
| self.assertGreater(len(rows[0]["reference"]), 12_000) # old cap is gone | |
| self.assertTrue(rows[0]["reference"].endswith("END-MARKER")) | |
| def test_faithfulness_row_excluded_when_evidence_exceeds_cap(self) -> None: | |
| # Fully captured but too big for the judge prompt: excluded, not | |
| # silently truncated. Holistic (which never uses evidence) still emits. | |
| bundle = self._bundle("z" * (FAITHFULNESS_EVIDENCE_MAX + 1)) | |
| types = {r["item_type"] for r in quality_sheet_rows(bundle)} | |
| self.assertIn("holistic", types) | |
| self.assertNotIn("faithfulness", types) | |
| class CheckTriggersMainTests(unittest.TestCase): | |
| """check_triggers must evaluate EVERY run: all() over a bare generator | |
| short-circuited on the first failing run, hiding later runs' diagnostics.""" | |
| def _run_main(self, runs: list[str], results: list[bool]) -> tuple[list[Path], int]: | |
| calls: list[Path] = [] | |
| def fake_check_run(run_dir: Path, expect_none: bool, **_kwargs: object) -> bool: | |
| calls.append(run_dir) | |
| return results[len(calls) - 1] | |
| argv = ["check_triggers", "--runs", *runs] | |
| with ( | |
| mock.patch.object(check_triggers, "check_run", fake_check_run), | |
| mock.patch.object(sys, "argv", argv), | |
| self.assertRaises(SystemExit) as ctx, | |
| ): | |
| check_triggers.main() | |
| return calls, ctx.exception.code | |
| def test_later_runs_still_checked_after_a_failure(self) -> None: | |
| calls, code = self._run_main( | |
| ["runs/a", "runs/b", "runs/c"], [False, True, True] | |
| ) | |
| self.assertEqual(calls, [Path("runs/a"), Path("runs/b"), Path("runs/c")]) | |
| self.assertEqual(code, 1) # exit semantics preserved | |
| def test_exit_zero_when_all_runs_pass(self) -> None: | |
| calls, code = self._run_main(["runs/a", "runs/b"], [True, True]) | |
| self.assertEqual(len(calls), 2) | |
| self.assertEqual(code, 0) | |
| class JudgeRubricTests(unittest.TestCase): | |
| def test_holistic_and_faithfulness_rubrics_exist(self) -> None: | |
| self.assertIs(rubric_for("holistic"), RUBRICS["holistic"]) | |
| self.assertIs(rubric_for("faithfulness"), RUBRICS["faithfulness"]) | |
| def test_unknown_item_type_falls_back_to_key_point(self) -> None: | |
| self.assertIs(rubric_for("mystery"), RUBRICS["key_point"]) | |
| def test_faithfulness_reference_labeled_as_evidence(self) -> None: | |
| row = { | |
| "item_type": "faithfulness", | |
| "question": "q", | |
| "criterion": "grounded?", | |
| "reference": "RETRIEVED_TEXT_X", | |
| "answer": "a", | |
| } | |
| prompt = build_prompt(row) | |
| self.assertIn("RETRIEVED EVIDENCE", prompt) | |
| self.assertNotIn("STAFF REFERENCE", prompt) | |
| def test_other_item_types_keep_staff_reference_label(self) -> None: | |
| row = { | |
| "item_type": "key_point", | |
| "question": "q", | |
| "criterion": "claim", | |
| "reference": "STAFF_TEXT", | |
| "answer": "a", | |
| } | |
| self.assertIn("STAFF REFERENCE", build_prompt(row)) | |
| class MergeHandgradesTests(unittest.TestCase): | |
| FIELDS = [ | |
| "sheet_row_id", | |
| "run_id", | |
| "item_type", | |
| "criterion", | |
| "grade", | |
| "note", | |
| ] | |
| def _merge(self, grades, filled_rows): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "filled.csv" | |
| with open(path, "w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=self.FIELDS) | |
| writer.writeheader() | |
| writer.writerows(filled_rows) | |
| return merge_handgrades(grades, path) | |
| def test_new_item_types_and_per_metric_source(self) -> None: | |
| grades = [{"run_id": "r1"}] | |
| filled = [ | |
| # human-graded key points | |
| { | |
| "sheet_row_id": "r1|key_point|a", | |
| "run_id": "r1", | |
| "item_type": "key_point", | |
| "criterion": "k1", | |
| "grade": "pass", | |
| "note": "", | |
| }, | |
| { | |
| "sheet_row_id": "r1|key_point|b", | |
| "run_id": "r1", | |
| "item_type": "key_point", | |
| "criterion": "k2", | |
| "grade": "fail", | |
| "note": "", | |
| }, | |
| # judge-graded new dimensions | |
| { | |
| "sheet_row_id": "r1|holistic|c", | |
| "run_id": "r1", | |
| "item_type": "holistic", | |
| "criterion": "h", | |
| "grade": "pass", | |
| "note": "[judge:high] good answer", | |
| }, | |
| { | |
| "sheet_row_id": "r1|faithfulness|d", | |
| "run_id": "r1", | |
| "item_type": "faithfulness", | |
| "criterion": "f", | |
| "grade": "fail", | |
| "note": "[judge:low] fabricated param", | |
| }, | |
| ] | |
| merged = self._merge(grades, filled)[0] | |
| self.assertEqual(merged["key_points_passed"], 1) | |
| self.assertEqual(merged["key_points_total"], 2) | |
| self.assertEqual(merged["key_points_source"], "human") | |
| self.assertTrue(merged["holistic_pass"]) | |
| self.assertEqual(merged["holistic_source"], "judge") | |
| self.assertFalse(merged["faithfulness_pass"]) | |
| self.assertEqual(merged["faithfulness_source"], "judge") | |
| # human key-points + judge new dims => overall mixed | |
| self.assertEqual(merged["grade_source"], "mixed") | |
| if __name__ == "__main__": | |
| unittest.main() | |