From 4c56b655fe32a229142ada2ff0b9e594ff42957b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Sat, 26 Sep 2026 11:50:22 +0200 Subject: [PATCH] Escape some newlines in question extraction --- copienator/commands/gemini_for_enonce.py | 30 ++++++++++++++++-------- copienator/utils.py | 18 +++++++++----- tests/test_utils_compile.py | 10 ++++++++ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index 058b542..c4aa68a 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -8,7 +8,7 @@ from pathlib import Path from google import genai from google.genai import types -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from copienator import configuration as config from copienator import utils @@ -22,7 +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.latex_json import repair_latex_json, normalize_literal_newlines from copienator.filesystem import staged_directory from copienator.utils import compile_to_pdf @@ -43,7 +43,15 @@ MODEL_ID = config.MODEL_LITE_ID api_key = config.API_KEY # --- Modèles pour la Requête 1 --- -class QuestionOnlyItem(BaseModel): +class StatementContent(BaseModel): + @field_validator("question_content", "solution_content", "context_content", + "rubric_content", "content", check_fields=False) + @classmethod + def normalize_newlines(cls, value: str) -> str: + return normalize_literal_newlines(value) + + +class QuestionOnlyItem(StatementContent): label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').") question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.") @@ -51,7 +59,7 @@ class ExamQuestions(BaseModel): questions: list[QuestionOnlyItem] # --- Modèles pour la Requête 2 --- -class SolutionOnlyItem(BaseModel): +class SolutionOnlyItem(StatementContent): label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.") solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.") @@ -59,7 +67,7 @@ class ExamSolutions(BaseModel): solutions: list[SolutionOnlyItem] # --- Modèles pour la Requête 3 --- -class ExtractedContext(BaseModel): +class ExtractedContext(StatementContent): target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.") last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.") context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.") @@ -68,7 +76,7 @@ class ExamContext(BaseModel): contexts: list[ExtractedContext] # --- Modèles pour la Requête 4 (Barèmes) --- -class RubricItem(BaseModel): +class RubricItem(StatementContent): label: str = Field(description="Label exact de la question, à conserver sans traduction.") 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.") @@ -110,6 +118,8 @@ Exemple d’échappement dans un barème (adapte les clés au schéma demandé) 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. +N'écris jamais \\n pour un saut de ligne : après décodage JSON, le texte +doit contenir un véritable saut de ligne, pas une commande LaTeX \n. """ @@ -124,12 +134,12 @@ def validate_rubric_content(content: str) -> str: return content # --- Modèle fusionné (pour le reste du script) --- -class QuestionItem(BaseModel): +class QuestionItem(StatementContent): label: str question_content: str solution_content: str -class ContextItem(BaseModel): +class ContextItem(StatementContent): target_question_label: str last_question_label: str content: str # Juste une string encapsulée pour le différencier facilement @@ -915,7 +925,7 @@ def process_exam( with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text: f_text.write("\n".join(text_content_lines)) - print(f"Success! Processed {len(grouped_extraction.groups)} groups.") + print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...") # ========================================== # PDF COMPILATION (4 Threads) # ========================================== @@ -926,7 +936,7 @@ def process_exam( try: content = tex_file.read_text(encoding="utf-8") pdf_path = tex_file.with_suffix(".pdf") - compile_to_pdf(content, pdf_path) + compile_to_pdf(content, pdf_path, strict=True) except Exception as e: # noqa: BLE001 - compiler worker boundary return f"Error compiling {tex_file.name}: {e}" return None diff --git a/copienator/utils.py b/copienator/utils.py index d0ba2c9..3009227 100644 --- a/copienator/utils.py +++ b/copienator/utils.py @@ -142,7 +142,7 @@ import subprocess from .configuration import LATEX_AFTER, LATEX_BEFORE -def compile_to_pdf(text, output_pdf_path): +def compile_to_pdf(text, output_pdf_path, *, strict=False): """Wraps text in standalone header/footer templates and compiles it to PDF.""" latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}" @@ -166,6 +166,7 @@ def compile_to_pdf(text, output_pdf_path): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", errors="replace", check=False ) if "minted" in text: @@ -175,6 +176,7 @@ def compile_to_pdf(text, output_pdf_path): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", errors="replace", check=False) generated_pdf = os.path.join(temp_dir, pdf_filename) @@ -183,17 +185,21 @@ def compile_to_pdf(text, output_pdf_path): shutil.move(generated_pdf, output_pdf_path) if result.returncode != 0: - error_lines = [ - line.strip() for line in result.stdout.splitlines() - if line.lstrip().startswith("!") - ] - detail = f": {error_lines[0]}" if error_lines else "" + lines = result.stdout.splitlines() + first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None) + detail = "\n" + "\n".join(lines[first:first + 5]) if first is not None else "" pdf_status = "a PDF was generated" if pdf_generated else "no PDF was generated" print( f"Warning: LaTeX compilation reported errors for {output_pdf_path} " f"(exit code {result.returncode}; {pdf_status}){detail}" ) + if strict: + raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}") elif not pdf_generated: print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}") + if strict: + raise RuntimeError(f"No PDF generated for {output_pdf_path}") except Exception as e: + if strict: + raise print(f"Compilation error for {output_pdf_path}: {e}") diff --git a/tests/test_utils_compile.py b/tests/test_utils_compile.py index abf48f1..1dd515f 100644 --- a/tests/test_utils_compile.py +++ b/tests/test_utils_compile.py @@ -10,6 +10,16 @@ from copienator import utils class CompileToPdfTests(unittest.TestCase): + def test_strict_compilation_propagates_failure_with_context(self): + result = subprocess.CompletedProcess(args=[], returncode=1, + stdout="! Undefined control sequence.\nl.13 broken command\n") + with tempfile.TemporaryDirectory() as directory, \ + patch.object(utils.subprocess, "run", return_value=result) as run: + with self.assertRaisesRegex(RuntimeError, "LaTeX failed") as error: + utils.compile_to_pdf("broken", Path(directory) / "out.pdf", strict=True) + self.assertIn("l.13 broken command", str(error.exception)) + self.assertEqual(run.call_args.kwargs["errors"], "replace") + def test_warns_when_latex_fails_even_if_partial_pdf_exists(self): with tempfile.TemporaryDirectory() as temporary: output = Path(temporary) / "result.pdf"