Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3754c0c11a | ||
|
|
370f3252ef |
No files matched your search
@@ -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,7 +295,17 @@ 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():
|
||||
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 (
|
||||
@@ -282,10 +321,11 @@ def apply_actions_and_regenerate_grouped(
|
||||
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}")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
+21
-3
@@ -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,12 +944,19 @@ 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
|
||||
|
||||
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:
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
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]
|
||||
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))
|
||||
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)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user