dismissed wrong label triggers recorrection
This commit is contained in:
1 parent
1959c5d3bb
commit
f39868207d
5 files changed
+198
-14
No files matched your search
@@ -392,7 +392,7 @@ def reserve_next_group_idx(label: str) -> int:
|
||||
reserved_group_indices[label] = idx + 1
|
||||
return idx
|
||||
|
||||
def handle_label_errors(pid, label, res, pdf_path):
|
||||
def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None):
|
||||
"""Handles Gemini labeling errors, moves/copies files, and returns new tasks."""
|
||||
new_tasks = []
|
||||
error_type = res.get("error")
|
||||
@@ -410,7 +410,33 @@ def handle_label_errors(pid, label, res, pdf_path):
|
||||
res["error"] = "wrg-lbl:cldtfix"
|
||||
return []
|
||||
if new_label == label:
|
||||
res["error"] = ""
|
||||
tprint(f"\t\tLabel confirmed for Copie{pid}: regrading {label}.")
|
||||
contents, config = prompting.request_for_confirmed_label(INPUT_DIR, pdf_path, label, pid)
|
||||
response = parse_correction_response(
|
||||
call_gemini_with_retries(MODEL_ID_flash, contents, config)
|
||||
)
|
||||
if not isinstance(response, list) or len(response) != 1 or response[0].get("id") != pid:
|
||||
raise ValueError(f"Confirmed-label regrading must return exactly Copie{pid}")
|
||||
replacement = prompting.EvaluationEntry.model_validate(response[0]).result.model_dump()
|
||||
if replacement["error"] == "wrong-label":
|
||||
raise ValueError(f"Regrading rejected the confirmed label {label} for Copie{pid}")
|
||||
if group_geometry is not None:
|
||||
yming, ymaxg, width_r, total_height = group_geometry
|
||||
for feedback in replacement["feedback"]:
|
||||
box = feedback.get("box_2d")
|
||||
if box and valid_feedback_box(box):
|
||||
ymin, xmin, ymax, xmax = box
|
||||
feedback["box_2d"] = [
|
||||
(yming + ymin * (ymaxg - yming) / 1000) * 1000 / total_height,
|
||||
xmin * width_r,
|
||||
(yming + ymax * (ymaxg - yming) / 1000) * 1000 / total_height,
|
||||
xmax * width_r,
|
||||
]
|
||||
# Keep the original response intact until regrading has succeeded.
|
||||
res.clear()
|
||||
res.update(replacement)
|
||||
if res["error"] == "additional-answer":
|
||||
return handle_label_errors(pid, label, res, pdf_path, group_geometry=group_geometry)
|
||||
return []
|
||||
|
||||
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
|
||||
@@ -574,14 +600,6 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
current_suffix = "_old"
|
||||
|
||||
# 1. Gestion de empty-answer
|
||||
if res.get("error") == "empty-answer":
|
||||
old_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
if pdf_path.exists() and pdf_path != old_path:
|
||||
move_answer_pdf(pdf_path, old_path)
|
||||
pdf_path = old_path
|
||||
current_suffix = "_old"
|
||||
|
||||
if (not can_spawn_tasks) and res["error"] == "additional-answer":
|
||||
tprint("\tSwallowing an additional-answer from a subsequent task.")
|
||||
res["error"]= ""
|
||||
@@ -601,7 +619,8 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
raise CorrectionStopRequested
|
||||
try:
|
||||
new_tasks.extend(
|
||||
handle_label_errors(pid, label, res, pdf_path)
|
||||
handle_label_errors(pid, label, res, pdf_path,
|
||||
group_geometry=(yming, ymaxg, width_r, total_height))
|
||||
)
|
||||
except CorrectionStopRequested:
|
||||
with io_lock:
|
||||
@@ -614,6 +633,14 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
if res.get("error", "").startswith("wrg-lbl-moved-to:"):
|
||||
current_suffix = "_old"
|
||||
|
||||
# Regrading a confirmed label can also identify an empty answer.
|
||||
if res.get("error") == "empty-answer":
|
||||
old_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
if pdf_path.exists() and pdf_path != old_path:
|
||||
move_answer_pdf(pdf_path, old_path)
|
||||
pdf_path = old_path
|
||||
current_suffix = "_old"
|
||||
|
||||
# 5. Enregistrer l'information dans correction.json
|
||||
if current_suffix:
|
||||
res["suffix"] = current_suffix
|
||||
|
||||
+27
-2
@@ -227,7 +227,11 @@ def generate_request(input_dir, file, full_label, *, thinking_level=settings.THI
|
||||
)
|
||||
]
|
||||
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
return (contents, grading_generation_config(thinking_level))
|
||||
|
||||
|
||||
def grading_generation_config(thinking_level):
|
||||
return types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
thinking_config=thinking_config(thinking_level),
|
||||
seed=fresh_seed(),
|
||||
@@ -235,7 +239,28 @@ def generate_request(input_dir, file, full_label, *, thinking_level=settings.THI
|
||||
response_mime_type= "application/json",
|
||||
response_json_schema= TypeAdapter(List[EvaluationEntry]).json_schema()
|
||||
)
|
||||
return (contents, generate_content_config)
|
||||
|
||||
|
||||
def request_for_confirmed_label(input_dir, pdf_path, label, student_id):
|
||||
prompt = make_prompt(input_dir, label)
|
||||
prompt += f"""
|
||||
|
||||
Cette image contient uniquement la réponse de l'élève d'identifiant `{student_id}`.
|
||||
Il n'y a pas d'en-tête imprimé à rechercher : utilise cet identifiant exact.
|
||||
Une vérification indépendante a confirmé que cette réponse appartient bien
|
||||
à la question `{label}`. Reprends sa correction mathématique depuis le début,
|
||||
avec une nouvelle note et des commentaires précis sur les erreurs ou les étapes
|
||||
manquantes. Une preuve incorrecte ou incomplète n'est pas une erreur de label.
|
||||
Ne renvoie pas `wrong-label` et ne rejette pas la réponse comme hors sujet.
|
||||
Les autres cas `additional-answer` et `empty-answer` restent applicables.
|
||||
Renvoie une liste JSON contenant exactement un résultat, pour `{student_id}`.
|
||||
Les coordonnées éventuelles portent sur l'image fournie entière.
|
||||
"""
|
||||
contents = [types.Content(role="user", parts=[
|
||||
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
|
||||
types.Part.from_text(text=prompt),
|
||||
])]
|
||||
return contents, grading_generation_config(settings.THINKING_LEVEL_FLASH)
|
||||
|
||||
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.commands import correction
|
||||
|
||||
|
||||
class ConfirmedLabelRegradingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.addCleanup(correction.stop_requested.clear)
|
||||
self.root = Path(temporary.name)
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
(self.root / 'labels').write_text('Ex 1\nEx 2\n')
|
||||
self.group = self.root / 'Par label/Ex 1/Group_1.jpg'
|
||||
self.group.parent.mkdir(parents=True)
|
||||
self.group.touch()
|
||||
atomic_write_json(self.group.with_suffix('.json'), [
|
||||
['02', 0, 400, 1], ['01', 450, 950, .5],
|
||||
])
|
||||
self.pdf = self.root / 'Copies/Copie01/Ex 1.pdf'
|
||||
self.pdf.parent.mkdir(parents=True)
|
||||
self.pdf.write_bytes(b'original answer')
|
||||
args = correction.build_parser().parse_args([str(self.root)])
|
||||
correction.configure_runtime(self.workspace, [(str(self.group), 'Ex 1')], args, api_client=Mock())
|
||||
self.original = [
|
||||
{'id': '02', 'result': {'score': 4, 'feedback': [], 'error': ''}},
|
||||
{'id': '01', 'result': {'score': 0, 'error': 'wrong-label',
|
||||
'feedback': [{'text': 'Wrong question', 'box_2d': None}]}},
|
||||
]
|
||||
self.regraded = [{'id': '01', 'result': {
|
||||
'score': 2.5, 'error': '', 'feedback': [
|
||||
{'text': 'Monotonicity is missing', 'box_2d': [100, 200, 300, 400]},
|
||||
],
|
||||
}}]
|
||||
|
||||
def process(self, responses):
|
||||
with (
|
||||
patch.object(correction.prompting, 'generate_request', return_value=([], {})),
|
||||
patch.object(correction.prompting, 'get_single_image_bytes', return_value=b'image'),
|
||||
patch.object(correction, 'call_gemini_with_retries', side_effect=responses) as call,
|
||||
):
|
||||
tasks = correction.process_single_task((str(self.group), 'Ex 1'), json.dumps(self.original))
|
||||
return tasks, call
|
||||
|
||||
def test_confirmed_label_replaces_rejection_and_maps_boxes_to_original_group(self):
|
||||
tasks, calls = self.process(['Ex 1', json.dumps(self.regraded)])
|
||||
self.assertEqual(tasks, [])
|
||||
self.assertEqual(calls.call_count, 2)
|
||||
self.assertEqual(correction.errors_summary, [])
|
||||
saved = read_json(self.workspace.correction_file)['Ex 1'][0]
|
||||
self.assertEqual(saved[0], self.original[0])
|
||||
result = saved[1]['result']
|
||||
self.assertEqual(result['score'], 2.5)
|
||||
self.assertEqual(result['error'], '')
|
||||
self.assertEqual(result['feedback'][0]['text'], 'Monotonicity is missing')
|
||||
box = result['feedback'][0]['box_2d']
|
||||
self.assertAlmostEqual(box[0], 500 * 1000 / 950)
|
||||
self.assertAlmostEqual(box[2], 600 * 1000 / 950)
|
||||
self.assertEqual([box[1], box[3]], [100, 200])
|
||||
self.assertEqual(self.pdf.read_bytes(), b'original answer')
|
||||
self.assertEqual(correction.completed_tasks, [(str(self.group), 'Ex 1')])
|
||||
|
||||
def test_invalid_regrading_never_publishes_stale_zero_or_marks_group_complete(self):
|
||||
for response in (
|
||||
[],
|
||||
[{'id': '02', 'result': self.regraded[0]['result']}],
|
||||
[self.regraded[0], self.regraded[0]],
|
||||
[self.original[1]],
|
||||
[{'id': '01', 'result': {'error': ''}}],
|
||||
):
|
||||
with self.subTest(response=response):
|
||||
self.process(['Ex 1', json.dumps(response)])
|
||||
self.assertFalse(self.workspace.correction_file.exists())
|
||||
self.assertEqual(correction.completed_tasks, [])
|
||||
self.assertEqual(correction.results['Ex 1'], [])
|
||||
self.assertEqual(self.pdf.read_bytes(), b'original answer')
|
||||
|
||||
def test_interrupted_regrading_keeps_wrong_label_in_pending_response(self):
|
||||
with self.assertRaises(correction.CorrectionStopRequested):
|
||||
self.process(['Ex 1', correction.CorrectionStopRequested()])
|
||||
pending = read_json(self.workspace.correction_pending_responses_file)
|
||||
self.assertEqual(json.loads(pending[str(self.group)]), self.original)
|
||||
self.assertFalse(self.workspace.correction_file.exists())
|
||||
self.process(['Ex 1', json.dumps(self.regraded)])
|
||||
self.assertEqual(read_json(self.workspace.correction_pending_responses_file), {})
|
||||
self.assertEqual(read_json(self.workspace.correction_file)['Ex 1'][0][1]['result']['score'], 2.5)
|
||||
|
||||
def test_empty_answer_from_regrading_uses_normal_empty_answer_handling(self):
|
||||
response = [{'id': '01', 'result': {'score': 0, 'feedback': [], 'error': 'empty-answer'}}]
|
||||
self.process(['Ex 1', json.dumps(response)])
|
||||
result = read_json(self.workspace.correction_file)['Ex 1'][0][1]['result']
|
||||
self.assertEqual(result['error'], 'empty-answer')
|
||||
self.assertEqual(result['suffix'], '_old')
|
||||
self.assertFalse(self.pdf.exists())
|
||||
self.assertEqual(self.pdf.with_name('Ex 1_old.pdf').read_bytes(), b'original answer')
|
||||
|
||||
def test_different_label_keeps_existing_move_behavior(self):
|
||||
(self.pdf.parent / 'Ex 2.pdf').touch()
|
||||
_, calls = self.process(['Ex 2'])
|
||||
self.assertEqual(calls.call_count, 1)
|
||||
result = read_json(self.workspace.correction_file)['Ex 1'][0][1]['result']
|
||||
self.assertEqual(result['error'], 'wrg-lbl:Ex 2?')
|
||||
self.assertEqual(result['delayed'], [['wrong-label', 'Ex 2']])
|
||||
|
||||
def test_additional_answer_from_regrading_still_gets_resolved(self):
|
||||
(self.pdf.parent / 'Ex 2.pdf').touch()
|
||||
self.regraded[0]['result']['error'] = 'additional-answer'
|
||||
_, calls = self.process(['Ex 1', json.dumps(self.regraded), '["Ex 1", "Ex 2"]'])
|
||||
self.assertEqual(calls.call_count, 3)
|
||||
result = read_json(self.workspace.correction_file)['Ex 1'][0][1]['result']
|
||||
self.assertEqual(result['score'], 2.5)
|
||||
self.assertEqual(result['delayed'], [['add-label', 'Ex 2']])
|
||||
@@ -1761,7 +1761,7 @@ class StandardCliTests(unittest.TestCase):
|
||||
module.stop_requested.set()
|
||||
yield SimpleNamespace(text=response)
|
||||
|
||||
def resolve_error(_pid, _label, result, _pdf_path):
|
||||
def resolve_error(_pid, _label, result, _pdf_path, **_kwargs):
|
||||
self.assertEqual(result["error"], error_type)
|
||||
result["error"] = ""
|
||||
return []
|
||||
|
||||
@@ -7,6 +7,21 @@ from copienator import prompting
|
||||
|
||||
|
||||
class PromptingTests(unittest.TestCase):
|
||||
def test_confirmed_label_request_keeps_grading_material_and_identifies_single_answer(self):
|
||||
with (
|
||||
patch.object(prompting, "get_single_image_bytes", return_value=b"answer"),
|
||||
patch.object(prompting, "get_label_text_content", return_value="Question"),
|
||||
patch.object(prompting, "get_label_sol_content", return_value="Solution"),
|
||||
patch.object(prompting, "get_label_persp_content", return_value="Marking criteria"),
|
||||
):
|
||||
contents, config = prompting.request_for_confirmed_label("evaluation", "answer.pdf", "Ex 2 : 2)b)", "01")
|
||||
prompt = contents[0].parts[1].text
|
||||
self.assertEqual(contents[0].parts[0].inline_data.data, b"answer")
|
||||
for text in ("Question", "Solution", "Marking criteria", "`01`", "`Ex 2 : 2)b)`",
|
||||
"Reprends sa correction mathématique", "Ne renvoie pas `wrong-label`"):
|
||||
self.assertIn(text, prompt)
|
||||
self.assertEqual(config.response_mime_type, "application/json")
|
||||
|
||||
def test_live_prompt_includes_only_metadata_ids_in_order(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
image = Path(directory) / "Group_16.jpg"
|
||||
|
||||
Reference in new issue
Block a user