Recorrection GUI step ; LaTeX issues debugging

This commit is contained in:
sebastien committed 2026-10-10 20:40:48 +02:00
1 parent 2d0f7a219f
commit 48602c24f1
12 files changed
+306 -8

No files matched your search

+47 -5
View File
@@ -4,6 +4,7 @@ import argparse
import io import io
import os import os
import re import re
import shutil
import subprocess import subprocess
import tempfile import tempfile
from collections.abc import Sequence from collections.abc import Sequence
@@ -175,6 +176,10 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
final_img.alpha_composite(img) final_img.alpha_composite(img)
return final_img return final_img
class AnnotationLatexError(RuntimeError):
"""A feedback comment failed to render; diagnostics have been retained."""
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19): def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
dpi = 100 dpi = 100
width_in = width_px / dpi width_in = width_px / dpi
@@ -194,16 +199,37 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
f.write(latex_template) f.write(latex_template)
# Compile to PDF # Compile to PDF
subprocess.run( compilation = subprocess.run(
['pdflatex', '-interaction=nonstopmode', 'text.tex'], ['pdflatex', '-interaction=nonstopmode', 'text.tex'],
cwd=temp_dir, cwd=temp_dir,
stdout=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, stderr=subprocess.STDOUT,
check=False, check=False,
) )
if not os.path.exists(pdf_path): if not os.path.exists(pdf_path):
raise RuntimeError("LaTeX compilation failed. Check your LaTeX syntax.") diagnostics = Path(tempfile.mkdtemp(prefix="copienator-latex-error-"))
shutil.copy2(tex_path, diagnostics / "text.tex")
log_path = Path(temp_dir) / "text.log"
output = (compilation.stdout or b"").decode("utf-8", errors="replace")
(diagnostics / "compiler-output.txt").write_text(output, encoding="utf-8")
if log_path.exists():
shutil.copy2(log_path, diagnostics / "text.log")
log = log_path.read_text(encoding="utf-8", errors="replace")
else:
log = output
lines = log.splitlines()
first_error = next((i for i, line in enumerate(lines) if line.startswith("!")), max(0, len(lines) - 20))
excerpt = "\n".join(lines[first_error:first_error + 25])
raise AnnotationLatexError(
f"Échec de compilation LaTeX (code {compilation.returncode}).\n"
f"Commentaire LaTeX fautif :\n{text}\n\n"
f"Extrait du journal :\n{excerpt}\n\n"
f"Source LaTeX complète : {diagnostics / 'text.tex'}\n"
f"Diagnostics conservés dans : {diagnostics}\n"
"Modifiez le commentaire concerné dans correction.json, enregistrez le fichier, "
"puis relancez cette étape de génération des annotations."
)
# Convert PDF to grayscale (ignoring pdf2image's broken transparency) # Convert PDF to grayscale (ignoring pdf2image's broken transparency)
images = convert_from_path(pdf_path, dpi=dpi) images = convert_from_path(pdf_path, dpi=dpi)
@@ -295,7 +321,7 @@ def compose_label_image(base_img, label, result, hmin,
with_error=True, with_error=True,
with_empty=False, with_empty=False,
more_right=False, more_right=False,
with_id=None): with_id=None, source_pdf=None):
""" """
Composes the final image with annotations. Composes the final image with annotations.
@@ -309,6 +335,21 @@ def compose_label_image(base_img, label, result, hmin,
called when elements are placed. Used for checkboxes. called when elements are placed. Used for checkboxes.
""" """
original_render = render_fn
def render_fn(text, *args, **kwargs):
try:
return original_render(text, *args, **kwargs)
except AnnotationLatexError as exc:
context = f"Question : {label}"
if source_pdf is not None:
source = Path(source_pdf)
context = f"Copie : {source.parent.name} — {context}\nPDF source : {source}"
context += f"\nFichier à modifier : {source.parent.parent.parent / 'correction.json'}"
elif with_id is not None:
context = f"Copie : {with_id} — {context}"
raise AnnotationLatexError(f"{context}\n{exc}") from exc
left_pad = 0 left_pad = 0
if base_img.width < TARGET_MIN_WIDTH: if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width total_missing = TARGET_MIN_WIDTH - base_img.width
@@ -489,6 +530,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
coordinates[0], coordinates[0],
with_empty=True, with_empty=True,
render_fn=render_real_latex_text, render_fn=render_real_latex_text,
source_pdf=pdf_full_path,
) )
final_img.save(staging / f"{label}.jpg") final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer": if result.get('error', "") != "empty-answer":
@@ -47,6 +47,7 @@ def render_item(item):
draw_callback=checkbox_renderer.callback, draw_callback=checkbox_renderer.callback,
more_right=True, more_right=True,
with_id=student_id, with_id=student_id,
source_pdf=pdf_path,
) )
if final_image is None: if final_image is None:
return None return None
@@ -184,6 +184,7 @@ def _render_student(
content["result"], content["result"],
content["coordinates"][0], content["coordinates"][0],
draw_callback=checkbox_renderer.callback, draw_callback=checkbox_renderer.callback,
source_pdf=pdf_path,
) )
if final_image is None: if final_image is None:
problems = True problems = True
+8
View File
@@ -23,6 +23,14 @@ def read_json(path: str | Path, *, default: T | object = _MISSING) -> T:
target = Path(path) target = Path(path)
try: try:
with target.open("r", encoding="utf-8") as stream: with target.open("r", encoding="utf-8") as stream:
if target.name == "refaire.json":
# This user-edited selection supports whole-line // comments.
# Preserve line numbers in JSON diagnostics and keep other JSON strict.
text = "".join(
"\n" if line.lstrip().startswith("//") else line
for line in stream
)
return cast(T, json.loads(text))
return cast(T, json.load(stream)) return cast(T, json.load(stream))
except FileNotFoundError: except FileNotFoundError:
if default is _MISSING: if default is _MISSING:
+29
View File
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
from .notifications import notify_desktop from .notifications import notify_desktop
from .syncthing import SyncthingPanel from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel from .manual_resolution import ManualResolutionPanel
from .recorrection import prepare_selection_file
from .refaire import SECTION as REFAIRE_SECTION from .refaire import SECTION as REFAIRE_SECTION
from .refaire import ( from .refaire import (
RefaireSelection, RefaireSelection,
@@ -930,6 +931,14 @@ class CopienatorApp(tk.Tk):
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory()) self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
self._refresh_persp_inventory() self._refresh_persp_inventory()
if step.id == "prepare_recorrection":
self.edit_recorrection_button = ttk.Button(
self.form, text="Ouvrir refaire.json (avec exemples)",
command=self._edit_recorrection,
)
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
row += 1
if step.id == "manual_resolution": if step.id == "manual_resolution":
def manual_evaluation(): def manual_evaluation():
target = self.arg_vars.get("target") target = self.arg_vars.get("target")
@@ -1117,6 +1126,7 @@ class CopienatorApp(tk.Tk):
"cutleft", "cutleft",
"labels", "labels",
"plotting", "plotting",
"splitting",
} }
if step.id in copy_selection_steps: if step.id in copy_selection_steps:
evaluation = self.evaluation evaluation = self.evaluation
@@ -1310,6 +1320,16 @@ class CopienatorApp(tk.Tk):
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture impossible", str(exc)) messagebox.showerror("Ouverture impossible", str(exc))
def _edit_recorrection(self) -> None:
if self.evaluation is None:
return
try:
path = prepare_selection_file(self.evaluation)
except OSError as exc:
messagebox.showerror("Préparation impossible", str(exc))
return
self._open_desktop_path(path, "fichier refaire.json")
def _select_variant(self, index: int) -> None: def _select_variant(self, index: int) -> None:
if not self.current_step or index < 0: if not self.current_step or index < 0:
return return
@@ -1546,6 +1566,15 @@ class CopienatorApp(tk.Tk):
if step.id == "refaire_selection": if step.id == "refaire_selection":
self._save_refaire_selection() self._save_refaire_selection()
return return
if step.id == "prepare_recorrection":
try:
load_selection(evaluation)
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
return
self._mark_step("success")
self._select_refaire_correction()
return
if step.section == REFAIRE_SECTION: if step.section == REFAIRE_SECTION:
try: try:
self._refaire_selection() self._refaire_selection()
+25
View File
@@ -0,0 +1,25 @@
"""Editable examples for the optional correction redo step."""
from pathlib import Path
from copienator import atomic_write_text
EXAMPLES = """// Exemples de sélection pour refaire la correction
// Les lignes commençant par // sont des commentaires et sont ignorées.
// Modifiez la liste JSON ci-dessous, puis enregistrez le fichier.
// Une copie entière : [["Copie46", []]]
// Certaines questions : [["Copie46", ["II)8)", "II)9)"]]
// Plusieurs copies : [["Copie46", []], ["Copie03", ["Ex 1"]]]
// Utilisez les noms exacts des copies et des labels de votre évaluation.
// [] après une copie signifie toutes ses questions ; [] seul ne sélectionne rien.
// Séparez les entrées par des virgules, sans virgule après la dernière.
"""
def prepare_selection_file(evaluation: Path) -> Path:
path = evaluation / "refaire.json"
existing = path.read_text(encoding="utf-8") if path.exists() else "[]\n"
if not existing.startswith(EXAMPLES):
atomic_write_text(path, EXAMPLES + existing)
return path
+15 -2
View File
@@ -371,12 +371,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"splitting", "splitting",
"Labels et regroupement", "Labels et regroupement",
"Découper les réponses par question", "Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.", "Découpe les copies à partir des coordonnées de labels vérifiées. "
"Ciblez une copie avec le sélecteur ou tout le dossier, puis cliquez sur Exécuter.",
(python("default", "Découpage", "split-answers"),), (python("default", "Découpage", "split-answers"),),
arguments=(arg_target(),), arguments=(arg_target(),),
requires=("Copies",), requires=("Copies",),
artifacts=("Copies/Copie*/*",), artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
), ),
StepDefinition( StepDefinition(
"crop_exercise_bottoms", "crop_exercise_bottoms",
@@ -511,6 +511,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
requires=("manual_resolutions.txt", "correction.json"), requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True, skip_without_manual_conflicts=True,
), ),
StepDefinition(
"prepare_recorrection",
"Correction",
"Choisir des copies à recorriger",
"Facultatif : ouvrez refaire.json et indiquez les copies entières ou les questions à recorriger. "
"Les exemples commentés expliquent la syntaxe. Enregistrez le fichier, puis marquez cette étape terminée : "
"vous reviendrez à Correction avec --refaire sélectionné, sans lancement automatique. "
"Les corrections sélectionnées seront remplacées ; celles des autres copies seront conservées. "
"Si vous avez modifié les découpes, régénérez les réponses avant la recorrection. "
"Ignorez cette étape si aucune reprise n’est nécessaire.",
(manual("default"),),
optional=True,
),
StepDefinition( StepDefinition(
"post_correction", "post_correction",
"Correction", "Correction",
+25
View File
@@ -251,6 +251,31 @@ coupure, et quelle moitié doit être gardée. L'interface graphique vous
fournira alors une instruction texte à copier et à mettre dans le fournira alors une instruction texte à copier et à mettre dans le
fichier texte. fichier texte.
Vous pouvez aussi ajouter vos propres instructions dans
`manual_resolutions.txt`, même si Gemini n’a pas signalé ces conflits.
Les lignes sont appliquées **de haut en bas**, avec une distinction importante :
- La **source** est toujours le PDF tel qu’il existait au début de cette
exécution, avant les modifications des lignes précédentes.
- La **cible** est sa version courante, tenant compte des lignes précédentes.
Par exemple, pour une copie ayant les labels `A`, `B` et `C` :
```text
Copie46 A c{40}1> B|
Copie46 C -> A|
```
La première ligne coupe A à 40 % de sa hauteur cumulée, conserve la première
partie sous le label A et ajoute les 60 % restants après B. La seconde ligne
ajoute C après **la partie conservée de A**, et non après A entier. C reste
également disponible sous son propre label.
En revanche, si une ligne ultérieure utilise **A comme source**, elle utilise
le PDF A entier du début de l’exécution, pas seulement sa partie conservée.
Une seule coupe par label source et par copie est autorisée dans une même
exécution. Remplacez `A`, `B` et `C` par les labels exacts de votre évaluation.
Enregistrez le fichier texte, éventuellement le **Recharger**, puis Enregistrez le fichier texte, éventuellement le **Recharger**, puis
**Exécuter**. **Exécuter**.
+58
View File
@@ -0,0 +1,58 @@
import tempfile
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import Mock, patch
from PIL import Image
from copienator.commands import annotating
class AnnotationLatexErrorTests(unittest.TestCase):
def test_failure_preserves_source_and_log_and_explains_recovery(self):
with tempfile.TemporaryDirectory() as directory:
diagnostics = Path(directory) / "diagnostics"
diagnostics.mkdir()
def compile_latex(command, **kwargs):
Path(kwargs["cwd"], "text.log").write_bytes(
b"preamble\n! Missing \\endcsname inserted.\nl.12 \\end{document}\n\xff"
)
return CompletedProcess(command, 1, stdout=b"compiler output\xff")
# Patch only the retained diagnostics directory, not TemporaryDirectory.
original_mkdtemp = tempfile.mkdtemp
def make_directory(*args, **kwargs):
if kwargs.get("prefix") == "copienator-latex-error-":
return str(diagnostics)
return original_mkdtemp(*args, **kwargs)
with patch.object(annotating.subprocess, "run", side_effect=compile_latex), patch.object(
annotating.tempfile, "mkdtemp", side_effect=make_directory
):
with self.assertRaises(annotating.AnnotationLatexError) as caught:
annotating.render_real_latex_text(r"Antislash \.", 400)
message = str(caught.exception)
for expected in ("code 1", r"Antislash \.", "Missing", "correction.json", "relancez cette étape", str(diagnostics)):
self.assertIn(expected, message)
self.assertIn(r"Antislash \.", (diagnostics / "text.tex").read_text())
self.assertTrue((diagnostics / "text.log").exists())
self.assertTrue((diagnostics / "compiler-output.txt").exists())
def test_comment_error_identifies_copy_label_and_correction_file(self):
for box in (None, [0, 0, 20, 20]):
with self.subTest(box=box), patch.object(
annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))
):
with self.assertRaises(annotating.AnnotationLatexError) as caught:
annotating.compose_label_image(
Image.new("RGB", (800, 200)), "I)7)",
{"feedback": [{"text": "Bad comment", "box_2d": box}]}, 0,
render_fn=Mock(side_effect=annotating.AnnotationLatexError("Bad comment")),
source_pdf=Path("/exam/Copies/Copie39/I)7).pdf"),
)
message = str(caught.exception)
for expected in ("Copie39", "I)7)", "/exam/correction.json", "Bad comment"):
self.assertIn(expected, message)
+62
View File
@@ -214,6 +214,49 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertNotIn("overwrite", self.app.arg_vars) self.assertNotIn("overwrite", self.app.arg_vars)
self.assertIn("--refaire", self.app._make_command()) self.assertIn("--refaire", self.app._make_command())
def test_optional_recorrection_opens_examples_and_returns_to_correction(self):
from copienator import read_json
ids = [step.id for step in self.app.steps]
self.assertEqual(ids[ids.index("manual_resolution") + 1], "prepare_recorrection")
copies = self.evaluation / "Copies"
copies.mkdir()
(copies / "Copie46.pdf").touch()
(self.evaluation / "labels").write_text("II)9)\n")
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
with patch("copienator_gui.app.open_path") as opened:
self.app.edit_recorrection_button.invoke()
path = self.evaluation / "refaire.json"
opened.assert_called_once_with(path)
self.assertEqual(read_json(path), [])
path.write_text(path.read_text().replace("\n[]\n", '\n[["Copie46", []]]\n'))
with patch.object(self.app.runner, "start") as start:
self.app.run_button.invoke()
self.app.update()
start.assert_not_called()
self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "success")
self.assertEqual(self.app.current_step.id, "correction")
self.assertEqual(self.app.variant_var.get(), "refaire")
self.assertIn("--refaire", self.app._make_command())
self.assertNotIn("--overwrite", self.app._make_command())
def test_ignoring_optional_recorrection_continues_without_creating_file(self):
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
self.app._skip_step()
self.app.update()
self.assertEqual(self.app.current_step.id, "post_correction")
self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "skipped")
self.assertFalse((self.evaluation / "refaire.json").exists())
def test_optional_recorrection_requires_a_saved_selection(self):
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
with patch("copienator_gui.app.messagebox.showerror") as error:
self.app.run_button.invoke()
error.assert_called_once()
self.assertEqual(self.app.current_step.id, "prepare_recorrection")
def test_giving_names_waits_for_explicit_manual_completion(self): def test_giving_names_waits_for_explicit_manual_completion(self):
return_dir = self.evaluation / "A Rendre" / "Student (01)" return_dir = self.evaluation / "A Rendre" / "Student (01)"
return_dir.mkdir(parents=True) return_dir.mkdir(parents=True)
@@ -560,6 +603,25 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertIn("Cibler la copie sélectionnée", controls) self.assertIn("Cibler la copie sélectionnée", controls)
self.assertIn("Cibler tout le dossier", controls) self.assertIn("Cibler tout le dossier", controls)
def test_answer_splitting_waits_for_target_and_can_select_one_copy(self):
copies = self.evaluation / "Copies"
copies.mkdir()
for name in ("Copie01.pdf", "Copie46.pdf"):
(copies / name).touch()
with patch.object(self.app, "_run_current_step") as run:
self.app.tree.selection_set("splitting")
self.app.update()
run.assert_not_called()
self.assertIn("Copie46.pdf", self.app.copy_paths)
self.app.copy_var.set("Copie46.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertIn("split-answers", command)
self.assertEqual(Path(command[5]), copies / "Copie46.pdf")
self.app._target_all_pages()
command = self.app._make_command()
self.assertEqual((self.repository / command[5]).resolve(), self.evaluation)
def test_label_review_can_show_and_target_one_copy(self): def test_label_review_can_show_and_target_one_copy(self):
copies = self.evaluation / "Copies" copies = self.evaluation / "Copies"
copies.mkdir() copies.mkdir()
+1 -1
View File
@@ -2378,7 +2378,7 @@ class WorkflowTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
auto_start, auto_start,
{"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"}, {"crop_blank_margins", "crop_exercise_bottoms", "grouping"},
) )
self.assertEqual( self.assertEqual(
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"} skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
+34
View File
@@ -0,0 +1,34 @@
import json
import tempfile
import unittest
from pathlib import Path
from copienator import read_json
from copienator_gui.recorrection import prepare_selection_file
class RecorrectionFileTests(unittest.TestCase):
def test_examples_are_inactive_and_opening_preserves_selection(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = prepare_selection_file(root)
self.assertEqual(read_json(path), [])
self.assertIn('// Une copie entière', path.read_text())
selection = [["Copie46", ["II)9)"]]]
path.write_text(json.dumps(selection))
prepare_selection_file(root)
self.assertEqual(read_json(path), selection)
before = path.read_bytes()
prepare_selection_file(root)
self.assertEqual(path.read_bytes(), before)
def test_comments_do_not_affect_strings_or_other_json_files(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "refaire.json"
path.write_text('// Example\n[["Copie01", ["a//b"]]]\n')
self.assertEqual(read_json(path), [["Copie01", ["a//b"]]])
other = root / "correction.json"
other.write_text(path.read_text())
with self.assertRaises(json.JSONDecodeError):
read_json(other)