| """ |
| NAH-8 — grade_answer strict-JSON grading + parser/repair retry. |
| |
| Runs with the real (non-stub) code path but with llm.chat monkeypatched, so no |
| model/GPU is needed. Verifies: clean JSON parses, a bad first reply triggers one |
| repair retry, and a never-valid model gives a safe default instead of crashing. |
| |
| RECALL_STUB=0 python3 test_grade_answer.py |
| """ |
| import os |
|
|
| os.environ["RECALL_STUB"] = "0" |
|
|
| import llm |
| import learning_engine as le |
| from schema import new_card |
|
|
| |
| |
| |
| |
| |
| llm.STUB = False |
|
|
|
|
| def _card(): |
| return new_card( |
| question="What does mitochondria do?", |
| answer="It produces ATP, the cell's energy.", |
| topic="Cell Biology", |
| ) |
|
|
|
|
| def _fake_chat(replies): |
| """Return a chat() that yields the given replies in order.""" |
| calls = {"n": 0} |
|
|
| def chat(messages, max_tokens=512): |
| i = min(calls["n"], len(replies) - 1) |
| calls["n"] += 1 |
| return replies[i] |
|
|
| return chat, calls |
|
|
|
|
| def test_clean_json_first_try(): |
| llm.chat, calls = _fake_chat([ |
| '{"score": 5, "explanation": "Spot on.", "missed_concept": ""}' |
| ]) |
| g = le.grade_answer(_card(), "It makes ATP energy") |
| assert g["score"] == 5 and g["correct"] is True |
| assert g["explanation"] == "Spot on." |
| assert calls["n"] == 1, "should not retry when first reply is valid" |
| print("ok clean JSON on first try") |
|
|
|
|
| def test_repair_retry_recovers(): |
| |
| llm.chat, calls = _fake_chat([ |
| "Sure! The student did okay I think, maybe a 2 or 3.", |
| '```json\n{"score": 2, "explanation": "Missed the ATP detail.", ' |
| '"missed_concept": "ATP production"}\n```', |
| ]) |
| g = le.grade_answer(_card(), "it is in the cell") |
| assert g["score"] == 2 and g["correct"] is False |
| assert g["missed_concept"] == "ATP production" |
| assert calls["n"] == 2, "should retry exactly once to repair bad JSON" |
| print("ok repair retry recovers bad first reply") |
|
|
|
|
| def test_safe_default_when_never_valid(): |
| llm.chat, calls = _fake_chat(["no json here", "still no json at all"]) |
| g = le.grade_answer(_card(), "it makes energy for the cell") |
| assert g["score"] == 2 |
| assert "reference" in g["explanation"].lower() |
| assert calls["n"] == 2, "tries once + one repair, then gives up" |
| print("ok safe default when model never returns JSON") |
|
|
|
|
| def test_out_of_range_score_rejected(): |
| |
| llm.chat, calls = _fake_chat([ |
| '{"score": 99, "explanation": "x"}', |
| 'also not valid json', |
| ]) |
| g = le.grade_answer(_card(), "whatever") |
| assert g["score"] == 2, "out-of-range score should fall through to default" |
| print("ok out-of-range score rejected -> safe default") |
|
|
|
|
| def test_third_person_possessive_rewritten_to_second(): |
| |
| |
| llm.chat, _ = _fake_chat([ |
| '{"score": 1, "explanation": "The student\'s answer, \'magic\', is wrong.", ' |
| '"missed_concept": "the student\'s grasp of the mechanism"}' |
| ]) |
| g = le.grade_answer(_card(), "magic") |
| assert g["explanation"] == "Your answer, 'magic', is wrong.", g["explanation"] |
| assert g["missed_concept"] == "your grasp of the mechanism", g["missed_concept"] |
| print("ok third-person possessive rewritten to second person") |
|
|
|
|
| def test_second_person_leaves_safe_subject_form_alone(): |
| |
| |
| assert le._to_second_person("The student identifies it.") == "The student identifies it." |
| assert le._to_second_person("Your answer is close.") == "Your answer is close." |
| print("ok subject-form third person left alone (no grammar mangling)") |
|
|
|
|
| def test_empty_answer_short_circuits_to_zero(): |
| |
| |
| llm.chat, calls = _fake_chat(['{"score": 5, "explanation": "x"}']) |
| for blank in ("", " ", "\n\t"): |
| g = le.grade_answer(_card(), blank) |
| assert g["score"] == 0 and g["correct"] is False, (blank, g) |
| assert "reference answer" in g["explanation"].lower() |
| assert calls["n"] == 0, "empty answer must not call the model" |
| print("ok empty answer short-circuits to score 0 (no model call)") |
|
|
|
|
| def test_non_answer_short_circuits_to_zero(): |
| |
| |
| llm.chat, calls = _fake_chat(['{"score": 5, "explanation": "x"}']) |
| for non in ("idk", "I don't know", "don know", "no idea", "?", "..."): |
| g = le.grade_answer(_card(), non) |
| assert g["score"] == 0 and g["correct"] is False, (non, g) |
| assert calls["n"] == 0, "a non-answer must not call the model" |
| |
| llm.chat, calls = _fake_chat(['{"score": 4, "explanation": "Close."}']) |
| g = le.grade_answer(_card(), "no, it is the stroma") |
| assert calls["n"] == 1, "a real attempt must still be graded by the model" |
| print("ok non-answers ('idk', \"don't know\", '?') short-circuit to score 0") |
|
|
|
|
| if __name__ == "__main__": |
| test_clean_json_first_try() |
| test_repair_retry_recovers() |
| test_safe_default_when_never_valid() |
| test_out_of_range_score_rejected() |
| test_third_person_possessive_rewritten_to_second() |
| test_second_person_leaves_safe_subject_form_alone() |
| test_empty_answer_short_circuits_to_zero() |
| test_non_answer_short_circuits_to_zero() |
| print("\nAll NAH-8 grade_answer tests passed.") |
|
|