Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0f7a219f | ||
|
|
f978310c4c | ||
|
|
705db1068f |
No files matched your search
+18
@@ -26,6 +26,24 @@ pour faire corriger des copies par Gemini.
|
|||||||
Le dossier `Example` contient deux exemples de copies pour illustrer
|
Le dossier `Example` contient deux exemples de copies pour illustrer
|
||||||
le résultat final.
|
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
|
** Avertissements
|
||||||
|
|
||||||
- 90% du code du repertoire a été vibe codé, sans vérification autre
|
- 90% du code du repertoire a été vibe codé, sans vérification autre
|
||||||
|
|||||||
@@ -532,6 +532,19 @@ def process_single_task(task_tuple, precomputed_response=None):
|
|||||||
|
|
||||||
json_data = parse_correction_response(full_response_text)
|
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
|
# Ensure consistency of answer placements
|
||||||
for p in json_data:
|
for p in json_data:
|
||||||
pid = p["id"]
|
pid = p["id"]
|
||||||
@@ -899,7 +912,10 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
|||||||
"data": b64_img,
|
"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],
|
||||||
|
)},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from queue import Empty, Queue
|
from queue import Empty, Queue
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
|
import pymupdf
|
||||||
from pdf2image import convert_from_path
|
from pdf2image import convert_from_path
|
||||||
from PIL import Image, ImageTk
|
from PIL import Image, ImageTk
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ DELIMITER_COLOR = (0, 0, 0)
|
|||||||
OUTPUT_SIZE = (1800, 1000)
|
OUTPUT_SIZE = (1800, 1000)
|
||||||
CROP_SHIFT_STEP = 50
|
CROP_SHIFT_STEP = 50
|
||||||
CROP_WIDTH_STEP = 50
|
CROP_WIDTH_STEP = 50
|
||||||
|
RENDER_DPI = 200
|
||||||
|
NARROW_PAGE_RATIO = 0.8
|
||||||
pdf_cache_lock = threading.Lock()
|
pdf_cache_lock = threading.Lock()
|
||||||
|
|
||||||
# The requested spelling is kept as the public switch for this alternate layout.
|
# 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:
|
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
|
||||||
if not image_list:
|
if not image_list:
|
||||||
return None
|
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
|
total_width += (len(image_list) - 1) * DELIMITER_WIDTH
|
||||||
max_height = max(image.height for image in image_list)
|
max_height = max(image.height for image in image_list)
|
||||||
combined = Image.new("RGB", (total_width, max_height), color="white")
|
combined = Image.new("RGB", (total_width, max_height), color="white")
|
||||||
x_offset = 0
|
x_offset = 0
|
||||||
for index, image in enumerate(image_list):
|
for index, image in enumerate(image_list):
|
||||||
combined.paste(image, (x_offset, 0))
|
combined.paste(image, (x_offset, 0))
|
||||||
x_offset += image.width
|
x_offset += column_width
|
||||||
if index < len(image_list) - 1:
|
if index < len(image_list) - 1:
|
||||||
delimiter = Image.new(
|
delimiter = Image.new(
|
||||||
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
|
"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]:
|
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
|
||||||
# Label coordinates use the full, displayed MediaBox, including on PDFs
|
# Label coordinates use the full, displayed MediaBox, including on PDFs
|
||||||
# previously cropped by crop-margins. Keep this in sync with split-answers.
|
# 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]:
|
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)
|
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(
|
def process_single_pdf(
|
||||||
pdf_path: Path,
|
pdf_path: Path,
|
||||||
shift_offset: int = 0,
|
shift_offset: int = 0,
|
||||||
@@ -91,10 +120,20 @@ def process_single_pdf(
|
|||||||
"""Convert one PDF into a preview, full-resolution splits and metadata."""
|
"""Convert one PDF into a preview, full-resolution splits and metadata."""
|
||||||
try:
|
try:
|
||||||
cropped_images = []
|
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
|
width, height = image.size
|
||||||
if max_per_file == 1:
|
if max_per_file == 1:
|
||||||
left, right = 0, width
|
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:
|
else:
|
||||||
left = max(0, 100 + shift_offset)
|
left = max(0, 100 + shift_offset)
|
||||||
right = min(
|
right = min(
|
||||||
@@ -103,6 +142,8 @@ def process_single_pdf(
|
|||||||
)
|
)
|
||||||
if right > left:
|
if right > left:
|
||||||
cropped_images.append(image.crop((left, 0, right, height)))
|
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:
|
if not cropped_images:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -123,6 +164,7 @@ def process_single_pdf(
|
|||||||
"total_pages": len(cropped_images),
|
"total_pages": len(cropped_images),
|
||||||
"number_of_files": len(split_images),
|
"number_of_files": len(split_images),
|
||||||
"columns_per_file": distribution,
|
"columns_per_file": distribution,
|
||||||
|
"full_width_pages": full_width_pages,
|
||||||
}
|
}
|
||||||
return preview, split_images, schema
|
return preview, split_images, schema
|
||||||
except Exception as exc: # noqa: BLE001 - interactive item failure
|
except Exception as exc: # noqa: BLE001 - interactive item failure
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -1016,24 +1017,35 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
|
|||||||
save_missing(path, '\n\n'.join(contents))
|
save_missing(path, '\n\n'.join(contents))
|
||||||
|
|
||||||
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
|
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
|
||||||
# ==========================================
|
compilation_status = compile_existing_pdfs(workspace)
|
||||||
# PDF COMPILATION (4 Threads)
|
return ExitCode.PARTIAL if processing_errors or compilation_status != ExitCode.SUCCESS else ExitCode.SUCCESS
|
||||||
# ==========================================
|
|
||||||
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
|
||||||
|
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:
|
def compile_worker(tex_file: Path) -> str | None:
|
||||||
"""Helper to read content and call the utility function."""
|
"""Helper to read content and call the utility function."""
|
||||||
try:
|
try:
|
||||||
pdf_path = tex_file.with_suffix('.pdf')
|
pdf_path = tex_file.with_suffix('.pdf')
|
||||||
status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json')
|
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
|
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):
|
and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
|
||||||
return None
|
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')
|
content = tex_file.read_text(encoding='utf-8')
|
||||||
compile_to_pdf(content, pdf_path, strict=True)
|
compile_to_pdf(content, pdf_path, strict=True, source_path=source_path)
|
||||||
atomic_write_json(status_path, {'complete': True})
|
atomic_write_json(status_path, {'complete': True, 'preamble_hash': fingerprint})
|
||||||
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
||||||
return f"Error compiling {tex_file.name}: {e}"
|
return f"Error compiling {tex_file.name}: {e}"
|
||||||
return None
|
return None
|
||||||
@@ -1045,8 +1057,7 @@ def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: Group
|
|||||||
]
|
]
|
||||||
for error in compile_errors:
|
for error in compile_errors:
|
||||||
report_error(folder, 'PDF compilation', error)
|
report_error(folder, 'PDF compilation', error)
|
||||||
processing_errors.extend(compile_errors)
|
return ExitCode.PARTIAL if compile_errors else ExitCode.SUCCESS
|
||||||
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -1056,6 +1067,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
help="Regroup existing questions with Gemini; update only label_groups")
|
help="Regroup existing questions with Gemini; update only label_groups")
|
||||||
actions.add_argument("--persp-only", action="store_true",
|
actions.add_argument("--persp-only", action="store_true",
|
||||||
help="Generate only missing Persp rubrics for existing groups")
|
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',
|
parser.add_argument('--delete-persp', action='store_true',
|
||||||
help='Delete current Persp before generation (requires --persp-only)')
|
help='Delete current Persp before generation (requires --persp-only)')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -1071,6 +1084,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
def run(args):
|
def run(args):
|
||||||
if args.delete_persp and not args.persp_only:
|
if args.delete_persp and not args.persp_only:
|
||||||
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS)
|
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:
|
if args.groups_only or args.persp_only:
|
||||||
kwargs = {'delete_persp': True} if args.delete_persp else {}
|
kwargs = {'delete_persp': True} if args.delete_persp else {}
|
||||||
return refine_existing(workspace_from_args(args),
|
return refine_existing(workspace_from_args(args),
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ from copienator import (
|
|||||||
from copienator.platform import launch_pdf_arranger
|
from copienator.platform import launch_pdf_arranger
|
||||||
from copienator.copy_errors import marked_copy_paths
|
from copienator.copy_errors import marked_copy_paths
|
||||||
|
|
||||||
# Keep the new shortcut available with older personal configuration files.
|
# Keep new shortcuts available with older personal configuration files.
|
||||||
PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB}
|
PAGE_SPLITTER_KB = {
|
||||||
|
"reverse_pages": "i",
|
||||||
|
"add_secondary_splitter": "<Insert>",
|
||||||
|
"delete_secondary_splitter": "<Delete>",
|
||||||
|
**PAGE_SPLITTER_KB,
|
||||||
|
}
|
||||||
|
|
||||||
# --- Constants ---
|
# --- Constants ---
|
||||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||||
@@ -166,10 +171,11 @@ class PDFPreviewer:
|
|||||||
|
|
||||||
# Dynamic instructions text
|
# Dynamic instructions text
|
||||||
instructions = (
|
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('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('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('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"
|
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,6 +191,8 @@ class PDFPreviewer:
|
|||||||
|
|
||||||
# --- Bindings ---
|
# --- Bindings ---
|
||||||
action_map = {
|
action_map = {
|
||||||
|
"add_secondary_splitter": self.add_secondary_splitter,
|
||||||
|
"delete_secondary_splitter": self.delete_secondary_splitter,
|
||||||
"move_left": self.move_line_left,
|
"move_left": self.move_line_left,
|
||||||
"move_right": self.move_line_right,
|
"move_right": self.move_line_right,
|
||||||
"rotate_page": self.rotate_page,
|
"rotate_page": self.rotate_page,
|
||||||
@@ -248,6 +256,7 @@ class PDFPreviewer:
|
|||||||
page = self.doc.load_page(self.current_page_index)
|
page = self.doc.load_page(self.current_page_index)
|
||||||
self.current_line_x = page.rect.width / 2
|
self.current_line_x = page.rect.width / 2
|
||||||
self.current_rotation = 0
|
self.current_rotation = 0
|
||||||
|
self.secondary_splitters = []
|
||||||
|
|
||||||
def load_page(self):
|
def load_page(self):
|
||||||
"""Loads and displays the current page on the canvas, scaled to fit."""
|
"""Loads and displays the current page on the canvas, scaled to fit."""
|
||||||
@@ -290,7 +299,14 @@ class PDFPreviewer:
|
|||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
# The line position is scaled by the same zoom factor
|
# The line position is scaled by the same zoom factor
|
||||||
line_x_scaled = self.current_line_x * self.current_zoom
|
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 ---
|
# --- Display on Canvas ---
|
||||||
@@ -335,17 +351,33 @@ class PDFPreviewer:
|
|||||||
self._initialize_current_page_settings()
|
self._initialize_current_page_settings()
|
||||||
self.load_page()
|
self.load_page()
|
||||||
|
|
||||||
def move_line_left(self, event=None):
|
def add_secondary_splitter(self, event=None):
|
||||||
"""Moves the split line to the left."""
|
"""Start a new line in the widest remaining interval of this page."""
|
||||||
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
|
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()
|
self.load_page()
|
||||||
|
|
||||||
def move_line_right(self, event=None):
|
def delete_secondary_splitter(self, event=None):
|
||||||
"""Moves the split line to the right."""
|
if self.secondary_splitters:
|
||||||
page = self.doc.load_page(self.current_page_index)
|
self.secondary_splitters.pop()
|
||||||
self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2)
|
|
||||||
self.load_page()
|
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):
|
def rotate_page(self, event=None):
|
||||||
"""Toggles the page rotation between 0 and 180 degrees."""
|
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||||
self.current_rotation = 180 if self.current_rotation == 0 else 0
|
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."""
|
"""Saves the settings for the current page and moves to the next."""
|
||||||
self.page_settings.append({
|
self.page_settings.append({
|
||||||
"line_x": self.current_line_x,
|
"line_x": self.current_line_x,
|
||||||
|
"secondary_splitters": list(self.secondary_splitters),
|
||||||
"rotation": self.current_rotation,
|
"rotation": self.current_rotation,
|
||||||
"keep": keep
|
"keep": keep
|
||||||
})
|
})
|
||||||
@@ -477,50 +510,32 @@ class PDFPreviewer:
|
|||||||
line_x = settings['line_x']
|
line_x = settings['line_x']
|
||||||
rotation_settings = settings['rotation']
|
rotation_settings = settings['rotation']
|
||||||
keep = settings['keep']
|
keep = settings['keep']
|
||||||
rotation = (page.rotation + rotation_settings +
|
# Bake source rotation and the preview's extra rotation into content,
|
||||||
self.file_rotation + self.global_rotation) % 360
|
# 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":
|
if keep == "as_is":
|
||||||
doc_full = pymupdf.open()
|
sides.append((0, width, self.split_filename_left(i)))
|
||||||
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)
|
|
||||||
else:
|
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:
|
for left, right, output_path in sides:
|
||||||
|
cuts = settings.get("secondary_splitters", [])
|
||||||
doc_left = pymupdf.open()
|
boundaries = [left, *sorted({x for x in cuts if left < x < right}), right]
|
||||||
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
|
# Each side is a multi-page PDF, keeping its subpages together
|
||||||
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
|
# when the existing booklet ordering moves this side.
|
||||||
page_left.set_rotation(rotation)
|
with pymupdf.open() as output:
|
||||||
|
for x0, x1 in zip(boundaries, boundaries[1:]):
|
||||||
output_path_left = self.split_filename_left(i)
|
clip = pymupdf.Rect(x0, 0, x1, height)
|
||||||
doc_left.save(output_path_left)
|
target = output.new_page(width=clip.width, height=clip.height)
|
||||||
doc_left.close()
|
target.show_pdf_page(target.rect, self.doc, i, clip=clip)
|
||||||
|
output.save(output_path)
|
||||||
# --- 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()
|
|
||||||
|
|
||||||
self.doc.close()
|
self.doc.close()
|
||||||
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
|
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
|
||||||
@@ -531,6 +546,11 @@ class PDFPreviewer:
|
|||||||
ps = self.page_settings
|
ps = self.page_settings
|
||||||
ri = 0
|
ri = 0
|
||||||
i = 0
|
i = 0
|
||||||
|
|
||||||
|
def copy_side(source, destination):
|
||||||
|
if os.path.isfile(source):
|
||||||
|
shutil.copy2(source, destination)
|
||||||
|
|
||||||
while i < len(ps):
|
while i < len(ps):
|
||||||
psk = ps[i]['keep']
|
psk = ps[i]['keep']
|
||||||
|
|
||||||
@@ -539,32 +559,32 @@ class PDFPreviewer:
|
|||||||
|
|
||||||
# 1. Page de garde (Extérieur Droit)
|
# 1. Page de garde (Extérieur Droit)
|
||||||
if ps[i]['keep'] in ["both", "right"]:
|
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
|
ri += 1
|
||||||
|
|
||||||
# 2. Intérieur Gauche
|
# 2. Intérieur Gauche
|
||||||
if ps[i+1]['keep'] in ["both", "left"]:
|
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
|
ri += 1
|
||||||
|
|
||||||
# 3. Intérieur Droit
|
# 3. Intérieur Droit
|
||||||
if ps[i+1]['keep'] in ["both", "right"]:
|
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
|
ri += 1
|
||||||
|
|
||||||
# 4. Dos de la copie (Extérieur Gauche)
|
# 4. Dos de la copie (Extérieur Gauche)
|
||||||
if ps[i]['keep'] in ["both", "left"]:
|
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
|
ri += 1
|
||||||
|
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
# Si c'est une page simple (ou as_is)
|
# Si c'est une page simple (ou as_is)
|
||||||
if psk in ["left", "both", "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
|
ri += 1
|
||||||
if psk in ["right", "both"]:
|
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
|
ri += 1
|
||||||
i += 1
|
i += 1
|
||||||
# def reorder_pdfs(self):
|
# def reorder_pdfs(self):
|
||||||
@@ -577,25 +597,25 @@ class PDFPreviewer:
|
|||||||
# # Si c'est une copie double
|
# # Si c'est une copie double
|
||||||
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
|
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
|
||||||
# and i < len(ps)-1 and (ps[i+1]['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
|
# ri += 1
|
||||||
# if ps[i+1]['keep'] != "none":
|
# 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
|
# ri += 1
|
||||||
# if ps[i+1]['keep'] != "left":
|
# 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
|
# ri += 1
|
||||||
# if ps[i]['keep'] == "both":
|
# 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
|
# ri += 1
|
||||||
# i += 2
|
# i += 2
|
||||||
# else:
|
# else:
|
||||||
# psk = ps[i]['keep']
|
# psk = ps[i]['keep']
|
||||||
# if psk == "left" or psk == "both" or psk == "as_is":
|
# 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
|
# ri += 1
|
||||||
# if psk == "right" or psk == "both":
|
# 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
|
# ri += 1
|
||||||
# i += 1
|
# i += 1
|
||||||
|
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
from . import utils
|
from . import utils
|
||||||
|
from . import read_json
|
||||||
from . import configuration as settings
|
from . import configuration as settings
|
||||||
from .gemini_config import thinking_config
|
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
|
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 ""
|
text = get_label_text_content(input_dir, full_label) or ""
|
||||||
corr = get_label_sol_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 ""
|
persp = get_label_persp_content(input_dir, full_label) or ""
|
||||||
@@ -117,7 +119,18 @@ def make_prompt(input_dir,full_label):
|
|||||||
+ persp
|
+ persp
|
||||||
+ "\n```\n"
|
+ "\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
|
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]
|
# The root model for parsing is be: List[EvaluationEntry]
|
||||||
def generate_request(input_dir, file, full_label, *, thinking_level=settings.THINKING_LEVEL_PRO):
|
def generate_request(input_dir, file, full_label, *, thinking_level=settings.THINKING_LEVEL_PRO):
|
||||||
"""Generates request for Gemini."""
|
"""Generates request for Gemini."""
|
||||||
prompt = make_prompt(input_dir, full_label)
|
|
||||||
image_path = Path(file)
|
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 = [
|
contents = [
|
||||||
types.Content(
|
types.Content(
|
||||||
|
|||||||
+39
-3
@@ -149,9 +149,43 @@ def report_latex_warning(output_pdf_path) -> None:
|
|||||||
print(f"{LATEX_WARNING_MARKER}{Path(output_pdf_path).resolve()}")
|
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."""
|
"""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:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
tex_filename = 'text.tex'
|
tex_filename = 'text.tex'
|
||||||
@@ -170,6 +204,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -180,6 +215,7 @@ def compile_to_pdf(text, output_pdf_path, *, strict=False):
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||||
cwd=temp_dir,
|
cwd=temp_dir,
|
||||||
|
env=env,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
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)
|
generated_pdf = os.path.join(temp_dir, pdf_filename)
|
||||||
pdf_generated = os.path.exists(generated_pdf)
|
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)
|
shutil.move(generated_pdf, output_pdf_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|||||||
+12
-5
@@ -65,8 +65,15 @@ class EvaluationWorkspace:
|
|||||||
)
|
)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
return False
|
return False
|
||||||
aliases = (f"{path.name}.pdf", f"{path.name}.tex", f"{path.name}c.tex")
|
workspace = EvaluationWorkspace(path)
|
||||||
return any((path / marker).exists() for marker in (*markers, *aliases))
|
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
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -75,9 +82,9 @@ class EvaluationWorkspace:
|
|||||||
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
|
def input_candidates(self, canonical_name: str) -> tuple[Path, ...]:
|
||||||
"""Return preferred paths for one of the three statement inputs."""
|
"""Return preferred paths for one of the three statement inputs."""
|
||||||
aliases = {
|
aliases = {
|
||||||
"enonce.pdf": ("enonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
|
"enonce.pdf": ("enonce.pdf", "énonce.pdf", "énoncé.pdf", f"{self.name}.pdf"),
|
||||||
"enonce.tex": ("enonce.tex", f"{self.name}.tex"),
|
"enonce.tex": ("enonce.tex", "énonce.tex", "énoncé.tex", f"{self.name}.tex"),
|
||||||
"correction.tex": ("correction.tex", f"{self.name}c.tex"),
|
"correction.tex": ("correction.tex", "corrige.tex", "corrigé.tex", f"{self.name}c.tex"),
|
||||||
}
|
}
|
||||||
names = aliases.get(canonical_name, (canonical_name,))
|
names = aliases.get(canonical_name, (canonical_name,))
|
||||||
return tuple(self.root / name for name in names)
|
return tuple(self.root / name for name in names)
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Vérifier les fichiers d’entrée",
|
"Vérifier les fichiers d’entrée",
|
||||||
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex, ou, "
|
"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. "
|
"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.",
|
"Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
|
||||||
(manual("check"),),
|
(manual("check"),),
|
||||||
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
|
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. ",
|
"Génère aussi des barèmes détaillés de chaque question. ",
|
||||||
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||||||
if show_personal_steps else ())
|
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=(
|
arguments=(
|
||||||
arg_target("le dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
@@ -285,6 +289,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Prétraitement des copies",
|
"Prétraitement des copies",
|
||||||
"Découper une partie à gauche pour détection des labels",
|
"Découper une partie à gauche pour détection des labels",
|
||||||
"Produit les images de la partie gauche des copies. "
|
"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 "
|
"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. "
|
"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."
|
"1 utilise les pages entières, Entrée valide et ! signale une erreur."
|
||||||
|
|||||||
@@ -49,9 +49,12 @@ THINKING_LEVEL_FLASH = "medium"
|
|||||||
# Modèle pour des choses dures
|
# Modèle pour des choses dures
|
||||||
MODEL_PRO_ID = "gemini-3.8-flash"
|
MODEL_PRO_ID = "gemini-3.8-flash"
|
||||||
THINKING_LEVEL_PRO = "medium"
|
THINKING_LEVEL_PRO = "medium"
|
||||||
|
# THINKING_LEVEL_PRO = "high"
|
||||||
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
|
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
|
||||||
|
|
||||||
PAGE_SPLITTER_KB = {
|
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_left": "<Left>",
|
||||||
"move_right": "<Right>",
|
"move_right": "<Right>",
|
||||||
"rotate_page": "r",
|
"rotate_page": "r",
|
||||||
|
|||||||
@@ -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")])
|
||||||
@@ -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])
|
||||||
@@ -26,6 +26,7 @@ class GeminiConfigTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
image = Path(directory) / "answer.jpg"
|
image = Path(directory) / "answer.jpg"
|
||||||
image.write_bytes(b"image")
|
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):
|
for level in ("minimal", "low", "medium", "high", None):
|
||||||
with self.subTest(level=level), patch.object(prompting, "make_prompt", return_value="Grade"):
|
with self.subTest(level=level), patch.object(prompting, "make_prompt", return_value="Grade"):
|
||||||
_, config = prompting.generate_request(directory, image, "Ex 1", thinking_level=level)
|
_, config = prompting.generate_request(directory, image, "Ex 1", thinking_level=level)
|
||||||
|
|||||||
+35
-3
@@ -127,8 +127,9 @@ class WorkspaceTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory) / "A"
|
root = Path(directory) / "A"
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
for name in ("enonce.pdf", "A.pdf", "enonce.tex", "A.tex",
|
for name in ("enonce.pdf", "énonce.pdf", "énoncé.pdf", "A.pdf",
|
||||||
"correction.tex", "Ac.tex"):
|
"enonce.tex", "énonce.tex", "énoncé.tex", "A.tex",
|
||||||
|
"correction.tex", "corrige.tex", "corrigé.tex", "Ac.tex"):
|
||||||
(root / name).touch()
|
(root / name).touch()
|
||||||
|
|
||||||
workspace = EvaluationWorkspace(root)
|
workspace = EvaluationWorkspace(root)
|
||||||
@@ -136,6 +137,30 @@ class WorkspaceTests(unittest.TestCase):
|
|||||||
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
|
self.assertEqual(workspace.statement_source_file, root / "enonce.tex")
|
||||||
self.assertEqual(workspace.correction_source_file, root / "correction.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:
|
def test_discover_and_require_report_clear_errors(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
@@ -1750,8 +1775,9 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
args,
|
args,
|
||||||
api_client=Mock(),
|
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)
|
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()
|
lines = (evaluation / "batch_requests_flash.jsonl").read_text().splitlines()
|
||||||
self.assertEqual(len(lines), 1)
|
self.assertEqual(len(lines), 1)
|
||||||
self.assertEqual(json.loads(lines[0])["key"], str(image))
|
self.assertEqual(json.loads(lines[0])["key"], str(image))
|
||||||
@@ -2259,6 +2285,12 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowTests(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:
|
def setUp(self) -> None:
|
||||||
self.steps = {step.id: step for step in build_workflow(True)}
|
self.steps = {step.id: step for step in build_workflow(True)}
|
||||||
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
|
self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example")
|
||||||
|
|||||||
@@ -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']])
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from copienator import prompting
|
from copienator import prompting
|
||||||
|
|
||||||
|
|
||||||
class PromptingTests(unittest.TestCase):
|
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:
|
def test_perspective_is_inserted_with_alternative_method_guidance(self) -> None:
|
||||||
with patch.object(
|
with patch.object(
|
||||||
prompting, "get_label_text_content", return_value="Question"
|
prompting, "get_label_text_content", return_value="Question"
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ class FullStatementResumeTests(unittest.TestCase):
|
|||||||
response(contexts=[]),
|
response(contexts=[]),
|
||||||
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])]
|
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):
|
def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
|
||||||
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
|
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
|
||||||
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
|
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,6 +11,61 @@ from copienator import utils
|
|||||||
|
|
||||||
|
|
||||||
class CompileToPdfTests(unittest.TestCase):
|
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):
|
def test_strict_compilation_propagates_failure_with_context(self):
|
||||||
result = subprocess.CompletedProcess(args=[], returncode=1,
|
result = subprocess.CompletedProcess(args=[], returncode=1,
|
||||||
stdout="! Undefined control sequence.\nl.13 broken command\n")
|
stdout="! Undefined control sequence.\nl.13 broken command\n")
|
||||||
|
|||||||
Reference in new issue
Block a user