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.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
+12 -6
View File
@@ -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}")
+10
View File
@@ -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"