Escape some newlines in question extraction

This commit is contained in:
sebastien committed 2026-09-26 11:50:22 +02:00
1 parent e4e25eca55
commit 4c56b655fe
3 files changed
+42 -16

No files matched your search

+20 -10
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from google import genai from google import genai
from google.genai import types 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 configuration as config
from copienator import utils from copienator import utils
@@ -22,7 +22,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.platform import validate_windows_labels 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.filesystem import staged_directory
from copienator.utils import compile_to_pdf from copienator.utils import compile_to_pdf
@@ -43,7 +43,15 @@ MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY api_key = config.API_KEY
# --- Modèles pour la Requête 1 --- # --- 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').") 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.") 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] questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 --- # --- 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.") 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.") 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] solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 --- # --- 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é.") 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.") 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é.") 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] contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) --- # --- 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.") 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.") 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 : Ne confonds pas les commandes LaTeX avec les échappements JSON :
\\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ; \\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ;
\n est réservé à un véritable saut de ligne du texte. \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 return content
# --- Modèle fusionné (pour le reste du script) --- # --- Modèle fusionné (pour le reste du script) ---
class QuestionItem(BaseModel): class QuestionItem(StatementContent):
label: str label: str
question_content: str question_content: str
solution_content: str solution_content: str
class ContextItem(BaseModel): class ContextItem(StatementContent):
target_question_label: str target_question_label: str
last_question_label: str last_question_label: str
content: str # Juste une string encapsulée pour le différencier facilement 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: with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
f_text.write("\n".join(text_content_lines)) 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) # PDF COMPILATION (4 Threads)
# ========================================== # ==========================================
@@ -926,7 +936,7 @@ def process_exam(
try: try:
content = tex_file.read_text(encoding="utf-8") content = tex_file.read_text(encoding="utf-8")
pdf_path = tex_file.with_suffix(".pdf") 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 except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}" return f"Error compiling {tex_file.name}: {e}"
return None return None
+12 -6
View File
@@ -142,7 +142,7 @@ import subprocess
from .configuration import LATEX_AFTER, LATEX_BEFORE 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.""" """Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}" latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}"
@@ -166,6 +166,7 @@ def compile_to_pdf(text, output_pdf_path):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
encoding="utf-8", errors="replace",
check=False check=False
) )
if "minted" in text: if "minted" in text:
@@ -175,6 +176,7 @@ def compile_to_pdf(text, output_pdf_path):
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
encoding="utf-8", errors="replace",
check=False) check=False)
generated_pdf = os.path.join(temp_dir, pdf_filename) 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) shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0: if result.returncode != 0:
error_lines = [ lines = result.stdout.splitlines()
line.strip() for line in result.stdout.splitlines() first = next((i for i, line in enumerate(lines) if line.lstrip().startswith("!")), None)
if line.lstrip().startswith("!") detail = "\n" + "\n".join(lines[first:first + 5]) if first is not None else ""
]
detail = f": {error_lines[0]}" if error_lines else ""
pdf_status = "a PDF was generated" if pdf_generated else "no PDF was generated" pdf_status = "a PDF was generated" if pdf_generated else "no PDF was generated"
print( print(
f"Warning: LaTeX compilation reported errors for {output_pdf_path} " f"Warning: LaTeX compilation reported errors for {output_pdf_path} "
f"(exit code {result.returncode}; {pdf_status}){detail}" f"(exit code {result.returncode}; {pdf_status}){detail}"
) )
if strict:
raise RuntimeError(f"LaTeX failed for {output_pdf_path}{detail}")
elif not pdf_generated: elif not pdf_generated:
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}") 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: except Exception as e:
if strict:
raise
print(f"Compilation error for {output_pdf_path}: {e}") print(f"Compilation error for {output_pdf_path}: {e}")
+10
View File
@@ -10,6 +10,16 @@ from copienator import utils
class CompileToPdfTests(unittest.TestCase): 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): def test_warns_when_latex_fails_even_if_partial_pdf_exists(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "result.pdf" output = Path(temporary) / "result.pdf"