diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index 2e6625b..03b3aee 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -22,6 +22,7 @@ from copienator import ( workspace_from_args, ) from copienator.platform import validate_windows_labels +from copienator.latex_json import repair_latex_json from copienator.filesystem import staged_directory from copienator.utils import compile_to_pdf @@ -69,7 +70,7 @@ class ExamContext(BaseModel): # --- Modèles pour la Requête 4 (Barèmes) --- class RubricItem(BaseModel): label: str = Field(description="Label exact de la question, à conserver sans traduction.") - rubric_content: str = Field(description="Barème détaillé sur 4 points : toutes les consignes, explications et justifications doivent être rédigées en français.") + rubric_content: str = Field(description="Barème détaillé sur 4 points, en français, avec formules LaTeX. Échapper chaque antislash LaTeX conformément à la syntaxe JSON.") class GroupRubrics(BaseModel): rubrics: list[RubricItem] @@ -101,6 +102,27 @@ question fournie, cette liste contient un objet avec son `label` exact et son barème en français dans `rubric_content`. """ +LATEX_JSON_INSTRUCTION = r""" +IMPORTANT — Échappement JSON du LaTeX : +Dans les chaînes JSON, chaque antislash LaTeX doit être doublé. +Exemple d’échappement dans un barème (adapte les clés au schéma demandé) : +{"rubrics":[{"label":"1)","rubric_content":"1 point : $\\frac{1}{2}$ et $f \\circ g$.\n1 point : $x \\rightarrow 0$ et $x \\not=0$."}]} +Ne confonds pas les commandes LaTeX avec les échappements JSON : +\\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ; +\n est réservé à un véritable saut de ligne du texte. +""" + + +def validate_rubric_content(content: str) -> str: + """Catch decoded control escapes; ordinary JSON newlines remain valid. + + A misescaped LaTeX command starting with n is indistinguishable from + an intentional newline. Do not guess or rewrite those after decoding. + """ + if any((ord(char) < 32 and char != "\n") or ord(char) == 127 for char in content): + raise ValueError("Rubric contains control characters, likely from unescaped LaTeX") + return content + # --- Modèle fusionné (pour le reste du script) --- class QuestionItem(BaseModel): label: str @@ -178,6 +200,12 @@ traduire, et conserve le LaTeX ainsi que les labels exacts. Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu. """ +PROMPT_1 += LATEX_JSON_INSTRUCTION +PROMPT_2 += LATEX_JSON_INSTRUCTION +PROMPT_3 += LATEX_JSON_INSTRUCTION +PROMPT_4 += LATEX_JSON_INSTRUCTION + + def find_file(folder: Path, base_name: str) -> Path | None: for ext in [".org", ".tex"]: path = folder / f"{base_name}{ext}" @@ -202,10 +230,10 @@ def generate_rubrics(client, group_context_text: str) -> dict[str, str]: response_json_schema=GroupRubrics.model_json_schema(), ), ) - rubrics = GroupRubrics.model_validate_json(response.text).rubrics + rubrics = GroupRubrics.model_validate_json(repair_latex_json(response.text)).rubrics if len({item.label for item in rubrics}) != len(rubrics): raise ValueError("Gemini returned duplicate rubric labels") - return {item.label: item.rubric_content for item in rubrics} + return {item.label: validate_rubric_content(item.rubric_content) for item in rubrics} def validate_groups(groups: list[list[str]], labels: list[str]) -> None: @@ -364,7 +392,7 @@ def process_exam( print("Saving questions to cache...") atomic_write_text(cache_q_file, response_q_text) - questions_data = ExamQuestions.model_validate_json(response_q_text) + questions_data = ExamQuestions.model_validate_json(repair_latex_json(response_q_text)) # ========================================== # REQUÊTE 2 : Extraction des Corrections @@ -403,7 +431,7 @@ def process_exam( print("Saving solutions to cache...") atomic_write_text(cache_s_file, response_s_text) - solutions_data = ExamSolutions.model_validate_json(response_s_text) + solutions_data = ExamSolutions.model_validate_json(repair_latex_json(response_s_text)) # ========================================== # REQUÊTE 3 : Extraction du Contexte (Notations, etc.) @@ -440,7 +468,7 @@ def process_exam( print("Saving context to cache...") atomic_write_text(cache_c_file, response_c_text) - context_data = ExamContext.model_validate_json(response_c_text) + context_data = ExamContext.model_validate_json(repair_latex_json(response_c_text)) # ========================================== # FUSION des trois résultats diff --git a/copienator_gui/app.py b/copienator_gui/app.py index d7e2a81..df5d299 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -1551,8 +1551,7 @@ class CopienatorApp(tk.Tk): if self.current_step and self.current_step.optional: step_id = self.current_step.id self._mark_step("skipped") - if step_id.startswith("refaire_"): - self._move_selection_from(step_id, 1) + self._move_selection_from(step_id, 1) def _poll_runner(self) -> None: while True: diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index 14cbfba..ad447af 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -190,7 +190,9 @@ class GuiConvenienceTests(unittest.TestCase): command = self.app._make_command() self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"]) self.app._skip_step() + self.app.update() self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped") + self.assertNotEqual(self.app.current_step.id, "crop_blank_margins") def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self): answers = self.evaluation / "Copies" / "Copie01" @@ -207,9 +209,11 @@ class GuiConvenienceTests(unittest.TestCase): ["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"], ) self.app._skip_step() + self.app.update() self.assertEqual( self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped" ) + self.assertNotEqual(self.app.current_step.id, "crop_exercise_bottoms") def test_redo_targets_selected_copy_and_copies_runnable_command(self): for folder in ("Copies", "Copies Originales"): diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py index 1269a46..a17836a 100644 --- a/tests/test_statement_personal_flow.py +++ b/tests/test_statement_personal_flow.py @@ -134,6 +134,42 @@ class SelectiveGeminiTests(unittest.TestCase): gemini.refine_existing(self.workspace, "persp", api_client=self.client) self.assertEqual(self.snapshot(), before) + def test_rubric_latex_and_line_breaks_survive_json_and_file_writes(self): + expected = ( + r'Barème : $f \circ g \rightarrow \frac{1}{2} \not= 0$.' + '\n' + r'$\begin{matrix}a & b \\ c & d\end{matrix}$, $\theta$, $\beta$, $\nu$.' + '\nUne "justification" vaut 1 point.' + ) + self.client.models.generate_content.side_effect = [ + self.response({'rubrics': [{'label': label, 'rubric_content': expected} for label in group]}) + for group in (self.labels[:2], self.labels[2:]) + ] + gemini.refine_existing(self.workspace, 'persp', api_client=self.client) + for label in self.labels: + self.assertEqual((self.root / 'Persp' / label).read_bytes(), + (label + '\n' + expected).encode('utf-8')) + + def test_invalid_latex_escapes_are_repaired_without_retry(self): + for command in (r'\circ', r'\rightarrow', r'\frac', r'\not', r'\theta', r'\beta'): + with self.subTest(command=command): + # Deliberately malformed wire JSON: do not json.dumps the command. + broken = SimpleNamespace(text='{"rubrics":[{"label":"Ex 2","rubric_content":"$' + + command + '$"}]}') + self.client.models.generate_content.reset_mock() + self.client.models.generate_content.side_effect = [broken] + self.assertEqual(gemini.generate_rubrics(self.client, 'context'), + {'Ex 2': '$' + command + '$'}) + self.assertEqual(self.client.models.generate_content.call_count, 1) + + def test_unrepairable_control_character_preserves_all_existing_rubrics(self): + before = self.snapshot() + broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]}) + self.client.models.generate_content.side_effect = [ + self.rubric_response(self.labels[:2]), broken] + with self.assertRaisesRegex(ValueError, 'control characters'): + gemini.refine_existing(self.workspace, 'persp', api_client=self.client) + self.assertEqual(self.snapshot(), before) + def test_cli_dispatches_selective_modes_without_full_extraction(self): with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object( gemini, "process_exam"