From f39868207d8eb116f5181d803a595918748304e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Fri, 23 Oct 2026 08:51:28 +0200 Subject: [PATCH] dismissed wrong label triggers recorrection --- copienator/commands/correction.py | 49 +++++++--- copienator/prompting.py | 29 +++++- tests/test_confirmed_label_regrading.py | 117 ++++++++++++++++++++++++ tests/test_gui_core.py | 2 +- tests/test_prompting.py | 15 +++ 5 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 tests/test_confirmed_label_regrading.py diff --git a/copienator/commands/correction.py b/copienator/commands/correction.py index 9e83d1d..0d7ad4a 100644 --- a/copienator/commands/correction.py +++ b/copienator/commands/correction.py @@ -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 diff --git a/copienator/prompting.py b/copienator/prompting.py index 3ac34e5..980c119 100644 --- a/copienator/prompting.py +++ b/copienator/prompting.py @@ -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 diff --git a/tests/test_confirmed_label_regrading.py b/tests/test_confirmed_label_regrading.py new file mode 100644 index 0000000..9329724 --- /dev/null +++ b/tests/test_confirmed_label_regrading.py @@ -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']]) diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 63969ce..811b08e 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -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 [] diff --git a/tests/test_prompting.py b/tests/test_prompting.py index 4d52b34..bade6de 100644 --- a/tests/test_prompting.py +++ b/tests/test_prompting.py @@ -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"