Restructuration de l'application

This commit is contained in:
2026-08-22 13:39:20 +02:00
parent dc25b5fefa
commit b9e9d00e7d
44 changed files with 561 additions and 8675 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Executable Copienator commands.
Command modules expose a ``main(argv=None)`` entry point and may also expose
their processing functions for reuse and tests.
"""
+128
View File
@@ -0,0 +1,128 @@
import argparse
import math
import shutil
import sys
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator.configuration import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates:
if not candidate:
continue
try:
return ImageFont.truetype(str(candidate), size)
except OSError:
continue
return ImageFont.load_default()
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def process_images(base_dir, output_dir):
# 1. Load Data
try:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for img_path in sorted(search_path.glob("*/*.jpg")):
student_name = img_path.stem # Filename without extension
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
continue
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
continue
# 5. Process Image
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, height = img.size
# Dynamic font size (15% of image height)
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
# Calculate text size and position (Top Right)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# 30px padding
x = width - text_w - 30
y = 30
# Draw Text (Red)
draw.text((x, y), text, fill=(255, 0, 0), font=font)
# Save
save_path = output_dir / f"{student_name}.jpg"
img.save(save_path)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in sorted(search_path.glob("*/*.pdf")):
student_name = pdf_path.stem # Filename without extension
save_path = output_dir / f"{student_name}.pdf"
shutil.copy(str(pdf_path), str(save_path))
def main(argv=None):
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+546
View File
@@ -0,0 +1,546 @@
from __future__ import annotations
import argparse
import io
import os
import re
import subprocess
import tempfile
from collections.abc import Sequence
from pathlib import Path
import matplotlib
import matplotlib.colors as mcolors
import PIL.ImageOps
matplotlib.use("Agg")
from matplotlib import pyplot as plt
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MARGIN_LEFT = 300
ANNOT_WIDTH = 600
def make_dictionary(root_dir, refaire=False, refaire_list=None):
"""Compatibility wrapper used by the annotation-reading scripts."""
workspace = EvaluationWorkspace(Path(root_dir))
loaded = load_annotation_data(
workspace,
refaire_list=(refaire_list or []) if refaire else None,
)
return loaded.data
def make_base_image(pdf_path):
pages = convert_from_path(pdf_path)
# Calculate total dimensions
total_h = sum(page.height for page in pages)
max_w = max(page.width for page in pages)
# Create concatenated base image
base_img = Image.new("RGBA", (max_w, total_h), "white")
current_y = 0
for page in pages:
base_img.paste(page.convert("RGBA"), (0, current_y))
current_y += page.height
return (base_img, total_h, max_w)
def normalize_mathtext(text):
"""
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
e.g. \\le -> \\leq, \\ge -> \\geq
Using lookahead (?![a-zA-Z]) prevents replacing \\left with \\leqft.
"""
text = re.sub(r'\\le(?![a-zA-Z])', r'\\leq', text)
text = re.sub(r'\\ge(?![a-zA-Z])', r'\\geq', text)
text = re.sub(r'\\implies', r'\\Rightarrow', text)
# Sometimes, Gemini escapes too much ? Not sure
text = text.replace("\\\\", "\\")
text = text.replace("\\llbracket", "[\\![")
text = text.replace("\\rrbracket", "]\\!]")
text = text.replace("\\R", "\\mathbb{R}")
text = text.replace("\\N", "\\mathbb{N}")
text = text.replace("\\Z", "\\mathbb{Z}")
text = text.replace("\\C", "\\mathbb{C}")
text = text.replace("\\Q", "\\mathbb{Q}")
# Sometimes, Gemini doesn't escape enough. In the json, you should have \\f
text = text.replace('\f', r'\f')
text = re.sub('\u0010', "", text)
return text
def wrap_latex_text(text, width_chars):
"""
Wraps text but keeps LaTeX math blocks ($...$) intact.
"""
# 1. Split text into chunks of: text, math, text, math...
# The regex looks for $...$ (non-greedy).
parts = re.split(r'(\$[^\$]+\$)', text)
# 2. Tokenize: Break plain text by spaces, keep math blocks whole.
tokens = []
for part in parts:
if part.startswith('$') and part.endswith('$'):
tokens.append(part) # Keep math block distinct
else:
tokens.extend(part.split()) # Split normal text by whitespace
# 3. Reconstruct lines using textwrap logic
lines = []
current_line = []
current_length = 0
for token in tokens:
# +1 for the space we will add
token_len = len(token)
if current_length + token_len + 1 > width_chars:
lines.append(" ".join(current_line))
current_line = [token]
current_length = token_len
else:
current_line.append(token)
current_length += token_len + 1
if current_line:
lines.append(" ".join(current_line))
res = "\n".join(lines)
return res
def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None,
fontsize=14):
# 1. Fix unsupported symbols
text = normalize_mathtext(text)
dpi = 100
fig_width = width_px / dpi
# Estimate characters per line based on width and font size (heuristic)
# FontSize 12 approx 0.5 inches wide for ~15 chars usually,
# but let's approximate: Width (inches) * ~10 chars/inch for size 12
chars_per_line = int(fig_width * 10)
# Pre-wrap the text respecting LaTeX boundaries
wrapped_text = wrap_latex_text(text, chars_per_line)
# Dynamic height based on actual number of lines
num_lines = wrapped_text.count('\n') + 1
if max_lines and num_lines > max_lines:
# logic to truncate if strictly necessary, or just expand
pass
# 0.3 inches per line buffer
fig_height = num_lines * 0.3 + 0.2
fig = plt.figure(figsize=(fig_width, fig_height), dpi=dpi)
# NOTE: wrap=False because we did it ourselves
plt.text(0.01, 0.95, wrapped_text, fontsize=fontsize,
verticalalignment='top', horizontalalignment='left',
wrap=False)
plt.axis('off')
buf = io.BytesIO()
plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0.1, transparent=True)
plt.close(fig)
buf.seek(0)
img = Image.open(buf).convert("RGBA")
# Create background
final_img = Image.new("RGBA", img.size, bg_color)
final_img.alpha_composite(img)
return final_img
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
dpi = 100
width_in = width_px / dpi
line_spacing = int(fontsize * 1.2)
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
header = LATEX_ANOT_BEFORE.substitute(
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
)
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
with tempfile.TemporaryDirectory() as temp_dir:
tex_path = os.path.join(temp_dir, 'text.tex')
pdf_path = os.path.join(temp_dir, 'text.pdf')
with open(tex_path, 'w', encoding='utf-8') as f:
f.write(latex_template)
# Compile to PDF
subprocess.run(
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if not os.path.exists(pdf_path):
raise RuntimeError("LaTeX compilation failed. Check your LaTeX syntax.")
# Convert PDF to grayscale (ignoring pdf2image's broken transparency)
images = convert_from_path(pdf_path, dpi=dpi)
gray_img = images[0].convert("L")
# 1. Invert grayscale to create an alpha mask (white bg = 0, black text = 255)
alpha_mask = PIL.ImageOps.invert(gray_img)
# 2. Create a transparent image with black text using the mask
text_img = Image.new("RGBA", gray_img.size, (0, 0, 0, 255))
text_img.putalpha(alpha_mask)
# 3. Create the requested background and composite the text over it
final_img = Image.new("RGBA", text_img.size, bg_color)
final_img.alpha_composite(text_img)
# (Optional) Truncate image height if max_lines is strictly enforced
if max_lines:
max_height_px = int((fontsize * 1.2 / 72.0) * dpi * max_lines) # Points to pixels
if final_img.height > max_height_px:
final_img = final_img.crop((0, 0, final_img.width, max_height_px))
return final_img
def color(score):
t = max(0.0, min(1.0, float(score) / 4.0))
t = t*1.5 - 0.25
t = max(0.0, min(1.0, t))
red = 200 * (1 - t)
green = 150 * t
return mcolors.to_hex((red/255, green/255, 0))
def render_score_text(label, score, error, width_px, fontsize=30,
bg_color=(255, 255, 255, 255),
with_error=True, id=None):
# 1. Build text segments: (text, color, is_bold)
parts = []
default_color = (0, 0, 0, 255)
prefix = f"{id} " if id else ""
prefix += f"{label} Note : "
parts.append((prefix, default_color, False))
parts.append((str(score), color(score), True))
if error and error != "null" and with_error:
fontsize=18
parts.append((" ", default_color, False))
parts.append((str(error), "orange", True))
# 2. Setup Image
height_px = 80 # roughly matches fig_height=0.8 at 100 dpi
img = Image.new("RGBA", (int(width_px), height_px), bg_color)
draw = ImageDraw.Draw(img)
# 3. Load Fonts
try:
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
except OSError:
# Fallback for systems without specific TTFs readily available
print("here")
try:
font_regular = ImageFont.load_default(size=fontsize) # Pillow >= 10.1.0
except TypeError:
print("there")
font_regular = ImageFont.load_default()
font_bold = font_regular
# 4. Draw segments horizontally
x, y = int(width_px * 0.125), int(height_px * 0.2)
for text, text_color, is_bold in parts:
f = font_bold if is_bold else font_regular
draw.text((x, y), text, fill=text_color, font=f)
# Advance X position by the width of the drawn text
bbox = draw.textbbox((x, y), text, font=f)
x = bbox[2]
return img
A4_WIDTH_200DPI = 1654
TARGET_MIN_WIDTH = int(A4_WIDTH_200DPI * 0.9) # 1406 pixels
def compose_label_image(base_img, label, result, hmin,
render_fn=render_real_latex_text,
draw_callback=None,
with_error=True,
with_empty=False,
more_right=False,
with_id=None):
"""
Composes the final image with annotations.
Args:
base_img: The source PDF converted to image.
label: Label name (e.g. "Ex1").
result: The JSON result object (score, feedbacks).
hmin: Vertical offset coordinate.
render_fn: Function to render text to image (allows threading injection).
draw_callback: Optional function(type, draw_obj, position_dict, data_dict)
called when elements are placed. Used for checkboxes.
"""
left_pad = 0
if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width
left_pad = min(total_missing, MARGIN_LEFT)
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
new_base.paste(base_img, (left_pad, 0))
base_img = new_base
score = result.get('score', 0)
error = result.get('error', "")
feedbacks = result.get('feedback', [])
if error == "empty-answer" and not with_empty:
return None, 0
# Filter deleted items (used by reading_annotations.py)
feedbacks = [f for f in feedbacks if "to_delete" not in f]
global_fb = [f for f in feedbacks if not f.get('box_2d')]
local_fb = [f for f in feedbacks if f.get('box_2d')]
local_fb.sort(key=lambda x: x['box_2d'][0])
# 1. Prepare Headers
header_elements = []
if more_right:
width = base_img.width // 2
else:
width = base_img.width // 2 - 150
img_score = render_score_text(label, score, error, width, with_error=with_error,
id=with_id)
header_elements.append({"type": "score", "img": img_score, "data": result})
# Global Feedbacks
for idx, fb in enumerate(global_fb):
img_fb = render_fn(fb['text'], base_img.width)
header_elements.append({"type": "global_fb", "img": img_fb, "data": fb, "index": idx})
# Calculate Header Height
header_height = sum(el["img"].height for el in header_elements)
total_height = base_img.height + header_height
# Create Canvas
final_img = Image.new("RGB", (base_img.width + MARGIN_LEFT, total_height), "white")
# Draw Headers
current_y = 0
draw = ImageDraw.Draw(final_img, "RGBA")
for el in header_elements:
if el["type"] == "score" and more_right:
final_img.paste(el["img"], (150, current_y))
else:
final_img.paste(el["img"], (0, current_y))
if draw_callback:
# Hook for checkboxes
draw_callback("header_item", draw,
{"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height},
el)
current_y += el["img"].height
# Paste Base Image
image_offset_y = current_y
final_img.paste(base_img, (MARGIN_LEFT, image_offset_y))
# 2. Draw Local Annotations
draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object
last_text_bottom = 0
for idx, fb in enumerate(local_fb):
box = fb.get('box_2d')
ymin, xmin, ymax, xmax = box
target_ymin = (ymin - hmin) + image_offset_y
target_ymax = (ymax - hmin) + image_offset_y
target_xmin = xmin + MARGIN_LEFT + left_pad
target_xmax = xmax + MARGIN_LEFT + left_pad
# Draw Rectangle (if not suppressed)
if "norectangle" not in fb:
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
if draw_callback:
draw_callback("local_rect", draw,
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
{"data": fb, "index": idx})
# Render Text
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
bg_color=(255, 200, 200, 180), max_lines=None)
# Calculate Position
center_y = (target_ymin + target_ymax) / 2
paste_y = center_y - (txt_img.height / 2)
paste_y = max(paste_y, image_offset_y)
if paste_y < last_text_bottom:
paste_y = last_text_bottom + 5
# Resize canvas if needed
required_height = int(paste_y + txt_img.height + 20)
if required_height > final_img.height:
new_final = Image.new("RGB", (final_img.width, required_height), "white")
new_final.paste(final_img, (0, 0))
final_img = new_final
draw = ImageDraw.Draw(final_img, "RGBA")
# Paste Text
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
if draw_callback:
draw_callback("local_text", draw,
{"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height},
{"data": fb, "index": idx})
last_text_bottom = paste_y + txt_img.height
return final_img, header_height
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
"""Helper function to process a single student."""
# Prepare output directory: Dir/Anot_CopieID
output_dir = Path(root_dir) / "Anot" / f"Copie{student_id}"
# Check if already processed (Concat.jpg exists)
concat_path = output_dir / "Concat.jpg"
if concat_path.exists() and not overwrite:
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
return "skipped"
print("Processing :", student_id)
problems = False
with staged_directory(output_dir) as staging:
d_notes = dict.fromkeys(all_labels, "")
label_images = []
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
for label, content in sorted_labels:
pdf_full_path = content.get('pdf_path')
if not pdf_full_path or not Path(pdf_full_path).exists():
print(f"File not found: {pdf_full_path}")
problems = True
continue
try:
base_img, _, _ = make_base_image(pdf_full_path)
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
print(f"Error converting {pdf_full_path}: {exc}")
problems = True
continue
result = content.get('result', {})
coordinates = content.get('coordinates', (0, 0))
d_notes[label] = str(result.get('score', 0))
final_img, _ = compose_label_image(
base_img,
label,
result,
coordinates[0],
with_empty=True,
render_fn=render_real_latex_text,
)
final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer":
label_images.append(final_img)
atomic_write_json(staging / "score.json", d_notes)
if label_images:
max_w = max(image.width for image in label_images)
total_h = sum(image.height for image in label_images)
canvas = Image.new('RGB', (max_w, total_h))
current_y = 0
for image in label_images:
canvas.paste(image, (0, current_y))
current_y += image.height
canvas.save(staging / "Concat.jpg")
elif labels_data:
problems = True
return "partial" if problems else "success"
def process_correction(root_dir, data, all_labels, overwrite=False):
# Ne pas thread cette application
# 1. Il faut protéger les appels à matplotlib
# 2. tu vas perdre les erreurs
statuses = [
process_student(student_id, labels, root_dir, all_labels, overwrite)
for student_id, labels in sorted(data.items())
]
return ExitCode.PARTIAL if "partial" in statuses else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate simple annotated copies.")
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace existing student output directories",
)
return parser
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
result = process_correction(
workspace.root,
loaded.data,
labels,
overwrite=overwrite,
)
if loaded.warnings and result == ExitCode.SUCCESS:
return ExitCode.PARTIAL
return result
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
)
if __name__ == "__main__":
raise SystemExit(main())
+392
View File
@@ -0,0 +1,392 @@
from __future__ import annotations
import argparse
import concurrent.futures
import os
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator.commands import annotating_with_checks
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
atomic_write_text,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000
def render_item(item):
student_id, label, content = item
pdf_path = Path(content["pdf_path"])
if not pdf_path.exists():
print(f"Warning: answer PDF not found: {pdf_path}")
return None
base_image, _, _ = annotating.make_base_image(pdf_path)
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
final_image, header_height = annotating.compose_label_image(
base_image,
label,
content["result"],
content["coordinates"][0],
draw_callback=checkbox_renderer.callback,
more_right=True,
with_id=student_id,
)
if final_image is None:
return None
return (
student_id,
label,
final_image,
header_height,
checkbox_renderer.checkboxes,
)
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
output_dir = output_root / f"{prefix} G{group_id}"
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width for item in batch)
total_height = sum(item[2].height for item in batch)
concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated)
checkbox_map: list[dict[str, Any]] = []
bnote_entries: list[dict[str, Any]] = []
current_y = 0
previous_student = None
for student_id, label, image, header_height, checkboxes in batch:
concatenated.paste(image, (0, current_y))
if student_id != previous_student:
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
previous_student = student_id
bnote_entries.append(
{
"id": student_id,
"label": label,
"header_height": header_height,
"hmin": current_y,
"hmax": current_y + image.height,
}
)
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
box[0],
box[1] + current_y,
box[2],
box[3] + current_y,
]
item["student_id"] = student_id
checkbox_map.append(item)
current_y += image.height
with staged_directory(output_dir) as staging:
atomic_write_json(
staging / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(staging / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage(
str(reference),
0,
0,
width=max_width,
height=total_height,
)
pdf_canvas.save()
def _initial_label_groups(labels: list[str]) -> str:
groups: dict[str, list[str]] = {}
for label in labels:
key = label.split(" : ")[0] if " : " in label else label
groups.setdefault(key, []).append(label)
return "".join(",".join(items) + "\n" for items in groups.values())
def _gemini_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]] | None:
source = workspace.gemini_exam_items_file
if not source.is_file():
return None
groups: list[list[str]] = []
current: list[str] = []
try:
source_lines = source.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
print(f"Warning: could not read Gemini question groups from {source}: {exc}")
return None
for raw_line in source_lines:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line == "---":
if current:
groups.append(current)
current = []
continue
if " ### " not in line:
continue
label = line.split(" ### ", 1)[0].strip()
if label and label != "CONTEXT":
current.append(label)
if current:
groups.append(current)
flattened = [label for group in groups for label in group]
known = set(labels)
if (
not flattened
or len(flattened) != len(set(flattened))
or set(flattened) != known
):
missing = sorted(known.difference(flattened), key=natural_key)
unknown = sorted(set(flattened).difference(known), key=natural_key)
details = []
if missing:
details.append("missing: " + ", ".join(missing))
if unknown:
details.append("unknown: " + ", ".join(unknown))
if len(flattened) != len(set(flattened)):
details.append("duplicate labels")
print(
f"Warning: ignoring incompatible Gemini question groups in {source}"
+ (f" ({'; '.join(details)})" if details else "")
)
return None
return groups
def _serialize_label_groups(groups: list[list[str]]) -> str:
return "".join(",".join(group) + "\n" for group in groups)
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]:
label_groups = workspace.label_groups_file
if not label_groups.exists():
gemini_groups = _gemini_label_groups(workspace, labels)
if gemini_groups is not None:
initial_content = _serialize_label_groups(gemini_groups)
source_description = "the groups selected in gemini_for_enonce.py"
else:
initial_content = _initial_label_groups(labels)
source_description = "the label-prefix fallback"
atomic_write_text(label_groups, initial_content)
print(
f"Created {label_groups} from {source_description}; "
"review the groups before continuing."
)
utils.edit_file_and_enter(label_groups)
known_labels = set(labels)
groups: list[list[str]] = []
unknown: set[str] = set()
for line in label_groups.read_text(encoding="utf-8").splitlines():
group = [label.strip() for label in line.split(",") if label.strip()]
unknown.update(label for label in group if label not in known_labels)
if group:
groups.append(group)
if unknown:
raise CliError(
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
)
return groups
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
used: set[str] = set()
result: list[tuple[str, list[str]]] = []
previous: str | None = None
for labels in groups:
safe_labels = [label.replace(":", "").strip() for label in labels]
base = os.path.commonprefix(safe_labels).strip() or "Group"
if base and previous is not None and natural_key(base) < natural_key(previous):
candidate = f"{safe_labels[0]}+"
if natural_key(candidate) > natural_key(previous):
base = candidate
prefix = base.removesuffix("i")
counter = 2
while prefix in used:
prefix = f"{base}-{counter}"
counter += 1
if counter == 2 and previous and previous in prefix:
prefix = f"{previous}-{counter}"
elif counter == 2:
previous = prefix
used.add(prefix)
result.append((prefix, labels))
return result
def _existing_group_state(
output_root: Path,
prefix: str,
) -> tuple[set[tuple[str, str]], int]:
existing_items: set[tuple[str, str]] = set()
maximum_group = 0
if not output_root.is_dir():
return existing_items, maximum_group
for directory in output_root.iterdir():
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
continue
try:
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
except ValueError:
continue
metadata = directory / "bnote.json"
if not metadata.exists():
continue
loaded = read_json(metadata)
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object in {metadata}")
for image in loaded.get("images", []):
existing_items.add((str(image["id"]), str(image["label"])))
return existing_items, maximum_group
def split_batches(rendered):
def split(maximum_height: float):
batches = []
current = []
current_height = 0
previous_student = None
for item in rendered:
student_id = item[0]
image_height = item[2].height
if (
current
and current_height + image_height > maximum_height
and student_id != previous_student
):
batches.append(current)
current = []
current_height = 0
current.append(item)
current_height += image_height
previous_student = student_id
if current:
batches.append(current)
return batches
strict = split(MAX_HEIGHT_PX)
relaxed = split(1.1 * MAX_HEIGHT_PX)
return relaxed if len(relaxed) < len(strict) else strict
def _generate_groups(
output_root: Path,
data,
groups: list[list[str]],
*,
resume: bool,
) -> tuple[int, bool]:
generated = 0
problems = False
for prefix, labels in _unique_prefixes(groups):
existing_items, maximum_group = (
_existing_group_state(output_root, prefix) if resume else (set(), 0)
)
items = [
(student_id, label, student_labels[label])
for student_id, student_labels in data.items()
for label in labels
if label in student_labels and (student_id, label) not in existing_items
]
if not items:
continue
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
rendered = list(executor.map(render_item, items))
if any(item is None for item in rendered):
problems = True
successful = [item for item in rendered if item is not None]
for index, batch in enumerate(split_batches(successful), start=1):
save_batch(batch, prefix, maximum_group + index, output_root)
generated += 1
return generated, problems
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
groups = _load_label_groups(workspace, labels)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_root = workspace.annotation_dir("grouped")
if overwrite:
class IncompleteGroupedOutput(Exception):
pass
try:
with staged_directory(output_root) as staging:
generated, problems = _generate_groups(
staging,
loaded.data,
groups,
resume=False,
)
if generated == 0 or problems or loaded.warnings:
raise IncompleteGroupedOutput
except IncompleteGroupedOutput:
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
return ExitCode.PARTIAL
else:
output_root.mkdir(parents=True, exist_ok=True)
generated, problems = _generate_groups(
output_root,
loaded.data,
groups,
resume=True,
)
if problems:
return ExitCode.PARTIAL
if generated == 0:
print("No new grouped annotations were required.")
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,331 @@
from __future__ import annotations
import argparse
import concurrent.futures
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import matplotlib
matplotlib.use("Agg")
from PIL import Image, ImageFont
from reportlab.pdfgen import canvas
from copienator.commands import annotating
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
BOX_SIZE = 30
SCORE_BOX_SIZE = 40
SCORES = [value * 0.5 for value in range(10)]
EXPECTED_OUTPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat.pdf")
try:
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
except OSError:
try:
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
except OSError:
CHECKBOX_FONT = ImageFont.load_default()
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
if label:
draw.text((x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT)
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
return [x, y, x + size, y + size]
class CheckboxRenderer:
def __init__(self, label_name):
self.label = label_name
self.checkboxes = []
def callback(self, kind, draw, pos, meta):
if kind == "header_item":
if meta.get("type") == "score":
start_x = pos["w"] + 20
for value in SCORES:
box = draw_checkbox(
draw,
start_x,
pos["y"] + 25,
SCORE_BOX_SIZE,
str(value),
)
self.checkboxes.append(
{
"type": "score",
"label": self.label,
"value": value,
"rel_box": box,
}
)
start_x += SCORE_BOX_SIZE + 45
start_x += SCORE_BOX_SIZE + 60
box = draw_checkbox(
draw,
start_x,
pos["y"] + 25,
SCORE_BOX_SIZE,
"clr",
)
self.checkboxes.append(
{"type": "clear_all", "label": self.label, "rel_box": box}
)
elif meta.get("type") == "global_fb":
box = draw_checkbox(
draw,
pos["w"] - BOX_SIZE - 5,
pos["y"] + 5,
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_global",
"label": self.label,
"index": meta["index"],
"rel_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
elif kind == "local_rect":
rectangle = pos["box"]
box = draw_checkbox(
draw,
rectangle[2] - BOX_SIZE,
rectangle[1],
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_local_rect",
"label": self.label,
"index": meta["index"],
"final_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
elif kind == "local_text":
box = draw_checkbox(
draw,
pos["x"] + pos["w"] - BOX_SIZE,
pos["y"],
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_local",
"label": self.label,
"index": meta["index"],
"final_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
def _output_complete(output_dir: Path) -> bool:
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
def _render_student(
workspace: EvaluationWorkspace,
student_id: str,
labels: dict[str, dict[str, Any]],
*,
overwrite: bool,
output_mode: str,
) -> str:
output_dir = workspace.annotation_dir(output_mode) / f"Copie{student_id}"
if _output_complete(output_dir) and not overwrite:
print(f"Skipping {student_id}: output is complete.")
return "skipped"
print(f"Generating checkable PDF for: {student_id}")
label_images: list[Image.Image] = []
checkbox_groups: list[list[dict[str, Any]]] = []
bnote_entries: list[dict[str, Any]] = []
problems = False
for label, content in sorted(labels.items(), key=lambda item: natural_key(item[0])):
pdf_path = Path(content["pdf_path"])
if not pdf_path.exists():
print(f"Warning: answer PDF not found: {pdf_path}")
problems = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
checkbox_renderer = CheckboxRenderer(label)
final_image, header_height = annotating.compose_label_image(
base_image,
label,
content["result"],
content["coordinates"][0],
draw_callback=checkbox_renderer.callback,
)
if final_image is None:
continue
label_images.append(final_image)
checkbox_groups.append(checkbox_renderer.checkboxes)
bnote_entries.append(
{
"id": student_id,
"label": label,
"header_height": header_height,
"img_h": final_image.height,
}
)
if not label_images:
print(f"Warning: no annotations could be rendered for Copie{student_id}")
return "partial"
max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images)
concatenated = Image.new("RGB", (max_width, total_height), "white")
checkbox_map: list[dict[str, Any]] = []
current_y = 0
for index, (image, checkboxes) in enumerate(
zip(label_images, checkbox_groups, strict=True)
):
concatenated.paste(image, (0, current_y))
bnote_entries[index]["hmin"] = current_y
bnote_entries[index]["hmax"] = current_y + image.height
del bnote_entries[index]["img_h"]
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
box[0],
box[1] + current_y,
box[2],
box[3] + current_y,
]
checkbox_map.append(item)
current_y += image.height
with staged_directory(output_dir) as staging:
atomic_write_json(
staging / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(staging / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage(
str(reference),
0,
0,
width=max_width,
height=total_height,
)
pdf_canvas.save()
return "partial" if problems else "success"
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
if target == workspace.root:
return None
match = re.search(r"Copie(\d+)", target.name)
if match is None:
raise CliError(f"Could not extract a copy id from target: {target}")
return match.group(1)
def _load_refaire(workspace: EvaluationWorkspace):
workspace.require_files("refaire.json")
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise CliError("refaire.json must contain a JSON array")
return loaded
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
overwrite: bool = False,
refaire: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
utils.read_all_labels(workspace.root)
copy_id = _copy_id_from_target(workspace, target)
refaire_list = _load_refaire(workspace) if refaire else None
loaded = load_annotation_data(
workspace,
refaire_list=refaire_list,
copy_id=None if refaire else copy_id,
)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_mode = "refaire" if refaire else "checks"
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
statuses: list[str] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
_render_student,
workspace,
student_id,
labels,
overwrite=overwrite,
output_mode=output_mode,
)
for student_id, labels in tasks
]
for future in futures:
statuses.append(future.result())
if loaded.warnings or "partial" in statuses:
return ExitCode.PARTIAL
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.")
parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs")
parser.add_argument(
"--refaire",
action="store_true",
help="Process only entries from refaire.json",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handler(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(
workspace,
target,
overwrite=args.overwrite,
refaire=args.refaire,
)
return execute(parser, argv, handler)
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
ExitCode,
atomic_write_bytes,
execute,
standard_parser,
)
def _client():
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
return genai.Client(api_key=config.API_KEY)
def list_jobs(*, client=None) -> ExitCode:
client = client or _client()
print("Fetching recent batch jobs...")
jobs = list(client.batches.list())
for job in jobs:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.name}: {state}")
if getattr(job, "display_name", None):
print(f" Display name: {job.display_name}")
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f" Error: {job.error}")
destination = getattr(job, "dest", None)
if state == "JOB_STATE_SUCCEEDED" and getattr(
destination, "file_name", None
):
print(f" Output file: {destination.file_name}")
if not jobs:
print("No batch jobs found.")
return ExitCode.SUCCESS
def download_job(
job_name: str,
*,
output: Path | None = None,
client=None,
) -> ExitCode:
client = client or _client()
job = client.batches.get(name=job_name)
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"State: {state}")
if state != "JOB_STATE_SUCCEEDED":
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f"Error: {job.error}")
return ExitCode.PARTIAL
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print("Job succeeded but no output file was found.")
return ExitCode.PARTIAL
payload = client.files.download(file=file_name)
output_path = output or Path(f"results_{job_name.replace('/', '_')}.jsonl")
atomic_write_bytes(output_path, payload)
print(f"Saved batch results to {output_path}")
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = standard_parser("List or download Gemini correction batch jobs")
parser.add_argument("--download", metavar="JOB_NAME")
parser.add_argument("--output", type=Path, help="Downloaded JSONL destination")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.output is not None and not args.download:
raise CliError("--output requires --download", ExitCode.INVALID_ARGUMENTS)
if args.download:
return download_job(args.download, output=args.output)
return list_jobs()
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import argparse
import uuid
from collections.abc import Sequence
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from copienator import (
EvaluationWorkspace,
ExitCode,
execute,
standard_parser,
workspace_from_args,
)
def copy_pdfs(directory: Path) -> list[Path]:
directory = directory.expanduser().resolve()
if not directory.is_dir():
raise NotADirectoryError(f"Dossier introuvable : {directory}")
return sorted(
(
path
for path in directory.glob("*.pdf")
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
),
key=lambda path: path.name.casefold(),
)
def rotate_pdf(path: Path) -> None:
temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf")
try:
with path.open("rb") as source, temporary.open("wb") as destination:
reader = PdfReader(source)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page.rotate(180))
if reader.metadata:
metadata = {
str(key): str(value)
for key, value in reader.metadata.items()
if value is not None
}
writer.add_metadata(metadata)
writer.write(destination)
temporary.replace(path)
finally:
if temporary.exists():
temporary.unlink()
def rotate_all(directory: Path) -> int:
files = copy_pdfs(directory)
for path in files:
rotate_pdf(path)
print(f"Rotated: {path.name}")
if not files:
print("No PDF copies found.")
return len(files)
def rename_all(directory: Path) -> list[tuple[Path, Path]]:
directory = directory.expanduser().resolve()
files = copy_pdfs(directory)
width = max(2, len(str(len(files))))
plan = [
(source, directory / f"Copie{index:0{width}d}.pdf")
for index, source in enumerate(files, start=1)
]
staged: list[tuple[Path, Path, Path]] = []
try:
for source, destination in plan:
temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf"
source.replace(temporary)
staged.append((source, temporary, destination))
completed: list[tuple[Path, Path, Path]] = []
try:
for source, temporary, destination in staged:
temporary.replace(destination)
completed.append((source, temporary, destination))
print(f"Renamed: {source.name} -> {destination.name}")
except OSError:
for _source, temporary, destination in completed:
if destination.exists():
destination.replace(temporary)
raise
except OSError:
for source, temporary, _destination in staged:
if temporary.exists():
temporary.replace(source)
raise
if not plan:
print("No PDF copies found.")
return [(source, destination) for source, _temporary, destination in staged]
def build_parser() -> argparse.ArgumentParser:
parser = standard_parser("Prepare scanned PDF copies.")
subparsers = parser.add_subparsers(dest="operation", required=True)
for operation in ("rotate", "rename"):
subparser = subparsers.add_parser(operation)
subparser.add_argument("evaluation", type=Path, help="Evaluation directory")
subparser.add_argument(
"--verbose",
action="store_true",
default=argparse.SUPPRESS,
help="Show a traceback when an unexpected error occurs",
)
return parser
def run(workspace: EvaluationWorkspace, *, operation: str) -> ExitCode:
if operation == "rotate":
rotate_all(workspace.root)
else:
rename_all(workspace.root)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), operation=args.operation),
)
if __name__ == "__main__":
raise SystemExit(main())
+985
View File
@@ -0,0 +1,985 @@
from __future__ import annotations
import argparse
import base64
import concurrent.futures
import json
import os
import shlex
import shutil
import sys
import threading
import time
from collections.abc import Sequence
from pathlib import Path
from google import genai
from copienator import configuration as config
from copienator.commands import grouping
from copienator import prompting
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
atomic_write_text,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.utils import enonce_total, read_all_labels
NB_THREADS = 12
# PROXY_URL = "http://192.168.241.1:3128"
MODEL_ID_pro = config.MODEL_PRO_ID
MODEL_ID_flash = config.MODEL_FLASH_ID
api_key = config.API_KEY
# Runtime globals retained while the processing helpers are migrated incrementally.
INPUT_DIR = Path()
COPIES_DIR = Path()
GROUPS_DIR = Path()
output_path = Path()
progress_path = Path()
tasks: list[tuple] = []
tasks_to_process: list[tuple] = []
results: dict = {}
completed_tasks: list = []
errors_summary: list = []
overwrite = False
limit = None
client = None
start_time = 0.0
# --- Thread-safe Logging ---
log_lock = threading.Lock()
thread_logs = {}
def tprint(*args, **kwargs):
"""Buffer messages per thread to group them."""
tid = threading.current_thread().name
msg = " ".join(map(str, args))
with log_lock:
if tid not in thread_logs:
thread_logs[tid] = []
thread_logs[tid].append(msg)
# Optional: Keep printing to console but prefix with thread name
print(f"[{tid}] {msg}", **kwargs)
def flush_thread_log(tid=None):
"""Append a thread's buffered messages to the log file contiguously."""
tid = tid or threading.current_thread().name
with log_lock:
if thread_logs.get(tid):
with open(INPUT_DIR / "correction_log", "a", encoding="utf-8") as f:
f.write(f"--- Task Log [{tid}] ---\n")
f.write("\n".join(thread_logs[tid]) + "\n\n")
thread_logs[tid].clear()
# --- Lock for thread-safe file writing ---
io_lock = threading.Lock()
pro_lock = threading.Lock()
pro_count = 0
flash_count = 0
pro_quota_exhausted = False
def discover_tasks(
workspace: EvaluationWorkspace,
targets: list[Path],
) -> tuple[list[tuple[str, str]], list[str]]:
workspace.require_directories("Copies", "Par label")
discovered: list[tuple[str, str]] = []
warnings: list[str] = []
for target in targets:
if target.is_file():
if target.suffix.casefold() != ".jpg":
raise CliError(
f"Correction target is not a group JPG: {target}",
ExitCode.INVALID_ARGUMENTS,
)
try:
target.relative_to(workspace.groups_dir)
except ValueError as exc:
raise CliError(
f"Group image is not inside {workspace.groups_dir}: {target}",
ExitCode.INVALID_ARGUMENTS,
) from exc
discovered.append((str(target), target.parent.name))
continue
group_directories = sorted(
(path for path in workspace.groups_dir.iterdir() if path.is_dir()),
key=lambda path: path.name.casefold(),
)
for group_directory in group_directories:
images = sorted(
group_directory.glob("*.jpg"), key=lambda path: path.name.casefold()
)
discovered.extend(
(str(image), group_directory.name) for image in images
)
if not group_directories:
warnings.append(f"No label groups found in {workspace.groups_dir}")
return list(dict.fromkeys(discovered)), warnings
def configure_runtime(
workspace: EvaluationWorkspace,
discovered_tasks: list[tuple[str, str]],
args: argparse.Namespace,
*,
api_client=None,
) -> None:
global INPUT_DIR, COPIES_DIR, GROUPS_DIR, output_path, progress_path
global tasks, tasks_to_process, results, completed_tasks, errors_summary
global overwrite, limit, client, start_time
global pro_count, flash_count, pro_quota_exhausted
INPUT_DIR = workspace.root
COPIES_DIR = workspace.copies_dir
GROUPS_DIR = workspace.groups_dir
output_path = workspace.correction_file
progress_path = workspace.correction_progress_file
tasks = list(discovered_tasks)
overwrite = bool(args.overwrite)
limit = args.limit
start_time = time.time()
errors_summary = []
completed_tasks = []
results = {label: [] for _file, label in tasks}
thread_logs.clear()
pro_count = 0
flash_count = 0
pro_quota_exhausted = False
if not overwrite:
if progress_path.is_file():
loaded_progress = read_json(progress_path)
if not isinstance(loaded_progress, list):
raise TypeError("correction_progress.json must contain a JSON array")
completed_tasks = loaded_progress
if output_path.is_file():
loaded_results = read_json(output_path)
if not isinstance(loaded_results, dict):
raise TypeError("correction.json must contain a JSON object")
results = loaded_results
completed_set = {(str(file_path), label) for file_path, label in completed_tasks}
tasks_to_process = [
task for task in tasks if (str(task[0]), task[1]) not in completed_set
]
client = api_client
def reset_workspace(workspace: EvaluationWorkspace) -> None:
"""Apply the explicitly requested correction reset."""
print("--- Running Reset ---")
for path in (workspace.correction_file, workspace.correction_progress_file):
if path.exists():
path.unlink()
print(f"Deleted: {path}")
if workspace.copies_dir.is_dir():
for copy_directory in workspace.copies_dir.iterdir():
if not copy_directory.is_dir():
continue
for old_pdf in copy_directory.glob("*_old.pdf"):
original = old_pdf.with_name(old_pdf.name.replace("_old.pdf", ".pdf"))
if original.exists():
original.unlink()
old_pdf.replace(original)
print(f"Moved: {copy_directory.name}/{old_pdf.name} -> {original.name}")
for new_pdf in copy_directory.glob("*_new.pdf"):
new_pdf.unlink()
print(f"Deleted: {copy_directory.name}/{new_pdf.name}")
print(
"Reset almost complete. Manually remove groups associated with deleted "
"_new PDFs from 'Par label'."
)
def call_gemini_with_retries(model_id, contents, config,
fallback_model_id=MODEL_ID_flash):
"""Handles requests to Gemini with a 1min and 5min retry mechanism, and quota fallback."""
global pro_quota_exhausted
delays = [60, 300]
for attempt in range(3):
# Switch to fallback immediately if quota was exhausted by another thread
if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id:
model_id = fallback_model_id
try:
full_response_text = ""
for chunk in client.models.generate_content_stream(
model=model_id,
contents=contents,
config=config,
):
if chunk.text:
full_response_text += chunk.text
return full_response_text
except Exception as e:
error_msg = str(e).lower()
is_quota_error = "429" in error_msg or "quota" in error_msg or "exhausted" in error_msg
is_minute_limit = "minute" in error_msg or "rpm" in error_msg or "tpm" in error_msg
if is_minute_limit:
import re
# Extract wait time if present, else use default delay
retry_match = re.search(r"retry in ([\d.]+)s", error_msg)
wait_time = float(retry_match.group(1)) + 1.0 if retry_match else delays[attempt]
tprint(f"\tGemini Pro minute limit hit. Waiting {wait_time:.1f}s...")
time.sleep(wait_time)
continue # Retry same model
# Immediately fallback to Flash without waiting if it's a Pro quota error
if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id:
tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...")
model_id = fallback_model_id
pro_quota_exhausted = True
continue # Retry immediately with Flash
if attempt < 2:
tprint(f"\tGemini API failure: {e}. Retrying in {delays[attempt]} seconds...")
time.sleep(delays[attempt])
else:
tprint(f"\tGemini API failure: {e}. Maximum retries reached.")
raise
def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
yming, ymaxg, width_r, total_height):
"""Requests corrected bounding boxes from Gemini Flash on the single image."""
# pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks)
response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config)
corrected_feedbacks = json.loads(response_text)
global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]]
# Map the coordinates back from the single image to the group canvas
for f in corrected_feedbacks:
b = f.get("box_2d")
if b:
ymin_s, xmin_s, ymax_s, xmax_s = b
# Y mapping: Add the group Y-offset (yming), then normalize to total_height
single_h = ymaxg - yming
new_ymin = int((yming + (ymin_s * single_h / 1000.0)) * 1000.0 / total_height)
new_ymax = int((yming + (ymax_s * single_h / 1000.0)) * 1000.0 / total_height)
# X mapping: Multiply by the width ratio of this sub-image vs the group image
new_xmin = int(xmin_s * width_r)
new_xmax = int(xmax_s * width_r)
f["box_2d"] = [new_ymin, new_xmin, new_ymax, new_xmax]
return global_feedbacks + corrected_feedbacks
def get_next_group_idx(label):
"""Finds the next available Group index for a given label."""
target_folder = GROUPS_DIR / label
target_folder.mkdir(exist_ok=True)
existing = list(target_folder.glob("Group_*.jpg"))
if not existing: return 0
return max([int(f.stem.split("_")[1]) for f in existing])
def handle_label_errors(pid, label, res, pdf_path):
"""Handles Gemini labeling errors, moves/copies files, and returns new tasks."""
new_tasks = []
error_type = res.get("error")
all_labels = read_all_labels(INPUT_DIR)
labels_txt = (INPUT_DIR / "labels").read_text(encoding="utf-8", errors="replace")
enonce = enonce_total(INPUT_DIR)
if error_type == "wrong-label":
tprint(f"\tHandling wrong-label for {pid} {label}")
contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt)
new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'')
if new_label not in all_labels:
tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring")
res["error"] = "wrg-lbl:cldtfix"
return []
if new_label == label:
res["error"] = ""
return []
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}_new.pdf"
if base_new_pdf_path.exists() or new_pdf_path.exists():
tprint(f"""\t\tCopie{pid} tried to move wrong {label} to {new_label},
but it already exists. Delaying.""")
# res["error"] = f"wrg-lbl:{new_label}?exists"
res["error"] = f"wrg-lbl:{new_label}?"
res.setdefault("delayed", []).append(["wrong-label", new_label])
else:
res["error"] = f"wrg-lbl-moved-to:{new_label}"
tprint(f"\t\tCopie{pid} : moving wrong {label} to {new_label}.")
# Copie vers _new, puis renommage de l'original vers _old
shutil.copy(str(pdf_path), str(new_pdf_path))
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(new_label)
height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
tprint(f"\t\tMaking {new_label} group {idx+1}")
new_tasks.append((str(GROUPS_DIR / new_label / f"Group_{idx+1}.jpg"),
new_label, False))
elif error_type == "additional-answer":
contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt)
tprint(f"\tHandling additional-answer for {pid} {label}")
try:
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config))
except Exception: # noqa: BLE001 - invalid auxiliary model response
add_labels = []
keep_error = False
error = "al:"
for add_label in add_labels:
if add_label == label:
continue
if add_label not in all_labels:
tprint(f"\t\t Inexistent label ({add_label}) from additional-answer processing {pid} {label}. Ignoring")
error += f"{add_label}??"
keep_error = True
continue
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}_new.pdf"
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
shutil.copy(str(pdf_path), str(add_pdf_path))
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
idx = get_next_group_idx(add_label)
tprint(f"\t\tMaking {add_label} group {idx+1}")
height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(add_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / add_label / f"Group_{idx+1}.jpg"),
add_label, False))
error += f"(->){add_label}"
keep_error = True
else:
keep_error = True
error += f"(->){add_label}?"
res.setdefault("delayed", []).append(["add-label", add_label])
tprint(f"\t\tAlready present (not copied) Copie{pid} : {label} -> {add_label}. Delaying.")
if not keep_error:
res["error"] = ""
else:
res["error"] = error
return new_tasks
def process_single_task(task_tuple, precomputed_response=None):
try:
global pro_count, flash_count
file_path = task_tuple[0]
label = task_tuple[1]
can_spawn_tasks = task_tuple[2] if len(task_tuple) > 2 else True
group_name = os.path.splitext(file_path)[0]
json_path = group_name + '.json'
new_tasks = []
group_data = read_json(json_path)
n = len(group_data)
d_data = {l[0]: (l[1], l[2], l[3]) for l in group_data}
total_height = group_data[-1][2]
use_flash = n >= 4 or total_height <= 500
# Only apply limits and counts if we are making a live call
if precomputed_response is None:
if not use_flash:
with pro_lock:
if pro_quota_exhausted:
use_flash = True
elif limit is None or pro_count < limit:
pro_count += 1
else:
use_flash = True
if use_flash:
with pro_lock:
flash_count += 1
try:
contents, config = prompting.generate_request(INPUT_DIR, file_path, label)
model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro
if precomputed_response:
tprint(f"Using batched response for: {label} {group_name}")
full_response_text = precomputed_response
else:
tprint(f"Asking Gemini {'Flash' if use_flash else 'Pro '}: {label} {group_name}")
full_response_text = call_gemini_with_retries(model_to_use, contents, config)
json_data = json.loads(full_response_text)
# Ensure consistency of answer placements
for p in json_data:
pid = p["id"]
res = p["result"]
yming, ymaxg, width_r = d_data[pid]
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
current_suffix = ""
# Détection du vrai fichier s'il a un suffixe
if not pdf_path.exists():
if pdf_path.with_name(f"{label}_new.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
current_suffix = "_new"
# Quand est-ce que ce chemin est utilisé ? Jamais ?
elif pdf_path.with_name(f"{label}_old.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
current_suffix = "_old"
# 1. Gestion de empty-answer
if res.get("error") == "empty-answer":
old_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path.exists() and pdf_path != old_path:
shutil.move(str(pdf_path), str(old_path))
pdf_path = old_path
current_suffix = "_old"
if (not can_spawn_tasks) and res["error"] == "additional-answer":
tprint("\tSwallowing an additional-answer from a subsequent task.")
res["error"]= ""
if res["error"] != "":
tprint("\tError :", res["error"], "for Copie", pid, group_name)
if can_spawn_tasks and res.get("error") in ["wrong-label", "additional-answer"]:
new_tasks.extend(handle_label_errors(pid, label, res, pdf_path))
# Si "wrong-label" a déplacé le fichier courant vers _old
if res.get("error", "").startswith("wrg-lbl-moved-to:"):
current_suffix = "_old"
# 5. Enregistrer l'information dans correction.json
if current_suffix:
res["suffix"] = current_suffix
needs_correction = []
for (i,f) in enumerate(res["feedback"]):
b = f.get("box_2d")
if b:
ymin, _xmin, ymax, xmax = b
ymin = ymin * total_height // 1000
ymax = ymax * total_height // 1000
if pid not in d_data:
tprint("Error : Gemini answered a copie id not present",
pid, label, group_name)
continue
if (ymin < yming - 50 or ymax > ymaxg + 50 or xmax / 1000 > width_r):
needs_correction.append(i)
break
if ymin < yming - 5:
ymin = yming - 5
b[0] = ymin * 1000 // total_height
if ymax > ymaxg + 5:
ymax = ymaxg + 5
b[2] = ymax * 1000 // total_height
if needs_correction:
tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...")
try:
# Pensez à passer pdf_path à la fonction modifiée !
res["feedback"] = correct_boxes_with_gemini(
pid, label, pdf_path, res["feedback"],
yming, ymaxg, width_r, total_height)
except Exception as e: # noqa: BLE001 - correction fallback
tprint(f"\tCorrection failed for Copie {pid}, {group_name} : {e}\n\tRemoving the boxes")
# Fallback if the second request fails entirely
for (i, f) in enumerate(res["feedback"]):
if i in needs_correction:
f["box_2d"] = None
# --- Use Lock for writing shared data ---
with io_lock:
if label not in results:
results[label] = []
results[label].append(json_data)
atomic_write_json(output_path, results)
# To track progress
completed_tasks.append((file_path, label))
atomic_write_json(progress_path, completed_tasks)
except json.JSONDecodeError:
tprint(f"Error decoding JSON for {file_path}", file=sys.stderr)
with io_lock:
errors_summary.append(("Error decoding JSON response", file_path))
except Exception as e: # noqa: BLE001 - per-task processing boundary
error_msg = f"Exception processing {file_path}: {e}"
print(error_msg, file=sys.stderr)
with io_lock:
errors_summary.append((error_msg, file_path))
return new_tasks
finally:
flush_thread_log()
def resolve_delayed_moves():
"""Scans the current results to find delayed moves and executes them if space was freed."""
new_tasks = []
with io_lock:
for label, batches in results.items():
for batch in batches:
for p in batch:
res = p.get("result", {})
delayed_list = res.get("delayed", [])
if not delayed_list:
continue
pid = p["id"]
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
if not pdf_path.exists():
if pdf_path.with_name(f"{label}_new.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
elif pdf_path.with_name(f"{label}_old.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
remaining_delayed = []
resolved_any = False
for delay_type, target_label in delayed_list:
# 1. Résolution de wrong-label
if delay_type == "wrong-label":
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
if not base_new_pdf_path.exists() and not new_pdf_path.exists():
tprint(f"Resolving delayed move: Copie{pid} {label} -> {target_label}")
res["error"] = f"wrg-lbl-moved-to:{target_label}"
res["suffix"] = "_old" # Fixed typo: was suffixe
resolved_any = True
shutil.copy(str(pdf_path), str(new_pdf_path))
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(target_label)
height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
else:
remaining_delayed.append([delay_type, target_label])
# 2. Résolution de additional-answer
elif delay_type == "add-label":
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
tprint(f"Resolving delayed additional-answer: Copie{pid} {label} -> {target_label}")
res["error"] = res["error"].replace(f"(xx){target_label}", f"(->){target_label}")
resolved_any = True
shutil.copy(str(pdf_path), str(add_pdf_path))
idx = get_next_group_idx(target_label)
height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
else:
remaining_delayed.append([delay_type, target_label])
if resolved_any:
if remaining_delayed:
res["delayed"] = remaining_delayed
else:
del res["delayed"]
if new_tasks:
atomic_write_json(output_path, results)
return new_tasks
def run_configured(args: argparse.Namespace) -> ExitCode:
global client, tasks_to_process
if client is None:
client = genai.Client(api_key=api_key)
if args.refaire:
refaire_path = INPUT_DIR / "refaire.json"
overwritten_path = INPUT_DIR / "overwritten_correction.json"
if refaire_path.exists():
refaire_list = read_json(refaire_path)
overwritten_data = []
if overwritten_path.exists():
overwritten_data = read_json(overwritten_path)
dirty_results = False
for copie_name, labels in refaire_list:
pid = copie_name.replace("Copie", "")
copie_dir = COPIES_DIR / copie_name
# If list is empty, redo all labels available for this Copie
if not labels:
labels_set = set()
for p in copie_dir.glob("*.pdf"):
if p.name.endswith("_old.pdf"):
continue # Strictly ignore old files
elif p.name.endswith("_new.pdf"):
labels_set.add(p.stem[:-4]) # Strip '_new' to get base label
else:
labels_set.add(p.stem)
labels = list(labels_set)
for label in labels:
# 1. Extract and backup old corrections
if label in results:
for batch in results[label]:
to_remove = None
for item in batch:
if item.get("id") == pid:
to_remove = item
break
if to_remove:
batch.remove(to_remove)
overwritten_data.append({
"pid": pid,
"label": label,
"data": to_remove,
"timestamp": time.time()
})
dirty_results = True
# Clean up empty batches
results[label] = [b for b in results[label] if b]
# 2. Make new group and add to tasks
pdf_path = copie_dir / f"{label}.pdf"
is_new = False
if (
not pdf_path.exists()
and (copie_dir / f"{label}_new.pdf").exists()
):
pdf_path = copie_dir / f"{label}_new.pdf"
is_new = True
# elif (copie_dir / f"{label}_old.pdf").exists():
# pdf_path = copie_dir / f"{label}_old.pdf"
if pdf_path.exists():
idx = get_next_group_idx(label)
height = grouping.get_pdf_height(str(pdf_path))
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg")
tasks_to_process.append((new_group_path, label, not is_new))
if dirty_results:
atomic_write_json(output_path, results)
atomic_write_json(overwritten_path, overwritten_data)
else:
print(f"Warning: --refaire flag used, but {refaire_path} not found.", file=sys.stderr)
if args.batch or args.batch_from:
all_labels = read_all_labels(INPUT_DIR)
batch_tasks = []
if args.batch_from:
for label in all_labels:
if label.startswith(args.batch_from):
args.batch_from = label
input(f"About to batch from: {args.batch_from}. Press Enter to confirm...")
break
if args.batch_from not in all_labels:
raise CliError(
f"Label '{args.batch_from}' not found. Available labels: "
f"{all_labels}",
ExitCode.INVALID_ARGUMENTS,
)
target_idx = all_labels.index(args.batch_from)
live_tasks = []
for task in tasks_to_process:
lbl = task[1]
# Any label found sequentially equal or after `args.batch_from` gets batched
if lbl in all_labels and all_labels.index(lbl) >= target_idx:
batch_tasks.append(task)
else:
live_tasks.append(task)
tasks_to_process = live_tasks # Keep live tasks to be run right after
else:
batch_tasks = tasks_to_process
tasks_to_process = [] # Run nothing live if just `--batch`
if batch_tasks:
batch_flash_file = INPUT_DIR / "batch_requests_flash.jsonl"
batch_pro_file = INPUT_DIR / "batch_requests_pro.jsonl"
count_flash = 0
count_pro = 0
flash_lines = []
pro_lines = []
for task in batch_tasks:
file_path, label = task[0], task[1]
json_path = Path(file_path).with_suffix(".json")
group_data = read_json(json_path)
use_flash = len(group_data) >= 4 or group_data[-1][2] <= 500
b64_img = base64.b64encode(Path(file_path).read_bytes()).decode(
"utf-8"
)
request = {
"key": file_path,
"request": {
"contents": [
{
"role": "user",
"parts": [
{
"inlineData": {
"mimeType": "image/jpeg",
"data": b64_img,
}
},
{"text": prompting.make_prompt(INPUT_DIR, label)},
],
}
],
"generation_config": {
"temperature": 1.0,
"topP": 0.95,
"maxOutputTokens": 65535,
"responseMimeType": "application/json",
"responseSchema": prompting.UNROLLED_SCHEMA,
},
},
}
line = json.dumps(request)
if use_flash:
flash_lines.append(line)
count_flash += 1
else:
pro_lines.append(line)
count_pro += 1
atomic_write_text(
batch_flash_file,
"\n".join(flash_lines) + ("\n" if flash_lines else ""),
)
atomic_write_text(
batch_pro_file,
"\n".join(pro_lines) + ("\n" if pro_lines else ""),
)
print("Batch generation complete.")
print(f" - {count_flash} requests saved to {batch_flash_file} (for {MODEL_ID_flash})")
print(f" - {count_pro} requests saved to {batch_pro_file} (for {MODEL_ID_pro})")
print("Upload these files via the File API and create two separate batch jobs.")
# If there's no live tasks to do, and we aren't doing a batched ingestion, exit right away
if not tasks_to_process and not args.deal_with_batched:
return ExitCode.SUCCESS
batched_responses = {}
if args.deal_with_batched:
batch_results_path = INPUT_DIR / "batched_correction_result.jsonl"
if batch_results_path.exists():
print(f"Loading batch results from {batch_results_path}...")
with open(batch_results_path, "r", encoding="utf-8") as f:
for line in f:
if not line.strip(): continue
data = json.loads(line)
task_id = data.get("key") # Corresponds to the key sent in the request
if "response" in data:
try:
# Extract the JSON response text per standard Batch API schema
resp_text = data["response"]["candidates"][0]["content"]["parts"][0]["text"]
batched_responses[task_id] = resp_text
except (KeyError, IndexError) as e:
print(f"Warning: Could not parse response for {task_id}: {e}", file=sys.stderr)
elif "error" in data:
print(f"Batch API Error for {task_id}: {data['error']}", file=sys.stderr)
else:
print(f"Warning: Batch results file {batch_results_path} not found.", file=sys.stderr)
made_progress = True
while tasks_to_process or made_progress:
if tasks_to_process:
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
futures = {}
for task in tasks_to_process:
file_path = task[0]
precomp = batched_responses.get(file_path)
futures[executor.submit(process_single_task, task, precomp)] = task
for future in concurrent.futures.as_completed(futures):
try:
new_generated_tasks = future.result()
if new_generated_tasks:
for new_task in new_generated_tasks:
futures[executor.submit(process_single_task, new_task)] = new_task
except Exception as e: # noqa: BLE001 - future boundary
print(f"Exception during task execution: {e}", file=sys.stderr)
failed_task = futures[future]
with io_lock:
errors_summary.append((str(e), failed_task[0]))
tasks_to_process = [] # Vider la liste une fois traitée
# Après avoir traité toutes les tâches actuelles (live ou batched),
# on tente de débloquer les mouvements qui étaient en attente
delayed_tasks = resolve_delayed_moves()
if delayed_tasks:
print(f"Resolved {len(delayed_tasks)} delayed moves! Running executor for new tasks...")
tasks_to_process.extend(delayed_tasks)
made_progress = True
else:
made_progress = False
# Check for remaining unresolved delayed tasks
unresolved_delayed = []
with io_lock:
for label, batches in results.items():
for batch in batches:
for p in batch:
res = p.get("result", {})
delayed = res.get("delayed", [])
pid = p["id"]
for delay_type, target_label in delayed:
if delay_type == "wrong-label":
unresolved_delayed.append(f"Copie{pid} {label} x> {target_label}|")
elif delay_type == "add-label":
unresolved_delayed.append(f"Copie{pid} {label} -> {target_label}|")
if unresolved_delayed:
manual_path = INPUT_DIR / "manual_resolutions.txt"
atomic_write_text(
manual_path,
"### Use -> x>, -x, ss, sx, xx, xs\n"
+ "\n".join(unresolved_delayed)
+ "\n",
)
print(f"\n[!] Unresolved delayed tasks found! Wrote to {manual_path}.")
print(" Please edit it manually, then run `python -m copienator resolve-manual <InputDir>`")
end_time = time.time()
print("Time elapsed : ", end_time - start_time)
print("Requests to pro / flash : ", pro_count, flash_count)
if errors_summary:
print("\n--- Summary of Exceptions (You can use several images on one instance) ---", file=sys.stderr)
for (err, file) in errors_summary:
print(err, file=sys.stderr)
escaped_path = shlex.quote(str(file))
print(f"Run : python -m copienator correct {escaped_path}")
return ExitCode.PARTIAL if errors_summary else ExitCode.SUCCESS
def run(
workspace: EvaluationWorkspace,
targets: list[Path],
args: argparse.Namespace,
*,
api_client=None,
) -> ExitCode:
if args.reset:
workspace.require_directories("Copies")
reset_workspace(workspace)
return ExitCode.SUCCESS
workspace.require_directories("Copies", "Par label")
workspace.require_files("labels")
if args.refaire:
workspace.require_files("refaire.json")
discovered, warnings = discover_tasks(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
configure_runtime(workspace, discovered, args, api_client=api_client)
if not discovered and not args.refaire:
return ExitCode.PARTIAL
try:
status = run_configured(args)
finally:
for thread_id in list(thread_logs):
flush_thread_log(thread_id)
if warnings and status == ExitCode.SUCCESS:
return ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Correct grouped answers with Gemini")
parser.add_argument(
"additional_targets",
nargs="*",
type=Path,
help="Additional group JPG files from the same evaluation",
)
parser.add_argument("--overwrite", action="store_true", help="Redo requests")
parser.add_argument("--limit", type=int, help="Maximum Gemini Pro calls")
parser.add_argument(
"--refaire",
action="store_true",
help="Redo copies and labels listed in refaire.json",
)
parser.add_argument(
"--batch",
action="store_true",
help="Generate Gemini batch request JSONL files",
)
parser.add_argument(
"--batch-from",
metavar="LABEL",
help="Process earlier labels live and batch from LABEL onward",
)
parser.add_argument(
"--deal-with-batched",
action="store_true",
help="Consume batched_correction_result.jsonl",
)
parser.add_argument(
"--reset",
action="store_true",
help="Delete correction state, restore _old PDFs, and delete _new PDFs",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
targets = [target]
for additional in args.additional_targets:
resolved = additional.expanduser().resolve()
if not resolved.exists():
raise CliError(
f"Target does not exist: {resolved}",
ExitCode.INVALID_WORKSPACE,
)
if EvaluationWorkspace.discover(resolved).root != workspace.root:
raise CliError(
"All targets must belong to the same evaluation",
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, args)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+344
View File
@@ -0,0 +1,344 @@
from __future__ import annotations
import argparse
import threading
import time
import tkinter as tk
from collections.abc import Sequence
from functools import lru_cache
from pathlib import Path
from queue import Empty, Queue
from threading import Thread
from pdf2image import convert_from_path
from PIL import Image, ImageTk
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_files
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
OUTPUT_SIZE = (1800, 1000)
pdf_cache_lock = threading.Lock()
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
"""Distribute pages into balanced chunks no larger than max_per_file."""
if total_pages == 0:
return []
number_of_files = (total_pages + max_per_file - 1) // max_per_file
base_count, remainder = divmod(total_pages, number_of_files)
return [
base_count + (1 if index < remainder else 0)
for index in range(number_of_files)
]
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)
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
if index < len(image_list) - 1:
delimiter = Image.new(
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
)
combined.paste(delimiter, (x_offset, 0))
x_offset += DELIMITER_WIDTH
return combined
@lru_cache(maxsize=3)
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
return convert_from_path(pdf_path)
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
"""Thread-safe wrapper around the small PDF conversion cache."""
with pdf_cache_lock:
return _get_pdf_pages_cached(pdf_path)
def process_single_pdf(
pdf_path: Path,
shift_offset: int = 0,
max_per_file: int = 5,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
cropped_images = []
for image in get_pdf_pages(pdf_path):
width, height = image.size
if max_per_file == 1:
left, right = 0, width
else:
left = max(0, 100 + shift_offset)
right = min(width, width // 3 + 100 + shift_offset)
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
if not cropped_images:
return None
distribution = distribute_pages(len(cropped_images), max_per_file)
split_images = []
current_index = 0
for count in distribution:
stitched = stitch_images(cropped_images[current_index : current_index + count])
if stitched is not None:
split_images.append(stitched)
current_index += count
full_stitch = stitch_images(cropped_images)
if full_stitch is None:
return None
preview = full_stitch.resize(OUTPUT_SIZE, Image.Resampling.BILINEAR)
schema: dict[str, object] = {
"original_filename": pdf_path.name,
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": distribution,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
print(f"Error processing {pdf_path.name}: {exc}")
return None
def _previous_cutleft_outputs(output_dir: Path, base_name: str) -> set[str]:
if not output_dir.is_dir():
return set()
result = {f"{base_name}_schema.json"}
for path in output_dir.glob(f"{base_name}_*.jpg"):
suffix = path.stem.removeprefix(f"{base_name}_")
if suffix.isdigit():
result.add(path.name)
return result
def save_results(
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
output_dir: Path,
) -> None:
"""Atomically replace every Cutleft output associated with one copy."""
_, splits, schema = result
base_name = pdf_path.stem
previous = _previous_cutleft_outputs(output_dir, base_name)
with staged_files(output_dir, remove=previous) as staging:
for index, image in enumerate(splits, start=1):
filename = f"{base_name}_{index:02d}.jpg"
image.save(staging / filename, "JPEG", quality=95)
atomic_write_json(staging / f"{base_name}_schema.json", schema)
for index in range(1, len(splits) + 1):
print(f"Saved: {base_name}_{index:02d}.jpg")
print(f"Saved schema: {base_name}_schema.json")
class ImageReviewer:
def __init__(
self,
files: list[Path],
output_dir: Path,
default_max_per_file: int = 5,
) -> None:
self.files = files
self.output_dir = output_dir
self.index = 0
self.current_shift = 0
self.default_max_per_file = default_max_per_file
self.current_max_per_file = default_max_per_file
self.current_preview: Image.Image | None = None
self.is_processing = False
self.manual_queue: Queue[
tuple[Image.Image, list[Image.Image], dict[str, object]] | None
] = Queue()
self.root = tk.Tk()
self.root.title("PDF Cropper")
self.root.geometry("+100+100")
self.label_img = tk.Label(self.root)
self.label_img.pack()
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5)
self.root.bind("<Return>", self.on_next)
self.root.bind("n", lambda _event: self.on_shift(50))
self.root.bind("N", lambda _event: self.on_shift(100))
self.root.bind("t", lambda _event: self.on_shift(-50))
self.root.bind("1", lambda _event: self.on_set_max_pages(1))
Thread(target=self.prefetch_worker, daemon=True).start()
self.load_current_image()
self.root.lift()
self.root.focus_force()
self.root.mainloop()
def on_set_max_pages(self, count: int) -> None:
if self.is_processing:
return
self.current_max_per_file = count
print(f"Setting max pages per file: {count}")
self.trigger_processing(self.files[self.index], self.current_shift)
def prefetch_worker(self) -> None:
processed_index = -1
while True:
target = self.index + 1
if target < len(self.files) and target != processed_index:
get_pdf_pages(self.files[target])
processed_index = target
time.sleep(0.05)
def load_current_image(self) -> None:
if self.index >= len(self.files):
print("All files processed.")
self.root.destroy()
return
self.is_processing = False
self.current_shift = 0
self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
self.is_processing = True
self.label_info.configure(
text=f"Processing {pdf_path.name} (Shift {shift})... Please wait.",
fg="red",
)
def worker() -> None:
self.manual_queue.put(
process_single_pdf(pdf_path, shift, self.current_max_per_file)
)
Thread(target=worker, daemon=True).start()
self.check_manual_queue(pdf_path)
def check_manual_queue(self, pdf_path: Path) -> None:
try:
result = self.manual_queue.get_nowait()
if result is None:
print(f"Failed to process {pdf_path.name}, skipping.")
self.index += 1
self.load_current_image()
else:
self.handle_processing_result(result, pdf_path)
self.is_processing = False
except Empty:
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
def handle_processing_result(
self,
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
) -> None:
self.current_preview = result[0]
save_results(result, pdf_path, self.output_dir)
self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None:
if self.current_preview is None:
return
tk_image = ImageTk.PhotoImage(self.current_preview)
self.label_img.configure(image=tk_image)
self.label_img.image = tk_image
self.label_info.configure(
text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | "
f"Shift: {self.current_shift}px\nFiles: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n"
"Enter: Next | n: +50 | N: +100 | t: -50 | "
"1: use single column"
),
fg="black",
)
def on_shift(self, amount: int) -> None:
if self.is_processing:
return
self.current_shift += amount
print(f"Applying shift: {self.current_shift}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_next(self, _event: object) -> None:
if self.is_processing:
return
self.index += 1
self.current_shift = 0
self.current_max_per_file = self.default_max_per_file
self.load_current_image()
def _selected_files(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if "nonc" not in path.name.casefold()
),
key=lambda path: path.name.casefold(),
)
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
fullpage: bool = False,
) -> ExitCode:
files = _selected_files(workspace, target)
if not files:
print("No PDF files found.")
return ExitCode.SUCCESS
workspace.cutleft_dir.mkdir(parents=True, exist_ok=True)
_get_pdf_pages_cached.cache_clear()
ImageReviewer(
files,
workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5,
)
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument(
"--fullpage",
action="store_true",
help="Use each complete page instead of cropping the label margin",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, fullpage=args.fullpage)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import urllib.request
from collections.abc import Sequence
from uuid import uuid4
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import WindowsLabelError, validate_windows_labels
from copienator.utils import compile_to_pdf
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
qinds = ",".join(map(str, indices))
if qinds:
url = f"http://localhost:8080/exercices/exo_q_text/{ex_id}/{qinds}"
else:
url = f"http://localhost:8080/exercices/exo_q_text/{ex_id}"
try:
with urllib.request.urlopen(url) as response:
content = response.read().decode('utf-8')
content = replace_dots(content.strip("\n"))
with open(os.path.join(text_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
f.write(content)
# Compile PDF
pdf_file = os.path.join(text_path, f"{label}.pdf")
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
qinds = ",".join(map(str, indices))
if qinds:
url = f"http://localhost:8080/exercices/exo_q_sol/{ex_id}/{qinds}"
else:
url = f"http://localhost:8080/exercices/exo_q_sol/{ex_id}"
try:
with urllib.request.urlopen(url) as response:
content = response.read().decode('utf-8')
content = replace_dots(content.strip("\n"))
with open(os.path.join(sol_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
f.write(content)
# Compile PDF
pdf_file = os.path.join(sol_path, f"{label}.pdf")
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
ROMANS_LOW = ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"]
def replace_dots(text):
# (?m) enables multiline mode so ^ matches start of each line
return re.sub(r"(?m)^(\s*.)\.", r"\1)", text)
def replace_problem_labels(text):
"""Replaces labels according to spaces depth when problem=True."""
def repl(m):
spaces = m.group(1)
label = m.group(2)
n = len(spaces)
try:
if n == 1 and label.isdigit(): # 1 space: 1) -> I)
return f"{spaces}{ROMANS_CAP[int(label)]})"
elif n == 4 and label.isalpha(): # 4 spaces: a) -> 1)
return f"{spaces}{ord(label.lower()) - 96})"
elif n == 7 and label.isdigit(): # 7 spaces: 1) -> a)
return f"{spaces}{chr(96 + int(label))})"
elif n == 10 and label.isdigit(): # 10 spaces: 1) -> i)
return f"{spaces}{ROMANS_LOW[int(label)]})"
except (IndexError, ValueError):
pass
return m.group(0)
# Matches start of line, spaces, alphanumeric label, and closing parenthesis
return re.sub(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)", repl, text)
def format_indices(indices, problem=False):
if not indices: return ""
if not problem:
res = f"{indices[0]})"
if len(indices) > 1: res += f"{chr(96 + indices[1])})"
if len(indices) > 2: res += f"{ROMANS_LOW[indices[2]]})"
return res
else:
res = ""
if len(indices) > 0: res += f"{ROMANS_CAP[indices[0]]})"
if len(indices) > 1: res += f"{indices[1]})"
if len(indices) > 2: res += f"{chr(96 + indices[2])})"
if len(indices) > 3: res += f"{ROMANS_LOW[indices[3]]})"
return res
def save_split_content(text, path, base_fname, problem):
# Always save the main aggregated file
with open(os.path.join(path, base_fname), 'w', encoding='utf-8') as f:
f.write(text)
pattern = re.compile(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)")
all_matches = list(pattern.finditer(text))
target_spaces = 4 if problem else 1
splits = [m for m in all_matches if len(m.group(1)) == target_spaces]
for i, match in enumerate(splits):
start_idx = match.start()
end_idx = splits[i+1].start() if i + 1 < len(splits) else len(text)
chunk = text[start_idx:end_idx].strip("\n")
label = match.group(2) + ")"
if problem:
# Find the most recent 1-space match before this 4-space match
sec_match = next((m for m in reversed(all_matches)
if len(m.group(1)) == 1 and m.start() < match.start()), None)
if sec_match:
label = f"{sec_match.group(2)}){label}"
sub_fname = f"{base_fname} : {label}"
with open(os.path.join(path, sub_fname), 'w', encoding='utf-8') as f:
f.write(chunk)
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
directory = str(workspace.root)
# Find the first .tex file in the directory
tex_files = glob.glob(os.path.join(directory, "*.tex"))
if not tex_files:
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
int_name = directory.removesuffix("/")
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
if os.path.exists(tex_path):
tex_file = tex_path
else:
raise CliError(
f"No .tex input found in {workspace.root}",
ExitCode.INVALID_WORKSPACE,
)
else:
tex_file = tex_files[0]
# Prepare output directories
paths = {
'Text': os.path.join(directory, "Text"),
'Text2': os.path.join(directory, "Text2"),
'Sol': os.path.join(directory, "Sol"),
'Sol2': os.path.join(directory, "Sol2"),
'Persp': os.path.join(directory, "Persp")
}
for p in paths.values():
os.makedirs(p, exist_ok=True)
labels_file = workspace.labels_file
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
current_ex_num = 1
had_errors = False
# Read entirely to allow chunking
with open(tex_file, 'r', encoding='utf-8') as f_in:
content = f_in.read()
# Split by the specific SHEETINFO tag
blocks = content.split("%%SHEETINFO :")
if len(blocks) == 1:
print(f"No SHEETINFO blocks found in {tex_file}")
return ExitCode.PARTIAL
with open(labels_staging, 'w', encoding='utf-8') as f_labels:
# Skip blocks[0] (content before first SHEETINFO)
for block in blocks[1:]:
parts_line = block.split("\n", 1)
json_str = parts_line[0].strip()
block_content = parts_line[1] if len(parts_line) > 1 else ""
# Check if text until next SHEETINFO block contains \Roman
problem = r"\Roman" in block_content
if not json_str: continue
try:
data = json.loads(json_str)
block_labels = []
# Construct 'ids' parameter
ex_id = str(data['id'])
selection = data.get('select')
if selection is not None:
sel_s = [i+1 for i in selection]
ids = f"{ex_id}.{','.join(map(str, sel_s))}"
else:
ids = ex_id
# 2. Handle Labels
indexes = data.get('indexes', [])
if not indexes:
label = f"Ex {current_ex_num}"
validate_windows_labels([label])
block_labels.append(label)
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
else:
for item in indexes:
suffix = format_indices(item['indices'], problem)
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
validate_windows_labels([label])
block_labels.append(label)
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
# Construct URL (append pb=true if \Roman matched)
url = f"http://localhost:8080/exercices/emacs/{ids}?pretty=true&all=true&persp=true"
# if problem:
# url += "&pb=true"
# Perform GET request
with urllib.request.urlopen(url) as response:
res_content = response.read().decode('utf-8')
# 4. Split and Save content
parts = res_content.split('###')
# Ensure we have at least 3 parts
while len(parts) < 3:
parts.append("")
t_text = replace_dots(parts[0].strip("\n"))
s_text = replace_dots(parts[1].strip("\n"))
p_text = replace_dots(parts[2].strip("\n"))
# Apply hierarchy depth replace if problem context
if problem:
t_text = replace_problem_labels(t_text)
s_text = replace_problem_labels(s_text)
p_text = replace_problem_labels(p_text)
base_filename = f"Ex {current_ex_num}"
if problem:
save_split_content(t_text, paths['Text'], base_filename, False)
else:
with open(os.path.join(paths['Text'], base_filename), 'w', encoding='utf-8') as f:
f.write(t_text)
save_split_content(s_text, paths['Sol'], base_filename, problem)
save_split_content(p_text, paths['Persp'], base_filename, problem)
for label in block_labels:
f_labels.write(f"{label}\n")
current_ex_num += 1
except WindowsLabelError:
labels_staging.unlink(missing_ok=True)
raise
except json.JSONDecodeError:
print(f"Error decoding JSON in block: {json_str}")
had_errors = True
except Exception as e: # noqa: BLE001 - one malformed exercise is partial
print(f"Error processing block {ex_id if 'ex_id' in locals() else 'unknown'}: {e}")
had_errors = True
labels_staging.replace(labels_file)
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Generate statement metadata from SHEETINFO blocks")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: process_directory(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator.configuration import EXPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def export_directory(
workspace: EvaluationWorkspace,
source_dir_name: str,
) -> ExitCode:
workspace.require_directories(source_dir_name)
source_dir = workspace.root / source_dir_name
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
sync_dir.mkdir(parents=True, exist_ok=True)
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
if source_dir_name == "BGnot" and subdirs:
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
if not all_start_with_copie:
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0
for subdir in subdirs:
concat_file = next(
(
candidate
for candidate in (subdir / "Concat.pdf", subdir / "Concat.jpg")
if candidate.is_file()
),
None,
)
if concat_file is None:
print(
f"Warning: no Concat.pdf or Concat.jpg found in {subdir}",
file=sys.stderr,
)
missing_outputs += 1
continue
destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Export annotated PDFs to the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory to export (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return export_directory(workspace, "BRnot" if refaire else annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,83 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
matching = []
if workspace.batch_jobs_file.is_file():
manifest = read_json(workspace.batch_jobs_file)
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
if not isinstance(jobs, dict):
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
matching = [
client.batches.get(name=entry["name"])
for entry in jobs.values()
if isinstance(entry, dict) and isinstance(entry.get("name"), str)
]
else:
matching = [
job
for job in client.batches.list()
if workspace.name in str(getattr(job, "display_name", ""))
]
if not matching:
raise CliError(
f"No batch jobs found for evaluation {workspace.name!r}"
)
for job in matching:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.display_name}: {state}")
if state != "JOB_STATE_SUCCEEDED":
print("Not all matching jobs have succeeded yet.")
return ExitCode.PARTIAL
chunks = []
incomplete = False
for job in matching:
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print(f"Warning: {job.display_name} has no output file.")
incomplete = True
continue
payload = client.files.download(file=file_name)
chunks.append(payload.rstrip(b"\n"))
if not chunks:
return ExitCode.PARTIAL
output_path = workspace.batched_correction_result_file
atomic_write_bytes(output_path, b"\n".join(chunks) + b"\n")
print(f"Saved combined batch results to {output_path}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Download and combine correction batch results")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+830
View File
@@ -0,0 +1,830 @@
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import validate_windows_labels
from copienator.utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str:
i = 0
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
i += 1
lcp = s1[:i]
if ')' in s1 or ')' in s2:
last_paren = lcp.rfind(')')
if last_paren != -1:
return lcp[:last_paren + 1]
return lcp
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
# --- Modèles pour la Requête 1 ---
class QuestionOnlyItem(BaseModel):
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
class ExamQuestions(BaseModel):
questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 ---
class SolutionOnlyItem(BaseModel):
label: str = Field(description="The exact unique label of the question provided in the input.")
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
class ExamSolutions(BaseModel):
solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 ---
class ExtractedContext(BaseModel):
target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
class ExamContext(BaseModel):
contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) ---
class RubricItem(BaseModel):
label: str = Field(description="The exact label of the question.")
rubric_content: str = Field(description="Le barème détaillé en français.")
class GroupRubrics(BaseModel):
rubrics: list[RubricItem]
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
Ta tâche :
Établir un barème de correction détaillé en français pour CHAQUE question.
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
Par exemple :
- Au moins 2 points si le résultat est correct.
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
- Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
"""
# --- Modèle fusionné (pour le reste du script) ---
class QuestionItem(BaseModel):
label: str
question_content: str
solution_content: str
class ContextItem(BaseModel):
target_question_label: str
last_question_label: str
content: str # Juste une string encapsulée pour le différencier facilement
class ExamExtraction(BaseModel):
items: list[QuestionItem | ContextItem] # Liste mixte
class GroupedExamExtraction(BaseModel):
groups: list[list[QuestionItem | ContextItem]]
PROMPT_1 = """I am providing:
1. A PDF of an exam (`enonce.pdf`)
2. The source code of the exam questions (`enonce` file)
Your task:
1. Identify all distinct question labels using the PDF document.
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
2. For each label, extract its exact corresponding question text
from the `enonce` source file. Do not include the label itself
in this extracted text (nor LaTeX like `item` nor org-mode list
labelling like `2.`).
Return the result as a JSON list in the exact reading order of the document.
"""
PROMPT_2 = """I am providing:
1. A JSON list of question labels and their texts extracted from an exam.
2. The source code of the exam solutions (`correction` file).
Your task:
For each question label provided in the JSON, extract its exact corresponding solution textual
content from the `correction` source file. Return the result as a JSON list in the exact same order.
"""
PROMPT_3 = """I am providing:
1. A JSON list of question labels and their texts extracted from an exam.
2. The source code of the exam questions (`enonce` file).
Your task:
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
For example, given LaTeX code like
\\item Let N, M be two commutating matrices
\\begin{itemize}
\\item Prove that N, M have a common eigenvector
\\item Prove that N, M are co-trigonalizable.
\\end{itemize}
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
For each extracted piece of information, identify:
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
2. The label of the LAST question that uses or relies on this information.
Return the result as a JSON list.
"""
def find_file(folder: Path, base_name: str) -> Path | None:
for ext in [".org", ".tex"]:
path = folder / f"{base_name}{ext}"
if path.is_file():
return path
return None
def process_exam(
workspace: EvaluationWorkspace,
restart: bool = False,
*,
api_client=None,
) -> ExitCode:
folder = workspace.root
cache_dir = folder / "Cache"
tmp_dir = folder / "Tmp"
cache_dir.mkdir(exist_ok=True)
tmp_dir.mkdir(exist_ok=True)
cache_q_file = cache_dir / "gemini_questions.json"
cache_s_file = cache_dir / "gemini_solutions.json"
cache_c_file = cache_dir / "gemini_context.json"
# 1. Resolve files
pdf_path = folder / "enonce.pdf"
enonce_path = find_file(folder, "enonce")
correction_path = find_file(folder, "correction")
missing = []
if not pdf_path.is_file(): missing.append("enonce.pdf")
if not enonce_path: missing.append("enonce.org or enonce.tex")
if not correction_path: missing.append("correction.org or correction.tex")
if missing:
raise CliError(
f"Missing files in {folder}: {', '.join(missing)}",
ExitCode.INVALID_WORKSPACE,
)
print("Reading files...")
pdf_bytes = pdf_path.read_bytes()
enonce_text = enonce_path.read_text(encoding="utf-8")
correction_text = correction_path.read_text(encoding="utf-8")
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
client = api_client
# ==========================================
# REQUÊTE 1 : Extraction des Énoncés
# ==========================================
contents_1 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_1),
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
],
)
]
config_1 = types.GenerateContentConfig(
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamQuestions.model_json_schema(),
)
if cache_q_file.is_file() and not restart:
print("Loading cached questions from Cache/gemini_questions.json...")
response_q_text = cache_q_file.read_text(encoding="utf-8")
else:
print("Sending request 1 (Questions) to Gemini...")
response_q = client.models.generate_content(
model=MODEL_ID,
contents=contents_1,
config=config_1
)
response_q_text = response_q.text
print("Saving questions to cache...")
atomic_write_text(cache_q_file, response_q_text)
questions_data = ExamQuestions.model_validate_json(response_q_text)
# ==========================================
# REQUÊTE 2 : Extraction des Corrections
# ==========================================
extracted_questions_json = questions_data.model_dump_json(indent=2)
contents_2 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_2),
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
],
)
]
config_2 = types.GenerateContentConfig(
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamSolutions.model_json_schema(),
)
if cache_s_file.is_file() and not restart:
print("Loading cached solutions from Cache/gemini_solutions.json...")
response_s_text = cache_s_file.read_text(encoding="utf-8")
else:
print("Sending request 2 (Solutions) to Gemini...")
response_s = client.models.generate_content(
model=MODEL_ID,
contents=contents_2,
config=config_2
)
response_s_text = response_s.text
print("Saving solutions to cache...")
atomic_write_text(cache_s_file, response_s_text)
solutions_data = ExamSolutions.model_validate_json(response_s_text)
# ==========================================
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
# ==========================================
contents_3 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_3),
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
],
)
]
config_3 = types.GenerateContentConfig(
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamContext.model_json_schema(),
)
if cache_c_file.is_file() and not restart:
print("Loading cached context from Cache/gemini_context.json...")
response_c_text = cache_c_file.read_text(encoding="utf-8")
else:
print("Sending request 3 (Context) to Gemini...")
response_c = client.models.generate_content(
model=MODEL_ID,
contents=contents_3,
config=config_3
)
response_c_text = response_c.text
print("Saving context to cache...")
atomic_write_text(cache_c_file, response_c_text)
context_data = ExamContext.model_validate_json(response_c_text)
# ==========================================
# FUSION des trois résultats
# ==========================================
sol_map = {s.label: s.solution_content for s in solutions_data.solutions}
# Map labels to their index to validate ordering
label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)}
for c in context_data.contexts:
first_idx = label_to_idx.get(c.target_question_label, -1)
last_idx = label_to_idx.get(c.last_question_label, -1)
# Enforce LAST is after (or equal to) FIRST
if first_idx != -1 and last_idx != -1 and last_idx < first_idx:
print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.")
c.last_question_label = c.target_question_label
elif last_idx == -1: # Fallback if invalid
c.last_question_label = c.target_question_label
# Grouper les contextes par label cible
ctx_map = {}
for c in context_data.contexts:
if c.target_question_label in ctx_map:
ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content
# Keep the furthest LAST question label
curr_last = ctx_map[c.target_question_label]['last']
if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1):
ctx_map[c.target_question_label]['last'] = c.last_question_label
else:
ctx_map[c.target_question_label] = {
'content': c.context_content,
'last': c.last_question_label
}
merged_items = []
for q in questions_data.questions:
if q.label in ctx_map:
merged_items.append(ContextItem(
target_question_label=q.label,
last_question_label=ctx_map[q.label]['last'],
content=ctx_map[q.label]['content']
))
sol_content = sol_map.get(q.label, "")
merged_items.append(QuestionItem(
label=q.label,
question_content=q.question_content,
solution_content=sol_content
))
extracted_data = ExamExtraction(items=merged_items)
# ==========================================
# INITIAL GROUPING COMPUTATION
# ==========================================
items_file = tmp_dir / "exam_items.txt"
full_items_file = tmp_dir / "exam_items_full.txt"
trunc_map = {}
# --- INITIAL GROUPING COMPUTATION ---
# 1. Normalize labels first
for item in extracted_data.items:
if isinstance(item, QuestionItem):
item.label = item.label.replace("Exercice", "Ex").replace(".", ")")
# 2. Extract questions and compute grouping indices
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
# questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
q_group_indices = []
if questions_only:
n = len(questions_only)
if n == 1:
q_group_indices = [[0]]
else:
adj_lcp = [get_lcp(questions_only[i].label, questions_only[i+1].label) for i in range(n - 1)]
current_g = [0]
for i in range(n - 1):
p = adj_lcp[i]
prev_p = adj_lcp[i - 1] if i > 0 else ""
next_p = adj_lcp[i + 1] if i < n - 2 else ""
# Group i and i+1 together if p is non-empty and at least as specific as adjacent LCPs
if p and len(p) >= len(prev_p) and len(p) >= len(next_p):
current_g.append(i + 1)
else:
q_group_indices.append(current_g)
current_g = [i + 1]
q_group_indices.append(current_g)
# Build list of unique ContextItems from extracted data
all_contexts = [item for item in extracted_data.items if isinstance(item, ContextItem)]
initial_groups = []
for g_indices in q_group_indices:
group_items = []
first_q_idx = g_indices[0]
for q_idx in g_indices:
q_item = questions_only[q_idx]
# 1. Collect contexts targeting this specific question
# 2. Or contexts carried over from an earlier group (only added at the start of the group)
for ctx in all_contexts:
target_idx = label_to_idx.get(ctx.target_question_label, -1)
last_idx = label_to_idx.get(ctx.last_question_label, -1)
if target_idx != -1 and last_idx != -1:
is_exact_target = (target_idx == q_idx)
is_carried_over = (q_idx == first_q_idx and target_idx < first_q_idx and last_idx >= first_q_idx)
if is_exact_target or is_carried_over:
group_items.append(ContextItem(
target_question_label=ctx.target_question_label,
last_question_label=ctx.last_question_label,
content=ctx.content
))
group_items.append(q_item)
initial_groups.append(group_items)
# ---- Transform labels, and check uniqueness
seen_labels = set()
label_updates = {}
for group in initial_groups:
for item in group:
if isinstance(item, QuestionItem):
orig_label = item.label
# 2. Ensure uniqueness (prefix with XX)
while item.label in seen_labels:
item.label = f"XX{item.label}"
seen_labels.add(item.label)
label_updates[orig_label] = item.label
# Sync the modified labels to ContextItem
for group in initial_groups:
for item in group:
if isinstance(item, ContextItem):
item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label)
item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label)
# --- WRITE TEXT FILES ---
print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
with open(items_file, "w", encoding="utf-8") as f, \
open(full_items_file, "w", encoding="utf-8") as f_full:
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
f.write(header)
f_full.write(header)
for g_idx, group in enumerate(initial_groups):
if g_idx > 0:
f.write("\n---\n\n")
f_full.write("\n---\n\n")
for item in group:
if isinstance(item, QuestionItem):
safe_content = item.question_content.replace('\n', ' \\n ')
f_full.write(f"{item.label} ### {safe_content}\n")
if len(safe_content) > 65:
trunc_content = safe_content[:64] + ""
trunc_map[trunc_content] = safe_content
else:
trunc_content = safe_content
f.write(f"{item.label} ### {trunc_content}\n")
elif isinstance(item, ContextItem):
safe_content = item.content.replace('\n', ' \\n ')
f.write(f"CONTEXT ### {safe_content}\n")
f_full.write(f"CONTEXT ### {safe_content}\n")
# --- OPEN EDITOR AND PARSE ---
while True:
print("Opening items file for editing...")
utils.edit_file_and_enter(items_file)
print(f"Parsing edited items from {items_file.name}...")
with open(items_file, "r", encoding="utf-8") as f:
edited_lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
# 1. Validation for XX labels
has_xx = False
for line in edited_lines:
if " ### " in line:
lbl = line.split(" ### ", 1)[0].strip()
if lbl.startswith("XX"):
has_xx = True
break
if has_xx:
print("\n!!! ERROR: Some labels still start with 'XX'. Please remove the 'XX' prefixes to ensure unique, valid labels.")
input("Press ENTER to return to the editor...")
continue
# Map original contexts by normalized content
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
# 2. Actual Parsing
grouped_items = []
all_new_q_labels = []
# Pass 1: Read all edited lines and collect question labels in sequence
for line in edited_lines:
if line == "---" or " ### " not in line:
continue
lbl, _content_raw = line.split(" ### ", 1)
lbl = lbl.strip()
if lbl != "CONTEXT":
all_new_q_labels.append(lbl)
# Mapping from original question index to new label
idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))}
orig_q_idx = 0
current_group = []
for line in edited_lines:
if line == "---":
if current_group:
grouped_items.append(current_group)
current_group = []
continue
if " ### " not in line:
continue
new_label, edited_content_raw = line.split(" ### ", 1)
new_label = new_label.strip()
if "" in edited_content_raw and edited_content_raw in trunc_map:
edited_content_raw = trunc_map[edited_content_raw]
edited_content = edited_content_raw.replace(' \\n ', '\n')
if new_label == "CONTEXT":
current_group.append(('CONTEXT', edited_content))
else:
sol_content = questions_only[orig_q_idx].solution_content if orig_q_idx < len(questions_only) else ""
current_group.append(QuestionItem(
label=new_label,
question_content=edited_content,
solution_content=sol_content
))
orig_q_idx += 1
if current_group:
grouped_items.append(current_group)
# Pass 2: Resolve ContextItem target/last labels per group
final_grouped_items = []
for group in grouped_items:
final_group = []
q_in_group = [item for item in group if isinstance(item, QuestionItem)]
g_first_label = q_in_group[0].label if q_in_group else ""
g_last_label = q_in_group[-1].label if q_in_group else ""
for i, item in enumerate(group):
if isinstance(item, tuple) and item[0] == 'CONTEXT':
c_text = item[1]
norm_text = c_text.strip()
# Find next question label in group following this context
next_q_label = g_first_label
for successor in group[i+1:]:
if isinstance(successor, QuestionItem):
next_q_label = successor.label
break
if norm_text in orig_contexts:
orig_c = orig_contexts[norm_text]
orig_target_idx = label_to_idx.get(orig_c.target_question_label, -1)
orig_last_idx = label_to_idx.get(orig_c.last_question_label, -1)
mapped_target = idx_to_new_label.get(orig_target_idx, next_q_label)
mapped_last = idx_to_new_label.get(orig_last_idx, g_last_label)
# Check if context's last question is BEFORE the first question of this group
first_q_idx_in_exam = all_new_q_labels.index(g_first_label) if g_first_label in all_new_q_labels else -1
last_q_idx_in_exam = all_new_q_labels.index(mapped_last) if mapped_last in all_new_q_labels else -1
if last_q_idx_in_exam != -1 and first_q_idx_in_exam != -1 and last_q_idx_in_exam < first_q_idx_in_exam:
mapped_last = g_last_label
final_group.append(ContextItem(
target_question_label=mapped_target,
last_question_label=mapped_last,
content=c_text
))
else:
# New context created by user
final_group.append(ContextItem(
target_question_label=next_q_label,
last_question_label=g_last_label,
content=c_text
))
else:
final_group.append(item)
final_grouped_items.append(final_group)
grouped_items = final_grouped_items
break
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
validate_windows_labels(labels_list)
# Save labels and proceed
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
# 2. Setup output directories
text_dir = folder / "Text"
sol_dir = folder / "Sol"
text2_dir = folder / "Text2"
sol2_dir = folder / "Sol2"
persp_dir = folder / "Persp"
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
import shutil
# Ask only if any directory already exists
if any(d.exists() for d in dirs):
answer = input(
"Output directories already exist. Delete their contents? [y/N] "
).strip().lower()
if answer not in ("y", "yes"):
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
# Empty each directory
for d in dirs:
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
else:
# Create them if they don't exist
for d in dirs:
d.mkdir(parents=True)
text_dir.mkdir(exist_ok=True)
sol_dir.mkdir(exist_ok=True)
print("Writing grouped question and solution files...")
processing_errors = []
for group in grouped_extraction.groups:
q_items = [item for item in group if isinstance(item, QuestionItem)]
labels = [q.label for q in q_items]
if not labels:
continue # Skip if a group has no questions (only contexts)
# ==========================================
# REQUÊTE 4 : Génération du Barème pour le groupe
# ==========================================
group_text_parts = []
for item in group:
if isinstance(item, QuestionItem):
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
elif isinstance(item, ContextItem):
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
group_context_text = "\n\n---\n\n".join(group_text_parts)
contents_4 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
],
)
]
config_4 = types.GenerateContentConfig(
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
)
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
try:
response_r = client.models.generate_content(
model=MODEL_ID,
contents=contents_4,
config=config_4
)
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
except Exception as e: # noqa: BLE001 - remote API boundary
print(f"Error generating rubric for group {labels[0]}: {e}")
processing_errors.append(str(e))
rubrics_map = {}
# 1. Compute the common prefix for the group
prefix = labels[0]
for lbl in labels[1:]:
prefix = get_lcp(prefix, lbl)
# 2. Format the Text filename: prefix [label1, label2]
labels_str = ",".join([label[len(prefix):] for label in labels])
group_filename = f"{prefix}[{labels_str}]"
safe_group_filename = group_filename.replace("/", "_")
text_content_lines = []
# 3. Process each item in the group
for item in group:
if isinstance(item, QuestionItem):
# 1. Prepare tabulated content:
# Start with a tab, then replace every newline+whitespace with newline+tab
raw_content = item.question_content.strip()
tabulated = "\t" + re.sub(r'\n\s*', '\n\t', raw_content)
# 2. Build Text entry
text_content_lines.append(f"{item.label} :")
text_content_lines.append(tabulated)
# Write individual Sol file (remains unchanged)
safe_label = item.label.replace("/", "_")
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol:
f_sol.write(f"{item.label}\n{item.solution_content}")
with open(text2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_t2:
f_t2.write(f"\\textbf{{{item.label}}} {item.question_content}")
with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2:
f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}")
# --- Écriture du Barème (Persp) ---
rubric_text = rubrics_map.get(item.label, "")
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp:
f_persp.write(f"{item.label}\n{rubric_text}")
elif isinstance(item, ContextItem):
raw_ctx = item.content.strip()
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
text_content_lines.append("CONTEXT :")
text_content_lines.append(tabulated_ctx)
# --- Save context to Text2 (Concatenating if exists) ---
safe_first = item.target_question_label.replace("/", "_")
safe_last = item.last_question_label.replace("/", "_")
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
ctxt_path = text2_dir / ctxt_filename
# If file exists, prepend some spacing before appending
prefix = "\n\n" if ctxt_path.exists() else ""
with open(ctxt_path, "a", encoding="utf-8") as f_c2:
f_c2.write(prefix + item.content)
# 4. Write the grouped Text file
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
f_text.write("\n".join(text_content_lines))
print(f"Success! Processed {len(grouped_extraction.groups)} groups.")
# ==========================================
# PDF COMPILATION (4 Threads)
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function."""
try:
content = tex_file.read_text(encoding="utf-8")
pdf_path = tex_file.with_suffix(".pdf")
compile_to_pdf(content, pdf_path)
except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}"
return None
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
with ThreadPoolExecutor(max_workers=4) as executor:
compile_errors = [
error for error in executor.map(compile_worker, all_tex_files) if error
]
for error in compile_errors:
print(error)
processing_errors.extend(compile_errors)
atomic_write_text(
workspace.labels_file,
"".join(f"{label}\n" for label in labels_list),
)
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Extract exam and solution code via Gemini")
parser.add_argument(
"--restart",
action="store_true",
help="Ignore cached Gemini extraction responses",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: process_exam(
workspace_from_args(args),
restart=args.restart,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+450
View File
@@ -0,0 +1,450 @@
from __future__ import annotations
import argparse
import re
import time
import typing
from collections import defaultdict
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
my_prompt = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts. If they are
horizontally far apart (example : if the `1)` is to the left and the
`Exercice i` is either to the right, or in the middle) then give only
the bounding box of the question label `1)` part. You should still
label it as `Exercice i : 1)` though.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels. Order the
box_2d by their position in the page, column by column : first column
(top to bottom), then second column, etc.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
Here's a list of the names of the students, pick the one that matches
the best or `\"Unknown\"` if you cannot read the name
##names##"""
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
This image is one part of a sequence (e.g., part 2 of 3) for a single
student. Here is the list of labels found in the *previous* parts of
this copy:
[
##prev_context##
]
If the first column starts with a number like =3)= or =c)=, look at
the labels in the list above. If the last relevant label was =Ex 4 :
2)=, you should label the new box =Ex 4 : 3)=.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`."""
class BoxItem(BaseModel):
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
label: str = Field(description="The label associated with the specific box")
class AnnotationData(BaseModel):
name: str = Field(description="The name identifier")
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
description="List of bounding box items"
)
def generate_request(file, labels, names, context_labels, wrong_labels):
"""Generates request for Gemini with context."""
image_path = Path(file)
# Format context list as a string
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
if context_labels == []:
text = my_prompt.replace("##labels##", labels)\
.replace("##names##", names)
else:
text = my_prompt2.replace("##labels##", labels)\
.replace("##prev_context##", context_str)
if wrong_labels:
text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.")
else:
text = text.replace("##wrong_labels##\n\n", "")
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=text),
],
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= AnnotationData.model_json_schema(),
)
return (contents, generate_content_config)
TARGET_INTERVAL = 3.5
Sleep = Callable[[float], None]
def selected_images(
workspace: EvaluationWorkspace,
targets: list[Path],
) -> tuple[list[Path], list[str]]:
"""Resolve evaluation, copy-PDF, or Cutleft-image targets."""
workspace.require_directories("Copies", "Cutleft")
images: list[Path] = []
warnings: list[str] = []
for target in targets:
if target.is_dir():
copy_pdfs = sorted(
workspace.copies_dir.glob("Copie*.pdf"), key=natural_key
)
if not copy_pdfs:
warnings.append(f"No Copie*.pdf files found in {workspace.copies_dir}")
stems = [path.stem for path in copy_pdfs]
elif target.suffix.casefold() in {".jpg", ".jpeg"}:
if target.parent != workspace.cutleft_dir:
raise CliError(
f"Image target is not in {workspace.cutleft_dir}: {target}",
ExitCode.INVALID_ARGUMENTS,
)
images.append(target)
continue
elif target.suffix.casefold() == ".pdf":
stems = [target.stem]
else:
raise CliError(
f"Unsupported target for label detection: {target}",
ExitCode.INVALID_ARGUMENTS,
)
for stem in stems:
found = sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"), key=natural_key
)
if found:
images.extend(found)
else:
warnings.append(
f"No Cutleft image variants found for {stem} in "
f"{workspace.cutleft_dir}"
)
return list(dict.fromkeys(images)), warnings
def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
groups: defaultdict[str, list[Path]] = defaultdict(list)
for image in image_files:
match = re.match(r"(.+)_(\d+)$", image.stem)
groups[match.group(1) if match else image.stem].append(image)
for files in groups.values():
files.sort(key=natural_key)
return dict(groups)
def _existing_context(output_json: Path) -> list[str]:
try:
loaded = read_json(output_json)
if not isinstance(loaded, dict):
return []
return [
str(item["label"])
for item in loaded.get("list", [])
if isinstance(item, dict) and "label" in item
]
except (OSError, TypeError, ValueError):
return []
def process_copy_group(
workspace: EvaluationWorkspace,
group_key: str,
files: list[Path],
*,
client,
labels_text: str,
names_text: str,
valid_labels: set[str],
valid_names: set[str],
overwrite: bool,
sleep: Sleep = time.sleep,
target_interval: float = TARGET_INTERVAL,
) -> int:
"""Process one student's image parts sequentially to preserve context."""
accumulated_labels: list[str] = []
generated = 0
for image_file in files:
started = time.monotonic()
output_json = workspace.copies_dir / f"{image_file.stem}.json"
if output_json.exists() and not overwrite:
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
accumulated_labels.extend(_existing_context(output_json))
continue
print(
f"[{group_key}] Processing {image_file.name} with "
f"{len(accumulated_labels)} accumulated labels..."
)
attempt = 0
wrong_labels: list[str] = []
while True:
if attempt > 0:
sleep(10 * attempt)
try:
contents, request_config = generate_request(
image_file,
labels_text,
names_text,
accumulated_labels,
wrong_labels,
)
response = client.models.generate_content(
model=MODEL_ID,
contents=contents,
config=request_config,
)
annotation = AnnotationData.model_validate_json(response.text)
unknown = [
item.label
for item in annotation.list
if item.label not in valid_labels
]
if unknown:
print(
f"Error: {image_file.name} contained unknown labels: "
f"{unknown}"
)
wrong_labels.extend(unknown)
attempt += 1
continue
if annotation.name not in valid_names:
print(
f"Error: {image_file.name} returned unknown name: "
f"{annotation.name}"
)
if attempt == 0:
attempt += 1
continue
annotation.name = "Unknown"
atomic_write_json(output_json, annotation.model_dump())
accumulated_labels.extend(box.label for box in annotation.list)
generated += 1
break
except KeyboardInterrupt:
raise
except Exception as exc: # noqa: BLE001 - remote API retry boundary
print(
f"Error processing {image_file.name}: {exc}\n"
"\tIt will be retried."
)
attempt += 1
sleep(max(0.0, target_interval - (time.monotonic() - started)))
return generated
def run(
workspace: EvaluationWorkspace,
targets: list[Path],
*,
overwrite: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
) -> ExitCode:
workspace.require_files("labels")
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
if not images:
return ExitCode.PARTIAL
all_labels = read_all_labels(workspace.root)
labels_text = "\n".join(all_labels) + "\n"
names_path = workspace.names_file()
if not names_path.is_file():
raise CliError(f"Names file not found: {names_path}", ExitCode.INVALID_WORKSPACE)
names_text = names_path.read_text(encoding="utf-8")
valid_names = {
line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"}
if client is None:
client = genai.Client(api_key=api_key)
groups = group_images(images)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
process_copy_group,
workspace,
group_key,
files,
client=client,
labels_text=labels_text,
names_text=names_text,
valid_labels=set(all_labels),
valid_names=valid_names,
overwrite=overwrite,
sleep=sleep,
)
for group_key, files in groups.items()
]
for future in futures:
future.result()
return ExitCode.PARTIAL if warnings else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Detect handwritten question labels with Gemini")
parser.add_argument(
"additional_targets",
nargs="*",
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Regenerate JSON outputs that already exist",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2]
)
targets = [target]
for additional in args.additional_targets:
resolved = additional.expanduser().resolve()
if not resolved.exists():
raise CliError(
f"Target does not exist: {resolved}",
ExitCode.INVALID_WORKSPACE,
)
additional_workspace = EvaluationWorkspace.discover(
resolved, repository=workspace.repository
)
if additional_workspace.root != workspace.root:
raise CliError(
"All targets must belong to the same evaluation",
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.platform import replace_with_link_or_copy, safe_filename
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Assign student names and prepare the return directory.")
parser.add_argument(
"annotation_dir",
choices=ANNOTATION_CHOICES,
help="Annotation directory to use",
)
return parser
def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
names_path = workspace.names_file()
if not names_path.exists():
print(
f"Warning: names file not found in {workspace.root} or the current directory.",
file=sys.stderr,
)
return set()
return {
line.strip()
for line in names_path.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def prepare_named_returns(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
workspace.require_directories("Copies", annotation_dir_name)
workspace.return_dir.mkdir(parents=True, exist_ok=True)
expected_names = _read_expected_names(workspace)
copies_map: defaultdict[str, list[str]] = defaultdict(list)
pattern = re.compile(r"^Copie(\d+)\.json$")
had_errors = False
for json_path in workspace.copies_dir.iterdir():
match = pattern.match(json_path.name)
if not match:
continue
try:
data = read_json(json_path)
if not isinstance(data, dict):
raise TypeError("expected a JSON object")
name = str(data.get("name", "Unknown")).strip()
copies_map[name].append(match.group(1))
except (OSError, TypeError, ValueError) as exc:
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
had_errors = True
assigned_names: set[str] = set()
selected_annotations = workspace.root / annotation_dir_name
fallback_annotations = workspace.annotation_dir("simple")
for name, copy_ids in copies_map.items():
if name == "Unknown":
print(
f"Warning: unknown name for copies: {', '.join(copy_ids)}",
file=sys.stderr,
)
elif len(copy_ids) > 1:
print(
f"Warning: name {name!r} is assigned to multiple copies: "
f"{', '.join(copy_ids)}",
file=sys.stderr,
)
safe_name = safe_filename(name)
for copy_id in copy_ids:
selected = selected_annotations / f"Copie{copy_id}"
fallback = fallback_annotations / f"Copie{copy_id}"
source_folder = None
for candidate in (selected, fallback):
if (candidate / "Concat.jpg").exists() and (
candidate / "score.json"
).exists():
source_folder = candidate
break
if source_folder is None:
continue
assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination.mkdir(parents=True, exist_ok=True)
links = (
("Concat.jpg", f"{safe_name}.jpg"),
("Concat_F.pdf", f"{safe_name}.pdf"),
("score.json", "score.json"),
)
for source_name, destination_name in links:
source = source_folder / source_name
if not source.exists():
continue
try:
method = replace_with_link_or_copy(
source,
destination / destination_name,
prefer="symlink",
)
if method == "copy":
print(
f"Copied {source_name} for {destination.name} "
"(links unavailable)"
)
except OSError as exc:
print(
f"Error linking {source} for {destination.name}: {exc}",
file=sys.stderr,
)
had_errors = True
unassigned = expected_names - assigned_names
if unassigned:
print("Names from the list that were not assigned:", file=sys.stderr)
for name in sorted(unassigned):
print(f" - {name}", file=sys.stderr)
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str,
) -> ExitCode:
return prepare_named_returns(workspace, annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args, repository=Path.cwd()),
annotation_dir=args.annotation_dir,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+278
View File
@@ -0,0 +1,278 @@
import argparse
import os
import re
import shutil
from collections import defaultdict
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from pdf2image import convert_from_path, pdfinfo_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
workspace_from_args,
)
# Configuration
DPI = 200 # Good balance for readability and size
A4_HEIGHT_INCHES = 11.69
FULL_PAGE_PX = int(A4_HEIGHT_INCHES * DPI)
MAX_GROUP_HEIGHT = 1.5 * FULL_PAGE_PX
MAX_GROUP_COUNT = 8
SEPARATOR_HEIGHT = 20
LABEL_HEIGHT = 50
MAX_FILE_SIZE_BYTES = 2.5 * 1024 * 1024 # 2MB
def get_pdf_height(path):
"""Returns total height of all pages in pixels at defined DPI."""
try:
info = pdfinfo_from_path(path)
# Get page count (default to 1)
num_pages = int(info["Pages"]) if "Pages" in info else 1
# 1 pt = 1/72 inch
pts_height = float(info['Page size'].split(' ')[2]) if 'Page size' in info else 0
# Height of one page in pixels
single_page_px = int((pts_height / 72.0) * DPI)
# Return total height
return single_page_px * num_pages
except Exception as e: # noqa: BLE001 - pdfinfo may raise backend-specific errors
print(f"Error reading {path}: {e}")
return 0
def collect_files(root_dir):
"""
Scans Dir/Copiedd/identifier.pdf
Returns dict: {identifier: [(dd, path, height), ...]}
"""
data = defaultdict(list)
# Regex to match 'Copie' followed by 2 digits
folder_pattern = re.compile(r'Copie(\d{2})')
for root, dirs, files in os.walk(root_dir):
folder_name = os.path.basename(root)
match = folder_pattern.match(folder_name)
if match:
dd = match.group(1)
for file in files:
if file.lower().endswith('.pdf'):
identifier = os.path.splitext(file)[0]
full_path = os.path.join(root, file)
# Calculate height (c)
height = get_pdf_height(full_path)
# Store triple (a, b, c)
data[identifier].append((dd, full_path, height))
return data
def group_files(file_list):
"""
Groups files using First Fit Decreasing algorithm to minimize group count.
"""
# 1. Sort by height DESCENDING. Large items are hardest to fit, handle them first.
# (Remove this sort if you must strictly preserve input order logic)
sorted_files = sorted(file_list, key=lambda x: x[2], reverse=True)
# Each group is a dict: {'items': [], 'current_height': 0}
groups = []
for item in sorted_files:
_, _, height = item
placed = False
# 2. Try to fit item into an existing group (First Fit)
for group in groups:
# Check Count Constraint
if len(group['items']) >= MAX_GROUP_COUNT:
continue
# Calculate Overhead (only if group is not empty)
overhead = (SEPARATOR_HEIGHT + 30) if group['items'] else 0
# Check Height Constraint
if group['current_height'] + height + overhead <= MAX_GROUP_HEIGHT:
group['items'].append(item)
group['current_height'] += height + overhead
placed = True
break
# 3. If it doesn't fit anywhere, create a new group
if not placed:
groups.append({
'items': [item],
'current_height': height
})
# Return list of lists (strip the metadata)
return [g['items'] for g in groups]
def stitch_pdf_pages(images_list):
"""Vertically concatenates a list of PIL images with no separator."""
if not images_list:
return None
if len(images_list) == 1:
return images_list[0]
max_width = max(img.width for img in images_list)
total_height = sum(img.height for img in images_list)
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in images_list:
combined.paste(img, (0, y_offset))
y_offset += img.height
return combined
def create_jpg(identifier, group_index, group, root_dir):
images = []
metadata = [] # To store (id, h_min, h_max)
# Render PDFs to images
for dd, path, _ in group:
try:
# Convert pdf to image
imgs = convert_from_path(path, dpi=DPI)
if imgs:
# Concatenate multi-page PDFs into one single image object
combined_img = stitch_pdf_pages(imgs)
if combined_img:
images.append((dd, combined_img))
except Exception as e: # noqa: BLE001 - PDF/image backends vary by platform
print(f"Failed to convert {path}: {e}")
if not images:
return
# Calculate total canvas size
total_width = max(img.width for _, img in images)
total_height = sum(img.height for _, img in images) + ((len(images) - 1) * SEPARATOR_HEIGHT)
# Add space for text (approx 40px per label)
total_height += len(images) * LABEL_HEIGHT
canvas = Image.new('RGB', (total_width, total_height), 'white')
draw = ImageDraw.Draw(canvas)
# Try loading a font, fallback to default
try:
font = ImageFont.truetype("DejaVuSans.ttf", 40)
except OSError:
print("font not found")
font = ImageFont.load_default()
y_offset = 0
for i, (dd, img) in enumerate(images):
# Draw separator if not first image
if i > 0:
draw.rectangle([0, y_offset, total_width, y_offset + SEPARATOR_HEIGHT], fill='black')
y_offset += SEPARATOR_HEIGHT
# Draw Text (dd)
text = f"ID: {dd}"
draw.text((10, y_offset + 5), text, fill='black', font=font)
y_offset += LABEL_HEIGHT # Space for text
# Record Image Coordinates
h_min = y_offset
h_max = y_offset + img.height
# identifier should be a label
metadata.append((dd, h_min, h_max, img.width/total_width, identifier))
# Draw Image
x_pos = 0
canvas.paste(img, (x_pos, y_offset))
y_offset += img.height
target_folder = os.path.join(root_dir, identifier)
os.makedirs(target_folder, exist_ok=True)
# Save JSON metadata
json_filename = f"Group_{group_index+1}.json"
json_path = os.path.join(target_folder, json_filename)
atomic_write_json(json_path, metadata, indent=None)
# Save with size constraints
output_filename = f"Group_{group_index+1}.jpg"
output_path = os.path.join(target_folder, output_filename)
quality = 90
while quality > 10:
canvas.save(output_path, "JPEG", quality=quality, optimize=True)
if os.path.getsize(output_path) <= MAX_FILE_SIZE_BYTES:
if quality < 90:
print("quality : ", quality)
break
quality -= 5
print(f"Saved {output_path} with {len(group)} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
from copienator.utils import natural_key
def process_identifier(identifier, files_info, output_dir):
# Clear output directory if it exists
target_folder = os.path.join(output_dir, identifier)
if os.path.exists(target_folder):
shutil.rmtree(target_folder)
os.makedirs(target_folder, exist_ok=True)
# files_info is list of (dd, path, height)
file_groups = group_files(files_info)
for idx, group in enumerate(file_groups):
create_jpg(identifier, idx, group, output_dir)
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Group copy extracts by question label.")
def run(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies")
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
print("Scanning files...")
data = collect_files(workspace.copies_dir)
print(f"Found {len(data)} identifiers. Processing...")
# Sort identifiers naturally
sorted_identifiers = sorted(data.keys(), key=natural_key)
# Process using 8 threads
with ThreadPoolExecutor(max_workers=8) as executor:
futures = [
executor.submit(
process_identifier,
identifier,
data[identifier],
workspace.groups_dir,
)
for identifier in sorted_identifiers
]
for future in futures:
future.result()
print("Done.")
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+95
View File
@@ -0,0 +1,95 @@
import argparse
import shutil
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator.configuration import IMPORT_DIR
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def sync_annotated(
workspace: EvaluationWorkspace,
*,
annotation_dir_name: str,
import_dir: Path,
) -> ExitCode:
workspace.require_directories(annotation_dir_name)
annotation_dir = workspace.root / annotation_dir_name
annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
annotated_files = sorted(
(
path
for path in annotated_dir.iterdir()
if path.is_file() and path.suffix.casefold() in {".pdf", ".jpg", ".jpeg"}
),
key=lambda path: path.name.casefold(),
)
for annotated_file in annotated_files:
target_subdir = annotation_dir / annotated_file.stem
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1
else:
suffix = annotated_file.suffix.lower()
dest_file = target_subdir / f"Concat_annotated{suffix}"
print(f"Copying {annotated_file} to {dest_file}")
shutil.copy2(annotated_file, dest_file)
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory receiving imported PDFs (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return sync_annotated(
workspace,
annotation_dir_name="BRnot" if refaire else annotation_dir,
import_dir=Path(IMPORT_DIR),
)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
import json
import sys
import concurrent.futures
from pathlib import Path
from copienator.commands import correction
def get_missing_tasks():
"""
Identifies tasks (groups) where NONE of the student IDs in that group
appear in the existing results for that label in correction.json.
"""
missing = []
# correction.results is already loaded from correction.json during 'from copienator.commands import correction'
# correction.tasks is populated with (filepath, label) during 'from copienator.commands import correction'
for task in correction.tasks:
file_path, label = task
# Find the group metadata file (Group_X.json) to know which IDs are inside
meta_path = Path(file_path).with_suffix('.json')
if not meta_path.exists():
print("Missing meta_path :", meta_path)
continue
with open(meta_path, 'r', encoding="utf-8") as f:
# group_data entries: [pid, ymin, ymax, width_ratio]
group_data = json.load(f)
pids_in_group = [str(item[0]) for item in group_data]
# Check correction.json results for this specific label
label_results = correction.results.get(label, [])
# Collect all student IDs that have already been processed for this label
covered_ids = set()
for result_list in label_results:
for entry in result_list:
covered_ids.add(str(entry.get('id')))
# Logic: Only process if EVERY ID in this group is missing from correction.json
if all(pid not in covered_ids for pid in pids_in_group):
missing.append(task)
return missing
def main(argv=None):
if argv:
print("missing-correction does not accept command-line arguments.", file=sys.stderr)
return 2
missing_tasks = get_missing_tasks()
print("\n Total nb of tasks : ", len(correction.tasks))
if not missing_tasks:
print("All groups are already present in correction.json. Nothing to do.")
return
print("\nThe following groups are missing from correction.json:")
for path, label in missing_tasks:
print(f" - [{label}] {path}")
confirm = input(f"\nFound {len(missing_tasks)} missing groups. Start processing? (y/N): ")
if confirm.lower() != 'y':
print("Aborted.")
return
print(f"Processing {len(missing_tasks)} tasks with {correction.NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=correction.NB_THREADS) as executor:
# Map tasks to the processing function defined in correction.py
futures = {executor.submit(correction.process_single_task, t): t for t in missing_tasks}
for future in concurrent.futures.as_completed(futures):
try:
# Handle potential sub-tasks (like label errors) generated during processing
new_generated_tasks = future.result()
if new_generated_tasks:
for nt in new_generated_tasks:
executor.submit(correction.process_single_task, nt)
except Exception as e:
t = futures[future]
print(f"Error processing {t[0]}: {e}")
print("\nProcessing complete.")
if __name__ == "__main__":
raise SystemExit(main())
+661
View File
@@ -0,0 +1,661 @@
from __future__ import annotations
import argparse
import glob
import os
import re
import shutil
import tempfile
import tkinter as tk
import uuid
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
import fitz # PyMuPDF
from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter
from copienator.configuration import PAGE_SPLITTER_KB
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
target_parser,
workspace_from_target,
)
from copienator.platform import launch_pdf_arranger
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72
def list_pdf_files(directory: str | Path) -> list[Path]:
paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold())
return [path for path in paths if "enonce" not in path.name.casefold()]
def _temporary_sibling(path: Path, purpose: str) -> Path:
return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp")
def commit_processed_pdf(
workspace: EvaluationWorkspace,
original_path: Path,
generated_path: Path,
) -> Path:
"""Commit a processed copy and its original backup with rollback."""
backup_path = workspace.original_copies_dir / original_path.name
output_path = workspace.copies_dir / original_path.name
workspace.original_copies_dir.mkdir(parents=True, exist_ok=True)
workspace.copies_dir.mkdir(parents=True, exist_ok=True)
staged_backup = None
if original_path.resolve() != backup_path.resolve():
staged_backup = _temporary_sibling(backup_path, "new-original")
shutil.copy2(original_path, staged_backup)
saved_backup = _temporary_sibling(backup_path, "old-original")
saved_output = _temporary_sibling(output_path, "old-output")
backup_replaced = False
output_replaced = False
try:
if staged_backup is not None:
if backup_path.exists():
backup_path.replace(saved_backup)
staged_backup.replace(backup_path)
backup_replaced = True
if output_path.exists():
output_path.replace(saved_output)
generated_path.replace(output_path)
output_replaced = True
if original_path.resolve() not in {
backup_path.resolve(),
output_path.resolve(),
}:
original_path.unlink()
except Exception:
if output_replaced and output_path.exists():
output_path.unlink()
if saved_output.exists():
saved_output.replace(output_path)
if backup_replaced and backup_path.exists():
backup_path.unlink()
if saved_backup.exists():
saved_backup.replace(backup_path)
raise
finally:
for temporary in (staged_backup, saved_backup, saved_output):
if temporary is not None and temporary.exists():
temporary.unlink()
return output_path
class PDFPreviewer:
def setup_next_file(self):
self.num += 1
if len(self.inputs) == 0:
return False
self.pdf_path = self.inputs.pop()
self.file_rotation = 0
self.base_name = self.pdf_path.stem
self._temporary_directory = tempfile.TemporaryDirectory(
prefix=f".{self.base_name}.page-splitter.",
dir=self.workspace.root,
)
working_dir = Path(self._temporary_directory.name)
self.split_dir = working_dir / "split"
self.reorder_dir = working_dir / "reorder"
self.final_file = working_dir / f"{self.base_name}.pdf"
self.current_page_index = 0
self.page_settings = []
self.processing = False # Flag to prevent multiple finish calls
try:
self.doc = fitz.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
messagebox.showerror("Error", f"Failed to open PDF file: {e}")
self.master.destroy()
return
self.master.title(f"PDF Splitter - {self.pdf_path.name}")
return True
def __init__(
self,
master: tk.Tk,
workspace: EvaluationWorkspace,
inputs: list[Path],
) -> None:
"""
Initializes the application.
Args:
master (tk.Tk): The root Tkinter window.
pdf_path (str): The path to the input PDF file.
"""
self.workspace = workspace
self.inputs = inputs
self.output_dir = None
self.master = master
self.num = 0
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
self.history = []
self.failed = False
if not self.setup_next_file():
print(f"No PDF files found in {workspace.root}")
master.destroy()
return
self._resize_job = None # For debouncing resize events
self._initialize_current_page_settings()
# --- UI Setup ---
# Set a reasonable initial size for the window
self.master.geometry("800x1000")
def fmt(action):
k = PAGE_SPLITTER_KB.get(action, "")
return k
# Dynamic instructions text
instructions = (
f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm 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"
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
self.info_label.pack(pady=5, side=tk.TOP)
self.page_label = tk.Label(master, text="", font=("Helvetica", 12))
self.page_label.pack(pady=5, side=tk.TOP)
# Canvas for PDF page preview
self.canvas = tk.Canvas(master, bg="gray")
self.canvas.pack(fill="both", expand=True)
# --- Bindings ---
action_map = {
"move_left": self.move_line_left,
"move_right": self.move_line_right,
"confirm_next": self.confirm_and_next_page,
"rotate_page": self.rotate_page,
"rotate_all_pages": self.rotate_all_pages,
"rotate_all_files": self.rotate_all_files,
"keep_left": self.keep_left,
"keep_right": self.keep_right,
"keep_as_is": self.keep_as_is,
"next_page": self.confirm_and_next_page,
"discard_page": self.discard_page,
"send_end": self.send_page_end,
"restart_file": self.restart_current_file,
"arranger": self.start_arranger,
"prev_file": self.go_to_previous_file,
}
for action, key in PAGE_SPLITTER_KB.items():
if action in action_map:
self.master.bind(key, action_map[action])
# self.master.bind("<Left>", self.move_line_left)
# self.master.bind("<Right>", self.move_line_right)
# self.master.bind("<Return>", self.confirm_and_next_page)
# self.master.bind("c", self.rotate_page)
# self.master.bind("C", self.rotate_all_pages)
# self.master.bind(",", self.rotate_all_files)
# self.master.bind("t", self.keep_left)
# self.master.bind("n", self.keep_right)
# self.master.bind("m", self.keep_as_is)
# self.master.bind("s", self.confirm_and_next_page)
# self.master.bind("r", self.discard_page)
# self.master.bind("z", self.send_page_end)
# self.master.bind("R", self.restart_current_file)
# self.master.bind("A", self.start_arranger)
# self.master.bind("P", self.go_to_previous_file)
# Bind the resize event on the canvas
self.canvas.bind("<Configure>", self.on_resize)
self.current_zoom = 1.0
def start_arranger(self):
try:
launch_pdf_arranger(self.pdf_path)
except FileNotFoundError as exc:
messagebox.showerror("PDF Arranger", str(exc))
def on_resize(self, event):
"""
Handles window resize events by reloading the page.
Uses a "debounce" mechanism to avoid excessive redrawing.
"""
if self._resize_job:
self.master.after_cancel(self._resize_job)
self._resize_job = self.master.after(250, self.load_page) # Redraw after 250ms of no resizing
def _initialize_current_page_settings(self):
"""Initializes or resets the settings for the current page."""
if self.current_page_index < len(self.doc):
page = self.doc.load_page(self.current_page_index)
self.current_line_x = page.rect.width / 2
self.current_rotation = 0
def load_page(self):
"""Loads and displays the current page on the canvas, scaled to fit."""
if self.current_page_index >= len(self.doc):
if not self.processing:
self.processing = True
self.finish_and_process()
return
page = self.doc.load_page(self.current_page_index)
self.page_label.config(text=f"Page {self.current_page_index + 1} of {len(self.doc)}")
# --- Calculate Scaling ---
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
# Don't try to render if the canvas has no size yet.
if canvas_width <= 1 or canvas_height <= 1:
return
page_rect = page.rect
zoom_x = canvas_width / page_rect.width
zoom_y = canvas_height / page_rect.height
# Use 98% of the smallest zoom factor to leave a small margin
self.current_zoom = min(zoom_x, zoom_y) * 0.98
# --- Render Page ---
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
# Apply rotation if needed *after* drawing the line
if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0:
img = img.rotate(180, expand=True)
# --- Draw Line and Rotate ---
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)
# --- Display on Canvas ---
self.photo_img = ImageTk.PhotoImage(img)
self.canvas.delete("all")
# Center the image on the canvas
self.canvas.create_image(canvas_width / 2, canvas_height / 2, anchor="center",
image=self.photo_img)
def restart_current_file(self, event=None):
"""Restarts the processing of the current file."""
# Close the modified in-memory document
if hasattr(self, 'doc'):
self.doc.close()
# Re-open the file from disk to reset changes (like moved pages)
try:
self.doc = fitz.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
self.master.destroy()
return
# Reset state variables for the current file
self.file_rotation = 0
self.current_page_index = 0
self.page_settings = []
self.processing = False
# Reload UI
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)
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)
self.load_page()
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
self.load_page()
def rotate_all_pages(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.file_rotation = 180 if self.file_rotation == 0 else 0
self.load_page()
def rotate_all_files(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.global_rotation = 180 if self.global_rotation == 0 else 0
self.load_page()
def keep_left(self, event=None):
self.confirm_and_next_page(keep="left")
def keep_right(self, event=None):
self.confirm_and_next_page(keep="right")
def discard_page(self, event=None):
self.confirm_and_next_page(keep="none")
def keep_as_is(self, event=None):
self.confirm_and_next_page(keep="as_is")
def send_page_end(self, event=None):
# Do nothing if we are already at or past the last page
if self.current_page_index >= len(self.doc) - 1:
return
# Move the current page to the end of the document
# -1 as the destination puts it after the last page
self.doc.move_page(self.current_page_index, -1)
# Initialize settings for the page that shifted into the current slot
self._initialize_current_page_settings()
# Reload the canvas to show the new page
self.load_page()
def confirm_and_next_page(self, event=None, keep="both"):
"""Saves the settings for the current page and moves to the next."""
self.page_settings.append({
"line_x": self.current_line_x,
"rotation": self.current_rotation,
"keep": keep
})
self.current_page_index += 1
if self.current_page_index < len(self.doc):
self._initialize_current_page_settings()
self.load_page()
else:
if not self.finish_and_process():
return
self.history.append(self.pdf_path)
if self.setup_next_file():
self._initialize_current_page_settings()
self.load_page()
else:
self.master.destroy()
def finish_and_process(self) -> bool:
"""Render and transactionally install the processed PDF."""
try:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
except Exception as exc: # noqa: BLE001 - interactive boundary
self.failed = True
self.processing = False
print(f"Failed to process {self.pdf_path}: {exc}")
messagebox.showerror("Error", f"Failed to process PDF: {exc}")
self._temporary_directory.cleanup()
self.master.destroy()
return False
else:
self._temporary_directory.cleanup()
return True
def go_to_previous_file(self, event=None):
"""Goes back to the beginning of the previously completed file."""
if not self.history:
return # Nowhere to go back to
# Close the currently open document to avoid lock issues
if hasattr(self, 'doc'):
self.doc.close()
if hasattr(self, "_temporary_directory"):
self._temporary_directory.cleanup()
# 1. Push current file back onto the stack so it processes next
self.inputs.append(self.pdf_path)
# 2. Reprocess the previous file from its preserved original backup
prev_file = self.history.pop()
backup = self.workspace.original_copies_dir / Path(prev_file).name
self.inputs.append(backup if backup.is_file() else Path(prev_file))
# 3. Reload environment (setup_next_file will pop prev_file back off the stack)
self.setup_next_file()
self._initialize_current_page_settings()
self.load_page()
def split_filename_left(self, i):
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}l.pdf")
def split_filename_right(self, i):
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}r.pdf")
def reorder_filename(self, i):
return os.path.join(self.reorder_dir, f"{self.base_name}_{i+1}.pdf")
def clean_up_dir(self, dir, make=True):
if make:
os.makedirs(dir, exist_ok=True)
pdf_files = glob.glob(os.path.join(dir, "*.pdf"))
for pdf in pdf_files:
try:
os.remove(pdf)
except OSError as e:
print(f"Error deleting {pdf}: {e}")
def split_pdf(self):
"""Splits each page of the PDF according to the saved settings."""
print("Starting PDF processing...")
self.clean_up_dir(self.split_dir)
for i, settings in enumerate(self.page_settings):
page = self.doc.load_page(i)
line_x = settings['line_x']
rotation_settings = settings['rotation']
keep = settings['keep']
rotation = (page.rotation + rotation_settings +
self.file_rotation + self.global_rotation) % 360
if keep == "as_is":
doc_full = fitz.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 = fitz.Rect(0, 0, line_x, page.rect.height)
else:
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
if (keep == "both" or keep == "left") and line_x > 0:
doc_left = fitz.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 = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
else:
rect_right = fitz.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 = fitz.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()
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
def reorder_pdfs(self):
"""Reordonne les pages, si ce sont des copies doubles."""
self.clean_up_dir(self.reorder_dir)
ps = self.page_settings
ri = 0
i = 0
while i < len(ps):
psk = ps[i]['keep']
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
# 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))
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))
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))
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))
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))
ri += 1
if psk in ["right", "both"]:
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
ri += 1
i += 1
# def reorder_pdfs(self):
# """Reordonne les pages, si ce sont des copies doubles."""
# self.clean_up_dir(self.reorder_dir)
# ps = self.page_settings
# ri = 0
# i = 0
# while i < len(ps):
# # 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))
# ri += 1
# if ps[i+1]['keep'] != "none":
# shutil.copy2(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))
# ri += 1
# if ps[i]['keep'] == "both":
# shutil.copy2(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))
# ri += 1
# if psk == "right" or psk == "both":
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
# ri += 1
# i += 1
def concate_files(self):
writer = PdfWriter()
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]
pdf_files = sorted(
glob.glob(os.path.join(self.reorder_dir, "*.pdf")),
key=natural_key
)
for pdf in pdf_files:
reader = PdfReader(pdf)
for page in reader.pages:
writer.add_page(page)
if self.output_dir != None:
os.makedirs(os.path.dirname(self.final_file), exist_ok=True)
with open(self.final_file, "wb") as f:
writer.write(f)
print(f"Created merged PDF: {self.final_file}")
def _selected_inputs(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
backup = workspace.original_copies_dir / target.name
return [backup if backup.is_file() else target]
directory = target
if target == workspace.copies_dir:
candidates = list_pdf_files(workspace.copies_dir)
candidates = [
(
workspace.original_copies_dir / path.name
if (workspace.original_copies_dir / path.name).is_file()
else path
)
for path in candidates
]
else:
candidates = list_pdf_files(directory)
return list(reversed(candidates))
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
inputs = _selected_inputs(workspace, target)
if not inputs:
print(f"No PDF files found in {target}")
return ExitCode.SUCCESS
root = tk.Tk()
application = PDFPreviewer(root, workspace, inputs)
root.mainloop()
return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively split and reorder scanned PDF pages")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+475
View File
@@ -0,0 +1,475 @@
from __future__ import annotations
import argparse
import queue
import threading
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
from PIL import Image, ImageDraw, ImageFont, ImageTk
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
# --- Configuration & Globals ---
padding = 60
try:
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
except OSError:
font = ImageFont.load_default()
# --- Helper Functions (Shared) ---
def page_number(b, nb_pages):
column_width = 1000 // nb_pages
center_x = (b[1] + b[3]) // 2
return center_x // column_width
def convert_box2d(b, pn_ori, npn, tot_ori, tot_dest):
l = b.copy()
l[1] = (l[1] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
+ (1000 // tot_dest) * (npn - 1)
l[3] = (l[3] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
+ (1000 // tot_dest) * (npn - 1)
return l
def convert_list(l, group_id, json_schema):
ll = []
nb_pages = json_schema["columns_per_file"][group_id-1]
nb_previous_pages = sum([json_schema["columns_per_file"][i] for i in range(group_id-1)])
nb_tot_pages = sum([e for e in json_schema["columns_per_file"]])
for e in l:
ee = e.copy()
pn = page_number(e["box_2d"], nb_pages)
npn = pn + nb_previous_pages
ee["box_2d"] = convert_box2d(ee["box_2d"], pn, npn, nb_pages, nb_tot_pages)
ee["part"] = group_id
ee["pn"] = npn
ll.append(ee)
return ll
def normalized_labels(entries):
return [
str(value["label"]).removeprefix("|").removesuffix("|")
for value in entries
if str(value["label"]) != "_"
]
def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_label_index):
im = Image.open(image_path)
im.load()
width, height = im.size
new_im = Image.new(im.mode, (width + padding, height), "white")
new_im.paste(im, (0, 0))
draw = ImageDraw.Draw(new_im)
bounding_boxes.sort(key=lambda b: (page_number(b["box_2d"], nb_pages), b["box_2d"][0]))
for bbox in bounding_boxes:
raw_y_min = int(bbox["box_2d"][0] * height / 1000)
raw_x_min = int(bbox["box_2d"][1] * width / 1000)
raw_y_max = int(bbox["box_2d"][2] * height / 1000)
raw_x_max = int(bbox["box_2d"][3] * width / 1000)
abs_y_min = max(0, raw_y_min - 10)
abs_x_min = max(0, raw_x_min - 10)
abs_y_max = min(height, raw_y_max + 10)
abs_x_max = min(width, raw_x_max + 10)
color = "black"
label = bbox.get("label")
if label and label in all_labels:
current_index = all_labels.index(label)
if current_index < last_label_index or (last_label_index == -1 and current_index != 0):
color = "red"
elif current_index > last_label_index + 1:
color = "orange"
last_label_index = current_index
draw.rectangle(((abs_x_min, abs_y_min), (abs_x_max, abs_y_max)), outline=color, width=4)
if label:
if abs_y_min > 80:
draw.text((abs_x_min + 8, abs_y_min - 30), label, fill=color, font=font)
else:
draw.text((abs_x_min + 8, abs_y_max + 6), label, fill=color, font=font)
return (new_im, last_label_index)
# --- Processing Logic (Worker Thread) ---
def _worker_items(base_dir, files_to_process, all_labels, output_queue):
"""
Iterates through files, prepares VISUALS only, and puts metadata in queue.
Does NOT write final JSON files anymore.
"""
previous_copie = None
last_label_index = None
for img_path in files_to_process:
json_path = base_dir / "Copies" / f"{img_path.stem}.json"
copie_part = int(img_path.stem[-2:])
copie = img_path.stem[:-3]
if copie != previous_copie:
last_label_index = -1
previous_copie = copie
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
try:
json_schema = read_json(json_schema_path)
except (OSError, TypeError, ValueError):
print("No json_schema : ", json_schema_path)
continue
nb_pages = json_schema["columns_per_file"][copie_part-1]
if json_path.exists():
# Read strictly for visualization purposes
bb_list = []
json_name = ""
try:
json_result = read_json(json_path)
bb_list = json_result.get("list", [])
json_name = json_result.get("name", "")
except Exception as e: # noqa: BLE001 - malformed user-editable JSON
print(f"Warning: {json_path.name} is malformed! Loading blank. {e}")
# We do NOT skip; we continue so the user can fix it in the GUI
try:
print(f"Buffering {img_path.name}...")
(pil_image, last_label_index) = \
prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index)
error_msg = None
except Exception as e: # noqa: BLE001 - keep the item editable in the GUI
print(f"Error processing {img_path.name}: {e}")
pil_image = Image.open(str(img_path))
error_msg = str(e)
metadata = {
"copie": copie,
"part": copie_part,
"schema": json_schema,
"name": json_name,
"error": error_msg
}
output_queue.put((pil_image, json_path, metadata))
def worker_thread(base_dir, files_to_process, all_labels, output_queue):
"""Prepare queue items and always terminate the GUI stream."""
failure = None
try:
_worker_items(base_dir, files_to_process, all_labels, output_queue)
except Exception as exc: # noqa: BLE001 - worker boundary
failure = str(exc)
print(f"Plotting worker failed: {exc}")
finally:
metadata = {"worker_error": failure} if failure else None
output_queue.put((None, None, metadata))
# --- GUI Logic (Main Thread) ---
class ImageViewer:
def __init__(self, root, workspace, valid_labels, input_queue):
self.root = root
self.root.resizable(False, False) # If you resize, coordinates will be wrong
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
x = int(screen_w * 0.1)
y = int(screen_h * 0.05)
root.geometry(f"+{x}+{y}")
self.workspace = workspace
self.base_dir = workspace.root
self.valid_labels = valid_labels
self.image_queue = input_queue
self.root.title("Bounding Box Viewer")
self.label = tk.Label(root, text="Waiting for images...")
self.label.pack(expand=True, fill="both")
# Display State
self.current_image = None
self.current_json_path = None
self.current_meta = None # Stores schema/copie info
self.is_viewing = False
self.scale_factor = 1.0
self.orig_size = (1, 1)
# Data Aggregation State
self.active_copie_name = None
self.accumulated_results = None # Dict with "name" and "list"
# To go back
self.history = []
self.forward_stack = []
self.current_pil_image = None
self.failed = False
from config import PLOTTING_KB
# Bindings
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
self.root.bind('<Escape>', lambda _event: self.close())
self.root.protocol("WM_DELETE_WINDOW", self.close)
self.label.bind('<Button-1>', self.on_click)
self.poll_queue()
def poll_queue(self):
if not self.is_viewing:
try:
# pil_image, json_path, metadata = image_queue.get_nowait()
if self.forward_stack:
pil_image, json_path, metadata = self.forward_stack.pop()
else:
pil_image, json_path, metadata = self.image_queue.get_nowait()
# Handle End of Stream
if pil_image is None:
if metadata and metadata.get("worker_error"):
self.failed = True
messagebox.showerror(
"Processing Error", metadata["worker_error"]
)
self.save_current_batch() # Save any remaining data
print("All images processed.")
self.root.quit()
return
# Check if we switched to a new "Copie" group
if self.active_copie_name != metadata["copie"]:
self.save_current_batch() # Write previous group to disk
# Start new batch
self.active_copie_name = metadata["copie"]
self.accumulated_results = {"name": metadata["name"], "list": []}
self.history.clear()
self.display_image(pil_image, json_path, metadata)
except queue.Empty:
pass
self.root.after(100, self.poll_queue)
def save_current_batch(self):
"""Writes the accumulated data to the main JSON file."""
if self.active_copie_name and self.accumulated_results:
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
print(f"Writing aggregated result to {main_json_path}")
atomic_write_json(main_json_path, self.accumulated_results)
self.accumulated_results = None
def close(self):
self.root.quit()
def on_previous(self, event):
if self.is_viewing and self.history:
print("Going back to previous image...")
prev_pil, prev_json, prev_meta, num_added = self.history.pop()
# Undo the accumulation to prevent duplicates when we hit Enter again
if self.accumulated_results and num_added > 0:
self.accumulated_results["list"] = self.accumulated_results["list"][:-num_added]
# Push current image to the forward stack so we don't lose it
self.forward_stack.append((self.current_pil_image,
self.current_json_path, self.current_meta))
# Display the previous image immediately
self.display_image(prev_pil, prev_json, prev_meta)
def display_image(self, pil_image, json_path, metadata):
self.current_pil_image = pil_image # ADD THIS LINE
self.orig_size = pil_image.size
self.scale_factor = 1.0
screen_h = self.root.winfo_screenheight() - 100
if pil_image.height > screen_h:
self.scale_factor = screen_h / pil_image.height
pil_image = pil_image.resize((int(pil_image.width * self.scale_factor),
int(pil_image.height * self.scale_factor)))
self.tk_image = ImageTk.PhotoImage(pil_image)
self.label.config(image=self.tk_image, text=f"Processing: {json_path.name}")
self.current_json_path = json_path
self.current_meta = metadata
self.is_viewing = True
self.root.lift()
if metadata.get("error"):
msg = f"Error generating boxes for {json_path.name}:\n\n{metadata['error']}\n\nPlease press 'e' to fix the JSON file, then press Enter to retry."
messagebox.showerror("Processing Error", msg)
def on_enter(self, event):
if self.is_viewing:
print(f"Committing data for {self.current_json_path.name}...")
num_added = 0 # ADD THIS LINE
try:
current_data = read_json(self.current_json_path)
# Perform the conversion now, post-edit
converted_items = convert_list(
current_data["list"],
self.current_meta["part"],
self.current_meta["schema"]
)
labels = normalized_labels(current_data["list"])
false_labels = [
label for label in labels if label not in self.valid_labels
]
if false_labels:
msg = f"Wrong label in {self.current_json_path.name}: {false_labels}\n\n\tPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("Label Error", msg)
return
num_added = len(converted_items)
# Add to accumulator
if self.accumulated_results:
self.accumulated_results["list"].extend(converted_items)
# Update name just in case (though usually consistent per group)
if "name" in current_data and current_data["name"] != "Continued":
self.accumulated_results["name"] = current_data["name"]
except Exception as e: # noqa: BLE001 - interactive validation boundary
# Warn user and STOP (do not advance to next image)
msg = f"Error reading {self.current_json_path.name}:\n\n{e}\n\nPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("JSON Error", msg)
return # Abort advancement
self.history.append((self.current_pil_image, self.current_json_path,
self.current_meta, num_added))
# Advance UI
self.is_viewing = False
self.label.config(image="", text="Loading next...")
def on_open_pdf(self, event):
if self.is_viewing and self.current_json_path:
a = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.current_json_path.with_name(a)
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_open_interro(self, event):
if self.is_viewing and self.current_json_path:
# Check local directory first
local_accent = self.base_dir / "énoncé.pdf"
local_plain = self.base_dir / "enonce.pdf"
if local_accent.exists():
pdf_path = str(local_accent)
elif local_plain.exists():
pdf_path = local_plain
else:
messagebox.showerror(
"PDF not found",
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
)
return
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_open_ori_pdf(self, event):
if self.is_viewing and self.current_json_path:
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.base_dir / "Copies Originales" / new_filename
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_edit(self, event):
if self.is_viewing and self.current_json_path:
print(f"Opening {self.current_json_path}")
open_path(self.current_json_path)
def on_click(self, event):
if not self.is_viewing: return
x = int(event.x / self.scale_factor)
y = int(event.y / self.scale_factor)
w, h = self.orig_size
box = [
int(max(0, y - 5) / h * 1000),
int(max(0, x - 5) / (w- padding) * 1000),
int(min(h, y + 5) / h * 1000),
int(min(w, x + 5) / (w - padding) * 1000),
]
box_str = "{ \"box_2d\": " + str(box) + ", \"label\": \"\" },"
print(f"Copied box at ({x},{y}): {box_str}")
self.root.clipboard_clear()
self.root.clipboard_append(box_str)
def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Cutleft", "Copies")
if target.is_file():
stem = target.stem
exact = workspace.cutleft_dir / f"{stem}.jpg"
if exact.is_file():
return [exact]
return sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"),
key=natural_key,
)
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
all_labels = read_all_labels(workspace.root)
files_to_process = _selected_images(workspace, target)
if not files_to_process:
print(f"No Cutleft images found for {target}")
return ExitCode.PARTIAL
print(
"o to open pdf, O original pdf, e to edit part, p to go back, "
"i to open the statement, click for coordinates"
)
input_queue = queue.Queue(maxsize=5)
worker = threading.Thread(
target=worker_thread,
args=(workspace.root, files_to_process, all_labels, input_queue),
daemon=True,
)
worker.start()
root = tk.Tk()
application = ImageViewer(root, workspace, set(all_labels), input_queue)
root.mainloop()
return ExitCode.PARTIAL if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively verify detected label coordinates")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Clean encoding and LaTeX issues in correction.json.")
def escape_latex_underscores(text: str) -> str:
r"""Escape underscores outside LaTeX math environments."""
math_pattern = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
parts: list[str] = []
last_end = 0
for match in math_pattern.finditer(text):
start, end = match.span()
parts.append(text[last_end:start].replace("_", r"\_"))
parts.append(match.group(0))
last_end = end
parts.append(text[last_end:].replace("_", r"\_"))
return "".join(parts)
def build_lookup_map(word_list_path: Path = WORD_LIST_FILE) -> dict[str, str]:
words = word_list_path.read_text(encoding="utf-8").splitlines()
lookup: dict[str, str] = {}
for word in words:
broken_key = ACCENT_PATTERN.sub("\x00", word)
if "\x00" in broken_key:
lookup[broken_key.lower()] = word
return lookup
def fast_fix(text: str, lookup: dict[str, str]) -> str:
def replacer(match: re.Match[str]) -> str:
broken_word = match.group(0)
return lookup.get(broken_word.lower(), broken_word)
return re.sub(r"[a-zA-Z\x00]+", replacer, text)
def fix_hex_corruption_safe(text: str) -> str:
return re.sub(
r"\x00([eEfF][0-9a-fA-F])",
lambda match: chr(int(match.group(1), 16)),
text,
)
def some_other_replacements(text: str) -> str:
return text.replace("\neq", "\\neq").replace("\not", "\\not")
def clean_string(text: str, lookup: dict[str, str]) -> str:
text = fix_hex_corruption_safe(text)
text = text.replace("\x19", "\x00")
text = text.replace("\x18", "\x00")
text = text.replace("\x00\x00", "\x00")
text = re.sub(r" \x00{1,2} ", " à ", text)
if "\x00" in text:
text = fast_fix(text, lookup).replace("\x00", "")
return escape_latex_underscores(some_other_replacements(text))
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
if isinstance(value, str):
return clean_string(value, lookup)
if isinstance(value, list):
return [clean_obj(item, lookup) for item in value]
if isinstance(value, dict):
return {
key: item if key == "suffix" else clean_obj(item, lookup)
for key, item in value.items()
}
return value
def run(
workspace: EvaluationWorkspace,
*,
word_list_path: Path = WORD_LIST_FILE,
) -> ExitCode:
workspace.require_files("correction.json")
lookup = build_lookup_map(word_list_path)
data = read_json(workspace.correction_file)
cleaned = clean_obj(data, lookup)
atomic_write_json(workspace.correction_file, cleaned)
print(f"Fixed JSON saved to {workspace.correction_file}")
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import numpy as np
from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
from copienator.commands import annotating
from copienator import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.filesystem import staged_files
Image.MAX_IMAGE_PIXELS = None
def detect_checks_and_notes(
output_dir: str | Path,
) -> tuple[list[dict[str, Any]], Image.Image | None]:
"""Detect checked boxes and extract handwritten notes from an annotated PDF."""
directory = Path(output_dir)
pdf_path = directory / "Concat_annotated.pdf"
reference_path = directory / "Reference.jpg"
boxes_path = directory / "checkboxes.json"
missing = [
path.name
for path in (pdf_path, reference_path, boxes_path)
if not path.is_file()
]
if missing:
print(f"\tMissing annotation input in {directory}: {', '.join(missing)}")
return [], None
boxes = read_json(boxes_path)
if not isinstance(boxes, list):
raise TypeError(f"Expected a JSON array in {boxes_path}")
with Image.open(reference_path) as opened_reference:
reference = opened_reference.convert("RGB").copy()
try:
pages = convert_from_path(pdf_path, dpi=72)
except Exception as exc: # noqa: BLE001 - PDF backends expose many errors
print(f"Error reading PDF {pdf_path}: {exc}")
return [], None
if not pages:
print(f"Error reading PDF {pdf_path}: no page found")
return [], None
user_image = Image.new("RGB", (pages[0].width, sum(page.height for page in pages)))
current_y = 0
for page in pages:
user_image.paste(page.convert("RGB"), (0, current_y))
current_y += page.height
if user_image.size != reference.size:
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
difference = np.abs(
np.array(reference).astype(int) - np.array(user_image).astype(int)
).astype(np.uint8)
difference_gray = np.mean(difference, axis=2)
keep_mask = Image.new("L", reference.size, 255)
mask_draw = ImageDraw.Draw(keep_mask)
actions: list[dict[str, Any]] = []
for raw_box in boxes:
if not isinstance(raw_box, dict) or "global_box" not in raw_box:
continue
x1, y1, x2, y2 = map(int, raw_box["global_box"])
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(reference.width, x2), min(reference.height, y2)
region = difference_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
if region.size == 0:
continue
density = np.sum(region > 30) / region.size
if density > 0.05:
actions.append(raw_box)
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
else:
mask_draw.rectangle([x1 - 2, y1 - 2, x2 + 2, y2 + 2], fill=0)
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
final_alpha = np.minimum(alpha, np.array(keep_mask))
notes = user_image.convert("RGBA")
notes.putalpha(Image.fromarray(final_alpha))
return actions, notes
def has_significant_notes(note_img: Image.Image | None, threshold: int = 20) -> bool:
"""Return whether an RGBA note layer contains enough visible pixels."""
if note_img is None or note_img.mode != "RGBA":
return False
alpha = np.array(note_img)[:, :, 3]
return bool(np.sum(alpha > 50) > threshold)
def concatenate(images: list[Image.Image]) -> Image.Image | None:
if not images:
return None
result = Image.new(
"RGB",
(max(image.width for image in images), sum(image.height for image in images)),
"white",
)
current_y = 0
for image in images:
result.paste(image, (0, current_y))
current_y += image.height
return result
def apply_actions_and_regenerate(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
notes_layer: Image.Image | None,
all_labels: list[str],
*,
update_score: bool = False,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
bnote_path = output_dir / "bnote.json"
if not bnote_path.is_file():
print(f" Missing {bnote_path}")
return ExitCode.PARTIAL
bnote_data = read_json(bnote_path)
if not isinstance(bnote_data, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id]
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
if update_score:
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
scores = dict.fromkeys(all_labels, "")
dirty_images: dict[str, Image.Image] = {}
concatenated: list[Image.Image] = []
filtered: list[Image.Image] = []
incomplete = False
for image_info in bnote_data.get("images", []):
if not isinstance(image_info, dict):
incomplete = True
continue
label = str(image_info.get("label", ""))
if label not in labels_data:
incomplete = True
continue
content = labels_data[label]
result = content["result"]
scores[label] = str(result.get("score", 0))
sub_note = None
if notes_layer is not None:
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
has_notes = has_significant_notes(sub_note)
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
print(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
if has_notes and sub_note is not None:
old_header_height = int(image_info.get("header_height", 0))
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes:
dirty_images[label] = final_image
concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image)
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
print(f" Saved regenerated files in {output_dir}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace, *, update_score: bool = False) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "Bnot")
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
for student_id in sorted(loaded.data, key=utils.natural_key):
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
if not output_dir.is_dir():
print(f"Warning: missing annotation directory {output_dir}")
status = ExitCode.PARTIAL
continue
print(f"Processing annotations for: {student_id}")
actions, notes = detect_checks_and_notes(output_dir)
if notes is None and not actions and not update_score:
print(" No readable annotation input found.")
status = ExitCode.PARTIAL
continue
result = apply_actions_and_regenerate(
workspace,
loaded.data,
student_id,
actions,
notes,
all_labels,
update_score=update_score,
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read checked annotations and regenerate copies")
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(workspace_from_args(args), update_score=args.update_score)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,401 @@
from __future__ import annotations
import argparse
import concurrent.futures
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
from copienator.commands import annotating
from copienator import utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.filesystem import staged_files
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
has_significant_notes,
)
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
def get_extra_pdfs_as_images(
root_dir: str | Path,
label: str,
annotating_module: Any,
all_labels: list[str],
) -> list[Image.Image]:
"""Convert the context, question and solution PDFs associated with a label."""
paths = [
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
utils.pdf_image_of_enonce(root_dir, label),
utils.pdf_image_of_solution(root_dir, label),
]
images = []
for path in paths:
if path:
image, _, _ = annotating_module.make_base_image(path)
if image is not None:
images.append(image)
return images
def save_paginated_pdf(image_groups: list[list[Image.Image]], output_path: Path) -> None:
"""Paginate vertically concatenated image groups and save them as a PDF."""
non_empty = [group for group in image_groups if group]
if not non_empty:
return
max_width = max(image.width for group in non_empty for image in group)
max_page_height = int(max_width * 1.414 * 1.25)
border = int((0.2 / 2.54) * 100)
left_margin = int((0.3 / 2.54) * 100)
vertical_margin = int((0.2 / 2.54) * 100)
max_content_height = max_page_height - 2 * vertical_margin
pages: list[Image.Image] = []
page_images: list[Image.Image] = []
page_height = 0
def finish_page() -> None:
nonlocal page_images, page_height
if not page_images:
return
page = Image.new(
"RGB",
(max_width + left_margin, page_height + 2 * vertical_margin),
"white",
)
current_y = vertical_margin
for image in page_images:
page.paste(image, (left_margin, current_y))
current_y += image.height
pages.append(page)
page_images = []
page_height = 0
for group in non_empty:
processed: list[Image.Image] = []
for index, image in enumerate(group):
if index in (0, 1):
image = image.copy()
color = "black" if index == 0 else "blue"
ImageDraw.Draw(image).rectangle(
[0, 0, image.width - 1, image.height - 1],
outline=color,
width=border,
)
processed.append(image)
group_height = sum(image.height for image in processed)
if page_images and page_height + group_height > max_content_height:
finish_page()
page_images.extend(processed)
page_height += group_height
finish_page()
pages[0].save(
output_path,
"PDF",
resolution=100.0,
save_all=True,
append_images=pages[1:],
)
def _scan_annotation_directory(
directory: Path,
only_ids: set[str] | None = None,
default_student_id: str | None = None,
) -> ScanResult:
bnote_path = directory / "bnote.json"
if not bnote_path.is_file():
raise FileNotFoundError(f"Missing {bnote_path}")
bnote = read_json(bnote_path)
if not isinstance(bnote, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
images = [item for item in bnote.get("images", []) if isinstance(item, dict)]
if only_ids and not any(
str(item.get("id", default_student_id)) in only_ids for item in images
):
return {}, {}
actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None:
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
for action in actions:
raw_student_id = action.get("student_id", default_student_id)
if raw_student_id is not None:
actions_by_student[str(raw_student_id)].append(action)
for image_info in images:
student_id = str(image_info.get("id", default_student_id or ""))
label = str(image_info.get("label", ""))
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
if student_id and label and hmax > hmin:
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
if has_significant_notes(crop):
notes_by_student[student_id][label] = {
"img": crop,
"old_header_h": int(image_info.get("header_height", 0)),
}
return dict(actions_by_student), dict(notes_by_student)
def _merge_scan_result(
target_actions: dict[str, list[dict[str, Any]]],
target_notes: dict[str, LabelNotes],
result: ScanResult,
) -> None:
actions, notes = result
for student_id, student_actions in actions.items():
target_actions[student_id].extend(student_actions)
for student_id, student_notes in notes.items():
target_notes[student_id].update(student_notes)
def apply_actions_and_regenerate_grouped(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
label_notes: LabelNotes,
all_labels: list[str],
*,
update_score: bool = False,
) -> tuple[ExitCode, str]:
"""Apply grouped annotations and atomically merge regenerated student files."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.annotation_dir("grouped") / f"Copie{student_id}"
labels_data = data.get(student_id, {})
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
if update_score:
dirty_labels |= apply_score_overrides(
labels_data, output_dir / "score.json", logs.append
)
scores = dict.fromkeys(all_labels, "")
dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
incomplete = False
for label, content in sorted(labels_data.items(), key=lambda item: utils.natural_key(item[0])):
result = content["result"]
scores[label] = str(result.get("score", 0))
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
has_notes = False
if label in label_notes:
sub_note = label_notes[label]["img"]
old_header_height = int(label_notes[label]["old_header_h"])
has_notes = has_significant_notes(sub_note)
if has_notes:
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
if label in dirty_labels or has_notes:
dirty_images[label] = final_image
logs.append(f" Saved dirty image: {label}.jpg")
concat_images.append(final_image)
feedbacks = result.get("feedback", [])
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
)
filtered_groups.append([*extras, final_image])
concat_image = concatenate(concat_images)
with staged_files(output_dir) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", scores)
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_groups:
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs)
def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str, list[str]]]:
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = []
by_student: dict[str, list[str]] = {}
for entry in loaded:
if not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[1], list):
raise TypeError(f"Malformed refaire entry: {entry!r}")
copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie")
normalized_labels = [str(label) for label in labels]
entries.append([str(copy_name), normalized_labels])
by_student[student_id] = normalized_labels
return entries, by_student
def run(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
update_score: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "BGnot")
refaire_list: RefaireList | None = None
refaire_by_student: dict[str, list[str]] = {}
if refaire:
workspace.require_files("refaire.json")
workspace.require_directories("BRnot")
refaire_list, refaire_by_student = _read_refaire(workspace)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace, refaire_list=refaire_list)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
only_ids = set(refaire_by_student) or None
group_dirs = [
path
for path in workspace.annotation_dir("grouped").iterdir()
if path.is_dir() and not path.name.startswith("Copie")
]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = [
executor.submit(_scan_annotation_directory, path, only_ids)
for path in group_dirs
]
for future in concurrent.futures.as_completed(futures):
_merge_scan_result(actions_by_student, notes_by_student, future.result())
refaire_incomplete = False
if refaire:
for student_id, requested_labels in refaire_by_student.items():
selected = requested_labels or list(loaded.data.get(student_id, {}))
selected_set = set(selected)
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}"
if not directory.is_dir():
print(f"Warning: missing refaire annotation directory {directory}")
refaire_incomplete = True
continue
actions_by_student[student_id] = [
action
for action in actions_by_student[student_id]
if str(action.get("label")) not in selected_set
]
for label in selected:
notes_by_student[student_id].pop(label, None)
refaire_actions, refaire_notes = _scan_annotation_directory(
directory, default_student_id=student_id
)
for action in refaire_actions.get(student_id, []):
if str(action.get("label")) in selected_set:
actions_by_student[student_id].append(action)
for label, note in refaire_notes.get(student_id, {}).items():
if label in selected_set:
notes_by_student[student_id][label] = note
status = (
ExitCode.PARTIAL
if loaded.warnings or refaire_incomplete
else ExitCode.SUCCESS
)
student_ids = list(refaire_by_student) if refaire else sorted(loaded.data, key=utils.natural_key)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
apply_actions_and_regenerate_grouped,
workspace,
loaded.data,
student_id,
actions_by_student[student_id],
notes_by_student[student_id],
all_labels,
update_score=update_score,
): student_id
for student_id in student_ids
if student_id in loaded.data
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read grouped annotations and regenerate copies")
parser.add_argument(
"--refaire",
action="store_true",
help="Use refaire.json and merge annotations from BRnot",
)
parser.add_argument(
"--update-score",
action="store_true",
help="Override generated scores with values from existing score.json files",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(
workspace_from_args(args),
refaire=args.refaire,
update_score=args.update_score,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import argparse
import re
import shutil
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pypdf import PdfWriter
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs)\s+")
COPY_PATTERN = re.compile(r"Copie(\d+)\s+(.+)")
@dataclass(frozen=True, slots=True)
class ManualInstruction:
copy_id: str
old_label: str
operator: str
new_label: str
pipe_first: bool
@property
def should_merge(self) -> bool:
return self.operator.endswith(">")
@property
def should_copy(self) -> bool:
return not self.should_merge and "s" not in self.operator
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Apply the instructions from manual_resolutions.txt.")
def parse_instructions(path: Path) -> list[ManualInstruction]:
instructions: list[ManualInstruction] = []
malformed: list[int] = []
for line_number, raw_line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
line = raw_line.strip()
if not line or line.startswith("###"):
continue
operator_match = OPERATOR_PATTERN.search(line)
if operator_match is None:
malformed.append(line_number)
continue
left = line[: operator_match.start()].strip()
right = line[operator_match.end() :].strip()
copy_match = COPY_PATTERN.fullmatch(left)
new_label = right.strip("|").strip()
if copy_match is None or not new_label:
malformed.append(line_number)
continue
instructions.append(
ManualInstruction(
copy_id=copy_match.group(1),
old_label=copy_match.group(2).strip(),
operator=operator_match.group(1),
new_label=new_label,
pipe_first=right.startswith("|"),
)
)
if malformed:
lines = ", ".join(str(number) for number in malformed)
raise CliError(f"Malformed manual resolution instruction at line(s): {lines}")
return instructions
def set_suffix_and_clean_error(
results: dict[str, Any],
copy_id: str,
label: str,
suffix: str | None,
new_label_target: str | None = None,
) -> None:
for batch in results.get(label, []):
for item in batch:
if item["id"] != copy_id:
continue
if suffix:
item["result"]["suffix"] = suffix
error = item["result"].get("error", "")
if new_label_target:
if f"wrg-lbl:{new_label_target}?delayed" in error:
item["result"]["error"] = (
f"wrg-lbl-moved-to:{new_label_target}"
)
if f"(delayed){new_label_target}" in error:
item["result"]["error"] = error.replace(
f"(delayed){new_label_target}",
f"(->){new_label_target}",
)
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
for candidate in (
base,
base.with_name(f"{label}_new.pdf"),
base.with_name(f"{label}_old.pdf"),
):
if candidate.exists():
return candidate
return base
def safe_strip_suffix(stem: str) -> str:
if stem.endswith(("_new", "_old")):
return stem[:-4]
return stem
def _validate_pdf_inputs(
instructions: list[ManualInstruction],
initial_paths: dict[tuple[str, str], Path],
) -> None:
missing: set[Path] = set()
for instruction in instructions:
source = initial_paths[(instruction.copy_id, instruction.old_label)]
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
if instruction.should_merge:
if not source.exists():
missing.add(source)
if not destination.exists():
missing.add(destination)
elif instruction.should_copy and not source.exists():
missing.add(source)
if missing:
rendered = ", ".join(str(path) for path in sorted(missing))
raise CliError(f"PDF input(s) required by manual resolutions not found: {rendered}")
def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_files("manual_resolutions.txt", "correction.json")
workspace.require_directories("Copies")
loaded = read_json(workspace.correction_file)
if not isinstance(loaded, dict):
raise CliError("correction.json must contain a JSON object")
results: dict[str, Any] = loaded
instructions = parse_instructions(workspace.manual_resolutions_file)
initial_paths: dict[tuple[str, str], Path] = {}
current_paths: dict[tuple[str, str], Path] = {}
for instruction in instructions:
for label in (instruction.old_label, instruction.new_label):
key = (instruction.copy_id, label)
if key not in initial_paths:
path = get_actual_pdf(workspace.copies_dir, *key)
initial_paths[key] = path
current_paths[key] = path
_validate_pdf_inputs(instructions, initial_paths)
files_to_old: set[Path] = set()
temp_files: list[Path] = []
try:
for instruction in instructions:
key_old = (instruction.copy_id, instruction.old_label)
key_new = (instruction.copy_id, instruction.new_label)
source = initial_paths[key_old]
destination = current_paths[key_new]
temp_output = (
workspace.copies_dir
/ f"Copie{instruction.copy_id}"
/ f"temp_{len(temp_files)}.pdf"
)
if instruction.operator.startswith("x"):
files_to_old.add(initial_paths[key_old])
if instruction.operator.endswith("x"):
files_to_old.add(initial_paths[key_new])
if instruction.should_merge:
writer = PdfWriter()
try:
if instruction.pipe_first:
writer.append(source)
writer.append(destination)
else:
writer.append(destination)
writer.append(source)
writer.write(temp_output)
except Exception:
temp_output.unlink(missing_ok=True)
raise
finally:
writer.close()
current_paths[key_new] = temp_output
temp_files.append(temp_output)
files_to_old.add(initial_paths[key_new])
elif instruction.should_copy:
shutil.copy(source, temp_output)
current_paths[key_new] = temp_output
temp_files.append(temp_output)
except Exception:
for temporary in temp_files:
temporary.unlink(missing_ok=True)
raise
for pdf in files_to_old:
if not pdf.exists():
continue
copy_id = pdf.parent.name.removeprefix("Copie")
label = safe_strip_suffix(pdf.stem)
old_name = pdf.with_name(f"{label}_old.pdf")
if pdf != old_name:
old_name.unlink(missing_ok=True)
shutil.move(str(pdf), str(old_name))
set_suffix_and_clean_error(results, copy_id, label, "_old")
for instruction in instructions:
set_suffix_and_clean_error(
results,
instruction.copy_id,
instruction.old_label,
None,
instruction.new_label,
)
refaire_by_copy: dict[str, list[str]] = {}
for (copy_id, label), current_path in current_paths.items():
if not current_path.name.startswith("temp_"):
continue
final_name = workspace.copies_dir / f"Copie{copy_id}" / f"{label}_new.pdf"
final_name.unlink(missing_ok=True)
shutil.move(str(current_path), str(final_name))
set_suffix_and_clean_error(results, copy_id, label, "_new")
labels = refaire_by_copy.setdefault(f"Copie{copy_id}", [])
if label not in labels:
labels.append(label)
used_temps = set(current_paths.values())
for temporary in temp_files:
if temporary not in used_temps:
temporary.unlink(missing_ok=True)
atomic_write_json(workspace.correction_file, results)
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
if refaire_tasks:
atomic_write_json(workspace.refaire_file, refaire_tasks)
workspace.manual_resolutions_file.unlink()
print("Manual resolutions successfully applied.")
if refaire_tasks:
print(
f"File {workspace.refaire_file.name} generated. Run "
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
"to process updates."
)
else:
print("No new corrections required.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return resolve_manual(workspace)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
import argparse
import shutil
import tempfile
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
import fitz
from pypdf import PdfReader, PdfWriter
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_directory
SQUARE = 1000 // 38
Coordinate = tuple[str, int, int, int, int, int]
ParsedCoordinate = tuple[str, str, int, int, int, int, int]
def decode_json(pdf_file: str | Path) -> tuple[str, list[Coordinate]]:
"""Read verified label coordinates associated with one copy PDF."""
pdf_path = Path(pdf_file)
loaded = read_json(pdf_path.with_suffix(".json"))
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object for {pdf_path}")
boxes = loaded.get("list")
if not isinstance(boxes, list):
raise TypeError(f"Expected a list of labels for {pdf_path}")
page_count = len(PdfReader(pdf_path).pages)
if page_count == 0:
raise ValueError(f"PDF contains no pages: {pdf_path}")
column_width = 1000 // page_count
result: list[Coordinate] = []
for entry in boxes:
if not isinstance(entry, dict):
raise TypeError(f"Malformed label entry for {pdf_path}: {entry!r}")
box = entry["box_2d"]
label = str(entry["label"])
page_number = ((box[1] + box[3]) // 2) // column_width
result.append(
(label, page_number, box[0] - SQUARE, box[2] - SQUARE, box[1], box[3])
)
result.sort(key=lambda item: (item[1], item[2]))
return str(loaded.get("name", "")), result
def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
parsed: list[ParsedCoordinate] = []
for label, page, y0, y1, x0, x1 in coords_list:
if label.startswith("|"):
kind, clean_label = "L", label[1:]
elif label.endswith("|"):
kind, clean_label = "R", label[:-1]
else:
kind, clean_label = "N", label
parsed.append((clean_label, kind, page, y0, y1, x0, x1))
filtered: list[ParsedCoordinate] = []
for item in parsed:
if not filtered or item[0] != filtered[-1][0]:
filtered.append(item)
return filtered
def _save_cropped_page(
document: fitz.Document,
page_number: int,
x0: float,
y0: float,
x1: float,
y1: float,
output_path: Path,
) -> None:
page = document[page_number]
rotated_rectangle = page.rect * page.transformation_matrix
visual_crop = fitz.Rect(
rotated_rectangle.x0 + x0,
y0,
rotated_rectangle.x0 + x1,
y1,
)
unrotated_clip = visual_crop * page.derotation_matrix
cropped = fitz.open()
try:
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
target_page.show_pdf_page(
target_page.rect,
document,
page_number,
rotate=-page.rotation,
clip=unrotated_clip,
)
cropped.save(output_path)
finally:
cropped.close()
def _render_split_outputs(
input_pdf: Path,
coords_list: list[Coordinate],
staging: Path,
) -> set[str]:
"""Render every current answer into an otherwise empty staging directory."""
document = fitz.open(input_pdf)
try:
parsed = _parse_coordinates(coords_list)
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory)
for index, item in enumerate(parsed):
clean_label, kind, start_page, y_start, _y_end, x0_raw, _x1_raw = item
if clean_label == "_":
continue
if not 0 <= start_page < document.page_count:
raise ValueError(
f"Invalid page {start_page} for {input_pdf.name}"
)
end_page = document.page_count - 1
end_y = 1000
for next_item in parsed[index + 1 :]:
_next_label, next_kind, next_page, next_y, *_rest = next_item
if (
(kind == "L" and next_kind in {"L", "N"})
or (kind == "R" and next_kind in {"R", "N"})
or kind == "N"
):
end_page = next_page
end_y = min(next_y + int(1.5 * SQUARE), 1000)
break
column_width = 1000 / document.page_count
if kind == "L":
fraction_x0 = (x0_raw % column_width) / column_width
fraction_x1 = 1.0
end_y = min(1000, end_y + 40)
elif kind == "R":
fraction_x0 = 0.0
left_labels = [entry for entry in parsed if entry[1] == "L"]
if left_labels:
closest = min(left_labels, key=lambda entry: abs(entry[3] - y_start))
center = (closest[5] + closest[6]) / 2.0
fraction_x1 = (center % column_width) / column_width
if fraction_x1 <= fraction_x0:
fraction_x1 = 1.0
else:
fraction_x1 = 1.0
else:
fraction_x0, fraction_x1 = 0.0, 1.0
for page_number in range(start_page, end_page + 1):
page = document[page_number]
y0 = (y_start / 1000) * page.rect.height if page_number == start_page else 0
y1 = (end_y / 1000) * page.rect.height if page_number == end_page else page.rect.height
if y1 <= y0 + 1:
continue
part_path = temporary / f"part-{index}-{page_number}.pdf"
_save_cropped_page(
document,
page_number,
fraction_x0 * page.rect.width,
y0,
fraction_x1 * page.rect.width,
y1,
part_path,
)
parts_by_label[clean_label].append(part_path)
generated: set[str] = set()
for label, parts in parts_by_label.items():
filename = f"{label}.pdf"
merger = PdfWriter()
try:
for part in parts:
merger.append(part)
merger.write(staging / filename)
finally:
merger.close()
generated.add(filename)
return generated
finally:
document.close()
def _preserve_previous_outputs(
output_dir: Path,
staging: Path,
generated_files: set[str],
) -> None:
if not output_dir.is_dir():
return
for directory in (path for path in output_dir.iterdir() if path.is_dir()):
shutil.copytree(directory, staging / directory.name, dirs_exist_ok=True)
missing_dir = staging / "Missing"
for item in (path for path in output_dir.iterdir() if path.is_file()):
if item.name in generated_files:
continue
print(f"ALERT: File '{item.name}' not generated. Moving to {missing_dir}")
missing_dir.mkdir(exist_ok=True)
shutil.copy2(item, missing_dir / item.name)
def split_an_interro(
workspace: EvaluationWorkspace,
input_pdf: Path,
coords_list: list[Coordinate],
) -> None:
"""Regenerate one copy's answers and preserve obsolete ones under Missing."""
output_dir = workspace.copies_dir / input_pdf.stem
with staged_directory(output_dir) as staging:
generated = _render_split_outputs(input_pdf, coords_list, staging)
_preserve_previous_outputs(output_dir, staging, generated)
def _selected_pdfs(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(workspace.copies_dir.glob("*.pdf"), key=lambda path: path.name.casefold())
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
utils.read_all_labels(workspace.root)
pdf_files = _selected_pdfs(workspace, target)
status = ExitCode.SUCCESS
for pdf_path in pdf_files:
json_path = pdf_path.with_suffix(".json")
if not json_path.is_file():
print(f"Warning: No JSON found for {pdf_path.name}")
status = ExitCode.PARTIAL
continue
name, coordinates = decode_json(pdf_path)
print(f"Decoded name: {name}")
split_an_interro(workspace, pdf_path, coordinates)
if not pdf_files:
print("No PDF copies found.")
return status
def build_parser() -> argparse.ArgumentParser:
return target_parser("Split verified PDF copies into answers by label")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from google.genai import types
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
batches = (
(
"flash",
workspace.root / "batch_requests_flash.jsonl",
config.MODEL_FLASH_ID,
f"flash-correction-{workspace.name}",
),
(
"pro",
workspace.root / "batch_requests_pro.jsonl",
config.MODEL_PRO_ID,
f"pro-correction-{workspace.name}",
),
)
manifest = {
"version": 1,
"evaluation": workspace.name,
"jobs": {},
}
if workspace.batch_jobs_file.is_file():
previous = read_json(workspace.batch_jobs_file)
if isinstance(previous, dict) and isinstance(previous.get("jobs"), dict):
manifest["jobs"] = previous["jobs"]
started = 0
for tier, file_path, model_id, display_name in batches:
if not file_path.is_file():
print(f"Skipping {model_id}: {file_path.name} does not exist.")
continue
if file_path.stat().st_size == 0:
print(f"Skipping {model_id}: {file_path.name} is empty.")
continue
print(f"Uploading {file_path.name} for model {model_id}...")
uploaded = client.files.upload(
file=str(file_path),
config=types.UploadFileConfig(
display_name=f"{display_name}-input",
mime_type="jsonl",
),
)
job = client.batches.create(
model=model_id,
src=uploaded.name,
config={"display_name": display_name},
)
started += 1
manifest["jobs"][tier] = {
"name": job.name,
"display_name": display_name,
"model": model_id,
"request_file": file_path.name,
}
atomic_write_json(workspace.batch_jobs_file, manifest)
print(f"Started batch job: {job.name}")
if not started:
print("No non-empty batch request files were found.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Upload correction JSONL files and start Gemini batches")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+172
View File
@@ -0,0 +1,172 @@
import argparse
import json
import os
import sys
from pathlib import Path
import ezodf
from copienator.configuration import CURRENT_SCORE_ODS_PATH
from copienator.utils import read_all_labels
# Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre"
def main(argv=None):
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
args = parser.parse_args(argv)
work_dir = os.path.abspath(args.work_dir)
all_labels = read_all_labels(Path(work_dir))
a_rendre_path = os.path.join(work_dir, TARGET_DIR_NAME)
if not os.path.isdir(a_rendre_path):
print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}")
sys.exit(1)
if not os.path.exists(ODS_PATH):
print(f"Error: ODS file not found at {ODS_PATH}")
sys.exit(1)
print(f"Opening ODS file: {ODS_PATH}...")
try:
doc = ezodf.opendoc(str(ODS_PATH))
except Exception as e:
print(f"Failed to open ODS: {e}")
sys.exit(1)
# Assuming the data is in the first sheet
sheet = doc.sheets[0]
# Map Student Names to Column Indices
# User specified: Names are in the second line (index 1)
# User specified: Ignore first 3 columns (0, 1, 2)
name_row_index = 1
name_to_col = {}
for col_idx in range(3, sheet.ncols()):
cell = sheet[name_row_index, col_idx]
if cell.value:
# Normalize name: strip spaces
name = str(cell.value).strip()
name_to_col[name] = col_idx
print(f"Found {len(name_to_col)} students in ODS.")
# Iterate over folders in "A Rendre"
for item in os.listdir(a_rendre_path):
student_dir = os.path.join(a_rendre_path, item)
# Check if it is a directory and has a name (ignoring the (ID) suffix if present from previous script)
# The directory name might be "Name" or "Name (ID)".
# The ODS usually contains just "Name".
if not os.path.isdir(student_dir):
continue
# Extract strict name for ODS matching (remove potential ID suffix if added by previous tool)
# Assuming the ODS name matches the folder name prefix
# If folder is "Doe John (123)", ODS likely has "Doe John" or "Doe John (123)"?
# Based on previous prompt, ODS has "AMELOT Gautier".
# We try exact match first, then simplified match.
json_path = os.path.join(student_dir, "score.json")
if not os.path.exists(json_path):
continue
with open(json_path, 'r', encoding='utf-8') as f:
try:
scores_data = json.load(f)
except json.JSONDecodeError:
print(f"Error decoding JSON for {item}")
continue
# Determine Column Index
col_idx = name_to_col.get(item)
# If not found exact match, try stripping ID suffix e.g. "Name (123)" -> "Name"
if col_idx is None and '(' in item:
clean_name = item.rsplit('(', 1)[0].strip()
col_idx = name_to_col.get(clean_name)
if col_idx is None:
print(f"Skipping '{item}': Name not found in ODS columns.")
continue
# Sort keys naturally (Ex 2 comes before Ex 10)
# sorted_keys = natsorted(scores_data.keys())
# Start filling from Row 2 (index 2), immediately below the name line
start_row = 2
if args.sum:
# Calculate total
total = 0.0
for val in scores_data.values():
try:
total += float(val)
except (ValueError, TypeError):
continue
cell = sheet[start_row, col_idx]
cell.set_value(total)
print(f"Set sum for {item}: {total}")
else:
for i, key in enumerate(all_labels):
row_idx = start_row + i
# Ensure we don't go out of bounds
if row_idx >= sheet.nrows():
sheet.append_rows(1)
if key in scores_data:
val_str = str(scores_data[key])
else:
val_str = ""
# Logic: if "" -> "NT"
new_val = "NT" if val_str == "" else val_str
cell = sheet[row_idx, col_idx]
current_val = cell.value
# Conflict Detection
# Normalize current ODS value to string for comparison
# ODS might store 2.0 as float 2.0. JSON has "2.0".
is_different = False
if current_val is not None and current_val != "":
# specific check to handle float/string mismatch (2.0 vs "2.0")
try:
if float(str(current_val)) != float(str(new_val)):
is_different = True
except ValueError:
# If conversion fails (e.g. comparing "NT" to "2.0"), compare strings
if str(current_val).strip() != str(new_val).strip():
is_different = True
if is_different:
print(f"DEBUG: Conflict for {item} at {key} (Row {row_idx}). "
f"Existing: '{current_val}' vs New: '{new_val}'. Overwriting.")
# Set value
# Try to set as float if it looks like a number, otherwise string
if new_val == "NT":
cell.set_value(new_val)
else:
try:
cell.set_value(float(new_val))
except ValueError:
cell.set_value(new_val)
print("Saving ODS file...")
doc.save()
print("Done.")
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import argparse
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
COPY_PATTERN = re.compile(r"Copie(\d+)")
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Verify that every answer PDF appears in group metadata.")
def collect_source_pdfs(copies_dir: Path) -> set[tuple[str, str]]:
source_pdfs: set[tuple[str, str]] = set()
for copy_dir in copies_dir.iterdir():
match = COPY_PATTERN.fullmatch(copy_dir.name)
if not match or not copy_dir.is_dir():
continue
copy_id = match.group(1)
for pdf_path in copy_dir.glob("*.pdf"):
source_pdfs.add((pdf_path.stem, copy_id))
return source_pdfs
def collect_grouped_pdfs(groups_dir: Path) -> tuple[set[tuple[str, str]], int]:
grouped: set[tuple[str, str]] = set()
read_errors = 0
for json_path in groups_dir.glob("*/Group_*.json"):
try:
data = read_json(json_path)
if not isinstance(data, list):
raise TypeError("expected a JSON array")
for entry in data:
grouped.add((str(entry[4]), str(entry[0])))
except (IndexError, OSError, TypeError, ValueError) as exc:
print(f"Error reading {json_path}: {exc}", file=sys.stderr)
read_errors += 1
return grouped, read_errors
def verify_groups(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies", "Par label")
source_pdfs = collect_source_pdfs(workspace.copies_dir)
grouped_pdfs, read_errors = collect_grouped_pdfs(workspace.groups_dir)
missing = source_pdfs - grouped_pdfs
if missing:
print(f"Verification failed: {len(missing)} files missing from groups:")
for label, copy_id in sorted(missing):
print(f"Copie{copy_id}/{label}.pdf")
return ExitCode.FAILURE
if read_errors:
print("Verification incomplete because some metadata could not be read.")
return ExitCode.PARTIAL
print("Verification successful: all files accounted for.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return verify_groups(workspace)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())