nz-nz commited on
Commit
22402d5
·
verified ·
1 Parent(s): a0b0535

Sync from GitHub via hub-sync

Browse files
Files changed (2) hide show
  1. learning_engine.py +44 -21
  2. test_grade_answer.py +23 -0
learning_engine.py CHANGED
@@ -7,6 +7,8 @@ app.py depends on them.
7
  """
8
  from __future__ import annotations
9
 
 
 
10
  import llm
11
  from schema import (
12
  Card, GradeResult, Session, new_card, new_card_state, new_grade, validate_card,
@@ -77,28 +79,27 @@ def grade_answer(card: Card, user_answer: str) -> GradeResult:
77
  else f"Not quite. Expected something like: {card['answer']}")
78
  return new_grade(score, expl, missed_concept=card["topic"])
79
 
 
 
 
 
80
  messages = [
81
  {"role": "system", "content":
82
- "You grade a student's answer against a reference answer. "
83
- "Scoring rubric (be strict): 0-1 = wrong or names the wrong thing; "
84
- "2 = partially relevant but misses the key idea; 3 = mostly correct with "
85
- "a minor gap; 4-5 = correct and complete. If the answer is factually "
86
- "wrong, score 0-2 (NOT 3). "
87
- "Score strictly, but write the feedback warmly. Speak DIRECTLY to the "
88
- "student in the second person ('you', 'your answer') with an "
89
- "encouraging coaching tone — acknowledge what they got right before what "
90
- "they missed. NEVER write in the third person or refer to them as 'the "
91
- "student' / 'the student's response'. "
92
- "Return ONLY a JSON object with keys: "
93
- "score (integer 0-5), explanation (string spoken to the student), "
94
- "missed_concept (short string naming what they got wrong, or \"\"). "
95
- "Example (return ONE object exactly like this, no other text):\n"
96
- '{"score": 1, "explanation": "Good instinct, but that\'s the wrong '
97
- 'location — the Calvin cycle actually runs in the stroma. Try linking '
98
- 'it to where the enzymes sit.", "missed_concept": "the specific location"}'},
99
  {"role": "user", "content":
100
  f"Question: {card['question']}\nReference answer: {card['answer']}\n"
101
- f"Student answer: {user_answer}\nGrade it."},
 
102
  ]
103
  # Parser + one repair retry; safe default if the model never returns JSON.
104
  # A generous 2048-token budget so even a long reasoning preamble can't push
@@ -113,14 +114,36 @@ def grade_answer(card: Card, user_answer: str) -> GradeResult:
113
  f"reference: {card['answer']}",
114
  card["topic"],
115
  )
 
116
  return new_grade(
117
  int(data["score"]),
118
- str(data.get("explanation", "")).strip()
119
- or f"Reference answer: {card['answer']}",
120
- str(data.get("missed_concept") or card["topic"]).strip(),
121
  )
122
 
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  def _valid_grade(data) -> bool:
125
  """A grade is usable only if it carries a numeric, in-range score."""
126
  if not isinstance(data, dict) or "score" not in data:
 
7
  """
8
  from __future__ import annotations
9
 
10
+ import re
11
+
12
  import llm
13
  from schema import (
14
  Card, GradeResult, Session, new_card, new_card_state, new_grade, validate_card,
 
79
  else f"Not quite. Expected something like: {card['answer']}")
80
  return new_grade(score, expl, missed_concept=card["topic"])
81
 
82
+ # Tone instruction is kept short and the strict "ONLY JSON" requirement is
83
+ # reasserted as the LAST thing the model reads (in the user turn) — a verbose
84
+ # "write warmly" preamble was nudging this small model into prose that didn't
85
+ # parse, and recency improves format compliance.
86
  messages = [
87
  {"role": "system", "content":
88
+ "You grade a student's answer against a reference answer.\n"
89
+ "Scoring (be strict): 0-1 = wrong / names the wrong thing; 2 = partially "
90
+ "relevant but misses the key idea; 3 = mostly correct, minor gap; "
91
+ "4-5 = correct and complete. A factually wrong answer is 0-2, never 3.\n"
92
+ "Write the feedback warmly, speaking to the learner as \"you\" (never "
93
+ "\"the student\"): note what's right, then what's missing.\n"
94
+ "JSON keys: score (0-5 int), explanation (spoken to \"you\"), "
95
+ "missed_concept (what was wrong, or \"\").\n"
96
+ "Example: {\"score\": 1, \"explanation\": \"Good instinct, but that's the "
97
+ "wrong spot the Calvin cycle runs in the stroma. Tie it to where the "
98
+ "enzymes sit.\", \"missed_concept\": \"the specific location\"}"},
 
 
 
 
 
 
99
  {"role": "user", "content":
100
  f"Question: {card['question']}\nReference answer: {card['answer']}\n"
101
+ f"Student answer: {user_answer}\n\n"
102
+ "Grade it. Reply with ONLY the JSON object — no prose, no markdown fences."},
103
  ]
104
  # Parser + one repair retry; safe default if the model never returns JSON.
105
  # A generous 2048-token budget so even a long reasoning preamble can't push
 
114
  f"reference: {card['answer']}",
115
  card["topic"],
116
  )
117
+ explanation = _to_second_person(str(data.get("explanation", "")).strip())
118
  return new_grade(
119
  int(data["score"]),
120
+ explanation or f"Reference answer: {card['answer']}",
121
+ _to_second_person(str(data.get("missed_concept") or card["topic"]).strip()),
 
122
  )
123
 
124
 
125
+ # This small model still slips into the third person ("The student's answer…")
126
+ # perhaps half the time despite the prompt. These swaps are the grammatically
127
+ # SAFE ones — possessives only — so we never mangle subject-verb agreement (we
128
+ # leave "The student identifies…" alone rather than produce "You identifies…").
129
+ _SECOND_PERSON_SUBS = [
130
+ (re.compile(r"\bthe student'?s answer\b", re.I), "your answer"),
131
+ (re.compile(r"\bthe student'?s response\b", re.I), "your answer"),
132
+ (re.compile(r"\bthe student'?s\b", re.I), "your"),
133
+ ]
134
+
135
+
136
+ def _to_second_person(text: str) -> str:
137
+ """Rewrite clinical third-person possessives to warm second person, matching
138
+ the original capitalization ('The student's answer' -> 'Your answer')."""
139
+ for pat, repl in _SECOND_PERSON_SUBS:
140
+ text = pat.sub(
141
+ lambda m, r=repl: r.capitalize() if m.group(0)[:1].isupper() else r,
142
+ text,
143
+ )
144
+ return text
145
+
146
+
147
  def _valid_grade(data) -> bool:
148
  """A grade is usable only if it carries a numeric, in-range score."""
149
  if not isinstance(data, dict) or "score" not in data:
test_grade_answer.py CHANGED
@@ -88,6 +88,27 @@ def test_out_of_range_score_rejected():
88
  print("ok out-of-range score rejected -> safe default")
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def test_empty_answer_short_circuits_to_zero():
92
  # An empty answer is a miss — score 0 with no model call (the model otherwise
93
  # ignores the blank input and hallucinates a 4/5 "correct").
@@ -105,5 +126,7 @@ if __name__ == "__main__":
105
  test_repair_retry_recovers()
106
  test_safe_default_when_never_valid()
107
  test_out_of_range_score_rejected()
 
 
108
  test_empty_answer_short_circuits_to_zero()
109
  print("\nAll NAH-8 grade_answer tests passed.")
 
88
  print("ok out-of-range score rejected -> safe default")
89
 
90
 
91
+ def test_third_person_possessive_rewritten_to_second():
92
+ # The model slips into "The student's answer/response" ~half the time; the
93
+ # safe possessive swaps are applied to the returned explanation.
94
+ llm.chat, _ = _fake_chat([
95
+ '{"score": 1, "explanation": "The student\'s answer, \'magic\', is wrong.", '
96
+ '"missed_concept": "the student\'s grasp of the mechanism"}'
97
+ ])
98
+ g = le.grade_answer(_card(), "magic")
99
+ assert g["explanation"] == "Your answer, 'magic', is wrong.", g["explanation"]
100
+ assert g["missed_concept"] == "your grasp of the mechanism", g["missed_concept"]
101
+ print("ok third-person possessive rewritten to second person")
102
+
103
+
104
+ def test_second_person_leaves_safe_subject_form_alone():
105
+ # We only swap possessives — a subject "The student identifies..." is left
106
+ # untouched rather than mangled into "You identifies...".
107
+ assert le._to_second_person("The student identifies it.") == "The student identifies it."
108
+ assert le._to_second_person("Your answer is close.") == "Your answer is close."
109
+ print("ok subject-form third person left alone (no grammar mangling)")
110
+
111
+
112
  def test_empty_answer_short_circuits_to_zero():
113
  # An empty answer is a miss — score 0 with no model call (the model otherwise
114
  # ignores the blank input and hallucinates a 4/5 "correct").
 
126
  test_repair_retry_recovers()
127
  test_safe_default_when_never_valid()
128
  test_out_of_range_score_rejected()
129
+ test_third_person_possessive_rewritten_to_second()
130
+ test_second_person_leaves_safe_subject_form_alone()
131
  test_empty_answer_short_circuits_to_zero()
132
  print("\nAll NAH-8 grade_answer tests passed.")