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()