Compare commits

..
3 Commits
Author SHA1 Message Date
sebastien 2d0f7a219f Provide Gemini with the list of IDs in correction request 2026-10-10 17:19:06 +02:00
sebastien f978310c4c secondary splitters 2026-10-10 15:47:31 +02:00
sebastien 705db1068f Personal tex compilation fixes 2026-10-10 10:01:16 +02:00
18 changed files with 638 additions and 94 deletions

No files matched your search

+18
View File
@@ -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
+17 -1
View File
@@ -532,6 +532,19 @@ def process_single_task(task_tuple, precomputed_response=None):
json_data = parse_correction_response(full_response_text)
# Validate the whole group before moving PDFs or applying corrections.
returned_ids = [entry["id"] for entry in json_data]
unexpected = sorted(set(returned_ids) - set(d_data))
missing = sorted(set(d_data) - set(returned_ids))
duplicates = sorted({pid for pid in returned_ids if returned_ids.count(pid) > 1})
if unexpected or missing or duplicates:
raise ValueError(
f"Correction ID mismatch: expected {list(d_data)}, "
f"received {returned_ids}; unexpected IDs: {unexpected}; "
f"missing IDs: {missing}; duplicate IDs: {duplicates}. "
"No corrections from this response were applied."
)
# Ensure consistency of answer placements
for p in json_data:
pid = p["id"]
@@ -899,7 +912,10 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
"data": b64_img,
}
},
{"text": prompting.make_prompt(INPUT_DIR, label)},
{"text": prompting.make_prompt(
INPUT_DIR, label,
expected_ids=[row[0] for row in group_data],
)},
],
}
],
+46 -4
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import pymupdf
from pdf2image import convert_from_path
from PIL import Image, ImageTk
@@ -31,6 +32,8 @@ DELIMITER_COLOR = (0, 0, 0)
OUTPUT_SIZE = (1800, 1000)
CROP_SHIFT_STEP = 50
CROP_WIDTH_STEP = 50
RENDER_DPI = 200
NARROW_PAGE_RATIO = 0.8
pdf_cache_lock = threading.Lock()
# The requested spelling is kept as the public switch for this alternate layout.
@@ -52,14 +55,17 @@ def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
if not image_list:
return None
total_width = sum(image.width for image in image_list)
# Downstream label coordinates assume equally spaced page columns. Pad
# narrower strips instead of scaling their content or vertical coordinates.
column_width = max(image.width for image in image_list)
total_width = column_width * len(image_list)
total_width += (len(image_list) - 1) * DELIMITER_WIDTH
max_height = max(image.height for image in image_list)
combined = Image.new("RGB", (total_width, max_height), color="white")
x_offset = 0
for index, image in enumerate(image_list):
combined.paste(image, (x_offset, 0))
x_offset += image.width
x_offset += column_width
if index < len(image_list) - 1:
delimiter = Image.new(
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
@@ -73,7 +79,7 @@ def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
# Label coordinates use the full, displayed MediaBox, including on PDFs
# previously cropped by crop-margins. Keep this in sync with split-answers.
return convert_from_path(pdf_path, use_cropbox=False)
return convert_from_path(pdf_path, use_cropbox=False, dpi=RENDER_DPI)
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
@@ -82,6 +88,29 @@ def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
return _get_pdf_pages_cached(pdf_path)
def normal_page_width(pdf_path: Path, images: list[Image.Image]) -> float:
"""Infer the unsplit sheet width, including for legacy PDFs without metadata."""
reference = max(image.width for image in images)
original = pdf_path.parent.parent / "Copies Originales" / pdf_path.name
try:
if not original.is_file():
return reference
with pymupdf.open(original) as document:
# Landscape scans contain two normal booklet pages; portrait scans
# contain one. MediaBox dimensions match get_pdf_pages above.
widths = []
for page in document:
rect = page.mediabox * page.rotation_matrix
width = rect.width / 2 if rect.width > rect.height else rect.width
widths.append(width * RENDER_DPI / 72)
if widths:
reference = max(widths)
except (OSError, RuntimeError, ValueError):
# A missing/unreadable backup must not prevent reviewing a valid copy.
pass
return reference
def process_single_pdf(
pdf_path: Path,
shift_offset: int = 0,
@@ -91,10 +120,20 @@ def process_single_pdf(
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
cropped_images = []
for image in get_pdf_pages(pdf_path):
images = get_pdf_pages(pdf_path)
if not images:
return None
reference_width = normal_page_width(pdf_path, images)
full_width_pages = []
for page_index, image in enumerate(images):
width, height = image.size
if max_per_file == 1:
left, right = 0, width
elif width < NARROW_PAGE_RATIO * reference_width:
# Secondary subpages start at their own left edge. Keep the
# normal margin width, capped to the available page content.
left = min(width - 1, max(0, shift_offset))
right = min(width, left + int(reference_width // 3) + max(0, width_offset))
else:
left = max(0, 100 + shift_offset)
right = min(
@@ -103,6 +142,8 @@ def process_single_pdf(
)
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
if left == 0 and right == width:
full_width_pages.append(page_index + 1)
if not cropped_images:
return None
@@ -123,6 +164,7 @@ def process_single_pdf(
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": distribution,
"full_width_pages": full_width_pages,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
+27 -10
View File
@@ -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),
+84 -64
View File
@@ -28,8 +28,13 @@ from copienator import (
from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths
# Keep the new shortcut available with older personal configuration files.
PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB}
# Keep new shortcuts available with older personal configuration files.
PAGE_SPLITTER_KB = {
"reverse_pages": "i",
"add_secondary_splitter": "<Insert>",
"delete_secondary_splitter": "<Delete>",
**PAGE_SPLITTER_KB,
}
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
@@ -166,10 +171,11 @@ class PDFPreviewer:
# Dynamic instructions text
instructions = (
f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n"
f"{fmt('move_left')} / {fmt('move_right')} : Move active line 0.5cm left/right\n"
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; orange = active)\n"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
@@ -185,6 +191,8 @@ class PDFPreviewer:
# --- Bindings ---
action_map = {
"add_secondary_splitter": self.add_secondary_splitter,
"delete_secondary_splitter": self.delete_secondary_splitter,
"move_left": self.move_line_left,
"move_right": self.move_line_right,
"rotate_page": self.rotate_page,
@@ -248,6 +256,7 @@ class PDFPreviewer:
page = self.doc.load_page(self.current_page_index)
self.current_line_x = page.rect.width / 2
self.current_rotation = 0
self.secondary_splitters = []
def load_page(self):
"""Loads and displays the current page on the canvas, scaled to fit."""
@@ -290,7 +299,14 @@ class PDFPreviewer:
draw = ImageDraw.Draw(img)
# The line position is scaled by the same zoom factor
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], fill="red", width=3)
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
fill="red" if self.secondary_splitters else "orange", width=3)
for index, position in enumerate(self.secondary_splitters):
x = position * self.current_zoom
active = index == len(self.secondary_splitters) - 1
draw.line([(x, 0), (x, pix.height)],
fill="orange" if active else "cyan", width=3)
draw.text((x + 5, 8), str(index + 1), fill="orange" if active else "cyan")
# --- Display on Canvas ---
@@ -335,17 +351,33 @@ class PDFPreviewer:
self._initialize_current_page_settings()
self.load_page()
def move_line_left(self, event=None):
"""Moves the split line to the left."""
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
def add_secondary_splitter(self, event=None):
"""Start a new line in the widest remaining interval of this page."""
width = self.doc.load_page(self.current_page_index).rect.width
boundaries = sorted({0, width, self.current_line_x, *self.secondary_splitters})
left, right = max(zip(boundaries, boundaries[1:]), key=lambda pair: pair[1] - pair[0])
self.secondary_splitters.append((left + right) / 2)
self.load_page()
def move_line_right(self, event=None):
"""Moves the split line to the right."""
page = self.doc.load_page(self.current_page_index)
self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2)
def delete_secondary_splitter(self, event=None):
if self.secondary_splitters:
self.secondary_splitters.pop()
self.load_page()
def _move_active_line(self, delta):
width = self.doc.load_page(self.current_page_index).rect.width
if self.secondary_splitters:
self.secondary_splitters[-1] = min(width, max(0, self.secondary_splitters[-1] + delta))
else:
self.current_line_x = min(width, max(0, self.current_line_x + delta))
self.load_page()
def move_line_left(self, event=None):
self._move_active_line(-CM_TO_POINTS / 2)
def move_line_right(self, event=None):
self._move_active_line(CM_TO_POINTS / 2)
def rotate_page(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.current_rotation = 180 if self.current_rotation == 0 else 0
@@ -388,6 +420,7 @@ class PDFPreviewer:
"""Saves the settings for the current page and moves to the next."""
self.page_settings.append({
"line_x": self.current_line_x,
"secondary_splitters": list(self.secondary_splitters),
"rotation": self.current_rotation,
"keep": keep
})
@@ -477,50 +510,32 @@ class PDFPreviewer:
line_x = settings['line_x']
rotation_settings = settings['rotation']
keep = settings['keep']
rotation = (page.rotation + rotation_settings +
self.file_rotation + self.global_rotation) % 360
# Bake source rotation and the preview's extra rotation into content,
# so all cut coordinates refer to the displayed page.
page.remove_rotation()
page.set_rotation((rotation_settings + self.file_rotation + self.global_rotation) % 360)
page.remove_rotation()
width, height = page.rect.width, page.rect.height
sides = []
if keep == "as_is":
doc_full = pymupdf.open()
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
page_full.show_pdf_page(page_full.rect, self.doc, i)
page_full.set_rotation(rotation)
output_path_full = self.split_filename_left(i)
doc_full.save(output_path_full)
doc_full.close()
continue # Skip left/right generation
# --- Create Left Part ---
if rotation == 0:
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
sides.append((0, width, self.split_filename_left(i)))
else:
rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
if keep in {"both", "left"} and line_x > 0:
sides.append((0, line_x, self.split_filename_left(i)))
if keep in {"both", "right"} and line_x < width:
sides.append((line_x, width, self.split_filename_right(i)))
if (keep == "both" or keep == "left") and line_x > 0:
doc_left = pymupdf.open()
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
page_left.set_rotation(rotation)
output_path_left = self.split_filename_left(i)
doc_left.save(output_path_left)
doc_left.close()
# --- Create Right Part ---
if rotation == 0:
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
else:
rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height)
if (keep == "both" or keep == "right") and line_x < page.rect.width:
doc_right = pymupdf.open()
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
page_right.set_rotation(rotation)
output_path_right = self.split_filename_right(i)
doc_right.save(output_path_right)
doc_right.close()
for left, right, output_path in sides:
cuts = settings.get("secondary_splitters", [])
boundaries = [left, *sorted({x for x in cuts if left < x < right}), right]
# Each side is a multi-page PDF, keeping its subpages together
# when the existing booklet ordering moves this side.
with pymupdf.open() as output:
for x0, x1 in zip(boundaries, boundaries[1:]):
clip = pymupdf.Rect(x0, 0, x1, height)
target = output.new_page(width=clip.width, height=clip.height)
target.show_pdf_page(target.rect, self.doc, i, clip=clip)
output.save(output_path)
self.doc.close()
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
@@ -531,6 +546,11 @@ class PDFPreviewer:
ps = self.page_settings
ri = 0
i = 0
def copy_side(source, destination):
if os.path.isfile(source):
shutil.copy2(source, destination)
while i < len(ps):
psk = ps[i]['keep']
@@ -539,32 +559,32 @@ class PDFPreviewer:
# 1. Page de garde (Extérieur Droit)
if ps[i]['keep'] in ["both", "right"]:
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
copy_side(self.split_filename_right(i), self.reorder_filename(ri))
ri += 1
# 2. Intérieur Gauche
if ps[i+1]['keep'] in ["both", "left"]:
shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
copy_side(self.split_filename_left(i+1), self.reorder_filename(ri))
ri += 1
# 3. Intérieur Droit
if ps[i+1]['keep'] in ["both", "right"]:
shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
copy_side(self.split_filename_right(i+1), self.reorder_filename(ri))
ri += 1
# 4. Dos de la copie (Extérieur Gauche)
if ps[i]['keep'] in ["both", "left"]:
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
copy_side(self.split_filename_left(i), self.reorder_filename(ri))
ri += 1
i += 2
else:
# Si c'est une page simple (ou as_is)
if psk in ["left", "both", "as_is"]:
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
copy_side(self.split_filename_left(i), self.reorder_filename(ri))
ri += 1
if psk in ["right", "both"]:
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
copy_side(self.split_filename_right(i), self.reorder_filename(ri))
ri += 1
i += 1
# def reorder_pdfs(self):
@@ -577,25 +597,25 @@ class PDFPreviewer:
# # Si c'est une copie double
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
# and i < len(ps)-1 and (ps[i+1]['keep'] != "right"):
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
# copy_side(self.split_filename_right(i), self.reorder_filename(ri))
# ri += 1
# if ps[i+1]['keep'] != "none":
# shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
# copy_side(self.split_filename_left(i+1), self.reorder_filename(ri))
# ri += 1
# if ps[i+1]['keep'] != "left":
# shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
# copy_side(self.split_filename_right(i+1), self.reorder_filename(ri))
# ri += 1
# if ps[i]['keep'] == "both":
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
# copy_side(self.split_filename_left(i), self.reorder_filename(ri))
# ri += 1
# i += 2
# else:
# psk = ps[i]['keep']
# if psk == "left" or psk == "both" or psk == "as_is":
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
# copy_side(self.split_filename_left(i), self.reorder_filename(ri))
# ri += 1
# if psk == "right" or psk == "both":
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
# copy_side(self.split_filename_right(i), self.reorder_filename(ri))
# ri += 1
# i += 1
+17 -3
View File
@@ -1,6 +1,8 @@
from pathlib import Path
import io
import json
from . import utils
from . import read_json
from . import configuration as settings
from .gemini_config import thinking_config
@@ -103,7 +105,7 @@ sur les autres questions."""
from .utils import get_label_text_content, get_label_sol_content, get_label_persp_content
def make_prompt(input_dir,full_label):
def make_prompt(input_dir,full_label, *, expected_ids=None):
text = get_label_text_content(input_dir, full_label) or ""
corr = get_label_sol_content(input_dir, full_label) or ""
persp = get_label_persp_content(input_dir, full_label) or ""
@@ -117,7 +119,18 @@ def make_prompt(input_dir,full_label):
+ persp
+ "\n```\n"
)
return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
prompt = main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
if expected_ids is not None:
prompt += (
"\n\nIdentifiants exacts présents dans cette image, de haut en bas : "
+ json.dumps(list(expected_ids), ensure_ascii=False)
+ ".\nRenvoie exactement un résultat par identifiant de cette liste, "
"sans omission, doublon ni identifiant supplémentaire. Conserve les zéros initiaux. "
"Utilise uniquement les en-têtes imprimés « ID: … » pour identifier les réponses ; "
"ignore les numéros manuscrits. Tout le contenu jusqu’au prochain en-tête "
"imprimé appartient au même identifiant, même s’il contient plusieurs feuilles."
)
return prompt
from pydantic import BaseModel, Field, TypeAdapter
@@ -190,8 +203,9 @@ def batch_generation_config(thinking_level):
# The root model for parsing is be: List[EvaluationEntry]
def generate_request(input_dir, file, full_label, *, thinking_level=settings.THINKING_LEVEL_PRO):
"""Generates request for Gemini."""
prompt = make_prompt(input_dir, full_label)
image_path = Path(file)
group_data = read_json(image_path.with_suffix(".json"))
prompt = make_prompt(input_dir, full_label, expected_ids=[row[0] for row in group_data])
contents = [
types.Content(
+39 -3
View File
@@ -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)(?<!\\)%[^\n]*", "", source)
begin = re.search(r"\\begin\s*\{document\}", source)
document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}"
configured_class = re.search(document_class, LATEX_BEFORE)
configured_begin = re.search(r"\\begin\s*\{document\}", LATEX_BEFORE)
if begin is None or configured_class is None or configured_begin is None:
return LATEX_BEFORE
preamble = source[:begin.start()]
if not re.search(document_class, preamble):
return LATEX_BEFORE
preamble = re.sub(document_class, lambda _: configured_class.group(), preamble, count=1)
# geometry overrides standalone's crop coordinates and can produce blank or
# clipped PDFs even when LaTeX succeeds. Keep other source packages intact.
def without_geometry(match):
packages = [name.strip() for name in match.group(2).split(',')
if name.strip() != 'geometry']
return (r'\usepackage' + (match.group(1) or '') + '{' + ','.join(packages) + '}'
if packages else '')
preamble = re.sub(r'\\usepackage\s*(\[[^\]]*\])?\s*\{([^}]+)\}',
without_geometry, preamble)
preamble = re.sub(r'\\(?:geometry|newgeometry)\s*\{[^}]*\}', '', preamble)
return preamble + LATEX_BEFORE[configured_begin.start():]
def compile_to_pdf(text, output_pdf_path, *, strict=False, source_path=None):
"""Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}"
latex_template = f"{fragment_latex_header(source_path)}{text}{LATEX_AFTER}"
env = os.environ.copy()
if source_path is not None:
source_dir = str(Path(source_path).resolve().parent)
env['TEXINPUTS'] = source_dir + os.pathsep + env.get('TEXINPUTS', '')
with tempfile.TemporaryDirectory() as temp_dir:
tex_filename = 'text.tex'
@@ -170,6 +204,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -180,6 +215,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
result = subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -188,7 +224,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
generated_pdf = os.path.join(temp_dir, pdf_filename)
pdf_generated = os.path.exists(generated_pdf)
if pdf_generated:
if pdf_generated and (not strict or result.returncode == 0):
shutil.move(generated_pdf, output_pdf_path)
if result.returncode != 0:
+12 -5
View File
@@ -65,8 +65,15 @@ class EvaluationWorkspace:
)
if not path.is_dir():
return False
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex")
return any((path / marker).exists() for marker in (*markers, *aliases))
workspace = EvaluationWorkspace(path)
inputs = (
candidate
for name in ("enonce.pdf", "enonce.tex", "correction.tex")
for candidate in workspace.input_candidates(name)
)
return any((path / marker).exists() for marker in markers) or any(
candidate.exists() for candidate in inputs
)
@property
def name(self) -> 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)
+6 -1
View File
@@ -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(
@@ -285,6 +289,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies",
"Découper une partie à gauche pour détection des labels",
"Produit les images de la partie gauche des copies. "
"Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. "
"Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone "
"de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. "
"1 utilise les pages entières, Entrée valide et ! signale une erreur."
+3
View File
@@ -49,9 +49,12 @@ THINKING_LEVEL_FLASH = "medium"
# Modèle pour des choses dures
MODEL_PRO_ID = "gemini-3.8-flash"
THINKING_LEVEL_PRO = "medium"
# THINKING_LEVEL_PRO = "high"
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
PAGE_SPLITTER_KB = {
"add_secondary_splitter": "<Insert>", # Current page only; arrows move newest
"delete_secondary_splitter": "<Delete>", # Remove newest secondary splitter
"move_left": "<Left>",
"move_right": "<Right>",
"rotate_page": "r",
+69
View File
@@ -0,0 +1,69 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from copienator import EvaluationWorkspace, atomic_write_json
from copienator.commands import correction
class CorrectionIdTests(unittest.TestCase):
def test_mismatched_ids_are_rejected_before_any_side_effects(self):
for ids, diagnostic in (
(["37", "03", "05"], "unexpected IDs: ['03']"),
(["37"], "missing IDs: ['05']"),
(["37", "05", "05"], "duplicate IDs: ['05']"),
):
with self.subTest(ids=ids), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
group = root / "Par label" / "Ex 1" / "Group_1.jpg"
group.parent.mkdir(parents=True)
group.touch()
atomic_write_json(group.with_suffix(".json"), [
["37", 0, 500, 1], ["05", 550, 1000, 1],
])
pdf = root / "Copies" / "Copie37" / "Ex 1.pdf"
pdf.parent.mkdir(parents=True)
pdf.write_bytes(b"original")
workspace = EvaluationWorkspace(root)
prior = {"Other": [[{"id": "22", "result": {"score": 4}}]]}
atomic_write_json(workspace.correction_file, prior)
before = workspace.correction_file.read_bytes()
args = correction.build_parser().parse_args([str(root)])
correction.configure_runtime(workspace, [(str(group), "Ex 1")], args, api_client=Mock())
response = [{"id": pid, "result": {
"score": 0, "error": "empty-answer", "feedback": [],
}} for pid in ids]
with patch.object(correction.prompting, "generate_request", return_value=([], {})), patch.object(
correction, "handle_label_errors"
) as handle:
correction.process_single_task((str(group), "Ex 1"), json.dumps(response))
handle.assert_not_called()
self.assertEqual(pdf.read_bytes(), b"original")
self.assertFalse(pdf.with_name("Ex 1_old.pdf").exists())
self.assertEqual(workspace.correction_file.read_bytes(), before)
self.assertEqual(correction.completed_tasks, [])
self.assertEqual(correction.results, prior)
self.assertIn(diagnostic, correction.errors_summary[0][0])
def test_valid_ids_can_be_returned_in_different_order(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
group = root / "Par label" / "Ex 1" / "Group_1.jpg"
group.parent.mkdir(parents=True)
group.touch()
atomic_write_json(group.with_suffix(".json"), [
["37", 0, 500, 1], ["05", 550, 1000, 1],
])
workspace = EvaluationWorkspace(root)
args = correction.build_parser().parse_args([str(root)])
correction.configure_runtime(workspace, [(str(group), "Ex 1")], args, api_client=Mock())
response = [{"id": pid, "result": {
"score": 4, "error": "", "feedback": [],
}} for pid in ["05", "37"]]
with patch.object(correction.prompting, "generate_request", return_value=([], {})):
correction.process_single_task((str(group), "Ex 1"), json.dumps(response))
self.assertEqual(correction.errors_summary, [])
self.assertEqual(correction.results["Ex 1"], [response])
self.assertEqual(correction.completed_tasks, [(str(group), "Ex 1")])
+73
View File
@@ -0,0 +1,73 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pymupdf
from PIL import Image
from copienator.commands import cutleft
class NarrowPageCropTests(unittest.TestCase):
def test_mixed_pages_crop_narrow_margin_from_very_left(self):
normal = Image.new('RGB', (900, 600), 'red')
normal.paste('green', (100, 0, 400, 600))
narrow = Image.new('RGB', (450, 600), 'red')
narrow.paste('blue', (0, 0, 300, 600))
thin = Image.new('RGB', (200, 600), 'yellow')
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow, thin]):
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
_, images, schema = result
image = images[0]
self.assertEqual(schema['full_width_pages'], [3])
self.assertEqual(schema['columns_per_file'], [3])
self.assertEqual(image.size, (910, 600))
self.assertEqual(image.getpixel((0, 0)), (0, 128, 0))
self.assertEqual(image.getpixel((299, 0)), (0, 128, 0))
self.assertEqual(image.getpixel((300, 0)), (0, 0, 0))
self.assertEqual(image.getpixel((305, 0)), (0, 0, 255))
self.assertEqual(image.getpixel((604, 0)), (0, 0, 255))
self.assertEqual(image.getpixel((610, 0)), (255, 255, 0))
self.assertEqual(image.getpixel((809, 0)), (255, 255, 0))
self.assertEqual(image.getpixel((810, 0)), (255, 255, 255))
self.assertEqual(image.getpixel((909, 0)), (255, 255, 255))
def test_narrow_margin_can_be_shifted_and_enlarged(self):
normal = Image.new('RGB', (900, 600), 'white')
narrow = Image.new('RGB', (450, 600), 'red')
narrow.paste('blue', (50, 0, 450, 600))
with patch.object(cutleft, 'get_pdf_pages', return_value=[normal, narrow]):
result = cutleft.process_single_pdf(
Path('/tmp/no-original/Copy.pdf'), shift_offset=50, width_offset=100)
self.assertEqual(result[1][0].size, (805, 600))
self.assertEqual(result[1][0].getpixel((405, 0)), (0, 0, 255))
self.assertEqual(result[1][0].getpixel((804, 0)), (0, 0, 255))
def test_legacy_original_identifies_narrow_pages_without_normal_siblings(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
originals = root / 'Copies Originales'
originals.mkdir()
with pymupdf.open() as document:
document.new_page(width=1191, height=842)
document.save(originals / 'Copy.pdf')
images = [Image.new('RGB', (900, 2339)), Image.new('RGB', (750, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
result = cutleft.process_single_pdf(root / 'Copies' / 'Copy.pdf')
self.assertEqual(result[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * int((1191 / 2 * 200 / 72) // 3) + 5)
def test_small_main_split_adjustments_keep_margin_cropping(self):
images = [Image.new('RGB', (1600, 2339)), Image.new('RGB', (1694, 2339))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'))
self.assertEqual(result[2]['full_width_pages'], [])
self.assertEqual(result[1][0].width, 2 * (1694 // 3) + 5)
def test_fullpage_option_still_keeps_every_page_separately(self):
images = [Image.new('RGB', (900, 600)), Image.new('RGB', (450, 600))]
with patch.object(cutleft, 'get_pdf_pages', return_value=images):
result = cutleft.process_single_pdf(Path('/tmp/no-original/Copy.pdf'), max_per_file=1)
self.assertEqual([image.size for image in result[1]], [(900, 600), (450, 600)])
self.assertEqual(result[2]['columns_per_file'], [1, 1])
+1
View File
@@ -26,6 +26,7 @@ class GeminiConfigTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
image = Path(directory) / "answer.jpg"
image.write_bytes(b"image")
image.with_suffix(".json").write_text('[["01", 0, 400, 1.0, "Ex 1"]]')
for level in ("minimal", "low", "medium", "high", None):
with self.subTest(level=level), patch.object(prompting, "make_prompt", return_value="Grade"):
_, config = prompting.generate_request(directory, image, "Ex 1", thinking_level=level)
+35 -3
View File
@@ -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)
@@ -1750,8 +1775,9 @@ class StandardCliTests(unittest.TestCase):
args,
api_client=Mock(),
)
with patch.object(module.prompting, "make_prompt", return_value="prompt"):
with patch.object(module.prompting, "make_prompt", return_value="prompt") as make_prompt:
self.assertEqual(module.run_configured(args), 0)
make_prompt.assert_called_once_with(evaluation, "Ex 1", expected_ids=["01"])
lines = (evaluation / "batch_requests_flash.jsonl").read_text().splitlines()
self.assertEqual(len(lines), 1)
self.assertEqual(json.loads(lines[0])["key"], str(image))
@@ -2259,6 +2285,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")
+92
View File
@@ -0,0 +1,92 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock
import pymupdf
from copienator.commands.page_splitter import CM_TO_POINTS, PDFPreviewer
class SecondarySplitterTests(unittest.TestCase):
def preview(self):
preview = PDFPreviewer.__new__(PDFPreviewer)
preview.doc = pymupdf.open()
self.addCleanup(lambda: None if preview.doc.is_closed else preview.doc.close())
for number in range(2):
page = preview.doc.new_page(width=400, height=100)
for column in range(4):
page.insert_text((column * 100 + 30, 50), f'{number}{column}')
preview.current_page_index = 0
preview.file_rotation = preview.global_rotation = 0
preview.page_settings = []
preview.load_page = Mock()
preview._initialize_current_page_settings()
return preview
def export(self, preview):
with tempfile.TemporaryDirectory() as directory:
preview.base_name = 'copy'
preview.split_dir = Path(directory) / 'split'
preview.reorder_dir = Path(directory) / 'reorder'
preview.final_file = Path(directory) / 'result.pdf'
preview.output_dir = None
preview.split_pdf()
preview.reorder_pdfs()
preview.concate_files()
with pymupdf.open(preview.final_file) as output:
return [(page.get_text().strip(), page.rect.width) for page in output]
def settings(self, keep='both', cuts=None, rotation=0, line=200):
return dict(keep=keep, secondary_splitters=cuts or [], rotation=rotation, line_x=line)
def test_newest_line_moves_and_deletes_and_settings_are_page_local(self):
preview = self.preview()
preview.add_secondary_splitter()
self.assertEqual(preview.secondary_splitters, [100])
preview.add_secondary_splitter()
self.assertEqual(preview.secondary_splitters, [100, 300])
preview.move_line_left()
self.assertAlmostEqual(preview.secondary_splitters[-1], 300 - CM_TO_POINTS / 2)
self.assertEqual(preview.current_line_x, 200)
preview.delete_secondary_splitter()
preview.move_line_right()
self.assertAlmostEqual(preview.secondary_splitters[-1], 100 + CM_TO_POINTS / 2)
saved = preview.secondary_splitters.copy()
preview.confirm_and_next_page()
self.assertEqual(preview.page_settings[0]['secondary_splitters'], saved)
self.assertEqual(preview.secondary_splitters, [])
preview.delete_secondary_splitter()
preview.move_line_left()
self.assertAlmostEqual(preview.current_line_x, 200 - CM_TO_POINTS / 2)
def test_subpages_replace_each_side_in_booklet_order(self):
preview = self.preview()
preview.page_settings = [self.settings(cuts=[300, 100]), self.settings(cuts=[100, 300])]
self.assertEqual(self.export(preview), [(label, 100) for label in ['02', '03', '10', '11', '12', '13', '00', '01']])
def test_discarded_sides_and_duplicate_boundary_cuts(self):
preview = self.preview()
preview.page_settings = [self.settings('left', [0, 100, 100, 200, 300, 400]), self.settings('none', [100])]
self.assertEqual(self.export(preview), [('00', 100), ('01', 100)])
def test_keep_as_is_ignores_main_splitter_but_applies_secondary_cuts(self):
preview = self.preview()
preview.page_settings = [self.settings('as_is', [100, 300]), self.settings('right', [300])]
self.assertEqual(self.export(preview), [('00', 100), ('01\n02', 200), ('03', 100), ('12', 100), ('13', 100)])
def test_rotated_preview_orders_subpages_visually_left_to_right(self):
preview = self.preview()
preview.page_settings = [self.settings(cuts=[100, 300], rotation=180), self.settings('none')]
self.assertEqual(self.export(preview), [(label, 100) for label in ['01', '00', '03', '02']])
def test_source_rotation_matches_preview(self):
preview = self.preview()
preview.doc[0].set_rotation(180)
preview.page_settings = [self.settings(cuts=[100, 300]), self.settings('none')]
self.assertEqual(self.export(preview), [(label, 100) for label in ['01', '00', '03', '02']])
def test_main_splitter_at_edge_does_not_break_reordering(self):
preview = self.preview()
preview.page_settings = [self.settings(cuts=[100, 200, 300], line=0), self.settings('none')]
self.assertEqual(self.export(preview), [(label, 100) for label in ['00', '01', '02', '03']])
+16
View File
@@ -1,10 +1,26 @@
import unittest
import tempfile
from pathlib import Path
from unittest.mock import patch
from copienator import prompting
class PromptingTests(unittest.TestCase):
def test_live_prompt_includes_only_metadata_ids_in_order(self) -> None:
with tempfile.TemporaryDirectory() as directory:
image = Path(directory) / "Group_16.jpg"
image.write_bytes(b"image")
image.with_suffix(".json").write_text(
'[["22", 50, 834, 1], ["37", 904, 3250, 1], '
'["13", 3320, 4072, 1], ["05", 4142, 4892, 1]]'
)
contents, _ = prompting.generate_request(directory, image, "I)12)")
prompt = contents[0].parts[1].text
self.assertIn('["22", "37", "13", "05"]', prompt)
self.assertIn("exactement un résultat par identifiant", prompt)
self.assertIn("ignore les numéros manuscrits", prompt)
def test_perspective_is_inserted_with_alternative_method_guidance(self) -> None:
with patch.object(
prompting, "get_label_text_content", return_value="Question"
+27
View File
@@ -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)
+56
View File
@@ -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")