nz-nz commited on
Commit
bed0974
·
unverified ·
2 Parent(s): 22364b0560c197

Merge pull request #3 from francisco-magana/hygienepile-0k/nah-8-grade_answer-strict-json-grading-parserrepair-retry

Browse files
Files changed (3) hide show
  1. learning_engine.py +27 -9
  2. llm.py +26 -0
  3. test_grade_answer.py +89 -0
learning_engine.py CHANGED
@@ -53,23 +53,41 @@ def grade_answer(card: Card, user_answer: str) -> GradeResult:
53
 
54
  messages = [
55
  {"role": "system", "content":
56
- "You grade a student's answer. Return ONLY JSON with keys: "
57
- "score (0-5 int), explanation (string), missed_concept (string)."},
 
 
58
  {"role": "user", "content":
59
  f"Question: {card['question']}\nReference answer: {card['answer']}\n"
60
  f"Student answer: {user_answer}\nGrade it."},
61
  ]
62
- data = llm.extract_json(llm.chat(messages, max_tokens=256))
63
- if not isinstance(data, dict) or "score" not in data:
64
- return new_grade(2, "Couldn't grade automatically — review the reference "
65
- f"answer: {card['answer']}", card["topic"])
 
 
 
 
 
66
  return new_grade(
67
- int(data.get("score", 0)),
68
- str(data.get("explanation", "")).strip(),
69
- str(data.get("missed_concept", card["topic"])).strip(),
 
70
  )
71
 
72
 
 
 
 
 
 
 
 
 
 
 
73
  # ---- Adaptation: SM-2-lite -------------------------------------------------
74
 
75
  def apply_result(session: Session, card: Card, grade: GradeResult) -> Session:
 
53
 
54
  messages = [
55
  {"role": "system", "content":
56
+ "You grade a student's answer against a reference answer. "
57
+ "Return ONLY a JSON object with keys: "
58
+ "score (integer 0-5), explanation (string for the student), "
59
+ "missed_concept (short string naming what they got wrong, or \"\")."},
60
  {"role": "user", "content":
61
  f"Question: {card['question']}\nReference answer: {card['answer']}\n"
62
  f"Student answer: {user_answer}\nGrade it."},
63
  ]
64
+ # Parser + one repair retry; safe default if the model never returns JSON.
65
+ data = llm.chat_json(messages, max_tokens=256)
66
+ if not _valid_grade(data):
67
+ return new_grade(
68
+ 2,
69
+ "Couldn't grade automatically — compare your answer to the "
70
+ f"reference: {card['answer']}",
71
+ card["topic"],
72
+ )
73
  return new_grade(
74
+ int(data["score"]),
75
+ str(data.get("explanation", "")).strip()
76
+ or f"Reference answer: {card['answer']}",
77
+ str(data.get("missed_concept") or card["topic"]).strip(),
78
  )
79
 
80
 
81
+ def _valid_grade(data) -> bool:
82
+ """A grade is usable only if it carries a numeric, in-range score."""
83
+ if not isinstance(data, dict) or "score" not in data:
84
+ return False
85
+ try:
86
+ return 0 <= int(data["score"]) <= 5
87
+ except (TypeError, ValueError):
88
+ return False
89
+
90
+
91
  # ---- Adaptation: SM-2-lite -------------------------------------------------
92
 
93
  def apply_result(session: Session, card: Card, grade: GradeResult) -> Session:
llm.py CHANGED
@@ -89,6 +89,32 @@ def extract_json(text: str):
89
  return None
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  # ---- Stub replies so the app runs with no model ----------------------------
93
 
94
  def _stub_reply(messages: list[dict]) -> str:
 
89
  return None
90
 
91
 
92
+ def chat_json(messages: list[dict], max_tokens: int = 256, retries: int = 1):
93
+ """
94
+ Call the model and parse its reply as JSON, with up to `retries` repair
95
+ passes. Model output is never trusted: if the first reply isn't valid JSON
96
+ we feed it back with a terse "return ONLY valid JSON" nudge and try again.
97
+
98
+ Returns the parsed object/array, or None if every attempt fails (callers
99
+ must handle None with a safe default — never crash the study loop).
100
+ """
101
+ convo = list(messages)
102
+ for attempt in range(retries + 1):
103
+ reply = chat(convo, max_tokens=max_tokens)
104
+ data = extract_json(reply)
105
+ if data is not None:
106
+ return data
107
+ if attempt < retries:
108
+ # Repair pass: show the model its bad reply and demand clean JSON.
109
+ convo = messages + [
110
+ {"role": "assistant", "content": reply},
111
+ {"role": "user", "content":
112
+ "That was not valid JSON. Reply again with ONLY the JSON "
113
+ "value — no prose, no markdown fences, no trailing text."},
114
+ ]
115
+ return None
116
+
117
+
118
  # ---- Stub replies so the app runs with no model ----------------------------
119
 
120
  def _stub_reply(messages: list[dict]) -> str:
test_grade_answer.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NAH-8 — grade_answer strict-JSON grading + parser/repair retry.
3
+
4
+ Runs with the real (non-stub) code path but with llm.chat monkeypatched, so no
5
+ model/GPU is needed. Verifies: clean JSON parses, a bad first reply triggers one
6
+ repair retry, and a never-valid model gives a safe default instead of crashing.
7
+
8
+ RECALL_STUB=0 python3 test_grade_answer.py
9
+ """
10
+ import os
11
+
12
+ os.environ["RECALL_STUB"] = "0" # exercise the real model path, not the heuristic
13
+
14
+ import llm
15
+ import learning_engine as le
16
+ from schema import new_card
17
+
18
+
19
+ def _card():
20
+ return new_card(
21
+ question="What does mitochondria do?",
22
+ answer="It produces ATP, the cell's energy.",
23
+ topic="Cell Biology",
24
+ )
25
+
26
+
27
+ def _fake_chat(replies):
28
+ """Return a chat() that yields the given replies in order."""
29
+ calls = {"n": 0}
30
+
31
+ def chat(messages, max_tokens=512):
32
+ i = min(calls["n"], len(replies) - 1)
33
+ calls["n"] += 1
34
+ return replies[i]
35
+
36
+ return chat, calls
37
+
38
+
39
+ def test_clean_json_first_try():
40
+ llm.chat, calls = _fake_chat([
41
+ '{"score": 5, "explanation": "Spot on.", "missed_concept": ""}'
42
+ ])
43
+ g = le.grade_answer(_card(), "It makes ATP energy")
44
+ assert g["score"] == 5 and g["correct"] is True
45
+ assert g["explanation"] == "Spot on."
46
+ assert calls["n"] == 1, "should not retry when first reply is valid"
47
+ print("ok clean JSON on first try")
48
+
49
+
50
+ def test_repair_retry_recovers():
51
+ # First reply is junk; repair pass returns valid JSON.
52
+ llm.chat, calls = _fake_chat([
53
+ "Sure! The student did okay I think, maybe a 2 or 3.",
54
+ '```json\n{"score": 2, "explanation": "Missed the ATP detail.", '
55
+ '"missed_concept": "ATP production"}\n```',
56
+ ])
57
+ g = le.grade_answer(_card(), "it is in the cell")
58
+ assert g["score"] == 2 and g["correct"] is False
59
+ assert g["missed_concept"] == "ATP production"
60
+ assert calls["n"] == 2, "should retry exactly once to repair bad JSON"
61
+ print("ok repair retry recovers bad first reply")
62
+
63
+
64
+ def test_safe_default_when_never_valid():
65
+ llm.chat, calls = _fake_chat(["no json here", "still no json at all"])
66
+ g = le.grade_answer(_card(), "dunno")
67
+ assert g["score"] == 2 # neutral safe default
68
+ assert "reference" in g["explanation"].lower()
69
+ assert calls["n"] == 2, "tries once + one repair, then gives up"
70
+ print("ok safe default when model never returns JSON")
71
+
72
+
73
+ def test_out_of_range_score_rejected():
74
+ # Score outside 0-5 must be treated as unusable, not clamped silently.
75
+ llm.chat, calls = _fake_chat([
76
+ '{"score": 99, "explanation": "x"}',
77
+ 'also not valid json',
78
+ ])
79
+ g = le.grade_answer(_card(), "whatever")
80
+ assert g["score"] == 2, "out-of-range score should fall through to default"
81
+ print("ok out-of-range score rejected -> safe default")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ test_clean_json_first_try()
86
+ test_repair_retry_recovers()
87
+ test_safe_default_when_never_valid()
88
+ test_out_of_range_score_rejected()
89
+ print("\nAll NAH-8 grade_answer tests passed.")