dismissed wrong label triggers recorrection

This commit is contained in:
sebastien committed 2026-10-23 08:51:28 +02:00
1 parent 1959c5d3bb
commit f39868207d
5 files changed
+198 -14

No files matched your search

+38 -11
View File
@@ -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
View File
@@ -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
+117
View File
@@ -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']])
+1 -1
View File
@@ -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 []
+15
View File
@@ -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"