From 705db1068f1a62114469406fd6f1069ab960a943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Sat, 10 Oct 2026 10:01:16 +0200 Subject: [PATCH] Personal tex compilation fixes --- Readme.org | 18 ++++++++ copienator/commands/gemini_for_enonce.py | 37 +++++++++++----- copienator/utils.py | 42 ++++++++++++++++-- copienator/workspace.py | 17 ++++--- copienator_gui/workflow.py | 6 ++- tests/test_gui_core.py | 35 ++++++++++++++- tests/test_statement_resume.py | 27 ++++++++++++ tests/test_utils_compile.py | 56 ++++++++++++++++++++++++ 8 files changed, 217 insertions(+), 21 deletions(-) diff --git a/Readme.org b/Readme.org index 57bc11d..4054bbe 100644 --- a/Readme.org +++ b/Readme.org @@ -26,6 +26,24 @@ pour faire corriger des copies par Gemini. Le dossier `Example` contient deux exemples de copies pour illustrer le résultat final. +** Reprendre une compilation LaTeX après l'analyse + +Les PDF des questions et solutions reprennent automatiquement les packages et +macros de =enonce.tex= et du corrigé, avec le format recadré de Copienator. +La géométrie des pages du document original est exclue pour préserver le recadrage. +Les packages et fichiers locaux doivent rester accessibles à LaTeX. + +Pour recompiler les fragments déjà extraits sans aucune requête Gemini : + +#+BEGIN_SRC bash +python -m copienator statement DSI01 --compile-only +#+END_SRC + +Dans l'interface, choisir « Recompiler les PDF sans Gemini » à l'étape +« Analyser l’énoncé ». Cette action conserve les sources, les groupes et les +barèmes. Elle reprend les échecs et recompile si les fragments ou leur préambule +ont changé. Une compilation en erreur ne remplace pas le PDF précédent. + ** Avertissements - 90% du code du repertoire a été vibe codé, sans vérification autre diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index e986be5..ce2d5c1 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import hashlib import io import json import re @@ -1016,24 +1017,35 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group save_missing(path, '\n\n'.join(contents)) print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...") - # ========================================== - # PDF COMPILATION (4 Threads) - # ========================================== - all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex")) + compilation_status = compile_existing_pdfs(workspace) + return ExitCode.PARTIAL if processing_errors or compilation_status != ExitCode.SUCCESS else ExitCode.SUCCESS + + +def compile_existing_pdfs(workspace: EvaluationWorkspace) -> ExitCode: + """Compile existing fragments without reading checkpoints or calling Gemini.""" + folder = workspace.root + all_tex_files = list(workspace.rendered_text_dir.glob("*.tex")) + list(workspace.rendered_solution_dir.glob("*.tex")) def compile_worker(tex_file: Path) -> str | None: """Helper to read content and call the utility function.""" try: pdf_path = tex_file.with_suffix('.pdf') status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json') - failed_before = status_path.exists() and not json.loads(status_path.read_text()).get('complete') + source_path = (workspace.correction_source_file if tex_file.parent.name == 'Sol2' + else workspace.statement_source_file) + if not source_path.is_file(): + source_path = None + fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path) + + utils.LATEX_AFTER + str(source_path)).encode()).hexdigest() + status = json.loads(status_path.read_text()) if status_path.exists() else {} + failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint if (pdf_path.is_file() and pdf_path.stat().st_size > 0 and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before): return None - atomic_write_json(status_path, {'complete': False}) + atomic_write_json(status_path, {'complete': False, 'preamble_hash': fingerprint}) content = tex_file.read_text(encoding='utf-8') - compile_to_pdf(content, pdf_path, strict=True) - atomic_write_json(status_path, {'complete': True}) + compile_to_pdf(content, pdf_path, strict=True, source_path=source_path) + atomic_write_json(status_path, {'complete': True, 'preamble_hash': fingerprint}) except Exception as e: # noqa: BLE001 - compiler worker boundary return f"Error compiling {tex_file.name}: {e}" return None @@ -1045,8 +1057,7 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group ] for error in compile_errors: report_error(folder, 'PDF compilation', error) - processing_errors.extend(compile_errors) - return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS + return ExitCode.PARTIAL if compile_errors else ExitCode.SUCCESS def build_parser() -> argparse.ArgumentParser: @@ -1056,6 +1067,8 @@ def build_parser() -> argparse.ArgumentParser: help="Regroup existing questions with Gemini; update only label_groups") actions.add_argument("--persp-only", action="store_true", help="Generate only missing Persp rubrics for existing groups") + actions.add_argument("--compile-only", action="store_true", + help="Retry existing fragment PDFs using source preambles; no Gemini requests") parser.add_argument('--delete-persp', action='store_true', help='Delete current Persp before generation (requires --persp-only)') parser.add_argument( @@ -1071,6 +1084,10 @@ def main(argv: Sequence[str] | None = None) -> int: def run(args): if args.delete_persp and not args.persp_only: raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS) + if args.restart and (args.compile_only or args.groups_only or args.persp_only): + raise CliError('--restart cannot be combined with a partial action', ExitCode.INVALID_ARGUMENTS) + if args.compile_only: + return compile_existing_pdfs(workspace_from_args(args)) if args.groups_only or args.persp_only: kwargs = {'delete_persp': True} if args.delete_persp else {} return refine_existing(workspace_from_args(args), diff --git a/copienator/utils.py b/copienator/utils.py index d1234f2..319cbde 100644 --- a/copienator/utils.py +++ b/copienator/utils.py @@ -149,9 +149,43 @@ def report_latex_warning(output_pdf_path) -> None: print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}") -def compile_to_pdf(text, output_pdf_path, *, strict=False): +def fragment_latex_header(source_path=None): + """Keep source packages/macros while using the configured fragment layout.""" + if source_path is None: + return LATEX_BEFORE + source = Path(source_path).read_text(encoding="utf-8") + # Ignore commented document commands when locating the preamble. + source = re.sub(r"(?m)(? str: @@ -75,9 +82,9 @@ class EvaluationWorkspace: def input_candidates(self, canonical_name: str) -> tuple[Path, ...]: """Return preferred paths for one of the three statement inputs.""" aliases = { - "enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"), - "enonce.tex": ("enonce.tex", f"{self.name}.tex"), - "correction.tex": ("correction.tex", f"{self.name}c.tex"), + "enonce.pdf": ("enonce.pdf", "énonce.pdf", "énoncé.pdf", f"{self.name}.pdf"), + "enonce.tex": ("enonce.tex", "énonce.tex", "énoncé.tex", f"{self.name}.tex"), + "correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"), } names = aliases.get(canonical_name, (canonical_name,)) return tuple(self.root / name for name in names) diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 9012bb8..0c35d7a 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -143,6 +143,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Vérifier les fichiers d’entrée", "Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex, ou, " "s’il s’appelle A, les fichiers A.pdf, A.tex et Ac.tex. " + "Les noms énonce.pdf/tex, énoncé.pdf/tex et corrige.tex/corrigé.tex " + "sont également acceptés. " "Le fichier names doit être présent dans l’évaluation ou à la racine du projet.", (manual("check"),), requires=("enonce.pdf", "enonce.tex", "correction.tex"), @@ -156,7 +158,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Génère aussi des barèmes détaillés de chaque question. ", ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),) if show_personal_steps else ()) - + (python("gemini", "Analyse avec Gemini", "statement"),), + + (python("gemini", "Analyse avec Gemini", "statement"), + python("compile", "Recompiler les PDF sans Gemini", "statement", + fixed_args=("--compile-only",))), arguments=( arg_target("le dossier de l’évaluation"), ArgumentSpec( diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index af71f73..3530f31 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -127,8 +127,9 @@ class WorkspaceTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "A" root.mkdir() - for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex", - "correction.tex", "Ac.tex"): + for name in ("enonce.pdf", "énonce.pdf", "énoncé.pdf", "A.pdf", + "enonce.tex", "énonce.tex", "énoncé.tex", "A.tex", + "correction.tex", "corrige.tex", "corrigé.tex", "Ac.tex"): (root / name).touch() workspace = EvaluationWorkspace(root) @@ -136,6 +137,30 @@ class WorkspaceTests(unittest.TestCase): self.assertEqual(workspace.statement_source_file, root / "enonce.tex") self.assertEqual(workspace.correction_source_file, root / "correction.tex") + def test_alternative_statement_inputs_are_detected_and_validated(self) -> None: + for statement in ("énonce", "énoncé"): + for correction in ("corrige", "corrigé"): + with self.subTest(statement=statement, correction=correction), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "Exam" + root.mkdir() + for name in (f"{statement}.pdf", f"{statement}.tex", f"{correction}.tex"): + (root / name).touch() + workspace = EvaluationWorkspace.discover(root) + workspace.require_files("enonce.pdf", "enonce.tex", "correction.tex") + self.assertEqual(workspace.statement_pdf_file, root / f"{statement}.pdf") + self.assertEqual(workspace.statement_source_file, root / f"{statement}.tex") + self.assertEqual(workspace.correction_source_file, root / f"{correction}.tex") + self.assertTrue(workspace.is_statement_pdf(root / f"{statement}.pdf")) + self.assertEqual(workspace.resolve("correction.tex"), root / f"{correction}.tex") + + def test_discovery_recognizes_each_alternative_input_on_its_own(self) -> None: + for name in ("énonce.pdf", "énonce.tex", "énoncé.pdf", "énoncé.tex", "corrige.tex", "corrigé.tex"): + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "Exam" + root.mkdir() + (root / name).touch() + self.assertEqual(EvaluationWorkspace.discover(root).root, root) + def test_discover_and_require_report_clear_errors(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -2259,6 +2284,12 @@ class StandardCliTests(unittest.TestCase): class WorkflowTests(unittest.TestCase): + def test_statement_compile_variant_avoids_gemini_and_restart(self) -> None: + command = self.command('statement', 'compile', + {'target': self.evaluation, 'restart': True}) + self.assertIn('--compile-only', command) + self.assertNotIn('--restart', command) + def setUp(self) -> None: self.steps = {step.id: step for step in build_workflow(True)} self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example") diff --git a/tests/test_statement_resume.py b/tests/test_statement_resume.py index f46c93a..30110e8 100644 --- a/tests/test_statement_resume.py +++ b/tests/test_statement_resume.py @@ -151,6 +151,33 @@ class FullStatementResumeTests(unittest.TestCase): response(contexts=[]), response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])] + def test_compile_only_never_enters_analysis_and_preserves_sources(self): + directory = self.root / 'Sol2' + directory.mkdir() + tex = directory / '1.tex' + tex.write_text('manually edited solution') + with patch.object(gemini, 'process_exam') as analysis, patch.object( + gemini, 'compile_to_pdf', self.compiler): + self.assertEqual(gemini.main([str(self.root), '--compile-only']), 0) + analysis.assert_not_called() + self.assertEqual(tex.read_text(), 'manually edited solution') + self.assertEqual((directory / '1.pdf').read_bytes(), b'PDF') + self.assertEqual(self.compiler.call_args.kwargs['source_path'], self.root / 'correction.tex') + + def test_preamble_change_recompiles_previously_successful_pdfs(self): + self.client.models.generate_content.side_effect = self.responses() + self.assertEqual(self.run_exam(), ExitCode.SUCCESS) + self.compiler.reset_mock() + self.assertEqual(self.run_exam(), ExitCode.SUCCESS) + self.compiler.assert_not_called() + (self.root / 'correction.tex').write_text( + r'\documentclass{article}\newcommand{\custom}{new}\begin{document}') + self.client.models.generate_content.reset_mock() + self.assertEqual(self.run_exam(), ExitCode.SUCCESS) + self.client.models.generate_content.assert_not_called() + self.assertEqual(self.compiler.call_count, 1) + self.assertEqual(self.compiler.call_args.args[1], self.root / 'Sol2/Ex 1.pdf') + def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self): self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')] self.assertEqual(self.run_exam(), ExitCode.PARTIAL) diff --git a/tests/test_utils_compile.py b/tests/test_utils_compile.py index 55efc07..8e84741 100644 --- a/tests/test_utils_compile.py +++ b/tests/test_utils_compile.py @@ -1,6 +1,7 @@ import contextlib import io import subprocess +import shutil import tempfile import unittest from pathlib import Path @@ -10,6 +11,61 @@ from copienator import utils class CompileToPdfTests(unittest.TestCase): + def test_source_preamble_keeps_macros_and_uses_fragment_layout(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'source.tex' + source.write_text('% \\begin{document}\n\\documentclass{article}\n' + '\\usepackage{amsthm}\n\\newcommand{\\custom}{works}\n' + '\\begin{document}original body\\end{document}') + header = utils.fragment_latex_header(source) + self.assertIn('standalone', header) + self.assertIn(r'\usepackage{amsthm}', header) + self.assertIn(r'\newcommand{\custom}{works}', header) + self.assertNotIn('original body', header) + + @unittest.skipUnless(shutil.which('pdflatex'), 'pdflatex is not installed') + def test_source_geometry_does_not_clip_fragment(self): + import pymupdf + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'source.tex' + source.write_text(r'\documentclass{article}' + r'\usepackage[margin=1cm]{geometry}' + r'\geometry{top=0.5cm}' + r'\newcommand{\custom}{FIRSTLINE}' + r'\begin{document}unused body\end{document}') + output = Path(directory) / 'out.pdf' + utils.compile_to_pdf(r'\custom\par LASTLINE', output, + source_path=source, strict=True) + with pymupdf.open(output) as doc: + self.assertIn('FIRSTLINE', doc[0].get_text()) + self.assertIn('LASTLINE', doc[0].get_text()) + for word in doc[0].get_text('words'): + self.assertGreaterEqual(word[1], 0) + self.assertLessEqual(word[3], doc[0].rect.height) + + def test_source_directory_is_available_to_tex(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'source.tex' + source.write_text(r'\documentclass{article}\begin{document}') + result = subprocess.CompletedProcess(args=[], returncode=1, stdout='! error') + with patch.object(utils.subprocess, 'run', return_value=result) as run: + with self.assertRaises(RuntimeError): + utils.compile_to_pdf('broken', Path(directory) / 'out.pdf', + source_path=source, strict=True) + self.assertTrue(run.call_args.kwargs['env']['TEXINPUTS'].startswith(directory)) + + def test_strict_failure_preserves_previous_pdf(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / 'out.pdf' + output.write_bytes(b'previous PDF') + def failed_run(*args, **kwargs): + (Path(kwargs['cwd']) / 'text.pdf').write_bytes(b'partial') + return subprocess.CompletedProcess(args=[], returncode=1, stdout='! error') + with patch.object(utils.subprocess, 'run', side_effect=failed_run): + with self.assertRaises(RuntimeError): + utils.compile_to_pdf('broken', output, strict=True) + self.assertEqual(output.read_bytes(), b'previous PDF') + def test_strict_compilation_propagates_failure_with_context(self): result = subprocess.CompletedProcess(args=[], returncode=1, stdout="! Undefined control sequence.\nl.13 broken command\n")