-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgrader_agent.py
More file actions
257 lines (212 loc) · 8.22 KB
/
Copy pathgrader_agent.py
File metadata and controls
257 lines (212 loc) · 8.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# grader_agent.py
# Grades Basil's responses using a two-grader architecture:
# english_grader (0-3): English presence + domain relevance
# task_grader (0-7): Task compliance + target word detection
# Final score = max(english_score, task_score)
#
# The programmatic Englishness floor (score_override.py) and target-word
# safety net are applied downstream in auto_session.py.
import os
import json
import re
from openai import OpenAI
from config import (
GRADER_MODEL,
PROMPT_GRADER,
PROMPT_ENGLISH_GRADER,
PROMPT_TASK_GRADER,
SCORE_MIN,
SCORE_MAX,
)
from llm_client import create_smart_client
client = create_smart_client()
def _load_template(path: str) -> str:
with open(path, "r") as f:
return f.read()
def _sanitize_for_json(text: str) -> str:
"""Sanitize text to be safe for JSON embedding."""
if not text:
return ""
sanitized = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', text)
sanitized = re.sub(r' +', ' ', sanitized)
return sanitized.strip()
def _try_repair_json(raw_output: str) -> dict:
"""Attempt basic JSON repairs for common LLM mistakes."""
text = raw_output.strip()
if text.startswith("```"):
lines = text.split("\n")
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
text = re.sub(r',\s*([\}\]])', r'\1', text)
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if json_match:
text = json_match.group(0)
return json.loads(text)
class _Defaults(dict):
"""Dict subclass that returns '{key}' for missing keys in format_map."""
def __missing__(self, key):
return f"{{{key}}}"
# ─── English Grader (0-3) ────────────────────────────────────────────────────
def grade_english(
basil_output: str,
subject: str = "",
lesson: str = "",
) -> dict:
"""
Score 0-3: English word presence and domain relevance.
Returns dict with: score, english_words, domain_words, reason
"""
template = _load_template(PROMPT_ENGLISH_GRADER)
safe_output = _sanitize_for_json(basil_output) or "(Empty response)"
prompt = template.format_map(_Defaults(
subject=subject or "(Not specified)",
lesson=lesson or "(Not specified)",
basil_output=safe_output,
))
try:
response = client.chat.completions.create(
model=GRADER_MODEL,
messages=[
{"role": "system", "content": "Output valid JSON only."},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=200,
)
raw = response.choices[0].message.content.strip()
data = _try_repair_json(raw)
data["score"] = max(0, min(3, int(data.get("score", 0))))
return data
except Exception as e:
print(f"[EnglishGrader] Error: {e}")
return {"score": 0, "english_words": [], "domain_words": [], "reason": f"Error: {e}"}
# ─── Task Grader (0-7) ───────────────────────────────────────────────────────
def grade_task(
task_text: str,
basil_output: str,
context: str = "",
subject: str = "",
lesson: str = "",
age_band: int = 0,
age_band_description: str = "",
) -> dict:
"""
Score 0-7: Task compliance and target word detection.
Returns dict with: score, target_word, target_found, target_evidence, reason
"""
template = _load_template(PROMPT_TASK_GRADER)
safe_output = _sanitize_for_json(basil_output) or "(Empty response)"
prompt = template.format_map(_Defaults(
subject=subject or "(Not specified)",
lesson=lesson or "(Not specified)",
task_text=task_text,
context=context or "(No additional context)",
basil_output=safe_output,
age_band=age_band,
age_band_description=age_band_description or f"age band {age_band}",
))
try:
response = client.chat.completions.create(
model=GRADER_MODEL,
messages=[
{"role": "system", "content": "Output valid JSON only."},
{"role": "user", "content": prompt},
],
temperature=0.3,
max_tokens=250,
)
raw = response.choices[0].message.content.strip()
data = _try_repair_json(raw)
data["score"] = max(SCORE_MIN, min(SCORE_MAX, int(data.get("score", 0))))
return data
except Exception as e:
print(f"[TaskGrader] Error: {e}")
return {"score": 0, "target_word": "", "target_found": False,
"target_evidence": None, "reason": f"Error: {e}"}
# ─── Combined Grade (public API) ─────────────────────────────────────────────
def grade_response(
task_text: str,
rubric: dict,
grader_instructions: str,
basil_output: str,
context: str = "",
age_band: int = 0,
age_band_description: str = "",
subject: str = "",
lesson: str = "",
) -> dict:
"""
Grade Basil's response using two specialized graders.
Calls english_grader (0-3) and task_grader (0-7) in sequence,
returns final_score = max(english_score, task_score).
Args:
task_text: The task/prompt given to Basil
rubric: Dict mapping scores to criteria (kept for API compat, not used by new graders)
grader_instructions: Additional guidance (kept for API compat)
basil_output: Basil's raw output
context: Recent conversation for context
age_band: Basil's current developmental stage (0-7)
age_band_description: Human-readable description of age band
subject: Lesson subject (e.g., "Botany")
lesson: Lesson title (e.g., "The Hierarchy of Plant Classification")
Returns:
dict with keys: score, justification, evidence, notes,
plus english_grade and task_grade sub-dicts for audit.
"""
eng = grade_english(basil_output, subject, lesson)
tsk = grade_task(task_text, basil_output, context, subject, lesson,
age_band, age_band_description)
eng_score = eng.get("score", 0)
tsk_score = tsk.get("score", 0)
# English grader gates the task grader: if the english_grader doesn't
# see domain-relevant words, the task_grader can't claim high task
# compliance. This prevents parroting (e.g. "Nice try") from scoring
# 4-5 when the english_grader correctly identifies no domain content.
if eng_score <= 1:
tsk_capped = min(tsk_score, 2)
elif eng_score == 2:
tsk_capped = min(tsk_score, 3)
else:
tsk_capped = tsk_score
final_score = max(eng_score, tsk_capped)
justification = tsk.get("reason", "")
if eng_score > tsk_capped:
justification = eng.get("reason", justification)
notes = f"eng={eng_score} task={tsk_score}"
if tsk_capped != tsk_score:
notes += f" task_capped={tsk_capped}"
evidence = []
if tsk.get("target_evidence"):
evidence.append(_sanitize_for_json(str(tsk["target_evidence"]))[:40])
for dw in eng.get("domain_words", []):
if isinstance(dw, str):
evidence.append(_sanitize_for_json(dw)[:40])
return {
"score": final_score,
"justification": justification,
"evidence": evidence[:5],
"notes": notes,
"english_grade": eng,
"task_grade": tsk,
}
def _error_grade(error_msg: str) -> dict:
"""Return a grade when grading fails entirely."""
return {
"score": 0,
"justification": f"Grading error: {error_msg}",
"evidence": [],
"notes": "Grader failed; score=0 assigned (no auto-upgrade).",
"grader_error": True,
"compliance": False,
}
if __name__ == "__main__":
print("Testing Two-Grader Architecture...")
result = grade_response(
task_text="Say one word that is an animal.",
rubric={},
grader_instructions="",
basil_output="cat the dog",
context="Tutor was asking about animals.",
subject="Animals",
lesson="Common Pets",
)
print(json.dumps(result, indent=2))