Recorrection GUI step ; LaTeX issues debugging
This commit is contained in:
1 parent
2d0f7a219f
commit
48602c24f1
12 files changed
+306
-8
No files matched your search
@@ -4,6 +4,7 @@ import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
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)
|
||||
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):
|
||||
dpi = 100
|
||||
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)
|
||||
|
||||
# Compile to PDF
|
||||
subprocess.run(
|
||||
compilation = subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
|
||||
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)
|
||||
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_empty=False,
|
||||
more_right=False,
|
||||
with_id=None):
|
||||
with_id=None, source_pdf=None):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
if base_img.width < TARGET_MIN_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],
|
||||
with_empty=True,
|
||||
render_fn=render_real_latex_text,
|
||||
source_pdf=pdf_full_path,
|
||||
)
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
|
||||
@@ -47,6 +47,7 @@ def render_item(item):
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
more_right=True,
|
||||
with_id=student_id,
|
||||
source_pdf=pdf_path,
|
||||
)
|
||||
if final_image is None:
|
||||
return None
|
||||
|
||||
@@ -184,6 +184,7 @@ def _render_student(
|
||||
content["result"],
|
||||
content["coordinates"][0],
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
source_pdf=pdf_path,
|
||||
)
|
||||
if final_image is None:
|
||||
problems = True
|
||||
|
||||
@@ -23,6 +23,14 @@ def read_json(path: str | Path, *, default: T | object = _MISSING) -> T:
|
||||
target = Path(path)
|
||||
try:
|
||||
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))
|
||||
except FileNotFoundError:
|
||||
if default is _MISSING:
|
||||
|
||||
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
|
||||
from .notifications import notify_desktop
|
||||
from .syncthing import SyncthingPanel
|
||||
from .manual_resolution import ManualResolutionPanel
|
||||
from .recorrection import prepare_selection_file
|
||||
from .refaire import SECTION as REFAIRE_SECTION
|
||||
from .refaire import (
|
||||
RefaireSelection,
|
||||
@@ -930,6 +931,14 @@ class CopienatorApp(tk.Tk):
|
||||
self.arg_vars['target'].trace_add('write', lambda *_args: 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":
|
||||
def manual_evaluation():
|
||||
target = self.arg_vars.get("target")
|
||||
@@ -1117,6 +1126,7 @@ class CopienatorApp(tk.Tk):
|
||||
"cutleft",
|
||||
"labels",
|
||||
"plotting",
|
||||
"splitting",
|
||||
}
|
||||
if step.id in copy_selection_steps:
|
||||
evaluation = self.evaluation
|
||||
@@ -1310,6 +1320,16 @@ class CopienatorApp(tk.Tk):
|
||||
except (OSError, RuntimeError) as 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:
|
||||
if not self.current_step or index < 0:
|
||||
return
|
||||
@@ -1546,6 +1566,15 @@ class CopienatorApp(tk.Tk):
|
||||
if step.id == "refaire_selection":
|
||||
self._save_refaire_selection()
|
||||
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:
|
||||
try:
|
||||
self._refaire_selection()
|
||||
|
||||
@@ -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
|
||||
@@ -371,12 +371,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"splitting",
|
||||
"Labels et regroupement",
|
||||
"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"),),
|
||||
arguments=(arg_target(),),
|
||||
requires=("Copies",),
|
||||
artifacts=("Copies/Copie*/*",),
|
||||
auto_start_first_visit=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"crop_exercise_bottoms",
|
||||
@@ -511,6 +511,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
requires=("manual_resolutions.txt", "correction.json"),
|
||||
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(
|
||||
"post_correction",
|
||||
"Correction",
|
||||
|
||||
@@ -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
|
||||
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
|
||||
**Exécuter**.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -214,6 +214,49 @@ class GuiConvenienceTests(unittest.TestCase):
|
||||
self.assertNotIn("overwrite", self.app.arg_vars)
|
||||
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):
|
||||
return_dir = self.evaluation / "A Rendre" / "Student (01)"
|
||||
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 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):
|
||||
copies = self.evaluation / "Copies"
|
||||
copies.mkdir()
|
||||
|
||||
@@ -2378,7 +2378,7 @@ class WorkflowTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
auto_start,
|
||||
{"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"},
|
||||
{"crop_blank_margins", "crop_exercise_bottoms", "grouping"},
|
||||
)
|
||||
self.assertEqual(
|
||||
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user