Escape some newlines in question extraction
This commit is contained in:
1 parent
e4e25eca55
commit
4c56b655fe
3 files changed
+42
-16
No files matched your search
@@ -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
@@ -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,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"
|
||||||
|
|||||||
Reference in new issue
Block a user