Allow signaling issues in label verification

This commit is contained in:
sebastien committed 2026-10-11 21:05:27 +02:00
1 parent 3754c0c11a
commit 9925362352
21 files changed
+1347 -36

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
"""Reproducible edits to reviewed answer bodies, never to headers or supplements."""
from __future__ import annotations
import re
from copy import deepcopy
from hashlib import sha256
from pathlib import Path
from PIL import Image, ImageDraw
from copienator import CliError, atomic_write_json, read_json, utils
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
DRAFT = "annotation_modifications.txt"
APPLIED = "annotation_modifications_applied.txt"
BASELINE = "annotation_baseline.json"
MODES = ("BGnot", "Bnot")
def read_instructions(text: str, mode: str) -> list[ManualInstruction]:
declared = re.findall(r"^### Source:\s*(\S+)\s*$", text, re.MULTILINE)
if declared != [mode] or mode not in MODES:
raise CliError(f"Le fichier doit contenir exactement une ligne '### Source: {mode}'.")
return parse_instruction_text(text)
def normalize_copy(root: Path, value: str) -> str:
number = value.strip().removeprefix("Copie")
if not number.isdecimal():
raise CliError("Numéro de copie invalide.")
matches = [p.name.removeprefix("Copie") for p in (root / "Copies").glob("Copie*")
if p.is_dir() and p.name.removeprefix("Copie").isdecimal()
and int(p.name.removeprefix("Copie")) == int(number)]
if len(matches) != 1:
raise CliError(f"Copie {value} introuvable ou ambiguë.")
return matches[0]
def baseline(root: Path) -> dict:
path = root / BASELINE
if not path.is_file():
raise CliError(f"Relancez « Lire les annotations manuscrites » pour {root.name} : "
"la séparation exacte entre en-tête et réponse est indisponible.")
return read_json(path)
def load_image(directory: Path, entry: dict) -> Image.Image:
filename = entry["file"]
if Path(filename).name != filename:
raise CliError("Chemin d’image de référence invalide.")
with Image.open(directory / filename) as image:
result = image.convert("RGB").copy()
height = entry["header_height"]
if not isinstance(height, int) or not 0 <= height < result.height:
raise CliError("Séparation en-tête/réponse invalide ; relancez la lecture des annotations.")
return result
def body_image(directory: Path, label: str) -> Image.Image:
entry = baseline(directory)["images"].get(label)
if entry is None:
raise CliError(f"Pas de réponse annotée pour {label} dans {directory.name}.")
image = load_image(directory, entry)
return image.crop((0, entry["header_height"], image.width, image.height))
def validate_instructions(root: Path, mode: str, instructions: list[ManualInstruction]) -> None:
labels = set(utils.read_all_labels(root))
sources = set()
targets = set()
for item in instructions:
for label in (item.old_label, item.new_label):
if label not in labels or Path(label).name != label or label in {".", ".."}:
raise CliError(f"Label inconnu ou invalide : {label}")
if item.old_label == item.new_label:
raise CliError("La source et la cible doivent être différentes.")
if normalize_copy(root, item.copy_id) != item.copy_id:
raise CliError(f"Utilisez Copie{normalize_copy(root, item.copy_id)} dans le fichier.")
key = (item.copy_id, item.old_label)
if key in sources:
raise CliError("Une seule instruction par label source et copie est autorisée.")
sources.add(key)
targets.add((item.copy_id, item.new_label))
directory = root / mode / f"Copie{item.copy_id}"
source = body_image(directory, item.old_label)
if item.cut:
y = round(source.height * item.cut[0] / 100)
if not 0 < y < source.height:
raise CliError("Chaque partie de la coupe doit contenir au moins un pixel.")
target = baseline(directory)["images"].get(item.new_label)
if target:
load_image(directory, target)
# Also report damaged current outputs instead of silently replacing them.
for label in (item.old_label, item.new_label):
path = directory / f"{label}.jpg"
if path.exists():
with Image.open(path) as image:
image.load()
if sources & targets:
raise CliError("Une cible ne peut pas également être source dans le même fichier.")
def stack(images: list[Image.Image]) -> Image.Image | None:
images = [image for image in images if image.height]
if not images:
return None
result = Image.new("RGB", (max(i.width for i in images), sum(i.height for i in images)), "white")
y = 0
for image in images:
result.paste(image, (0, y))
y += image.height
return result
def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruction]):
headers, bodies = {}, {}
for label, entry in meta["images"].items():
image = load_image(directory, entry)
h = entry["header_height"]
headers[label] = image.crop((0, 0, image.width, h))
bodies[label] = image.crop((0, h, image.width, image.height))
originals = bodies.copy()
changed = set()
for item in instructions:
source, target = item.old_label, item.new_label
if source not in originals:
raise CliError(f"Source annotée absente : {source}. Les modifications n’ont pas été appliquées.")
piece = originals[source]
if item.cut:
percent, keep = item.cut
y = round(piece.height * percent / 100)
if not 0 < y < piece.height:
raise CliError(f"Coupe invalide pour {source}.")
top, bottom = piece.crop((0, 0, piece.width, y)), piece.crop((0, y, piece.width, piece.height))
bodies[source], piece = (top, bottom) if keep == 1 else (bottom, top)
changed.add(source)
elif item.operator.startswith("x"):
bodies.pop(source, None)
changed.add(source)
if item.should_merge:
parts = ([bodies[target]] if target in bodies else [])
parts.insert(0 if item.pipe_first else len(parts), piece)
bodies[target] = stack(parts)
changed.add(target)
elif item.should_copy:
bodies[target] = piece
changed.add(target)
elif item.operator.endswith("x"):
bodies.pop(target, None)
changed.add(target)
return headers, bodies, changed
def output_removals(directory: Path) -> tuple[str, ...]:
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
*(f"{label}.jpg" for label, entry in info.items()
if entry.get("not_empty") and Path(label).name == label))
def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
instructions: list[ManualInstruction], scores: dict) -> None:
# Imported lazily: both readers use this module while saving their baseline.
from copienator.commands import annotating
from copienator.commands.reading_grouped_annotations import (
get_extra_pdfs_as_images,
save_paginated_pdf,
)
headers, bodies, changed = edited_bodies(directory, meta, instructions)
info = deepcopy(meta["info"])
filtered = set(meta["filtered"]) | changed
all_labels = utils.read_all_labels(root)
images, groups = [], []
seen_contexts = set()
order = sorted(bodies, key=utils.natural_key)
for label in order:
if label not in bodies:
continue
body = bodies[label]
header = headers.get(label)
if header is None:
# No prior answer means no prior header. Do not invent a score or
# resurrect Gemini feedback from correction.json.
header = Image.new("RGB", (max(body.width, 800), 80), "white")
ImageDraw.Draw(header).text((20, 20), f"{label} Note : {scores.get(label, '')}",
fill="black", font_size=30)
image = stack([header, body])
image.save(staging / f"{label}.jpg")
images.append(image)
if label in filtered:
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
seen_contexts=seen_contexts) if mode == "BGnot" else []
groups.append([*extras, image])
for label, entry in info.items():
entry["not_empty"] = label in bodies
if label in changed and label in bodies:
entry["present"] = True
entry["touched"] = mode == "BGnot" and label in bodies and label in filtered
entry["score"] = scores.get(label, entry["score"])
atomic_write_json(staging / "info.json", info)
if images:
stack(images).save(staging / "Concat.jpg")
if groups:
if mode == "BGnot":
save_paginated_pdf(groups, staging / "Concat_F.pdf")
else:
stack([image for group in groups for image in group]).save(staging / "Concat_F.jpg")
def stage_baseline(root: Path, mode: str, output: Path, staging: Path,
images: dict[str, Image.Image], heights: dict[str, int],
info: dict, filtered: list[str], scores: dict) -> None:
"""Save exact lossless reviewed layers, then replay only applied edits."""
if mode not in MODES:
return
applied = output.parent / APPLIED
instructions = read_instructions(applied.read_text(encoding="utf-8"), mode) if applied.is_file() else []
selected = [i for i in instructions if f"Copie{i.copy_id}" == output.name]
if not images.keys() <= heights.keys():
if selected:
raise CliError("Relancez la lecture principale des annotations avant de réappliquer les modifications.")
# Legacy selective redo can still preserve its old images. A full read is
# required before body editing; never guess the old header boundaries.
return
meta = {"version": 1, "images": {}, "info": info, "filtered": filtered}
for label, image in images.items():
filename = ".annotation-base-" + sha256(label.encode()).hexdigest() + ".png"
image.save(staging / filename)
meta["images"][label] = {"file": filename, "header_height": heights[label]}
atomic_write_json(staging / BASELINE, meta)
if selected:
# Remove freshly staged aggregates before rebuilding; empty filtered
# output must not retain the reader's previous version.
for name in ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", *(f"{label}.jpg" for label in images)):
(staging / name).unlink(missing_ok=True)
rebuild(root, mode, staging, staging, meta, selected, scores)
+1 -1
View File
@@ -64,7 +64,7 @@ def make_base_image(pdf_path):
current_y = 0
for page in pages:
base_img.paste(page.convert("RGBA"), (0, current_y))
base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y))
current_y += page.height
return (base_img, total_h, max_w)
+1 -1
View File
@@ -471,7 +471,7 @@ def run(
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter")
parser.add_argument(
"--fullpage",
action="store_true",
+11 -1
View File
@@ -16,6 +16,7 @@ from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator.gemini_config import thinking_config
from copienator.copy_errors import marked_copy_paths
from copienator import (
CliError,
EvaluationWorkspace,
@@ -530,6 +531,7 @@ def run(
targets: list[Path],
*,
overwrite: bool = False,
marked: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
@@ -538,6 +540,12 @@ def run(
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
workspace.require_files("labels")
if marked:
targets = marked_copy_paths(workspace)
if not targets:
print("No flagged copies to detect labels for.")
return ExitCode.SUCCESS
overwrite = True
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
@@ -626,6 +634,8 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument("--marked", action="store_true",
help="Regenerate labels only for copies with remaining flags")
parser.add_argument(
"--overwrite",
action="store_true",
@@ -664,7 +674,7 @@ def main(argv: Sequence[str] | None = None) -> int:
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite, max_workers=args.workers)
return run(workspace, targets, overwrite=args.overwrite, marked=args.marked, max_workers=args.workers)
return execute(parser, argv, handle)
+2 -2
View File
@@ -117,7 +117,7 @@ def group_files(file_list):
return [g['items'] for g in groups]
def stitch_pdf_pages(images_list):
"""Vertically concatenates a list of PIL images with no separator."""
"""Stack PIL images vertically, centering each within the widest image."""
if not images_list:
return None
if len(images_list) == 1:
@@ -130,7 +130,7 @@ def stitch_pdf_pages(images_list):
y_offset = 0
for img in images_list:
combined.paste(img, (0, y_offset))
combined.paste(img, ((max_width - img.width) // 2, y_offset))
y_offset += img.height
return combined
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from tempfile import TemporaryDirectory
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_modifications import (
APPLIED,
DRAFT,
MODES,
baseline,
output_removals,
read_instructions,
rebuild,
validate_instructions,
)
from copienator.filesystem import publish_file_sets
def run(workspace: EvaluationWorkspace, *, annotation_dir: str = "BGnot") -> ExitCode:
workspace.require_files("labels", DRAFT)
workspace.require_directories("Copies", annotation_dir)
text = (workspace.root / DRAFT).read_text(encoding="utf-8")
instructions = read_instructions(text, annotation_dir)
validate_instructions(workspace.root, annotation_dir, instructions)
directory = workspace.root / annotation_dir
applied = directory / APPLIED
previous = read_instructions(applied.read_text(encoding="utf-8"), annotation_dir) if applied.exists() else []
copy_ids = sorted({item.copy_id for item in [*previous, *instructions]})
# Prepare every copy before publishing anything, including the applied file.
with TemporaryDirectory(prefix=".annotation-edits-", dir=directory) as temporary:
staging_root = Path(temporary)
file_sets = {}
for copy_id in copy_ids:
output = directory / f"Copie{copy_id}"
meta = baseline(output)
staging = staging_root / output.name
staging.mkdir()
selected = [item for item in instructions if item.copy_id == copy_id]
rebuild(workspace.root, annotation_dir, output, staging, meta, selected,
read_json(output / "score.json"))
file_sets[output] = (staging, output_removals(output))
manifest = staging_root / "manifest"
manifest.mkdir()
(manifest / APPLIED).write_text(text, encoding="utf-8")
file_sets[directory] = (manifest, ())
publish_file_sets(file_sets)
print(f"Modifications appliquées à {len(copy_ids)} copie(s). Les scores sont conservés.")
print("Relancez « Attribuer les noms et préparer A Rendre » pour actualiser tous les livrables.")
return ExitCode.SUCCESS
def build_parser():
parser = evaluation_parser("Modify reviewed answer bodies using annotation_modifications.txt")
parser.add_argument("--annotation-dir", choices=MODES, default="BGnot")
return parser
def main(argv: Sequence[str] | None = None) -> int:
return execute(build_parser(), argv, lambda args: run(
workspace_from_args(args), annotation_dir=args.annotation_dir))
if __name__ == "__main__":
raise SystemExit(main())
+49 -5
View File
@@ -21,6 +21,7 @@ from copienator import (
target_parser,
workspace_from_target,
)
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
@@ -307,6 +308,7 @@ class ImageViewer:
self.forward_stack = []
self.current_pil_image = None
self.failed = False
self.skipped_copies = set()
from config import PLOTTING_KB
@@ -314,6 +316,7 @@ class ImageViewer:
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
self.root.bind("P", self.on_previous)
self.root.bind("!", self.on_skip)
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)
@@ -345,6 +348,10 @@ class ImageViewer:
self.root.quit()
return
if metadata["copie"] in self.skipped_copies:
self.root.after(0, self.poll_queue)
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
@@ -361,8 +368,21 @@ class ImageViewer:
"""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"
accepted = [item[2] for item in self.history
if item[3] == self.active_copie_name]
parts = {item["part"] for item in accepted}
expected = (set(range(1, len(accepted[-1]["schema"]["columns_per_file"]) + 1))
if accepted else set())
if not expected or parts != expected:
mark_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
"Vérification des labels incomplète", source="plotting")
self.failed = True
self.accumulated_results = None
return
print(f"Writing aggregated result to {main_json_path}")
atomic_write_json(main_json_path, self.accumulated_results)
clear_copy_error(self.workspace, main_json_path.with_suffix(".pdf"),
source="plotting")
self.accumulated_results = None
def close(self):
@@ -467,6 +487,21 @@ class ImageViewer:
self.is_viewing = False
self.label.config(image="", text="Loading next...")
def on_skip(self, _event=None):
if not self.is_viewing:
return
copie = self.current_meta["copie"]
mark_copy_error(self.workspace, self.workspace.copies_dir / f"{copie}.pdf",
"Problème repéré pendant la vérification des labels", source="plotting")
self.failed = True
self.skipped_copies.add(copie)
# Discard accepted parts of this copy without replacing its previous result.
self.accumulated_results = None
self.history = [item for item in self.history if item[3] != copie]
self.forward_stack = [item for item in self.forward_stack if item[2]["copie"] != copie]
self.is_viewing = False
self.label.config(image="", text="Copie signalée. Chargement de la suivante…")
def on_open_pdf(self, event):
if self.is_viewing and self.current_json_path:
a = self.current_json_path.stem.split('_')[0] + ".pdf"
@@ -530,17 +565,24 @@ def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
workspace.require_files("labels")
all_labels = read_all_labels(workspace.root)
files_to_process = _selected_images(workspace, target)
if marked:
copies = marked_copy_paths(workspace)
if not copies:
print("No flagged copies to verify.")
return ExitCode.SUCCESS
files_to_process = [image for pdf in copies for image in _selected_images(workspace, pdf)]
else:
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"
"i to open the statement, ! to flag and skip the copy, click for coordinates"
)
input_queue = queue.Queue(maxsize=5)
worker = threading.Thread(
@@ -556,7 +598,9 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively verify detected label coordinates")
parser = target_parser("Interactively verify detected label coordinates")
parser.add_argument("--marked", action="store_true", help="Verify only copies with remaining flags")
return parser
def main(argv: Sequence[str] | None = None) -> int:
@@ -564,7 +608,7 @@ def main(argv: Sequence[str] | None = None) -> int:
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return run(workspace, target, marked=args.marked)
return execute(parser, argv, handle)
+40 -14
View File
@@ -1,4 +1,4 @@
"""Persistent copy flags shared by page splitting, margin review and the GUI."""
"""Persistent per-step copy flags shared by repair commands and the GUI."""
from pathlib import Path
from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json
@@ -8,31 +8,57 @@ def _path(workspace: EvaluationWorkspace) -> Path:
return workspace.metadata_dir / "copy_errors.json"
def _validate(value) -> dict[str, str]:
if not isinstance(value, dict) or any(
not isinstance(name, str) or not isinstance(reason, str)
or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf"
for name, reason in value.items()
):
raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions")
return value
def _validate(value) -> dict[str, dict[str, str]]:
if not isinstance(value, dict):
raise CliError("Invalid copy_errors.json: expected PDF filenames and step flags")
normalized = {}
for name, flags in value.items():
if (not isinstance(name, str) or "/" in name or "\\" in name
or Path(name).suffix.casefold() != ".pdf"):
raise CliError("Invalid copy_errors.json: invalid PDF filename")
# Older versions only reported errors from margin cropping.
if isinstance(flags, str):
flags = {"cutleft": flags}
if (not isinstance(flags, dict) or not flags
or any(step not in {"cutleft", "plotting"} or not isinstance(reason, str)
for step, reason in flags.items())):
raise CliError("Invalid copy_errors.json: invalid step flags")
normalized[name] = dict(flags)
return normalized
def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
return _validate(read_json(_path(workspace), default={}))
"""Return a summary per copy, preserving the existing GUI-facing API."""
flags = _validate(read_json(_path(workspace), default={}))
return {name: "; ".join(reasons.values()) for name, reasons in flags.items()}
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None:
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
*, source: str = "cutleft") -> None:
if source not in {"cutleft", "plotting"}:
raise ValueError(f"Unknown copy flag source: {source}")
def update(errors):
_validate(errors)[pdf.name] = reason
normalized = _validate(errors)
normalized.setdefault(pdf.name, {})[source] = reason
errors.clear()
errors.update(normalized)
atomic_update_json(_path(workspace), update, default_factory=dict)
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None:
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path,
*, source: str = "cutleft") -> None:
if source not in {"cutleft", "plotting"}:
raise ValueError(f"Unknown copy flag source: {source}")
if not _path(workspace).exists():
return
def update(errors):
_validate(errors).pop(pdf.name, None)
normalized = _validate(errors)
flags = normalized.get(pdf.name, {})
flags.pop(source, None)
if not flags:
normalized.pop(pdf.name, None)
errors.clear()
errors.update(normalized)
atomic_update_json(_path(workspace), update, default_factory=dict)
+2 -2
View File
@@ -237,7 +237,7 @@ import json
def get_single_image_bytes(pdf_path):
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
"""Convert PDF pages into a single JPEG, centering pages within the widest."""
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
if not imgs:
raise ValueError(f"No pages in {pdf_path}")
@@ -250,7 +250,7 @@ def get_single_image_bytes(pdf_path):
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in imgs:
combined.paste(img, (0, y_offset))
combined.paste(img, ((max_width - img.width) // 2, y_offset))
y_offset += img.height
img_byte_arr = io.BytesIO()
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tempfile import TemporaryDirectory
from tkinter import messagebox, ttk
from copienator import CliError, utils
from copienator.annotation_modifications import (
DRAFT,
baseline,
body_image,
edited_bodies,
normalize_copy,
read_instructions,
validate_instructions,
)
from copienator.commands.resolve_manual import ManualInstruction
from copienator.platform import open_path
from .cut_helper import CutHelper
from .manual_resolution import ManualResolutionPanel
HELP = """Ajoutez une copie, un label source et un label cible. Source, Cible et Cut montrent uniquement le corps de la réponse avec ses annotations conservées, sans note, commentaire global, contexte, énoncé ou corrigé. Les en-têtes restent associés à leur label ; les scores ne changent pas.
Format : Copie43 Label source OP Label cible|
-> ajoute la source à la cible ; x> la déplace ; -x copie en remplaçant la cible ; xx déplace en remplaçant. ss conserve les deux ; sx vide la cible ; xs vide la source.
c{43}1> conserve les premiers 43 % de la source et ajoute le reste à la cible ; c{43}2x conserve la fin et remplace la cible par le début. Le pourcentage porte uniquement sur le corps de la réponse. |Cible place le fragment avant la cible ; Cible| le place après.
Une instruction par source. Une cible ne peut pas également être source. Plusieurs sources peuvent alimenter une cible dans l’ordre du fichier. Une cible sans image est vide.
Cut affiche une commande à recopier dans l’éditeur. Enregistrez puis Recharger. Aperçu du résultat montre les corps après toutes les instructions enregistrées.
Make modifications applique uniquement le fichier enregistré. Chaque application repart des images originales relues : modifier ou retirer une ligne puis appliquer remplace ou annule son effet. Les lignes ### sont des commentaires ; conservez la ligne ### Source: BGnot ou Bnot. Le fichier est conservé. Une nouvelle lecture des annotations réapplique les dernières instructions appliquées, jamais un brouillon. Relancez ensuite la préparation de A Rendre (sans l’option limitée à answers) pour actualiser tous les livrables."""
def annotation_mode_for_reader(reader):
if reader.get("last_run_variant", reader.get("variant")) == "standard":
return "Bnot"
values = reader.get("last_run_values", reader.get("values", {}))
if values.get("refaire") and values.get("annotation_dir") == "Bnot":
return "Bnot"
return "BGnot"
class AnnotationModificationsPanel(ManualResolutionPanel):
def __init__(self, parent, get_evaluation, get_mode):
self.get_mode = get_mode
self.previews = TemporaryDirectory(prefix="copienator-body-preview-")
super().__init__(parent, get_evaluation, help_text=HELP, show_provenance=False)
self.bind("<Destroy>", self._cleanup, add="+")
form = ttk.Frame(self)
form.pack(fill="x", before=self.editor_button.master, pady=5)
self.copy_var, self.source_var, self.target_var = (tk.StringVar() for _ in range(3))
ttk.Label(form, text="Copie").grid(row=0, column=0)
ttk.Entry(form, textvariable=self.copy_var, width=9).grid(row=1, column=0, padx=3)
self.label_choices = []
for column, title, variable in ((1, "Label source", self.source_var), (2, "Label cible", self.target_var)):
ttk.Label(form, text=title).grid(row=0, column=column)
combo = ttk.Combobox(form, textvariable=variable, width=22)
combo.grid(row=1, column=column, padx=3)
self.label_choices.append(combo)
ttk.Button(form, text="Ajouter", command=self.add_instruction).grid(row=1, column=3, padx=5)
ttk.Button(self.editor_button.master, text="Aperçu du résultat", command=self.preview_result).pack(side="left")
self.reload()
def _cleanup(self, event):
if event.widget is self:
self.previews.cleanup()
def manual_path(self):
evaluation = self.get_evaluation()
return evaluation / DRAFT if evaluation else None
def _read(self):
path = self.manual_path()
if path is None:
raise CliError("Chargez une évaluation.")
text = path.read_text(encoding="utf-8") if path.exists() else f"### Source: {self.get_mode()}\n"
return path, text, read_instructions(text, self.get_mode())
def reload(self):
super().reload()
self.editor_button.configure(state="normal" if self.manual_path() else "disabled")
try:
path, _, instructions = self._read()
validate_instructions(path.parent, self.get_mode(), instructions)
for combo in getattr(self, "label_choices", []):
combo.configure(values=utils.read_all_labels(path.parent))
except (CliError, OSError, ValueError, KeyError) as exc:
self.status.configure(text=str(exc))
def open_editor(self):
try:
path = self.manual_path()
if path is None:
raise CliError("Chargez une évaluation.")
if not path.exists():
path.write_text(f"### Source: {self.get_mode()}\n", encoding="utf-8")
open_path(path)
except (CliError, OSError, ValueError, RuntimeError) as exc:
messagebox.showerror("Ouverture du fichier", str(exc))
def add_instruction(self):
try:
path, text, instructions = self._read()
copy_id = normalize_copy(path.parent, self.copy_var.get())
source, target = self.source_var.get().strip(), self.target_var.get().strip()
item = ManualInstruction(copy_id, source, "->", target, False)
validate_instructions(path.parent, self.get_mode(), [*instructions, item])
text = text.rstrip() + f"\nCopie{copy_id} {source} -> {target}|\n"
path.write_text(text, encoding="utf-8")
self.reload()
except (CliError, OSError, ValueError, KeyError) as exc:
messagebox.showerror("Instruction invalide", str(exc))
def _show(self, image, name):
path = Path(self.previews.name) / f"{name}.png"
image.save(path)
open_path(path)
def open_pdf(self, evaluation, copy_id, label):
try:
self._show(body_image(evaluation / self.get_mode() / f"Copie{copy_id}", label),
f"Copie{copy_id} - {label} - corps")
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
messagebox.showinfo("Aperçu du corps annoté", str(exc))
def open_cut(self, evaluation, instruction):
def accepted(operator):
target = "|" + instruction.new_label if instruction.pipe_first else instruction.new_label + "|"
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
try:
source = body_image(evaluation / self.get_mode() / f"Copie{instruction.copy_id}", instruction.old_label)
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
CutHelper(self, source, accepted, initial)
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
messagebox.showerror("Coupe du corps annoté", str(exc))
def preview_result(self):
try:
path, _, instructions = self._read()
validate_instructions(path.parent, self.get_mode(), instructions)
# Preview just the requested copy, avoiding a window for every copy.
copy_id = normalize_copy(path.parent, self.copy_var.get())
selected = [i for i in instructions if i.copy_id == copy_id]
if not selected:
raise CliError("Aucune instruction pour cette copie. Saisissez son numéro dans Copie.")
directory = path.parent / self.get_mode() / f"Copie{copy_id}"
_, bodies, changed = edited_bodies(directory, baseline(directory), selected)
for label in sorted(changed):
if label in bodies:
self._show(bodies[label], f"Copie{copy_id} - {label} - résultat")
emptied = sorted(changed - bodies.keys())
if emptied:
messagebox.showinfo("Réponses vidées", ", ".join(emptied))
except (CliError, OSError, ValueError, RuntimeError, KeyError) as exc:
messagebox.showerror("Aperçu du résultat", str(exc))
+4 -3
View File
@@ -212,6 +212,7 @@ def plotting_shortcut_lines() -> list[str]:
f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}"
for action, description in labels
]
lines.append("! : signaler une erreur et passer à la copie suivante")
lines.append("Échap : fermer la fenêtre")
return lines
@@ -1189,7 +1190,7 @@ class CopienatorApp(tk.Tk):
)
ttk.Label(copies, text=help_text + "Vérifiez la commande puis cliquez sur Exécuter.",
wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
if step.id in {"page_splitter", "cutleft"}:
if step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
try:
marked = copy_errors(EvaluationWorkspace(evaluation)) if evaluation else {}
marked_summary = ", ".join(marked) or "Aucune copie signalée."
@@ -1316,7 +1317,7 @@ class CopienatorApp(tk.Tk):
self.arg_vars["target"].set(self._evaluation_arg())
def _run_marked_copies(self) -> None:
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft"}
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft", "labels", "plotting"}
or self.active_step_id or self.runner.running or not self.state_store.evaluation):
return
self.arg_vars["target"].set(self._evaluation_arg())
@@ -2146,7 +2147,7 @@ class CopienatorApp(tk.Tk):
if self.current_step and self.current_step.id == "batch_status":
self.watch_batches_button.configure(state="disabled" if running or self.batch_monitor.active or not self.state_store.evaluation else "normal")
self.stop_watch_batches_button.configure(state="normal" if self.batch_monitor.active else "disabled")
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft"}:
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
self.marked_copies_button.configure(state="disabled")
if self.current_step and self.current_step.id == "rename":
self.validate_rename_button.configure(
+23 -3
View File
@@ -292,7 +292,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. "
"Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone "
"de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. "
"1 utilise les pages entières, Entrée valide et ! signale une erreur."
"1 utilise les pages entières, Entrée valide et ! signale une erreur. "
"La validation efface uniquement le signalement de découpe, sans effacer celui des labels."
f"{personnal_binding} "
"Une copie précise peut être ciblée.",
(python("default", "Découpe", "crop-labels"),),
@@ -331,6 +332,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "Détection des labels", "labels"),),
arguments=(
arg_target(),
ArgumentSpec(
"marked",
"Copies signalées uniquement",
"bool",
"--marked",
help="Traite uniquement les copies ayant un signalement encore actif. "
"En détection, régénère aussi les résultats existants.",
),
ArgumentSpec(
"workers",
"Requêtes simultanées",
@@ -361,9 +370,20 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Labels et regroupement",
"Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie. "
"Le raccourci p revient à l’image précédente, y compris dans la copie précédente.",
"Le raccourci p revient à l’image précédente, y compris dans la copie précédente. "
"! signale une erreur et passe à la copie suivante. Entrée valide chaque image ; "
"le signalement de vérification est effacé après validation complète de la copie.",
(python("default", "Vérification", "review-labels"),),
arguments=(arg_target(),),
arguments=(
arg_target(),
ArgumentSpec(
"marked",
"Copies signalées uniquement",
"bool",
"--marked",
help="Vérifie uniquement les copies ayant un signalement encore actif.",
),
),
requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",),
),
+14 -1
View File
@@ -106,7 +106,8 @@ l'envers, ou dont les pages sont dans le mauvais ordre. Utiliser **!**
pour signaler une copie problématique.
Vous pourrez alors relancer l'étape de réordonnement des copies avec
les copies signalées.
les copies signalées. Une découpe gauche enregistrée avec succès efface uniquement
son propre signalement ; un signalement de vérification des labels reste actif.
## Labels et regroupement
@@ -114,6 +115,10 @@ les copies signalées.
**Effet :** reconnaît les identifiants de questions et leur position dans les copies.
**Copies signalées uniquement** limite la détection aux copies ayant au moins
un signalement actif et régénère leurs résultats existants. La détection
n’efface aucun signalement.
### Vérifier visuellement les labels
**Effet :** valide ou corrige les repères qui serviront à découper les réponses.
@@ -131,6 +136,14 @@ Pour faire des modifications : **e** ouvre le fichier .json à modifier à la ma
**Entrée** pour valider, **i** pour ouvrir l'énoncer, **o** pour
ouvrir la copie d'origine, **p** pour revenir en arrière.
**!** signale une erreur et passe à la copie suivante sans remplacer le résultat
complet précédent par une vérification partielle. Les copies signalées peuvent
être reprises dans **Découper à gauche**, **Détecter les labels**, puis ici, avec
**Copies signalées uniquement**. Le signalement de vérification est effacé
uniquement lorsque toutes les images de la copie sont validées et le résultat
complet enregistré. Fermer la fenêtre ou interrompre la reprise conserve le
signalement. Une copie peut porter les deux signalements indépendamment.
### Découper les réponses par question
**Effet :** crée un document de réponse pour chaque question repérée
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
from copienator.annotation_modifications import (
APPLIED,
BASELINE,
DRAFT,
body_image,
read_instructions,
stack,
stage_baseline,
)
from copienator.commands import modify_annotations
from copienator.commands import reading_annotations as standard
from copienator.commands import reading_grouped_annotations as grouped
from copienator.filesystem import staged_files
from copienator.return_answers import publish_answer_returns
class ModificationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / "Copies" / "Copie43").mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\nEx 3\nEmpty\n")
self.workspace = EvaluationWorkspace(self.root)
self.mode = "Bnot"
self.images = {
"Ex 1": stack([Image.new("RGB", (100, 20), "red"),
Image.new("RGB", (100, 40), "blue"),
Image.new("RGB", (100, 60), "green")]),
"Ex 2": stack([Image.new("RGB", (120, 30), "yellow"),
Image.new("RGB", (120, 50), "purple")]),
"Ex 3": stack([Image.new("RGB", (100, 10), "orange"),
Image.new("RGB", (100, 30), "cyan")]),
}
self.heights = {"Ex 1": 20, "Ex 2": 30, "Ex 3": 10}
self.scores = {"Ex 1": "3", "Ex 2": "4", "Ex 3": "4", "Empty": ""}
self.info = {label: {"present": label != "Empty", "not_empty": label != "Empty",
"touched": False, "score": score}
for label, score in self.scores.items()}
self.seed()
@property
def output(self):
return self.root / self.mode / "Copie43"
def seed(self):
self.output.mkdir(parents=True, exist_ok=True)
with staged_files(self.output) as staging:
for label, image in self.images.items():
image.save(staging / f"{label}.jpg")
atomic_write_json(staging / "score.json", self.scores)
atomic_write_json(staging / "info.json", self.info)
stage_baseline(self.root, self.mode, self.output, staging, self.images,
self.heights, self.info, ["Ex 1"], self.scores)
def apply(self, *lines):
(self.root / DRAFT).write_text(f"### Source: {self.mode}\n" + "\n".join(lines))
return modify_annotations.run(self.workspace, annotation_dir=self.mode)
def image(self, label):
with Image.open(self.output / f"{label}.jpg") as image:
return image.convert("RGB").copy()
def assert_color(self, image, xy, expected):
expected = Image.new("RGB", (1, 1), expected).getpixel((0, 0))
self.assertTrue(all(abs(a-b) < 12 for a, b in zip(image.getpixel(xy), expected)),
(image.getpixel(xy), expected))
def test_cut_uses_body_height_preserves_own_headers_and_scores(self):
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
source, target = self.image("Ex 1"), self.image("Ex 2")
self.assertEqual(source.size, (100, 80))
self.assertEqual(target.size, (120, 120))
self.assert_color(source, (50, 5), "red")
self.assert_color(source, (50, 45), "green")
self.assert_color(target, (50, 10), "yellow")
self.assert_color(target, (50, 45), "blue")
self.assert_color(target, (50, 90), "purple")
self.assertEqual(read_json(self.output / "score.json"), self.scores)
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
def test_repeated_application_edit_and_removal_rebuild_from_baseline(self):
line = "Copie43 Ex 1 c{40}1> Ex 2|"
self.apply(line)
first = {p.name: p.read_bytes() for p in self.output.glob("*.jpg")}
self.apply(line)
self.assertEqual(first, {p.name: p.read_bytes() for p in self.output.glob("*.jpg")})
self.apply("Copie43 Ex 1 c{60}1> Ex 2|")
self.assertEqual(self.image("Ex 2").height, 120)
self.apply()
self.assertEqual(self.image("Ex 1").size, self.images["Ex 1"].size)
self.assertEqual(self.image("Ex 2").size, self.images["Ex 2"].size)
self.assertEqual(read_json(self.output / "info.json"), self.info)
def test_empty_target_order_multiple_sources_and_header_creation(self):
self.apply("Copie43 Ex 1 c{40}1> Empty|", "Copie43 Ex 3 -> |Empty")
image = self.image("Empty")
self.assertEqual(image.height, 80 + 60 + 30)
self.assert_color(image, (50, 90), "cyan")
self.assert_color(image, (50, 140), "green")
info = read_json(self.output / "info.json")["Empty"]
self.assertTrue(info["present"] and info["not_empty"])
self.assertEqual(info["score"], "")
self.apply()
self.assertFalse((self.output / "Empty.jpg").exists())
self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"])
def test_invalid_file_does_not_apply_valid_first_instruction(self):
before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}
with self.assertRaises(CliError):
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 3 -> Unknown|")
self.assertEqual(before, {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()})
self.assertFalse((self.output.parent / APPLIED).exists())
with self.assertRaises(CliError):
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 2 -> Ex 3|")
with self.assertRaises(CliError):
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 1 -> Ex 3|")
with self.assertRaises(CliError):
read_instructions("### Source: BGnot\n", "Bnot")
def test_missing_baseline_requires_rereading_not_guessing_header(self):
(self.output / BASELINE).unlink()
with self.assertRaisesRegex(CliError, "Relancez"):
self.apply("Copie43 Ex 1 -> Ex 2|")
def test_grouped_supplements_use_destination_label_and_force_inclusion(self):
self.mode = "BGnot"
self.seed()
extras = Image.new("RGB", (120, 12), "black")
with patch.object(grouped, "get_extra_pdfs_as_images", return_value=[extras]) as get_extra, \
patch.object(grouped, "save_paginated_pdf") as save:
self.apply("Copie43 Ex 1 c{40}2> Ex 2|")
self.assertEqual([c.args[1] for c in get_extra.call_args_list], ["Ex 1", "Ex 2"])
self.assertIs(get_extra.call_args_list[0].kwargs["seen_contexts"],
get_extra.call_args_list[1].kwargs["seen_contexts"])
groups = save.call_args.args[0]
self.assertEqual([group[0].height for group in groups], [12, 12])
self.assertEqual(groups[1][1].height, 120)
self.assertEqual(self.image("Ex 2").height, 120) # supplement never in answer
self.assertTrue(read_json(self.output / "info.json")["Ex 2"]["touched"])
def test_rereading_replays_applied_not_draft_and_refreshes_baseline(self):
self.apply("Copie43 Ex 1 c{40}1> Ex 2|")
(self.root / DRAFT).write_text("broken unapplied draft")
self.images["Ex 1"] = stack([Image.new("RGB", (100, 20), "red"),
Image.new("RGB", (100, 100), "cyan")])
self.seed()
self.assertEqual(self.image("Ex 2").height, 140)
self.assert_color(self.image("Ex 2"), (50, 110), "cyan")
self.assertEqual(body_image(self.output, "Ex 1").getpixel((20, 20)), (0, 255, 255))
def test_transaction_rolls_back_all_copies_and_applied_file(self):
import shutil
(self.root / "Copies/Copie44").mkdir()
second = self.output.parent / "Copie44"
shutil.copytree(self.output, second)
self.apply("Copie43 Ex 1 -> Ex 2|")
before = {str(p.relative_to(self.output.parent)): p.read_bytes()
for p in self.output.parent.rglob("*") if p.is_file()}
original = Path.replace
def fail_once(path, target):
if path.parent.name == "Copie44" and path.name == "Concat.jpg" and ".annotation-edits-" in str(path):
raise OSError("simulated publish failure")
return original(path, target)
with patch.object(Path, "replace", fail_once), self.assertRaises(OSError):
self.apply("Copie43 Ex 1 c{40}1> Ex 2|", "Copie44 Ex 1 -> Ex 2|")
after = {str(p.relative_to(self.output.parent)): p.read_bytes()
for p in self.output.parent.rglob("*") if p.is_file()}
self.assertEqual(before, after)
def test_individual_returns_contain_modified_body_with_original_target_header(self):
from copienator import configuration
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
destination = self.root / "A Rendre/Student (43)"
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False):
publish_answer_returns(self.root, self.output, destination)
with Image.open(destination / "answers/Ex 2.jpg") as image:
self.assertEqual(image.size, (120, 120))
self.assert_color(image, (50, 10), "yellow")
self.assert_color(image, (50, 45), "blue")
def test_empty_everything_clears_old_combined_outputs(self):
self.apply("Copie43 Ex 1 xs Empty|", "Copie43 Ex 2 xs Empty|", "Copie43 Ex 3 xs Empty|")
self.assertFalse((self.output / "Concat.jpg").exists())
self.assertFalse((self.output / "Concat_F.jpg").exists())
self.assertFalse(any(entry["not_empty"] for entry in read_json(self.output / "info.json").values()))
def test_both_readers_capture_reviewed_header_and_handwriting_separately(self):
from copy import deepcopy
from copienator.commands import annotating
for mode in ("BGnot", "Bnot"):
with self.subTest(mode=mode):
self.mode = mode
self.seed()
answer = self.root / "answer.pdf"
answer.touch()
data = {"43": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0),
"result": {"score": 3, "feedback": [{"text": "delete me"}, {"text": "keep me"}]}}}}
notes = Image.new("RGBA", (100, 110), (0, 0, 0, 0))
notes.paste((255, 0, 255, 255), (20, 40, 40, 60)) # body ink, old header = 10
notes.paste((0, 0, 0, 255), (20, 2, 40, 7)) # header ink stays there
actions = [{"label": "Ex 1", "type": "del_global", "index": 0}]
atomic_write_json(self.output / "bnote.json", {"images": [
{"label": "Ex 1", "hmin": 0, "hmax": 110, "header_height": 10}]})
def compose(_base, _label, result, *_args, **_kwargs):
self.assertTrue(result["feedback"][0]["to_delete"])
self.assertNotIn("to_delete", result["feedback"][1])
return self.images["Ex 1"].copy(), 20
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
patch.object(annotating, "compose_label_image", side_effect=compose), \
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
if mode == "Bnot":
status = standard.apply_actions_and_regenerate(
self.workspace, deepcopy(data), "43", actions, notes, list(self.scores))
else:
status, _ = grouped.apply_actions_and_regenerate_grouped(
self.workspace, deepcopy(data), "43", actions,
{"Ex 1": {"img": notes, "old_header_h": 10}}, list(self.scores))
self.assertEqual(status, 0)
body = body_image(self.output, "Ex 1")
self.assertEqual(body.size, (100, 100))
self.assertEqual(body.getpixel((25, 35)), (255, 0, 255))
self.assertEqual(body.getpixel((25, 5)), (0, 0, 255))
self.apply("Copie43 Ex 1 c{40}2> Empty|")
self.assert_color(self.image("Empty"), (25, 115), "magenta")
self.assert_color(self.image("Ex 1"), (25, 4), "black")
def test_selective_redo_does_not_bake_in_or_duplicate_previous_transfers(self):
from copy import deepcopy
from copienator.commands import annotating
self.mode = "BGnot"
self.seed()
self.apply("Copie43 Ex 1 x> Ex 2|")
self.assertFalse((self.output / "Ex 1.jpg").exists())
before = self.image("Ex 2").size
answer = self.root / "answer.pdf"
answer.touch()
data = {"43": {label: {"result": {"score": self.scores[label], "feedback": []},
"pdf_path": answer, "coordinates": (0, 0)}
for label in self.images}}
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
patch.object(annotating, "compose_label_image", return_value=(self.images["Ex 3"], 10)), \
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
for _ in range(2):
status, _ = grouped.apply_actions_and_regenerate_grouped(
self.workspace, deepcopy(data), "43", [], {}, list(self.scores),
annotation_dir="BGnot", selected_labels={"Ex 3"})
self.assertEqual(status, 0)
self.assertEqual(self.image("Ex 2").size, before)
self.assertFalse((self.output / "Ex 1.jpg").exists())
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
+79
View File
@@ -0,0 +1,79 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import atomic_write_json, read_json
from copienator.commands import annotating_by_label as grouped
from copienator.utils import natural_key
class GroupedNamingTests(unittest.TestCase):
def test_dsi_question_groups_have_short_unique_ordered_prefixes(self):
groups = [
["Ex 1", "Ex 2", "Ex 3"],
["Ex 4 : 1)", "Ex 4 : 2)"],
["I)1)", "I)2)"],
["I)3)", "I)4)"],
["I)5)", "I)6)", "I)7)"],
["I)8)", "I)9)"],
["I)10)", "I)11)", "I)12)"],
["I)13)"],
["I)14)", "I)15)", "I)16)"],
["II)1)", "II)2)"],
["II)3)a)", "II)3)b)"],
["II)4)", "II)5)", "II)6)", "II)7)"],
["II)8)", "II)9)"],
["II)10)"],
]
named = grouped._unique_prefixes(groups)
prefixes = [prefix for prefix, _labels in named]
self.assertEqual(len(prefixes), len(set(prefixes)))
self.assertEqual(prefixes, sorted(prefixes, key=natural_key))
self.assertEqual(prefixes, [
"Ex", "Ex-2", "I)", "I)-2", "I)-3", "I)-4", "I)-5",
"I)-6", "I)-7", "II)", "II)-2", "II)-3", "II)-4", "II)-5",
])
self.assertEqual([labels for _prefix, labels in named], groups)
def test_resume_preserves_legacy_names_and_appends_only_missing_answers(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
# The old naming bug put distinct question groups under I)-2.
for number, label in enumerate(["I)3)", "I)10)", "II)1)"], start=1):
folder = output / f"I)-2 G{number}"
folder.mkdir()
atomic_write_json(folder / "bnote.json", {
"images": [{"id": "01", "label": label}],
})
(folder / "Concat.pdf").write_bytes(b"reviewed PDF")
before = {p: p.read_bytes() for p in output.rglob("*") if p.is_file()}
groups = [["I)1)", "I)2)"], ["I)3)", "I)4)"],
["I)10)", "I)11)"], ["II)1)", "II)2)"]]
data = {"01": {label: {} for label in ["I)3)", "I)10)", "II)1)"]}}
with patch.object(grouped, "render_item") as render:
self.assertEqual(grouped._generate_groups(
output, data, groups, resume=True,
), (0, False))
render.assert_not_called()
self.assertEqual(before, {
p: p.read_bytes() for p in output.rglob("*") if p.is_file()
})
data["02"] = {"I)3)": {}}
with patch.object(grouped, "render_item", return_value=(
"02", "I)3)", Image.new("RGB", (100, 100), "white"), 0, [],
)) as render:
self.assertEqual(grouped._generate_groups(
output, data, groups, resume=True,
), (1, False))
render.assert_called_once_with(("02", "I)3)", {}))
self.assertEqual(read_json(output / "I)-2 G4/bnote.json")["images"][0]["id"], "02")
for path, content in before.items():
self.assertEqual(path.read_bytes(), content)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,77 @@
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator import atomic_write_json
from copienator.annotation_modifications import DRAFT, stage_baseline
from copienator.filesystem import staged_files
from copienator_gui.app import CopienatorApp
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk tests require a display")
class AnnotationModificationGuiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / "Copies/Copie43").mkdir(parents=True)
(self.root / "labels").write_text("Ex 1\nEx 2\n")
self.output = self.root / "Bnot/Copie43"
images = {"Ex 1": Image.new("RGB", (100, 120), "blue")}
scores = {"Ex 1": "3", "Ex 2": ""}
info = {label: {"present": label == "Ex 1", "not_empty": label == "Ex 1",
"touched": False, "score": score} for label, score in scores.items()}
with staged_files(self.output) as staging:
images["Ex 1"].save(staging / "Ex 1.jpg")
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", info)
stage_baseline(self.root, "Bnot", self.output, staging, images, {"Ex 1": 20}, info, ["Ex 1"], scores)
self.app = CopienatorApp(Path.cwd(), False, self.root)
self.app.state_store.update_step("read_annotations", last_run_variant="standard")
self.app.tree.selection_set("modify_annotations")
self.app.update()
self.panel = self.app.manual_panel
def tearDown(self):
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
self.app.after_cancel(callback)
self.app.destroy()
self.temp.cleanup()
def test_add_validate_edit_reload_and_cut_only_body(self):
from copienator.annotation_modifications import read_instructions
self.assertEqual(self.app.arg_vars["annotation_dir"].get(), "Bnot")
self.assertEqual(self.app.run_button.cget("text"), "Make modifications")
self.panel.copy_var.set("43")
self.panel.source_var.set("Ex 1")
self.panel.target_var.set("Ex 2")
self.panel.add_instruction()
path = self.root / DRAFT
self.assertIn("Copie43 Ex 1 -> Ex 2|", path.read_text())
item = read_instructions(path.read_text(), "Bnot")[0]
with patch("copienator_gui.annotation_modifications.CutHelper") as helper:
self.panel.open_cut(self.root, item)
self.assertEqual(helper.call_args.args[1].size, (100, 100))
helper.call_args.args[2]("c{40}2>")
self.assertEqual(self.panel.cut_result.get(), "Copie43 Ex 1 c{40}2> Ex 2|")
with patch("copienator_gui.annotation_modifications.messagebox.showerror") as error:
self.panel.add_instruction() # duplicate source
error.assert_called_once()
path.write_text("bad instruction")
self.panel.reload()
with patch("copienator_gui.annotation_modifications.open_path") as editor:
self.panel.open_editor() # malformed files must remain editable
editor.assert_called_once_with(path)
def test_workflow_is_between_reader_and_publisher_and_builds_command(self):
steps = list(self.app.step_by_id)
self.assertLess(steps.index("read_annotations"), steps.index("modify_annotations"))
self.assertLess(steps.index("modify_annotations"), steps.index("giving_names"))
command = self.app._make_command()
self.assertIn("modify-annotations", command)
self.assertEqual(command[-2:], ["--annotation-dir", "Bnot"])
+4
View File
@@ -976,6 +976,9 @@ class StandardCliTests(unittest.TestCase):
(evaluation / "Copies").mkdir(parents=True)
viewer = module.ImageViewer.__new__(module.ImageViewer)
viewer.base_dir = evaluation
viewer.workspace = EvaluationWorkspace(evaluation)
viewer.history = [(None, None, {"part": 1, "schema": {"columns_per_file": [1]}},
"Copie01", None)]
viewer.active_copie_name = "Copie01"
viewer.accumulated_results = {
"name": "Test",
@@ -1121,6 +1124,7 @@ class StandardCliTests(unittest.TestCase):
root.bind.assert_any_call("p", viewer.on_previous)
root.bind.assert_any_call("P", viewer.on_previous)
root.bind.assert_any_call("!", viewer.on_skip)
def test_plotting_validates_plain_and_directional_labels(self) -> None:
module = self.modules["plotting"]
+194
View File
@@ -0,0 +1,194 @@
import os
import tempfile
import tkinter as tk
import unittest
from pathlib import Path
from queue import Queue
from unittest.mock import Mock, patch
from PIL import Image
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.commands import cutleft, gemini_for_labels, plotting
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error, marked_copy_paths
class LabelCopyFlagTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.workspace = EvaluationWorkspace(Path(self.temp.name))
self.workspace.copies_dir.mkdir()
self.workspace.cutleft_dir.mkdir()
self.pdf = self.workspace.copies_dir / 'Copie01.pdf'
self.pdf.touch()
def flag(self, source):
mark_copy_error(self.workspace, self.pdf, source, source=source)
def viewer(self, parts=2):
viewer = plotting.ImageViewer.__new__(plotting.ImageViewer)
viewer.workspace = self.workspace
viewer.base_dir = self.workspace.root
viewer.active_copie_name = 'Copie01'
viewer.accumulated_results = {'name': 'Student', 'list': []}
viewer.current_meta = {'copie': 'Copie01', 'part': 1,
'schema': {'columns_per_file': [1] * parts}}
viewer.current_json_path = self.pdf.with_name('Copie01_01.json')
atomic_write_json(viewer.current_json_path, {'name': 'Student', 'list': []})
viewer.valid_labels = set()
viewer.history = []
viewer.forward_stack = []
viewer.skipped_copies = set()
viewer.current_pil_image = Image.new('RGB', (20, 20), 'white')
viewer.is_viewing = True
viewer.failed = False
viewer.label = Mock()
viewer.root = Mock()
viewer.image_queue = Queue()
return viewer
def test_legacy_flags_migrate_and_steps_clear_independently(self):
atomic_write_json(self.workspace.metadata_dir / 'copy_errors.json',
{self.pdf.name: 'Legacy cropping problem'})
self.flag('plotting')
clear_copy_error(self.workspace, self.pdf)
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
self.flag('cutleft')
clear_copy_error(self.workspace, self.pdf, source='plotting')
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
clear_copy_error(self.workspace, self.pdf)
self.assertEqual(marked_copy_paths(self.workspace), [])
def test_successful_crop_preserves_review_flag_and_selection(self):
self.flag('cutleft')
self.flag('plotting')
image = Image.new('RGB', (20, 20), 'white')
with patch.object(cutleft, 'process_single_pdf', return_value=(image, [image], {})), \
patch.object(cutleft, 'save_results'):
self.assertEqual(cutleft.run(self.workspace, self.workspace.root, marked=True,
fullpage=True, no_visual_check=True), ExitCode.SUCCESS)
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
self.assertEqual(marked_copy_paths(self.workspace), [self.pdf])
def test_full_review_clears_only_review_flag_after_save(self):
self.flag('cutleft')
self.flag('plotting')
viewer = self.viewer()
viewer.on_enter(None)
viewer.current_meta = dict(viewer.current_meta, part=2)
viewer.is_viewing = True
viewer.on_enter(None)
with patch.object(plotting, 'atomic_write_json', side_effect=OSError('disk full')):
with self.assertRaises(OSError):
viewer.save_current_batch()
self.assertIn('plotting', copy_errors(self.workspace)[self.pdf.name])
viewer.save_current_batch()
self.assertEqual(read_json(self.pdf.with_suffix('.json')), {'name': 'Student', 'list': []})
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
def test_incomplete_review_preserves_previous_aggregate_and_flag(self):
self.flag('plotting')
old = {'name': 'Old', 'list': [{'label': 'Ex 1'}]}
atomic_write_json(self.pdf.with_suffix('.json'), old)
viewer = self.viewer()
viewer.on_enter(None)
viewer.save_current_batch()
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
self.assertIn(self.pdf.name, copy_errors(self.workspace))
self.assertTrue(viewer.failed)
def test_flag_skips_remaining_copy_parts_without_saving_partial_data(self):
self.flag('cutleft')
old = {'name': 'Old', 'list': []}
atomic_write_json(self.pdf.with_suffix('.json'), old)
viewer = self.viewer(parts=3)
viewer.on_enter(None)
viewer.is_viewing = True
viewer.current_meta = dict(viewer.current_meta, part=2)
viewer.on_skip()
viewer.save_current_batch()
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
self.assertEqual(set(read_json(self.workspace.metadata_dir / 'copy_errors.json')[self.pdf.name]),
{'cutleft', 'plotting'})
viewer.image_queue.put((viewer.current_pil_image, viewer.current_json_path,
dict(viewer.current_meta, part=3)))
viewer.display_image = Mock()
viewer.poll_queue()
viewer.display_image.assert_not_called()
self.assertFalse(viewer.is_viewing)
def test_detection_regenerates_only_flagged_copies_and_preserves_flags(self):
self.flag('plotting')
(self.workspace.copies_dir / 'Copie02.pdf').touch()
for name in ('Copie01_01', 'Copie02_01'):
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
atomic_write_json(self.workspace.copies_dir / f'{name}.json', {'name': 'Old', 'list': []})
with patch.object(self.workspace.__class__, 'require_files'), \
patch.object(gemini_for_labels, 'read_all_labels', return_value=[]), \
patch.object(self.workspace.__class__, 'names_file', return_value=self.pdf), \
patch.object(gemini_for_labels, 'process_copy_group', return_value=1) as process:
status = gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True, client=Mock())
self.assertEqual(status, ExitCode.SUCCESS)
process.assert_called_once()
self.assertEqual(process.call_args.args[1], 'Copie01')
self.assertTrue(process.call_args.kwargs['overwrite'])
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
def test_visual_review_selects_only_flagged_copy_images(self):
self.flag('plotting')
for name in ('Copie01_01', 'Copie01_02', 'Copie02_01'):
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
with patch.object(self.workspace.__class__, 'require_files'), \
patch.object(plotting, 'read_all_labels', return_value=[]), \
patch.object(plotting.threading, 'Thread') as worker, \
patch.object(plotting.tk, 'Tk'), \
patch.object(plotting, 'ImageViewer') as viewer:
viewer.return_value.failed = False
self.assertEqual(plotting.run(self.workspace, self.workspace.root, marked=True),
ExitCode.SUCCESS)
selected = worker.call_args.kwargs['args'][1]
self.assertEqual([image.name for image in selected], ['Copie01_01.jpg', 'Copie01_02.jpg'])
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
def test_closing_review_preserves_flags_and_does_not_save_partial_result(self):
self.flag('plotting')
viewer = self.viewer()
viewer.on_enter(None)
viewer.close()
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
self.assertFalse(self.pdf.with_suffix('.json').exists())
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
def test_exclamation_key_flags_current_copy_in_real_viewer(self):
root = tk.Tk()
try:
image_queue = Queue()
image_queue.put((Image.new('RGB', (100, 100), 'white'),
self.pdf.with_name('Copie01_01.json'),
{'copie': 'Copie01', 'part': 1, 'name': 'Student',
'schema': {'columns_per_file': [1, 1]}}))
viewer = plotting.ImageViewer(root, self.workspace, set(), image_queue)
root.update()
root.focus_force()
root.update()
root.event_generate('<KeyPress>', keysym='exclam')
root.update()
self.assertEqual(viewer.skipped_copies, {'Copie01'})
self.assertFalse(viewer.is_viewing)
self.assertIn(self.pdf.name, copy_errors(self.workspace))
finally:
for callback in root.tk.splitlist(root.tk.call('after', 'info')):
root.after_cancel(callback)
root.destroy()
def test_empty_marked_selection_does_not_process_whole_evaluation(self):
with patch.object(self.workspace.__class__, 'require_files'), \
patch.object(gemini_for_labels, 'selected_images') as select:
self.assertEqual(gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True),
ExitCode.SUCCESS)
select.assert_not_called()
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -43,7 +43,7 @@ class LabelTimeoutTests(unittest.TestCase):
self.assertEqual(labels.main([str(self.workspace.root), *arguments]), 0)
run.assert_called_once_with(
unittest.mock.ANY, [self.workspace.root],
overwrite=False, max_workers=expected,
overwrite=False, marked=False, max_workers=expected,
)
def test_selected_concurrency_controls_executor(self):
+3 -2
View File
@@ -233,12 +233,13 @@ class MarkedCopiesGuiTests(unittest.TestCase):
self.assertEqual(copy_errors(self.workspace), {})
self.assertTrue((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
def test_both_buttons_run_marked_and_single_copy_target_clears_filter(self):
def test_all_marked_buttons_and_single_copy_target_clears_filter(self):
mark_copy_error(self.workspace, self.files[1], "Review needed")
app = CopienatorApp(Path.cwd(), False, self.workspace.root)
try:
app.update()
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels")):
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels"),
("labels", "labels"), ("plotting", "review-labels")):
app.tree.selection_set(ident)
app.update()
self.assertIn("(1)", app.marked_copies_button.cget("text"))
+110
View File
@@ -0,0 +1,110 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from PIL import Image
from reportlab.pdfgen import canvas
from copienator.commands.reading_grouped_annotations import (
context_content_key, get_extra_pdfs_as_images, save_paginated_pdf,
)
class PdfSupplementTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
(self.root / "Text2").mkdir()
(self.root / "Sol2").mkdir()
self.labels = ["Q1", "Q2", "Q3"]
self.rendered = []
def render(path):
self.rendered.append(Path(path).name)
return Image.new("RGB", (100, 30), "white"), 30, 100
self.renderer = SimpleNamespace(make_base_image=render)
def pdf(self, folder, name, text):
path = self.root / folder / name
document = canvas.Canvas(str(path))
document.drawString(20, 800, text)
document.save()
return path
def extras(self, label, seen):
return get_extra_pdfs_as_images(
self.root, label, self.renderer, self.labels, seen_contexts=seen,
)
def test_content_deduplication_across_ranges_and_copy_reset(self):
self.pdf("Text2", "CTXT Q1 -> Q2.pdf", "Bob collects coupons: N >= 1")
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Bob collects coupons: N >= 1")
self.pdf("Text2", "CTXT Q2 -> Q2.pdf", "Bob collects coupons: N >= 2")
seen = set()
self.assertEqual(len(self.extras("Q1", seen)), 1)
self.assertEqual(len(self.extras("Q2", seen)), 1)
self.assertEqual(len(self.extras("Q3", seen)), 0)
self.assertEqual(len(seen), 2)
self.assertEqual(len(self.extras("Q3", set())), 1)
def test_first_retained_question_gets_context_and_semantic_borders(self):
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Other context")
self.pdf("Text2", "Q2.pdf", "Question")
self.pdf("Sol2", "Q2.pdf", "Solution")
# Q1 was filtered out; it never consumes the shared context.
images = self.extras("Q2", set())
self.assertEqual([i.getpixel((0, 0)) for i in images],
[(255, 255, 255), (255, 255, 255), (0, 0, 0), (0, 0, 255)])
self.assertTrue(all(i.getpixel((50, 15)) == (255, 255, 255) for i in images))
def test_missing_question_does_not_shift_solution_border(self):
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
self.pdf("Sol2", "Q2.pdf", "Solution")
images = self.extras("Q2", set())
self.assertEqual([i.getpixel((0, 0)) for i in images],
[(255, 255, 255), (0, 0, 255)])
def test_unrendered_context_is_not_consumed(self):
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
seen = set()
with patch.object(self.renderer, "make_base_image", return_value=(None, 0, 0)):
self.assertEqual(self.extras("Q1", seen), [])
self.assertFalse(seen)
self.assertEqual(len(self.extras("Q2", seen)), 1)
def test_missing_solution_does_not_shift_border_to_student_answer(self):
self.pdf("Text2", "Q2.pdf", "Question")
images = self.extras("Q2", set())
answer = Image.new("RGB", (100, 200), "white")
captured = []
def save(page, *args, **kwargs):
captured.extend([page.copy(), *kwargs['append_images']])
with patch.object(Image.Image, "save", save):
save_paginated_pdf([images + [answer], [answer]], self.root / "out.pdf")
self.assertEqual(len(captured), 2)
self.assertEqual(captured[0].getpixel((11, 7)), (0, 0, 0))
self.assertEqual(captured[0].getpixel((11, 37)), (255, 255, 255))
self.assertEqual(captured[1].getpixel((11, 7)), (255, 255, 255))
self.assertEqual(answer.getpixel((0, 0)), (255, 255, 255))
def test_empty_extracted_text_does_not_merge_distinct_contexts(self):
paths = []
for name, color in [("a.pdf", "red"), ("b.pdf", "blue")]:
path = self.root / name
document = canvas.Canvas(str(path))
document.setFillColor(color)
document.rect(20, 20, 100, 100, fill=1)
document.save()
paths.append(path)
self.assertNotEqual(context_content_key(paths[0]), context_content_key(paths[1]))
if __name__ == "__main__":
unittest.main()