Sync from GitHub via hub-sync
Browse files- learning_engine.py +44 -21
- 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
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
"
|
| 87 |
-
"
|
| 88 |
-
"
|
| 89 |
-
"
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
"
|
| 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}\
|
|
|
|
| 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 |
-
|
| 119 |
-
|
| 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.")
|