Compare commits

..
2 Commits
Author SHA1 Message Date
sebastien 3754c0c11a Fix context duplication in pdf output 2026-10-11 20:02:11 +02:00
sebastien 370f3252ef Support for post annotation label reassignement. 2026-10-11 19:03:04 +02:00
11 changed files with 295 additions and 73 deletions

No files matched your search

+19 -5
View File
@@ -9,20 +9,26 @@ 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 configuration, utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
configuration,
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_modifications import (
BASELINE,
output_removals,
stage_baseline,
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
@@ -168,6 +174,8 @@ def apply_actions_and_regenerate(
scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
header_heights: dict[str, int] = {}
filtered_labels: list[str] = []
concatenated: list[Image.Image] = []
filtered: list[Image.Image] = []
incomplete = False
@@ -221,15 +229,18 @@ def apply_actions_and_regenerate(
final_image.paste(body, (0, new_header_height), mask=body)
dirty_images[label] = final_image
header_heights[label] = new_header_height
answer_labels.append(label)
concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image)
filtered_labels.append(label)
if incomplete:
return ExitCode.PARTIAL
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
"touched.json", "answer_labels.json")) as staging:
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
@@ -241,6 +252,9 @@ def apply_actions_and_regenerate(
concat_image.save(staging / "Concat.jpg")
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels),
filtered_labels, scores)
if preserve_score_file:
print(f" Preserved existing score.json in {output_dir}")
@@ -2,18 +2,21 @@ from __future__ import annotations
import argparse
import concurrent.futures
import unicodedata
from hashlib import sha256
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
import pymupdf
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
configuration,
evaluation_parser,
execute,
read_json,
@@ -21,8 +24,15 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_modifications import (
BASELINE,
baseline,
load_image,
output_removals,
stage_baseline,
)
from copienator.answer_info import build_answer_info
from copienator.commands import annotating
from copienator.commands.reading_annotations import (
concatenate,
@@ -37,24 +47,51 @@ ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
SCAN_WORKERS = 2
def context_content_key(path: str | Path) -> tuple[str, str]:
"""Identify context by its text, independently of filename and PDF metadata."""
with pymupdf.open(path) as document:
text = "\n".join(page.get_text(sort=True) for page in document)
text = " ".join(unicodedata.normalize("NFC", text).split())
if text:
return "text", text
# Image-only contexts must not all collapse to the same empty-text key.
return "file", sha256(Path(path).read_bytes()).hexdigest()
def get_extra_pdfs_as_images(
root_dir: str | Path,
label: str,
annotating_module: Any,
all_labels: list[str],
*,
seen_contexts: set[tuple[str, str]] | None = None,
) -> list[Image.Image]:
"""Convert the context, question and solution PDFs associated with a label."""
"""Render supplements with semantic borders and optional per-copy deduplication."""
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),
*(("context", path) for path in utils.pdf_images_of_contexts(root_dir, label, all_labels)),
("question", utils.pdf_image_of_enonce(root_dir, label)),
("solution", utils.pdf_image_of_solution(root_dir, label)),
]
images = []
for path in paths:
for role, path in paths:
if path:
key = None
if role == "context" and seen_contexts is not None:
key = context_content_key(path)
if key in seen_contexts:
continue
image, _, _ = annotating_module.make_base_image(path)
if image is not None:
if role in {"question", "solution"}:
image = image.copy()
ImageDraw.Draw(image).rectangle(
[0, 0, image.width - 1, image.height - 1],
outline="black" if role == "question" else "blue",
width=int((0.2 / 2.54) * 100),
)
images.append(image)
if key is not None:
seen_contexts.add(key)
return images
@@ -67,7 +104,6 @@ def save_paginated_pdf(
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
@@ -94,21 +130,10 @@ def save_paginated_pdf(
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)
group_height = sum(image.height for image in group)
if page_images and page_height + group_height > max_content_height:
finish_page()
page_images.extend(processed)
page_images.extend(group)
page_height += group_height
finish_page()
pages[0].save(
@@ -247,8 +272,12 @@ def apply_actions_and_regenerate_grouped(
touched = dict.fromkeys(all_labels, False)
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
header_heights: dict[str, int] = {}
filtered_labels: list[str] = []
previous_baseline = baseline(output_dir) if (output_dir / BASELINE).is_file() else None
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
seen_contexts: set[tuple[str, str]] = set()
incomplete = False
for label, content in sorted(
@@ -266,9 +295,19 @@ def apply_actions_and_regenerate_grouped(
if result.get("error") == "empty-answer":
continue
saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file():
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
baseline_entry = (previous_baseline or {}).get("images", {}).get(label)
if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry):
if annotation_dir in {"BGnot", "Bnot"}:
if previous_baseline is not None and label in previous_baseline["images"]:
entry = previous_baseline["images"][label]
final_image = load_image(output_dir, entry)
header_heights[label] = entry["header_height"]
else:
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
else:
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
if (
simple_annotated is not None
and label in simple_layout["images"]
@@ -278,14 +317,15 @@ def apply_actions_and_regenerate_grouped(
final_image = simple_annotated.crop(
(0, hmin, simple_annotated.width, hmax)
)
dirty_images[label] = final_image
dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image)
answer_labels.append(label)
filtered_labels.append(label)
# Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
workspace.root, label, annotating, all_labels, seen_contexts=seen_contexts
)
filtered_groups.append([*extras, final_image])
touched[label] = True
@@ -327,6 +367,7 @@ def apply_actions_and_regenerate_grouped(
# Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image
header_heights[label] = new_header_height
answer_labels.append(label)
concat_images.append(final_image)
@@ -335,8 +376,10 @@ def apply_actions_and_regenerate_grouped(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
filtered_labels.append(label)
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels,
seen_contexts=seen_contexts)
if annotation_dir == "BGnot"
else []
)
@@ -346,8 +389,7 @@ def apply_actions_and_regenerate_grouped(
concat_image = concatenate(concat_images)
if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
"touched.json", "answer_labels.json")) as staging:
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
@@ -370,6 +412,9 @@ def apply_actions_and_regenerate_grouped(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
filtered_labels, scores)
if preserve_score_file:
logs.append(f" Preserved existing score.json in {output_dir}")
logs.append(f" Saved regenerated files in {output_dir}")
+1
View File
@@ -40,6 +40,7 @@ COMMANDS: dict[str, Command] = {
"import": Command("import_annotations", "Import handwritten annotations"),
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
"modify-annotations": Command("modify_annotations", "Modify reviewed answer bodies"),
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
+42
View File
@@ -92,3 +92,45 @@ def staged_files(
if not destination_path.exists():
saved.replace(destination_path)
_remove_path(backup)
def publish_file_sets(file_sets: dict[Path, tuple[Path, Iterable[str]]]) -> None:
"""Commit several staged file sets together, rolling every set back on error.
Staging and destination directories must be on the same filesystem. Unlike
nesting staged_files, failure in a later directory restores earlier ones too.
"""
token = uuid.uuid4().hex
backups: list[tuple[Path, Path]] = []
committed: list[Path] = []
try:
for target, (staging, remove) in file_sets.items():
target.mkdir(parents=True, exist_ok=True)
backup = target / f".publish-{token}.backup"
backup.mkdir()
backups.append((target, backup))
staged = sorted(path for path in staging.iterdir() if path.is_file())
names = {path.name for path in staged} | set(remove)
for name in sorted(names):
if Path(name).name != name:
raise ValueError(f"Invalid output filename: {name}")
destination = target / name
if destination.exists() or destination.is_symlink():
destination.replace(backup / name)
for source in staged:
destination = target / source.name
source.replace(destination)
committed.append(destination)
except Exception:
for destination in reversed(committed):
_remove_path(destination)
for target, backup in reversed(backups):
for saved in backup.iterdir():
saved.replace(target / saved.name)
raise
finally:
for _, backup in backups:
if backup.exists() and not any(backup.iterdir()):
backup.rmdir()
for _, backup in backups:
_remove_path(backup)
+1 -1
View File
@@ -70,7 +70,7 @@ def pdf_images_of_contexts(root_dir, label, all_labels):
pertinent_contexts = []
for filename in os.listdir(text2_dir):
for filename in sorted(os.listdir(text2_dir)):
if filename.startswith("CTXT ") and filename.endswith(".pdf"):
# Extract "first_label -> last_label" from "CTXT first_label -> last_label.pdf"
core = filename[5:-4]
+22 -4
View File
@@ -30,6 +30,7 @@ from .scores import ScoreEditorPanel
from .notifications import notify_desktop
from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel
from .annotation_modifications import AnnotationModificationsPanel, annotation_mode_for_reader
from .recorrection import prepare_selection_file
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
@@ -824,6 +825,7 @@ class CopienatorApp(tk.Tk):
"labels",
"plotting",
"manual_resolution",
"modify_annotations",
"giving_names",
"edit_scores",
"statement_persp",
@@ -888,6 +890,9 @@ class CopienatorApp(tk.Tk):
value = False # Deletion is a one-run choice, never a saved default.
if step.id == 'statement' and spec.name == 'restart':
value = False
if step.id == "modify_annotations" and spec.name == "annotation_dir" and spec.name not in values:
reader = self.state_store.step("read_annotations")
value = annotation_mode_for_reader(reader)
choices = spec.choices
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
choices, value = self._annotation_directory_choices(step.id)
@@ -939,13 +944,20 @@ class CopienatorApp(tk.Tk):
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
row += 1
if step.id == "manual_resolution":
if step.id in {"manual_resolution", "modify_annotations"}:
def manual_evaluation():
target = self.arg_vars.get("target")
value = target.get().strip() if target else ""
return (self.repository / value).resolve() if value else self.evaluation
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
if step.id == "modify_annotations":
self.manual_panel = AnnotationModificationsPanel(
self.form, manual_evaluation,
lambda: self.arg_vars["annotation_dir"].get(),
)
self.arg_vars["annotation_dir"].trace_add("write", lambda *_: self.manual_panel.reload())
else:
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
if "target" in self.arg_vars:
self.arg_vars["target"].trace_add("write", lambda *_args: self.manual_panel.reload())
@@ -966,7 +978,7 @@ class CopienatorApp(tk.Tk):
attach_tooltip(extra_entry, step.extra_arguments_help)
row += 1
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
self.run_button.configure(text="Make modifications" if step.id == "modify_annotations" else "Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
if step.id == "personal_create":
self.run_button.configure(text="Marquer Réussie")
if step.id == "personal_read":
@@ -1953,7 +1965,7 @@ class CopienatorApp(tk.Tk):
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
status, return_code = "failed", 1
self.pending_refaire_commands = []
if step_id == "refaire_merge" and status == "success":
if step_id in {"refaire_merge", "modify_annotations"} and status == "success":
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
entry = self.state_store.step(downstream)
if entry.get("status") in {"success", "detected", "skipped"}:
@@ -1995,6 +2007,12 @@ class CopienatorApp(tk.Tk):
"manual": False, "reason": reason,
})
self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n")
if step_id == "read_annotations" and status == "success":
reader = self.state_store.step(step_id)
mode = annotation_mode_for_reader(reader)
values = dict(self.state_store.step("modify_annotations").get("values", {}))
values["annotation_dir"] = mode
self.state_store.update_step("modify_annotations", values=values)
if step_id == "annotation" and status == "success":
generation_variant = self.state_store.step("annotation").get(
"last_run_variant"
+23 -15
View File
@@ -45,21 +45,29 @@ def cut_operator(percent: float, keep: int, mode: str) -> str:
class CutHelper(tk.Toplevel):
def __init__(self, parent, path: Path, on_accept, initial=None):
# Load the source before creating a window so invalid PDFs leave no dialog.
with pymupdf.open(path) as document:
if not len(document):
raise ValueError("Le PDF est vide.")
self.heights = [page.rect.height for page in document]
width = min(850, parent.winfo_screenwidth() - 100)
self.scale = min(1.5, width / max(page.rect.width for page in document))
rendered = []
for page in document:
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
colorspace=pymupdf.csRGB)
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
def __init__(self, parent, path: Path | Image.Image, on_accept, initial=None):
# Body edits pass a raster without headers or supplementary pages.
width = min(850, parent.winfo_screenwidth() - 100)
if isinstance(path, Image.Image):
self.heights = [path.height]
self.scale = min(1.5, width / path.width)
rendered = [path.resize((round(path.width * self.scale),
round(path.height * self.scale)), Image.Resampling.LANCZOS)]
title = "Corps de la réponse annotée"
else:
with pymupdf.open(path) as document:
if not len(document):
raise ValueError("Le PDF est vide.")
self.heights = [page.rect.height for page in document]
self.scale = min(1.5, width / max(page.rect.width for page in document))
rendered = []
for page in document:
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
colorspace=pymupdf.csRGB)
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
title = path.name
super().__init__(parent)
self.title(f"Cut — {path.name}")
self.title(f"Cut — {title}")
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
self.transient(parent.winfo_toplevel())
self.on_accept = on_accept
@@ -125,7 +133,7 @@ class CutHelper(tk.Toplevel):
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
rounded = float(operator.split("{")[1].split("}")[0])
if not 0 < rounded < 100:
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion de la réponse.", parent=self)
return
self.destroy()
self.on_accept(operator)
+5 -4
View File
@@ -37,9 +37,10 @@ Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter
class ManualResolutionPanel(ttk.Frame):
def __init__(self, parent, get_evaluation):
def __init__(self, parent, get_evaluation, *, help_text=HELP, show_provenance=True):
super().__init__(parent)
self.get_evaluation = get_evaluation
self.show_provenance = show_provenance
self.pdf_buttons = []
self.cut_buttons = []
self.copy_buttons = []
@@ -67,7 +68,7 @@ class ManualResolutionPanel(ttk.Frame):
self.text.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.text.pack(fill="both", expand=True)
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
ttk.Label(self, text=help_text, wraplength=650, justify="left").pack(fill="x", pady=8)
self.reload()
def manual_path(self) -> Path | None:
@@ -123,7 +124,7 @@ class ManualResolutionPanel(ttk.Frame):
raise FileNotFoundError("Chargez une évaluation.")
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
self.status.configure(text=f"{path.name if path else 'Fichier'} indisponible : {exc}")
else:
self.editor_button.configure(state="normal")
malformed = []
@@ -142,7 +143,7 @@ class ManualResolutionPanel(ttk.Frame):
key = (instruction.copy_id, instruction.old_label)
if key not in provenance:
try:
provenance[key] = populated_labels(path.parent, *key)
provenance[key] = populated_labels(path.parent, *key) if self.show_provenance else []
except (OSError, ValueError) as exc:
provenance[key] = []
provenance_error = f" — Provenance indisponible : {exc}"
+17 -9
View File
@@ -668,6 +668,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
),
) + return_answer_arguments,
),
StepDefinition(
"modify_annotations",
"Finalisation",
"Modifier les réponses annotées",
"Coupez ou transférez les corps de réponses après lecture des annotations. "
"Les en-têtes, les scores et les documents complémentaires restent associés à chaque label.",
(python("default", "Modifications", "modify-annotations"),),
arguments=(
arg_target("le dossier de l’évaluation"),
ArgumentSpec("annotation_dir", "Dossier d’annotations", "choice", "--annotation-dir",
default="BGnot", choices=("BGnot", "Bnot"),
help="Choisissez le mode utilisé pour lire les annotations manuscrites ; les coupes portent uniquement sur les corps des réponses."),
),
optional=True,
requires=("labels",),
artifacts=("annotation_modifications.txt",),
),
StepDefinition(
"giving_names",
"Finalisation",
@@ -778,15 +795,6 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(manual("deploy"),),
personal=True,
),
StepDefinition(
"personal_print",
"Étapes personnelles",
"Imprimer une copie",
"Étape manuelle via le lecteur PDF.",
(manual("print"),),
personal=True,
optional=True,
),
StepDefinition(
"clean",
"Archivage",
+41 -6
View File
@@ -177,17 +177,22 @@ For each retained answer, the PDF stacks the following available material:
3. The model solution from `Sol2/<label>.pdf`.
4. The same compiled answer block used for the JPEG.
Missing supplementary PDFs are skipped. Contexts can repeat for successive
questions. Each question's complete group stays together on one page; groups
Missing supplementary PDFs are skipped. Each distinct context appears only once
per student PDF, before its first retained question. Duplicate detection uses
extracted PDF text with Unicode and whitespace normalization, including contexts
stored under different filenames or validity ranges. Numbers, mathematical
symbols and case remain significant. Contexts without extractable text use a
PDF-content hash instead, so unrelated image-only contexts are not collapsed.
The same rule applies to selective redo and manual annotation modifications.
Each question's complete group stays together on one page; groups
are packed until the next would exceed the target height. An oversized group
gets its own taller page, rather than being split. Pages are raster images saved
as PDF at 100 dpi, with variable heights, not fixed A4 sheets or searchable text.
The target height is `int(max_image_width * 1.414 * 1.25)` pixels. Small white
margins are added on the left and above/below the page. The first image of each
group receives a black border and the second a blue border. These borders are
assigned by position, so they do not consistently identify question and solution
when contexts are present or supplementary files are missing.
margins are added on the left and above/below the page. The question receives
a black border and the model solution a blue border. Context and student answer
blocks receive neither border, including when supplementary files are missing.
If nothing survives filtering, `read-grouped` removes old `Concat_F` outputs and
does not create a new PDF. During a selective `--refaire` merge, saved answer
@@ -256,3 +261,33 @@ ones. Weighting and the final overall mark belong to the separate grading flow.
- [ODS export](../copienator/commands/update_ods.py)
- [Final-mark stamping](../copienator/commands/add_final_score.py)
- [Cleanup retention](../copienator/commands/clean.py)
## Manual modifications after annotation reading
`modify-annotations EVALUATION --annotation-dir BGnot` (or `Bnot`) applies
`annotation_modifications.txt`. The GUI exposes it between annotation reading
and return preparation. Its `### Source: BGnot`/`Bnot` line must match the mode.
Instruction operators follow manual resolution syntax but act on reviewed raster
**bodies**, excluding headers and all supplementary pages. Headers and scores stay
with their labels. Missing target bodies are allowed; their new header identifies
the target and its existing score, without inventing feedback or a score.
Both readers record `annotation_baseline.json` and lossless hidden
`.annotation-base-*.png` images in each copy's annotation directory. The JSON
stores the exact header height, baseline answer metadata, and filtered membership.
Older outputs require one full annotation read before body editing. Legacy
selective redo still works, but cannot infer missing header boundaries.
Each application rebuilds from that baseline and publishes all affected copies
and the mode's `annotation_modifications_applied.txt` in one rollback-capable
transaction. Reapplying is idempotent; removing an instruction restores its prior
content and membership. Annotation rereads refresh the baseline and replay the
applied snapshot, not the draft. Selective redo preserves unaffected baseline
layers so previous transfers are never included twice.
The operation rebuilds per-label JPEGs, `info.json`, `Concat.jpg`, and the filtered
BGnot PDF or Bnot JPEG. Every changed non-empty body is included in the filtered
output, with its destination label's supplementary material. `score.json` is not
modified. The GUI marks downstream return preparation stale. Rerun `giving-names`
without `--update` to refresh both full outputs and individual answer exports.
+50
View File
@@ -368,6 +368,56 @@ score.json** sert à mettre à jour les scores sur les images finales à
partir d'une modification manuelle des fichiers `score.json`.
### Modifier les réponses annotées
Cette étape facultative se trouve après la lecture des annotations, pour **Bnot**
et **BGnot**. Pour une ancienne évaluation, relancez d’abord **Lire les annotations
manuscrites** : cette lecture enregistre maintenant la limite exacte de chaque
en-tête et une référence sans perte des images annotées.
Saisissez la **Copie**, le **Label source** et le **Label cible**, puis **Ajouter**.
Les deux labels doivent exister ; la source doit avoir une réponse annotée. Une
cible sans réponse est acceptée. Vous pouvez ajouter plusieurs instructions.
Les boutons **Source**, **Cible** et **Cut** montrent uniquement le **corps de la
réponse** : copie originale, annotations Gemini conservées et annotations
manuscrites situées sur ce corps. Ils excluent le score, les commentaires globaux
de l’en-tête, le contexte, l’énoncé et le corrigé. Les annotations manuscrites de
l’en-tête restent dans leur en-tête. Le pourcentage de coupe porte sur la hauteur
du corps uniquement. Aucun fragment n’est redimensionné lors des transferts.
Les instructions sont enregistrées dans `annotation_modifications.txt` :
```text
### Source: BGnot
Copie43 Ex 1 c{43}1> Ex 2|
```
Cet exemple conserve le début de la source (43 %) et ajoute la fin après le corps
de la cible. `2` conserve la fin, `x` remplace le corps cible au lieu d’ajouter, et
`|Ex 2` place le fragment avant la cible. Les opérateurs sans coupe de la résolution
manuelle sont aussi disponibles et décrits dans le panneau. Une instruction par
source ; une cible ne peut pas être source dans le même fichier. Plusieurs sources
peuvent alimenter une cible, dans l’ordre du fichier.
**Cut** prépare une commande à recopier. Utilisez **Ouvrir dans un éditeur de
texte**, enregistrez puis **Recharger**. **Aperçu du résultat** montre les corps
résultants pour la copie saisie. Cliquez sur **Make modifications** pour appliquer
le fichier enregistré. Chaque label conserve son en-tête ; les scores restent
inchangés. Les images individuelles, les images combinées, le PDF filtré BGnot
(ou l’image filtrée Bnot) et les informations de présence sont reconstruits.
Les réponses modifiées non vides sont incluses dans le résultat filtré, avec les
compléments propres à leur label.
Le fichier reste éditable après application. Réappliquer ne cumule pas les
transferts : le traitement repart de la référence issue de la lecture des
annotations. Retirer une ligne puis appliquer annule son effet. Une nouvelle
lecture des annotations renouvelle la référence et réapplique les dernières
instructions **appliquées**, sans prendre en compte un brouillon non appliqué.
Relancez ensuite **Attribuer les noms et préparer A Rendre**, sans cocher l’option
limitée aux images `answers`, afin d’actualiser tous les livrables.
### Attribuer les noms et préparer A Rendre
**Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.