Initial commit for finalisation web interface

This commit is contained in:
sebastien committed 2026-11-01 08:41:37 +01:00
1 parent b295f871a7
commit c36642e8de
33 files changed
+2745 -138

No files matched your search

+3
View File
@@ -155,6 +155,9 @@ def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
kept_files = set(processed_copies)
kept_files.add(workspace.correction_file)
lock_file = workspace.root / ".copienator-finalisation.lock"
if lock_file in files:
kept_files.add(lock_file)
kept_files.update(_return_artifacts(workspace))
kept_files.update(
path
+33
View File
@@ -0,0 +1,33 @@
"""Optional browser workflow; all existing finalisation commands stay available."""
from pathlib import Path
import time
import webbrowser
from copienator import evaluation_parser
def main(argv=None):
parser = evaluation_parser('Open the local finalisation workspace')
parser.add_argument('--annotation-dir', choices=('BGnot', 'Bnot'), default='BGnot')
parser.add_argument('--no-browser', action='store_true')
args = parser.parse_args(argv)
from copienator.finalisation_server import WebSession
session = None
try:
session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir)
print(session.url, flush=True)
if not args.no_browser:
webbrowser.open(session.url)
while session.alive:
time.sleep(.2)
except KeyboardInterrupt:
if session:
print('Fermeture après la fin du traitement en cours…', flush=True)
while session.service.job['running']:
time.sleep(.2)
session.request_stop()
session.thread.join()
except Exception as exc:
print(f'Erreur : {exc}', flush=True)
return 1
return 0
+11 -3
View File
@@ -18,6 +18,7 @@ from copienator import (
)
from copienator.platform import replace_with_link_or_copy, safe_filename
from copienator.return_answers import publish_answer_returns
from copienator.score_editing import remember_return_source
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -119,7 +120,9 @@ def update_named_return_answers(
destination,
answers_only=True,
)
remember_return_source(workspace.root, copy_id, source_folder)
if refresh_named:
replace_with_link_or_copy(source_folder / "score.json", destination / "score.json", prefer="symlink")
stem = destination.name[:match.start()].rstrip()
for source_name, suffix, enabled in (
("Concat.jpg", ".jpg", configuration.RETURN_JPEG_ENABLED),
@@ -167,6 +170,9 @@ def prepare_named_returns(
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
had_errors = True
# A manually renamed folder is authoritative, keyed by stable copy ID.
from copienator.return_identity import return_folders
existing_returns = return_folders(workspace.root)
assigned_names: set[str] = set()
for name, copy_ids in copies_map.items():
if name == "Unknown":
@@ -190,17 +196,19 @@ def prepare_named_returns(
continue
assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination = existing_returns.get(int(copy_id), workspace.return_dir / f"{safe_name} ({copy_id})")
output_name = destination.name.rsplit(" (", 1)[0]
destination.mkdir(parents=True, exist_ok=True)
try:
publish_answer_returns(workspace.root, source_folder, destination)
remember_return_source(workspace.root, copy_id, source_folder)
except (OSError, TypeError, ValueError) as exc:
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
continue
links = (
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
("Concat.jpg", f"{output_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{output_name}.pdf", configuration.RETURN_PDF_ENABLED),
("score.json", "score.json", True),
)
for source_name, destination_name, enabled in links:
+11 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from copienator import review_snapshot
import argparse
from collections.abc import Sequence
from pathlib import Path
@@ -160,6 +163,7 @@ def apply_actions_and_regenerate(
*,
update_score: bool = False,
label_notes: dict[str, Image.Image] | None = None,
review_metadata: dict | None = None,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
@@ -168,15 +172,17 @@ def apply_actions_and_regenerate(
content["result"].get("error") == "empty-answer"
for content in data[student_id].values()
)
if not bnote_path.is_file() and not only_empty:
if review_metadata is None and not bnote_path.is_file() and not only_empty:
print(f" Missing {bnote_path}")
return ExitCode.PARTIAL
bnote_data = read_json(bnote_path) if bnote_path.is_file() else {"images": []}
bnote_data = review_metadata if review_metadata is not None else (read_json(bnote_path) if bnote_path.is_file() else {"images": []})
if not isinstance(bnote_data, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id]
apply_checkbox_actions(labels_data, actions, print)
reviewed_data = deepcopy(labels_data)
captured_notes = {}
apply_custom_remarks(workspace.root, student_id, labels_data)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
@@ -217,6 +223,8 @@ def apply_actions_and_regenerate(
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
if sub_note is not None:
captured_notes[label] = {"img": sub_note}
has_notes = has_significant_notes(sub_note)
pdf_path = Path(content["pdf_path"])
@@ -288,6 +296,7 @@ def apply_actions_and_regenerate(
workspace.root, [(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf", general=general,
)
review_snapshot.stage(output_dir, staging, reviewed_data, captured_notes, engine="standard", bnote=bnote_data)
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels),
filtered_labels, scores)
@@ -1,5 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from copienator import review_snapshot
import argparse
import concurrent.futures
import unicodedata
@@ -231,12 +234,14 @@ def apply_actions_and_regenerate_grouped(
update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
force_filtered: set[str] | None = None,
) -> tuple[ExitCode, str]:
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {})
apply_checkbox_actions(labels_data, actions, logs.append)
reviewed_data = deepcopy(labels_data)
apply_custom_remarks(workspace.root, student_id, labels_data)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
@@ -400,7 +405,7 @@ def apply_actions_and_regenerate_grouped(
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
if not perfect or has_notes or label in (force_filtered or set()):
filtered_labels.append(label)
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels,
@@ -450,6 +455,10 @@ def apply_actions_and_regenerate_grouped(
[(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf", general=general,
)
if annotation_dir in {"Bnot", "BGnot"}:
review_snapshot.stage(output_dir, staging, reviewed_data, label_notes,
engine="grouped", selected=selected_labels,
force_filtered=(set(filtered_labels) - selected_labels) if selected_labels else (force_filtered or ()))
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)
@@ -0,0 +1,38 @@
"""Re-render saved handwriting interpretation with current scores and remarks."""
from copienator import EvaluationWorkspace, ExitCode, evaluation_parser, execute, workspace_from_args, utils
from copienator.review_snapshot import load, FILENAME
from copienator.commands.reading_annotations import apply_actions_and_regenerate
from copienator.commands.reading_grouped_annotations import apply_actions_and_regenerate_grouped
def run(workspace: EvaluationWorkspace, mode: str):
directories = sorted((p for p in (workspace.root / mode).glob('Copie*')
if p.is_dir() and p.name[5:].isdigit() and (p / 'score.json').is_file()))
# Validate every snapshot before starting to publish any copy.
for directory in directories:
if not (directory / FILENAME).is_file():
raise ValueError(f'{directory.name} : résultat de lecture sauvegardé absent ; relisez les annotations manuscrites.')
load(workspace.root, directory)
status = ExitCode.SUCCESS
for directory in directories:
cid = directory.name[5:]
saved, data, notes = load(workspace.root, directory)
if saved['engine'] == 'standard':
result = apply_actions_and_regenerate(workspace, {cid: data}, cid, [], None,
utils.read_all_labels(workspace.root), update_score=True,
label_notes={label: note['img'] for label, note in notes.items()},
review_metadata=saved['bnote'])
else:
result, log = apply_actions_and_regenerate_grouped(workspace, {cid: data}, cid, [], notes,
utils.read_all_labels(workspace.root), update_score=True, annotation_dir=mode,
force_filtered=set(saved.get('force_filtered', [])))
print(log, flush=True)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def main(argv=None):
parser = evaluation_parser('Regenerate from saved reviewed inputs without rescanning handwriting')
parser.add_argument('--annotation-dir', choices=('Bnot', 'BGnot'), required=True)
return execute(parser, argv, lambda args: run(workspace_from_args(args), args.annotation_dir))