Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a54b5ac2b | ||
|
|
9d0e40cc4f | ||
|
|
28e3b5bfb9 | ||
|
|
213576f83d | ||
|
|
c36642e8de |
No files matched your search
@@ -11,3 +11,5 @@ config.py
|
||||
.copienator-gui.json
|
||||
.copienator/
|
||||
tmp/
|
||||
/web/node_modules/
|
||||
.copienator-finalisation.lock
|
||||
@@ -55,6 +55,8 @@ class AnnotationReadCache:
|
||||
self.signatures[copy_id] = sha256(payload).hexdigest()
|
||||
|
||||
def unchanged(self, copy_id: str) -> bool:
|
||||
if not (self.workspace.root / self.mode / f"Copie{copy_id}" / "review_snapshot.json").is_file():
|
||||
return False
|
||||
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
|
||||
try:
|
||||
state = read_json(directory / STATE_FILE)
|
||||
|
||||
@@ -109,6 +109,8 @@ def execute(
|
||||
) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
from .evaluation_lock import command_lock
|
||||
with command_lock(args):
|
||||
result = handler(args)
|
||||
except CliError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -153,8 +153,12 @@ def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
|
||||
|
||||
files, directories = _workspace_entries(workspace.root)
|
||||
|
||||
kept_files = set(processed_copies)
|
||||
kept_files = {path for path in files if path.is_relative_to(workspace.root / "BackUp")}
|
||||
kept_files.update(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
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@ import io
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -112,7 +112,7 @@ N'utilise pas de caractères mathématiques Unicode dans `rubric_content`.
|
||||
Par exemple :
|
||||
- Au moins 2 points si le résultat est correct.
|
||||
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
|
||||
- Retirer 1,5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
- Retirer 1.5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
|
||||
Renvoie uniquement un objet JSON contenant une liste `rubrics`. Pour chaque
|
||||
question fournie, cette liste contient un objet avec son `label` exact et
|
||||
@@ -333,6 +333,26 @@ def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
|
||||
raise CliError("Groups must contain every existing label exactly once")
|
||||
|
||||
|
||||
def batch_rubric_groups(groups: list[list[str]]) -> list[list[str]]:
|
||||
"""Pack consecutive whole groups into requests of at most six questions.
|
||||
|
||||
An existing group larger than six is sent alone, without splitting it.
|
||||
"""
|
||||
batches: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for group in groups:
|
||||
if current and len(current) + len(group) > 6:
|
||||
batches.append(current)
|
||||
current = []
|
||||
current.extend(group)
|
||||
if len(current) >= 6:
|
||||
batches.append(current)
|
||||
current = []
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
|
||||
|
||||
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None,
|
||||
delete_persp: bool = False) -> ExitCode:
|
||||
"""Regroup questions or generate missing rubrics, retaining completed groups."""
|
||||
@@ -401,26 +421,46 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
|
||||
pass
|
||||
present.clear()
|
||||
print('Deleted current Persp; generating all rubrics.')
|
||||
generated = 0
|
||||
pending_groups = []
|
||||
for group in groups:
|
||||
missing = [label for label in group if label not in present]
|
||||
if not missing:
|
||||
if all(label in present for label in group):
|
||||
print(f"Skipping existing Persp for group: {', '.join(group)}.")
|
||||
continue
|
||||
pending_groups.append(group)
|
||||
batches = batch_rubric_groups(pending_groups)
|
||||
print(f"Generating rubrics in {len(batches)} requests, with up to 4 concurrent requests.")
|
||||
|
||||
def generate_batch(group):
|
||||
missing = [label for label in group if label not in present]
|
||||
print(f"Generating missing rubric (Persp): {', '.join(missing)}...")
|
||||
group_content = (
|
||||
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n"
|
||||
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
|
||||
+ ", ".join(missing) + "\n\n" + "\n\n".join(questions[label] for label in group)
|
||||
)
|
||||
rubrics = generate_rubrics(
|
||||
return generate_rubrics(
|
||||
api_client, group_content, workspace=workspace, expected_labels=missing,
|
||||
)
|
||||
|
||||
generated = 0
|
||||
first_error = None
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = [executor.submit(generate_batch, group) for group in batches]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
rubrics = future.result()
|
||||
# Commit on this thread so directory transactions never overlap.
|
||||
with staged_files(workspace.root / "Persp") as staging:
|
||||
for label, rubric in rubrics.items():
|
||||
(staging / label.replace("/", "_")).write_text(
|
||||
f"{label}\n{rubric}", encoding="utf-8")
|
||||
generated += len(rubrics)
|
||||
except Exception as error:
|
||||
# Save other successful requests even when one request fails.
|
||||
if first_error is None:
|
||||
first_error = error
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
print(f"Generated {generated} missing Persp rubrics; all {len(labels)} are now present.")
|
||||
else:
|
||||
raise ValueError(f"Unknown statement refinement: {mode}")
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator.gemini_config import fresh_seed, thinking_config
|
||||
@@ -166,7 +166,23 @@ class BoxItem(BaseModel):
|
||||
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
|
||||
label: str = Field(description="The label associated with the specific box")
|
||||
|
||||
def _require_success_fields(schema: dict) -> None:
|
||||
"""Mirror the conditional payload validators in the schema sent to Gemini."""
|
||||
required = ["list"]
|
||||
if "name" in schema["properties"]:
|
||||
required.append("name")
|
||||
schema["anyOf"] = [
|
||||
{"required": required},
|
||||
{"required": ["error"],
|
||||
"properties": {"error": {"type": "string", "enum": ["insufficient_crop"]}}},
|
||||
{"required": ["upside_down_pages"],
|
||||
"properties": {"upside_down_pages": {"type": "array", "minItems": 1}}},
|
||||
]
|
||||
|
||||
|
||||
class LabelData(BaseModel):
|
||||
model_config = ConfigDict(json_schema_extra=_require_success_fields)
|
||||
|
||||
upside_down_pages: typing.List[typing.Annotated[int, Field(strict=True, ge=1)]] = Field(
|
||||
default_factory=list,
|
||||
description='1-based column numbers in this image that appear upside down (180 degrees)',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Reversible removal of one stable copy ID and its working artifacts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator import atomic_write_bytes, read_json
|
||||
from copienator.evaluation_lock import EvaluationLock
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemovalPlan:
|
||||
root: Path
|
||||
copy: str
|
||||
removed: tuple[Path, ...]
|
||||
updated: dict[Path, bytes]
|
||||
|
||||
|
||||
def available_copies(root: Path) -> list[str]:
|
||||
names = set()
|
||||
for folder in (root, root / "Copies", root / "Copies Originales"):
|
||||
names.update(p.stem for p in folder.glob("Copie*.pdf")
|
||||
if re.fullmatch(r"Copie[0-9]+", p.stem))
|
||||
return sorted(names, key=lambda name: int(name[5:]))
|
||||
|
||||
|
||||
def _json_bytes(data) -> bytes:
|
||||
return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def removal_plan(root: Path, copy: str) -> RemovalPlan:
|
||||
root = root.resolve()
|
||||
if not re.fullmatch(r"Copie[0-9]+", copy) or copy not in available_copies(root):
|
||||
raise ValueError("Choisissez une copie existante.")
|
||||
ident = copy[5:]
|
||||
def same_id(value):
|
||||
return isinstance(value, (str, int)) and str(value).removeprefix("Copie").isdecimal() and int(str(value).removeprefix("Copie")) == int(ident)
|
||||
def owned(name):
|
||||
match = re.match(r"^Copie([0-9]+)(?:$|[_. -])", name)
|
||||
return bool(match and same_id(match[1]))
|
||||
|
||||
removed = set()
|
||||
files = []
|
||||
# Never traverse symlinks or the backups themselves.
|
||||
for current, dirs, names in os.walk(root, followlinks=False):
|
||||
directory = Path(current)
|
||||
dirs[:] = [name for name in dirs if name not in {"BackUp", ".git"}
|
||||
and not (directory / name).is_symlink()]
|
||||
for name in list(dirs):
|
||||
returned = re.search(r" \(([0-9]+)\)$", name) if directory.name == "A Rendre" else None
|
||||
if owned(name) or (returned and same_id(returned[1])):
|
||||
removed.add(directory / name)
|
||||
dirs.remove(name)
|
||||
for name in names:
|
||||
path = directory / name
|
||||
if path.is_symlink():
|
||||
continue
|
||||
if owned(name):
|
||||
removed.add(path)
|
||||
else:
|
||||
files.append(path)
|
||||
|
||||
updated = {}
|
||||
dropped = object()
|
||||
def prune(value, *, rows=False, numeric_keys=False):
|
||||
if isinstance(value, dict):
|
||||
if any(same_id(value.get(key)) for key in ("id", "copy_id", "copy", "copie")):
|
||||
return dropped
|
||||
result = {}
|
||||
for key, item in value.items():
|
||||
if owned(key) or (numeric_keys and same_id(key)):
|
||||
continue
|
||||
new = prune(item, rows=rows, numeric_keys=numeric_keys)
|
||||
if new is not dropped:
|
||||
result[key] = new
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
if value and ((rows and same_id(value[0])) or
|
||||
(isinstance(value[0], str) and value[0] == copy)):
|
||||
return dropped
|
||||
return [new for item in value
|
||||
if (new := prune(item, rows=rows, numeric_keys=numeric_keys)) is not dropped]
|
||||
return value
|
||||
|
||||
for path in files:
|
||||
relative = path.relative_to(root)
|
||||
# Historical logs and GUI history are audit records, not active inputs.
|
||||
if "logs" in relative.parts or path.name == ".copienator-gui.json":
|
||||
continue
|
||||
if path.name in {"manual_resolutions.txt", "annotation_modifications.txt"}:
|
||||
text = path.read_text()
|
||||
pattern = re.compile(r"\bCopie0*" + str(int(ident)) + r"\b")
|
||||
new_text = "".join(line for line in text.splitlines(keepends=True) if not pattern.search(line))
|
||||
if new_text != text:
|
||||
updated[path] = new_text.encode()
|
||||
if path.suffix == ".json":
|
||||
data = read_json(path) # Fail before moving anything if an input is malformed.
|
||||
is_group = path.name.startswith("Group_") and "Par label" in relative.parts
|
||||
if is_group and isinstance(data, list):
|
||||
targets = [entry for entry in data if isinstance(entry, list) and entry and same_id(entry[0])]
|
||||
if targets:
|
||||
remaining = [entry for entry in data if entry not in targets]
|
||||
image_path = path.with_suffix(".jpg")
|
||||
if not remaining:
|
||||
removed.add(path)
|
||||
if image_path.exists():
|
||||
removed.add(image_path)
|
||||
continue
|
||||
# Blank the removed answer without shifting other students' coordinates.
|
||||
from copienator.commands.grouping import LABEL_HEIGHT, SEPARATOR_HEIGHT
|
||||
with Image.open(image_path) as source:
|
||||
image = source.convert("RGB")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for entry in targets:
|
||||
top = int(entry[1]) - LABEL_HEIGHT
|
||||
draw.rectangle((0, max(0, top - SEPARATOR_HEIGHT), image.width, int(entry[2]) - 1), fill="white")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=95)
|
||||
updated[image_path] = buffer.getvalue()
|
||||
numeric_keys = path.name in {"custom_remarks.json", "general_remarks.json"}
|
||||
new = prune(data, rows=is_group or path.name == "refaire.json", numeric_keys=numeric_keys)
|
||||
if new is dropped:
|
||||
removed.add(path)
|
||||
elif new != data:
|
||||
updated[path] = _json_bytes(new)
|
||||
|
||||
# Cached contact sheets and batch payloads contain several students. Discard
|
||||
# them as a unit so stale recognition/results cannot reintroduce the copy.
|
||||
for directory in (root / "Cutleft/Names/Grids",):
|
||||
if directory.is_dir() and not directory.is_symlink():
|
||||
removed.add(directory)
|
||||
for path in files:
|
||||
if (path.name.startswith(("batch_", "batched_")) or
|
||||
path.name in {"correction_progress.json", "correction_pending_responses.json",
|
||||
"histogramme.pdf", "merged_histogramme.pdf"}):
|
||||
removed.add(path)
|
||||
# Collapse children when a whole directory is being removed.
|
||||
removed = {p for p in removed if not any(parent in removed for parent in p.parents)}
|
||||
updated = {p: data for p, data in updated.items()
|
||||
if p not in removed and not any(parent in removed for parent in p.parents)}
|
||||
return RemovalPlan(root, copy, tuple(sorted(removed)), updated)
|
||||
|
||||
|
||||
def remove_copy(root: Path, copy: str) -> Path:
|
||||
"""Back up every changed file first; roll back all edits on failure."""
|
||||
root = root.resolve()
|
||||
if (root / "BackUp").is_symlink():
|
||||
raise ValueError("BackUp doit être un dossier dans l’évaluation, pas un lien symbolique.")
|
||||
with EvaluationLock(root):
|
||||
plan = removal_plan(root, copy)
|
||||
backup = plan.root / "BackUp" / (datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + copy + "-" + uuid.uuid4().hex[:8])
|
||||
originals = backup / "files"
|
||||
originals.mkdir(parents=True)
|
||||
paths = [*plan.removed, *plan.updated]
|
||||
for path in paths:
|
||||
target = originals / path.relative_to(plan.root)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_dir():
|
||||
shutil.copytree(path, target, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(path, target)
|
||||
manifest = {"copy": copy, "removed": [str(p.relative_to(plan.root)) for p in plan.removed],
|
||||
"updated": [str(p.relative_to(plan.root)) for p in plan.updated], "status": "prepared"}
|
||||
atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest))
|
||||
changed = []
|
||||
try:
|
||||
for path in plan.removed:
|
||||
changed.append(path)
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
for path, data in plan.updated.items():
|
||||
changed.append(path)
|
||||
atomic_write_bytes(path, data)
|
||||
manifest["status"] = "complete"
|
||||
atomic_write_bytes(backup / "manifest.json", _json_bytes(manifest))
|
||||
except Exception:
|
||||
for path in reversed(changed):
|
||||
source = originals / path.relative_to(plan.root)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, path, dirs_exist_ok=True, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(source, path)
|
||||
raise
|
||||
return backup
|
||||
@@ -41,6 +41,8 @@ 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"),
|
||||
"regenerate-reviewed": Command("regenerate_reviewed", "Regenerate saved reviewed annotations"),
|
||||
"finalise-web": Command("finalise_web", "Open the optional local finalisation workspace"),
|
||||
"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"),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""OS-backed evaluation lease; released automatically on process exit."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class EvaluationLock:
|
||||
def __init__(self, root: Path):
|
||||
self.path = root / '.copienator-finalisation.lock'
|
||||
self.stream = None
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
|
||||
def __enter__(self):
|
||||
from copienator import CliError
|
||||
self.stream = self.path.open('a+b')
|
||||
try:
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
self.stream.seek(0)
|
||||
if not self.stream.read(1):
|
||||
self.stream.write(b' ')
|
||||
self.stream.flush()
|
||||
self.stream.seek(0)
|
||||
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
raise CliError('Cette évaluation est utilisée par un autre traitement ou la finalisation web.') from None
|
||||
self.stream.seek(0)
|
||||
self.stream.truncate()
|
||||
self.stream.write(json.dumps({'pid': os.getpid(), 'token': self.token}).encode())
|
||||
self.stream.flush()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
|
||||
|
||||
def command_lock(args):
|
||||
from contextlib import nullcontext
|
||||
from copienator import EvaluationWorkspace
|
||||
root = getattr(args, 'evaluation', None)
|
||||
if root is None and getattr(args, 'target', None):
|
||||
try:
|
||||
root = EvaluationWorkspace.discover(Path(args.target).resolve()).root
|
||||
except Exception:
|
||||
return nullcontext()
|
||||
if root is None or not Path(root).is_dir():
|
||||
return nullcontext()
|
||||
root = Path(root).resolve()
|
||||
token = os.environ.get('COPIENATOR_FINALISATION_TOKEN')
|
||||
if token:
|
||||
try:
|
||||
data = json.loads((root / '.copienator-finalisation.lock').read_text())
|
||||
if secrets.compare_digest(token, data['token']):
|
||||
return nullcontext()
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
return EvaluationLock(root)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Shared, file-compatible services for the optional finalisation browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from copienator import atomic_write_json, read_json, utils
|
||||
from copienator import custom_remarks, general_remarks
|
||||
from copienator.annotation_modifications import DRAFT, APPLIED, BASELINE, read_instructions, validate_instructions
|
||||
from copienator.return_identity import return_folders, RETURN_FOLDER_PATTERN, set_copy_name
|
||||
from copienator.score_editing import set_score
|
||||
|
||||
|
||||
class Finalisation:
|
||||
def __init__(self, root: Path, mode: str, token: str, lock_fd: int | None = None):
|
||||
self.root = root.resolve()
|
||||
if mode not in ('Bnot', 'BGnot'):
|
||||
raise ValueError('Choisissez Bnot ou BGnot.')
|
||||
self.mode, self.token = mode, token
|
||||
self.lock_fd = lock_fd
|
||||
self.labels = utils.read_all_labels(self.root)
|
||||
self.mutex = threading.RLock()
|
||||
self.closing = False
|
||||
self.job = {'running': False, 'status': 'idle', 'log': ''}
|
||||
self.dirty_path = self.root / '.copienator' / f'finalisation-{mode}.json'
|
||||
|
||||
def copies(self):
|
||||
return sorted((p for p in (self.root / self.mode).glob('Copie*')
|
||||
if p.is_dir() and p.name[5:].isdigit() and (p / 'score.json').is_file()),
|
||||
key=lambda p: int(p.name[5:]))
|
||||
|
||||
def directory(self, copy: str):
|
||||
matches = [p for p in self.copies() if copy.isdecimal() and int(p.name[5:]) == int(copy)]
|
||||
if len(matches) != 1:
|
||||
raise ValueError('Copie introuvable ou ambiguë.')
|
||||
return matches[0]
|
||||
|
||||
def label(self, label: str):
|
||||
if label not in self.labels or Path(label).name != label or label in ('.', '..'):
|
||||
raise ValueError('Label inconnu.')
|
||||
return label
|
||||
|
||||
def draft(self):
|
||||
path = self.root / DRAFT
|
||||
return path.read_text() if path.exists() else f'### Source: {self.mode}\n'
|
||||
|
||||
def revision(self):
|
||||
paths = [self.root / DRAFT, self.root / 'custom_remarks.json', self.root / 'general_remarks.json',
|
||||
self.root / self.mode / APPLIED, self.dirty_path]
|
||||
paths += list((self.root / 'Copies').glob('Copie*.json'))
|
||||
paths += [p / 'score.json' for p in self.copies()]
|
||||
digest = sha256()
|
||||
for p in sorted(paths):
|
||||
digest.update(str(p).encode())
|
||||
digest.update(p.read_bytes() if p.exists() else b'absent')
|
||||
for key, folder in sorted(return_folders(self.root).items()):
|
||||
digest.update(f'{key}:{folder.name}'.encode())
|
||||
return digest.hexdigest()
|
||||
|
||||
def state(self):
|
||||
with self.mutex:
|
||||
folders = return_folders(self.root)
|
||||
copies = []
|
||||
for directory in self.copies():
|
||||
cid = directory.name[5:]
|
||||
metadata = read_json(self.root / 'Copies' / f'Copie{cid}.json', default={})
|
||||
folder = folders.get(int(cid))
|
||||
name = RETURN_FOLDER_PATTERN.fullmatch(folder.name)['name'] if folder else metadata.get('name', 'Unknown')
|
||||
scores = read_json(directory / 'score.json')
|
||||
info = read_json(directory / 'info.json', default={})
|
||||
base = read_json(directory / BASELINE, default={})
|
||||
copies.append({'id': cid, 'name': name, 'general': general_remarks.remarks_for_copy(self.root, cid),
|
||||
'answers': [{'label': label, 'score': str(scores.get(label, '')),
|
||||
'remark': custom_remarks.remarks_for_copy(self.root, cid).get(label, ''),
|
||||
'image': (directory / f'{label}.jpg').is_file(),
|
||||
'editableBody': label in base.get('images', {}),
|
||||
'info': info.get(label, {})} for label in self.labels]})
|
||||
applied = self.root / self.mode / APPLIED
|
||||
applied_text = applied.read_text() if applied.exists() else f'### Source: {self.mode}\n'
|
||||
return {'evaluation': self.root.name, 'mode': self.mode, 'labels': self.labels, 'copies': copies,
|
||||
'draft': self.draft(), 'applied': applied_text, 'revision': self.revision(),
|
||||
'dirty': read_json(self.dirty_path, default={}).get('dirty', False), 'job': dict(self.job)}
|
||||
|
||||
def save(self, data):
|
||||
with self.mutex:
|
||||
if self.job['running'] or self.closing:
|
||||
raise ValueError('Régénération en cours ; attendez la fin avant de modifier.')
|
||||
if data['revision'] != self.revision():
|
||||
raise ValueError('Les données ont changé. Rechargez la page avant de réessayer.')
|
||||
kind, value = data['kind'], data['value']
|
||||
if kind == 'draft':
|
||||
instructions = read_instructions(value, self.mode)
|
||||
validate_instructions(self.root, self.mode, instructions)
|
||||
# Same filesystem and atomic replacement; retain the GUI-readable format.
|
||||
from copienator.filesystem import staged_files
|
||||
with staged_files(self.root) as staging:
|
||||
(staging / DRAFT).write_text(value, encoding='utf-8')
|
||||
else:
|
||||
directory = self.directory(data['copy'])
|
||||
cid = directory.name[5:]
|
||||
if kind == 'score':
|
||||
set_score(directory / 'score.json', self.label(data['label']), value)
|
||||
elif kind == 'remark':
|
||||
custom_remarks.set_remark(self.root, cid, self.label(data['label']), value)
|
||||
elif kind == 'general':
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
raise ValueError('Liste de remarques invalide.')
|
||||
general_remarks.set_remarks(self.root, cid, value)
|
||||
elif kind == 'name':
|
||||
set_copy_name(self.root, cid, value)
|
||||
else:
|
||||
raise ValueError('Modification inconnue.')
|
||||
self.dirty_path.parent.mkdir(exist_ok=True)
|
||||
atomic_write_json(self.dirty_path, {'dirty': True})
|
||||
return self.state()
|
||||
|
||||
def start_job(self, revision: str, apply_draft: bool):
|
||||
with self.mutex:
|
||||
if self.job['running'] or self.closing or revision != self.revision():
|
||||
raise ValueError('Traitement déjà en cours ou données modifiées. Rechargez la page.')
|
||||
if apply_draft:
|
||||
validate_instructions(self.root, self.mode, read_instructions(self.draft(), self.mode))
|
||||
if not (self.root / DRAFT).exists():
|
||||
(self.root / DRAFT).write_text(self.draft())
|
||||
self.job = {'running': True, 'status': 'running', 'log': ''}
|
||||
threading.Thread(target=self._run_job, args=(apply_draft,), daemon=True).start()
|
||||
return dict(self.job)
|
||||
|
||||
def _run_job(self, apply_draft):
|
||||
try:
|
||||
self._regenerate(apply_draft)
|
||||
except Exception as exc:
|
||||
with self.mutex:
|
||||
self.job.update(status='failed', log=self.job['log'] + '\n' + str(exc))
|
||||
finally:
|
||||
with self.mutex:
|
||||
self.job['running'] = False
|
||||
|
||||
def _regenerate(self, apply_draft):
|
||||
from copienator.return_answers import load_return_answer_options
|
||||
options = load_return_answer_options(self.root)
|
||||
reader = 'read-grouped' if self.mode == 'BGnot' else 'read-annotations'
|
||||
commands = [[reader, str(self.root), '--update-score']]
|
||||
# The readers expose BooleanOptionalAction for supplementary answer options.
|
||||
for name, enabled in options.items():
|
||||
commands[0].append(f'--{"" if enabled else "no-"}return-answers-{name}')
|
||||
if apply_draft:
|
||||
commands.append(['modify-annotations', str(self.root), '--annotation-dir', self.mode])
|
||||
commands.append(['giving-names', str(self.root), self.mode])
|
||||
from copienator.review_snapshot import FILENAME
|
||||
if all((directory / FILENAME).is_file() for directory in self.copies()):
|
||||
commands[0] = ['regenerate-reviewed', str(self.root), '--annotation-dir', self.mode]
|
||||
elif (self.root / 'BRnot').is_dir():
|
||||
with self.mutex:
|
||||
self.job.update(running=False, status='failed', log='Résultat de lecture sauvegardé absent pour une évaluation avec reprise. Relisez le passage principal puis fusionnez la reprise avant de régénérer dans le navigateur. Aucun résultat n’a été remplacé.')
|
||||
return
|
||||
env = dict(os.environ, COPIENATOR_FINALISATION_TOKEN=self.token, PYTHONUNBUFFERED='1')
|
||||
from copienator import configuration
|
||||
if configuration.CONFIG_PATH is not None:
|
||||
env['COPIENATOR_CONFIG'] = str(configuration.CONFIG_PATH)
|
||||
worker_options = {'pass_fds': (self.lock_fd,)} if os.name != 'nt' and self.lock_fd is not None else {}
|
||||
try:
|
||||
for command in commands:
|
||||
with self.mutex:
|
||||
self.job['log'] += f'\n→ {command[0]}\n'
|
||||
with subprocess.Popen([sys.executable, '-m', 'copienator', *command],
|
||||
cwd=Path(__file__).resolve().parent.parent, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **worker_options) as process:
|
||||
for line in process.stdout:
|
||||
with self.mutex:
|
||||
self.job['log'] = (self.job['log'] + line)[-100000:]
|
||||
if process.wait() != 0:
|
||||
raise RuntimeError(f'{command[0]} a échoué ou est incomplet. Consultez le journal puis relancez.')
|
||||
with self.mutex:
|
||||
self.dirty_path.parent.mkdir(exist_ok=True)
|
||||
atomic_write_json(self.dirty_path, {'dirty': False})
|
||||
self.job['status'] = 'success'
|
||||
except Exception as exc:
|
||||
with self.mutex:
|
||||
self.job['status'] = 'failed'
|
||||
self.job['log'] += f'\n{exc}\n'
|
||||
finally:
|
||||
with self.mutex:
|
||||
self.job['running'] = False
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Loopback-only web UI, served by the same Python installation as Copienator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, StrictStr, Field
|
||||
import uvicorn
|
||||
|
||||
from copienator import CliError
|
||||
from copienator.annotation_modifications import baseline, body_image, edited_bodies, read_instructions, validate_instructions
|
||||
from copienator.evaluation_lock import EvaluationLock
|
||||
from copienator.finalisation import Finalisation
|
||||
from copienator.return_identity import original_copy_path
|
||||
|
||||
|
||||
class Change(BaseModel):
|
||||
revision: StrictStr
|
||||
kind: StrictStr
|
||||
value: StrictStr | list[StrictStr]
|
||||
copy_id: StrictStr = Field(default='', alias='copy')
|
||||
label: StrictStr = ''
|
||||
|
||||
|
||||
class Regenerate(BaseModel):
|
||||
revision: str
|
||||
apply_draft: bool = False
|
||||
|
||||
|
||||
class Preview(BaseModel):
|
||||
copy_id: str = Field(alias="copy")
|
||||
text: str
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
def create_app(service: Finalisation, browser_token: str, stop=lambda: None):
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
cookie_name = 'copienator_' + browser_token[:12]
|
||||
|
||||
@app.middleware('http')
|
||||
async def local_session(request: Request, call_next):
|
||||
host = request.headers.get('host', '').split(':')[0]
|
||||
if host not in ('127.0.0.1', 'localhost', 'testserver'):
|
||||
return Response(status_code=403)
|
||||
origin = request.headers.get('origin')
|
||||
if origin and origin != str(request.base_url).rstrip('/'):
|
||||
return Response(status_code=403)
|
||||
if request.url.path == '/' and secrets.compare_digest(request.query_params.get('token', ''), browser_token):
|
||||
response = RedirectResponse('/', status_code=303)
|
||||
response.set_cookie(cookie_name, browser_token, httponly=True, samesite='strict')
|
||||
return response
|
||||
if not secrets.compare_digest(request.cookies.get(cookie_name, ''), browser_token):
|
||||
return Response('Ouvrez le lien fourni par Copienator.', status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
|
||||
return response
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
@app.exception_handler(CliError)
|
||||
@app.exception_handler(OSError)
|
||||
async def invalid(request, exc):
|
||||
return JSONResponse({'detail': str(exc)}, status_code=409)
|
||||
|
||||
@app.get('/api/state')
|
||||
def state():
|
||||
return service.state()
|
||||
|
||||
@app.get('/api/job')
|
||||
def job():
|
||||
with service.mutex:
|
||||
return dict(service.job)
|
||||
|
||||
@app.post('/api/change')
|
||||
def change(data: Change):
|
||||
if data.kind != 'general' and not isinstance(data.value, str):
|
||||
raise ValueError('Texte attendu.')
|
||||
return service.save(data.model_dump(by_alias=True))
|
||||
|
||||
@app.post('/api/regenerate')
|
||||
def regenerate(data: Regenerate):
|
||||
return service.start_job(data.revision, data.apply_draft)
|
||||
|
||||
@app.post('/api/stop')
|
||||
def shutdown():
|
||||
with service.mutex:
|
||||
if service.job['running']:
|
||||
raise ValueError('Attendez la fin de la régénération avant de fermer le serveur.')
|
||||
stop()
|
||||
return {'stopped': True}
|
||||
|
||||
@app.get('/api/image')
|
||||
def image(copy: str, label: str, body: bool = False):
|
||||
directory = service.directory(copy)
|
||||
service.label(label)
|
||||
if body:
|
||||
result = body_image(directory, label)
|
||||
output = BytesIO()
|
||||
result.save(output, format='PNG')
|
||||
return Response(output.getvalue(), media_type='image/png')
|
||||
path = directory / f'{label}.jpg'
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, 'Aucune image pour cette réponse.')
|
||||
return FileResponse(path)
|
||||
|
||||
@app.get('/api/document')
|
||||
def document(copy: str, kind: str, label: str = ''):
|
||||
directory = service.directory(copy)
|
||||
if kind == 'original':
|
||||
path = original_copy_path(service.root, directory.name[5:])
|
||||
elif kind == 'output':
|
||||
path = directory / 'Concat_F.pdf'
|
||||
elif kind == 'context':
|
||||
from copienator import utils
|
||||
from pypdf import PdfWriter
|
||||
paths = utils.pdf_images_of_contexts(service.root, service.label(label), service.labels)
|
||||
if not paths:
|
||||
raise HTTPException(404, 'Aucun contexte pour ce label.')
|
||||
writer = PdfWriter()
|
||||
for item in paths:
|
||||
if not Path(item).resolve().is_relative_to(service.root):
|
||||
raise HTTPException(403)
|
||||
writer.append(item)
|
||||
buffer = BytesIO()
|
||||
writer.write(buffer)
|
||||
return Response(buffer.getvalue(), media_type='application/pdf')
|
||||
elif kind in ('question', 'solution'):
|
||||
path = service.root / ('Text2' if kind == 'question' else 'Sol2') / f'{service.label(label)}.pdf'
|
||||
else:
|
||||
raise ValueError('Document inconnu.')
|
||||
if path is None or not path.is_file():
|
||||
raise HTTPException(404, 'Ce document est absent.')
|
||||
if not path.resolve().is_relative_to(service.root):
|
||||
raise HTTPException(403)
|
||||
return FileResponse(path, media_type='application/pdf')
|
||||
|
||||
@app.post('/api/preview')
|
||||
def preview(data: Preview):
|
||||
with service.mutex:
|
||||
if service.job['running']:
|
||||
raise ValueError('Régénération en cours.')
|
||||
directory = service.directory(data.copy_id)
|
||||
service.label(data.source)
|
||||
service.label(data.target)
|
||||
instructions = read_instructions(data.text, service.mode)
|
||||
validate_instructions(service.root, service.mode, instructions)
|
||||
_, bodies, _ = edited_bodies(directory, baseline(directory),
|
||||
[i for i in instructions if i.copy_id == directory.name[5:]])
|
||||
result = {}
|
||||
for label in (data.source, data.target):
|
||||
image = bodies.get(label)
|
||||
if image:
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format='PNG')
|
||||
result[label] = 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode()
|
||||
else:
|
||||
result[label] = None
|
||||
return result
|
||||
|
||||
app.mount('/', StaticFiles(directory=Path(__file__).parent / 'web_static', html=True), name='web')
|
||||
return app
|
||||
|
||||
|
||||
class WebSession:
|
||||
def __init__(self, root: Path, mode: str):
|
||||
self.lease = EvaluationLock(root)
|
||||
self.lease.__enter__()
|
||||
try:
|
||||
self.service = Finalisation(root, mode, self.lease.token, self.lease.stream.fileno())
|
||||
if not self.service.copies():
|
||||
raise ValueError('Lisez les annotations manuscrites dans ce mode avant d’ouvrir la finalisation web.')
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
self.socket = socket.socket()
|
||||
self.socket.bind(('127.0.0.1', 0))
|
||||
self.port = self.socket.getsockname()[1]
|
||||
self.app = create_app(self.service, self.token, self.request_stop)
|
||||
self.server = uvicorn.Server(uvicorn.Config(self.app, log_level='warning', access_log=False))
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
deadline = time.monotonic() + 5
|
||||
while not self.server.started and self.thread.is_alive() and time.monotonic() < deadline:
|
||||
time.sleep(.02)
|
||||
if not self.server.started:
|
||||
self.server.should_exit = True
|
||||
raise RuntimeError('Le serveur web n’a pas démarré.')
|
||||
except Exception:
|
||||
self.lease.__exit__()
|
||||
raise
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f'http://127.0.0.1:{self.port}/?token={self.token}'
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
self.server.run(sockets=[self.socket])
|
||||
finally:
|
||||
# Never release the GUI/CLI lease while a generation worker is active.
|
||||
while self.service.job['running']:
|
||||
time.sleep(.1)
|
||||
self.socket.close()
|
||||
self.lease.__exit__()
|
||||
|
||||
def request_stop(self):
|
||||
with self.service.mutex:
|
||||
if self.service.job['running']:
|
||||
raise ValueError('Attendez la fin de la régénération.')
|
||||
self.service.closing = True
|
||||
self.server.should_exit = True
|
||||
|
||||
@property
|
||||
def alive(self):
|
||||
return self.thread.is_alive()
|
||||
+70
-16
@@ -31,10 +31,13 @@ def save_crop(page: Image.Image, pdf: Path, directory: Path, dpi: int) -> None:
|
||||
})
|
||||
|
||||
|
||||
def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
"""Rebuild deterministic 6-by-4 sheets, excluding deleted or changed copies."""
|
||||
def build_grids(directory: Path, copies_dir: Path, *, columns: int = COLUMNS,
|
||||
rows: int = ROWS, stems: set[str] | None = None) -> list[dict]:
|
||||
"""Rebuild numbered sheets, excluding deleted or changed copies."""
|
||||
crops = []
|
||||
for path in sorted(directory.glob("*.jpg"), key=natural_key):
|
||||
if stems is not None and path.stem not in stems:
|
||||
continue
|
||||
pdf = copies_dir / f"{path.stem}.pdf"
|
||||
try:
|
||||
if pdf.is_file() and read_json(path.with_suffix('.json'))['source_sha256'] == fingerprint(pdf):
|
||||
@@ -44,8 +47,8 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
grids_dir = directory / "Grids"
|
||||
grids = []
|
||||
with staged_files(grids_dir, remove=[p.name for p in grids_dir.glob('*') if p.is_file()]) as staging:
|
||||
for offset in range(0, len(crops), COLUMNS * ROWS):
|
||||
batch = crops[offset:offset + COLUMNS * ROWS]
|
||||
for offset in range(0, len(crops), columns * rows):
|
||||
batch = crops[offset:offset + columns * rows]
|
||||
images = []
|
||||
for path in batch:
|
||||
with Image.open(path) as image:
|
||||
@@ -54,11 +57,11 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
height = max(image.height for image in images)
|
||||
border, header = 6, 32
|
||||
cell_width, cell_height = width + border, height + header + border
|
||||
sheet = Image.new('RGB', (COLUMNS * cell_width + border, ROWS * cell_height + border), 'black')
|
||||
sheet = Image.new('RGB', (columns * cell_width + border, rows * cell_height + border), 'black')
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
cells = []
|
||||
for index in range(COLUMNS * ROWS):
|
||||
row, column = divmod(index, COLUMNS)
|
||||
for index in range(columns * rows):
|
||||
row, column = divmod(index, columns)
|
||||
x, y = border + column * cell_width, border + row * cell_height
|
||||
draw.rectangle((x, y, x + width - 1, y + height + header - 1), fill='white')
|
||||
if index < len(images):
|
||||
@@ -70,7 +73,7 @@ def build_grids(directory: Path, copies_dir: Path) -> list[dict]:
|
||||
draw.text((x + 5, y + 5), 'EMPTY', fill='black')
|
||||
filename = f"grid_{len(grids) + 1:03d}.jpg"
|
||||
sheet.save(staging / filename, 'JPEG', quality=95)
|
||||
grids.append({'image': filename, 'columns': COLUMNS, 'rows': ROWS, 'cells': cells})
|
||||
grids.append({'image': filename, 'columns': columns, 'rows': rows, 'cells': cells})
|
||||
atomic_write_json(staging / 'manifest.json', grids)
|
||||
return grids
|
||||
|
||||
@@ -103,11 +106,12 @@ def name_bottom_height_fraction(workspace, stem: str) -> float | None:
|
||||
This is intended as a future lower boundary for margin analysis.
|
||||
"""
|
||||
directory = workspace.cutleft_dir / 'Names'
|
||||
for candidate in (directory, directory / 'Wide'):
|
||||
try:
|
||||
cached = read_json(directory / f'{stem}_result.json')
|
||||
cached = read_json(candidate / f'{stem}_result.json')
|
||||
value = cached['name_bottom_height_fraction']
|
||||
if (cached['source_sha256'] == fingerprint(workspace.copies_dir / f'{stem}.pdf')
|
||||
and cached['crop_sha256'] == fingerprint(directory / f'{stem}.jpg')
|
||||
and cached['crop_sha256'] == fingerprint(candidate / f'{stem}.jpg')
|
||||
and cached['name'] != MISSING
|
||||
and type(value) in (int, float) and 0 < value <= 1):
|
||||
return float(value)
|
||||
@@ -116,14 +120,17 @@ def name_bottom_height_fraction(workspace, stem: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
|
||||
thinking_level: str | None = "low") -> dict[str, str]:
|
||||
def _identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
|
||||
thinking_level: str | None = "low", wide: bool = False) -> dict[str, str]:
|
||||
"""Identify pending copies before label requests; safely fall back on any failure."""
|
||||
directory = workspace.cutleft_dir / 'Names'
|
||||
if wide:
|
||||
directory /= 'Wide'
|
||||
result = {stem: MISSING for stem in groups}
|
||||
if not groups or not directory.is_dir():
|
||||
return result # Older workspaces retain their existing label/name workflow.
|
||||
grids = build_grids(directory, workspace.copies_dir)
|
||||
grids = build_grids(directory, workspace.copies_dir, columns=1 if wide else COLUMNS,
|
||||
rows=4 if wide else ROWS, stems=set(groups) if wide else None)
|
||||
roster = {line.strip() for line in names_text.splitlines() if line.strip()} - {MISSING, 'Unknown', 'Continued'}
|
||||
for grid in grids:
|
||||
selected = {cell['copy']: cell for cell in grid['cells'] if cell['copy'] in groups}
|
||||
@@ -145,8 +152,9 @@ def identify_names(workspace, groups, *, client, model: str, names_text: str, ov
|
||||
if not pending:
|
||||
continue
|
||||
positions = '\n'.join(f"Cell {c['cell']}: row {c['row']}, column {c['column']}" for c in grid['cells'])
|
||||
prompt = f"""Identify student names from the top-left corners of exam first pages.
|
||||
The image is a grid with exactly 6 columns and 4 rows. Read rows left to right,
|
||||
region = 'full-width top quarters' if wide else 'top-left corners'
|
||||
prompt = f"""Identify student names from the {region} of exam first pages.
|
||||
The image is a grid with exactly {grid['columns']} columns and {grid['rows']} rows. Read rows left to right,
|
||||
then top to bottom. Thick black borders separate cells; headers show row, column,
|
||||
and cell number. Each cell belongs to a DIFFERENT student. Never carry a name
|
||||
from one cell into another. Do not include class names such as MPSI 3 in the name.
|
||||
@@ -165,7 +173,7 @@ readable handwriting to an exact authorized name below. Do not guess or assign
|
||||
names by elimination. Return "Missing" for an unreadable, absent, or ambiguous name.
|
||||
Authorized names:
|
||||
{names_text}"""
|
||||
print(f"Identifying student names: {grid['image']} ({len(grid['cells'])} occupied cells, model={model}, thinking={thinking_level or 'default'}).", flush=True)
|
||||
print(f"Identifying student names{' (full-width retry)' if wide else ''}: {grid['image']} ({len(grid['cells'])} occupied cells, model={model}, thinking={thinking_level or 'default'}).", flush=True)
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
@@ -202,3 +210,49 @@ Authorized names:
|
||||
print(f"Name recognition failed for {grid['image']}: {type(exc).__name__}: {exc}; using label/name fallback.")
|
||||
# Do not cache transport/schema failures: a resumed run should retry.
|
||||
return result
|
||||
|
||||
|
||||
def identify_names(workspace, groups, *, client, model: str, names_text: str, overwrite: bool,
|
||||
thinking_level: str | None = "low") -> dict[str, str]:
|
||||
"""Try narrow grids, then collated full-width headers before label fallback."""
|
||||
options = dict(client=client, model=model, names_text=names_text,
|
||||
overwrite=overwrite, thinking_level=thinking_level)
|
||||
result = _identify_names(workspace, groups, **options)
|
||||
directory = workspace.cutleft_dir / 'Names'
|
||||
if not directory.is_dir():
|
||||
return result
|
||||
wide_directory = directory / 'Wide'
|
||||
pending = {}
|
||||
for stem, name in result.items():
|
||||
if name != MISSING:
|
||||
continue
|
||||
pdf = workspace.copies_dir / f'{stem}.pdf'
|
||||
try:
|
||||
source_hash = fingerprint(pdf)
|
||||
crop_path = wide_directory / f'{stem}.jpg'
|
||||
try:
|
||||
current = (crop_path.is_file() and
|
||||
read_json(crop_path.with_suffix('.json'))['source_sha256'] == source_hash)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
current = False
|
||||
if not current:
|
||||
from pdf2image import convert_from_path
|
||||
page = convert_from_path(pdf, dpi=200, first_page=1, last_page=1,
|
||||
use_cropbox=False)[0]
|
||||
try:
|
||||
height = max(1, page.height // 4)
|
||||
with staged_files(wide_directory) as staging:
|
||||
page.crop((0, 0, page.width, height)).save(
|
||||
staging / crop_path.name, 'JPEG', quality=95)
|
||||
atomic_write_json(staging / f'{stem}.json', {
|
||||
'source_sha256': source_hash,
|
||||
'height_fraction': height / page.height,
|
||||
})
|
||||
finally:
|
||||
page.close()
|
||||
pending[stem] = groups[stem]
|
||||
except Exception as exc:
|
||||
print(f'Full-width name crop failed for {stem}: {type(exc).__name__}: {exc}; using label/name fallback.')
|
||||
if pending:
|
||||
result.update(_identify_names(workspace, pending, wide=True, **options))
|
||||
return result
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from copienator import EvaluationWorkspace
|
||||
from copienator.platform import safe_filename
|
||||
|
||||
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NameIssue:
|
||||
folder: Path
|
||||
name: str
|
||||
copy_id: str
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||||
"""Return unknown and duplicated names currently present in A Rendre."""
|
||||
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||||
parsed: list[tuple[Path, str, str]] = []
|
||||
if return_dir.is_dir():
|
||||
for folder in return_dir.iterdir():
|
||||
if not folder.is_dir() or folder.is_symlink():
|
||||
continue
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if match:
|
||||
parsed.append(
|
||||
(folder, match.group("name"), match.group("copy_id"))
|
||||
)
|
||||
|
||||
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||||
issues = []
|
||||
for folder, name, copy_id in parsed:
|
||||
reasons = []
|
||||
if name.casefold() == "unknown":
|
||||
reasons.append("nom inconnu")
|
||||
elif counts[name.casefold()] > 1:
|
||||
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||||
if reasons:
|
||||
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||||
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||||
|
||||
|
||||
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||||
workspace = EvaluationWorkspace(evaluation)
|
||||
filename = f"Copie{copy_id}.pdf"
|
||||
for candidate in (
|
||||
workspace.original_copies_dir / filename,
|
||||
workspace.copies_dir / filename,
|
||||
workspace.root / filename,
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||||
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||||
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||||
if folder.parent.name != "A Rendre":
|
||||
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||||
|
||||
entered_name = requested_name.strip()
|
||||
if not entered_name:
|
||||
raise ValueError("Saisissez un nom.")
|
||||
name = safe_filename(entered_name, fallback="")
|
||||
if not name:
|
||||
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||||
|
||||
copy_id = match.group("copy_id")
|
||||
destination = folder.with_name(f"{name} ({copy_id})")
|
||||
if destination != folder and os.path.lexists(destination):
|
||||
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||||
|
||||
assets: dict[str, Path] = {}
|
||||
for child in folder.iterdir():
|
||||
suffix = child.suffix.casefold()
|
||||
if suffix not in {".jpg", ".pdf"}:
|
||||
continue
|
||||
if suffix in assets:
|
||||
raise ValueError(
|
||||
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||||
)
|
||||
assets[suffix] = child
|
||||
|
||||
renames: list[tuple[Path, Path]] = []
|
||||
for suffix, source in assets.items():
|
||||
target = source.with_name(f"{name}{suffix}")
|
||||
if target != source and os.path.lexists(target):
|
||||
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||||
if target != source:
|
||||
renames.append((source, target))
|
||||
|
||||
completed: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for source, target in renames:
|
||||
source.rename(target)
|
||||
completed.append((source, target))
|
||||
if destination != folder:
|
||||
folder.rename(destination)
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
if os.path.lexists(target) and not os.path.lexists(source):
|
||||
target.rename(source)
|
||||
raise
|
||||
return destination
|
||||
|
||||
|
||||
|
||||
def return_folders(root: Path) -> dict[int, Path]:
|
||||
result = {}
|
||||
for folder in (root / "A Rendre").glob("*"):
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or not match:
|
||||
continue
|
||||
key = int(match["copy_id"])
|
||||
if key in result:
|
||||
raise ValueError(f"Plusieurs dossiers A Rendre pour la copie {key}.")
|
||||
result[key] = folder
|
||||
return result
|
||||
|
||||
|
||||
def set_copy_name(root: Path, copy_id: str, name: str) -> None:
|
||||
from copienator import atomic_update_json
|
||||
name = name.strip()
|
||||
if not name or not safe_filename(name, fallback=""):
|
||||
raise ValueError("Saisissez un nom utilisable.")
|
||||
metadata = root / "Copies" / f"Copie{copy_id}.json"
|
||||
if not metadata.is_file():
|
||||
raise ValueError("Métadonnées de copie absentes.")
|
||||
folder = return_folders(root).get(int(copy_id))
|
||||
renamed = rename_return_copy(folder, name) if folder else None
|
||||
try:
|
||||
atomic_update_json(metadata, lambda data: data.update(name=name), default_factory=dict)
|
||||
except Exception:
|
||||
if renamed and renamed != folder:
|
||||
rename_return_copy(renamed, RETURN_FOLDER_PATTERN.fullmatch(folder.name)["name"])
|
||||
raise
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Rendering inputs captured after handwriting interpretation, before user overrides.
|
||||
|
||||
Optional for the legacy readers. The web renderer consumes this state without
|
||||
rescanning annotation PDFs. A selective redo merges only the selected labels.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from copienator import atomic_write_json, read_json
|
||||
|
||||
FILENAME = 'review_snapshot.json'
|
||||
|
||||
|
||||
def stage(output, staging, data, notes, *, engine, bnote=None, selected=None, force_filtered=()):
|
||||
previous = read_json(output / FILENAME, default=None)
|
||||
if selected and previous is None:
|
||||
# Old preserved images cannot tell us their original feedback and ink.
|
||||
return
|
||||
if previous and previous['engine'] == 'standard' and engine == 'grouped':
|
||||
geometry = {item['label']: item for item in previous['bnote']['images']}
|
||||
for label, note in previous['notes'].items():
|
||||
note['old_header_h'] = geometry.get(label, {}).get('header_height', 0)
|
||||
if 'layout' in geometry.get(label, {}):
|
||||
note['layout'] = geometry[label]['layout']
|
||||
retained = set(force_filtered)
|
||||
if selected:
|
||||
base = read_json(output / 'annotation_baseline.json', default={})
|
||||
retained.update(set(base.get('filtered', [])) - set(selected))
|
||||
selected = set(selected or data)
|
||||
if previous:
|
||||
# Preserve raw inputs for the labels not re-read in a selective redo.
|
||||
merged_data = previous['data'] if selected != set(data) else {}
|
||||
merged_notes = previous['notes'] if selected != set(data) else {}
|
||||
else:
|
||||
merged_data, merged_notes = {}, {}
|
||||
for label in selected:
|
||||
if label in data:
|
||||
content = deepcopy(data[label])
|
||||
path = Path(content['pdf_path']).resolve()
|
||||
root = output.parent.parent.resolve()
|
||||
content['pdf_path'] = str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
|
||||
merged_data[label] = content
|
||||
merged_notes.pop(label, None)
|
||||
note = notes.get(label)
|
||||
if note is not None:
|
||||
filename = '.review-note-' + sha256(label.encode()).hexdigest() + '.png'
|
||||
note['img'].save(staging / filename)
|
||||
merged_notes[label] = {k: v for k, v in note.items() if k != 'img'} | {'file': filename}
|
||||
payload = {'version': 1, 'engine': engine, 'data': merged_data, 'notes': merged_notes, 'bnote': bnote, 'force_filtered': sorted(retained)}
|
||||
# Roundtrip normalizes tuples while rejecting unsupported rendering data.
|
||||
atomic_write_json(staging / FILENAME, json.loads(json.dumps(payload)))
|
||||
|
||||
|
||||
def load(root, output):
|
||||
saved = read_json(output / FILENAME)
|
||||
if saved.get('version') != 1:
|
||||
raise ValueError('Version du résultat de lecture inconnue. Relisez les annotations.')
|
||||
data = saved['data']
|
||||
for content in data.values():
|
||||
path = Path(content['pdf_path'])
|
||||
content['pdf_path'] = path if path.is_absolute() else root / path
|
||||
notes = {}
|
||||
for label, metadata in saved['notes'].items():
|
||||
filename = metadata['file']
|
||||
if Path(filename).name != filename:
|
||||
raise ValueError('Image manuscrite invalide.')
|
||||
with Image.open(output / filename) as image:
|
||||
notes[label] = {k: v for k, v in metadata.items() if k != 'file'} | {'img': image.copy()}
|
||||
return saved, data, notes
|
||||
@@ -0,0 +1,58 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from copienator import atomic_update_json, read_json
|
||||
|
||||
def read_scores(path: Path) -> dict:
|
||||
scores = read_json(annotation_score_for_return(path))
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
|
||||
return scores
|
||||
|
||||
|
||||
def set_score(path: Path, label: str, value: str) -> str:
|
||||
try:
|
||||
number = Decimal(value.strip().replace(",", "."))
|
||||
except InvalidOperation:
|
||||
raise ValueError("Le score doit être un nombre.") from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise ValueError("Le score doit être un nombre positif ou nul.")
|
||||
previous = ""
|
||||
|
||||
def update(scores):
|
||||
nonlocal previous
|
||||
if not isinstance(scores, dict) or label not in scores:
|
||||
raise ValueError(f"Label absent de score.json : {label}")
|
||||
previous = str(scores[label])
|
||||
scores[label] = str(number)
|
||||
|
||||
# A Rendre/score.json is often a symlink to the annotation score file.
|
||||
# Resolve it before atomic replacement so the link continues to work.
|
||||
atomic_update_json(annotation_score_for_return(path).resolve(strict=True), update, default_factory=dict)
|
||||
return previous
|
||||
|
||||
|
||||
|
||||
def annotation_score_for_return(path: Path) -> Path:
|
||||
"""Resolve fallback return copies as well as symlinks to their source scores."""
|
||||
if path.is_symlink() or path.parent.parent.name != 'A Rendre':
|
||||
return path.resolve()
|
||||
from copienator.return_identity import RETURN_FOLDER_PATTERN
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(path.parent.name)
|
||||
if not match:
|
||||
return path
|
||||
root = path.parent.parent.parent
|
||||
cid = match['copy_id']
|
||||
sources = read_json(root / '.copienator' / 'return_sources.json', default={})
|
||||
mode = sources.get(str(int(cid)))
|
||||
modes = (mode,) if mode in ('Bnot', 'BGnot', 'Anot') else ('Bnot', 'BGnot', 'Anot')
|
||||
candidates = [p for m in modes for p in (root / m).glob('Copie*/score.json')
|
||||
if p.parent.name[5:].isdigit() and int(p.parent.name[5:]) == int(cid)]
|
||||
if len(candidates) > 1:
|
||||
raise ValueError('Source des scores ambiguë : relancez la préparation de A Rendre dans le mode souhaité.')
|
||||
return candidates[0] if candidates else path
|
||||
|
||||
|
||||
def remember_return_source(root: Path, copy_id: str, source: Path):
|
||||
path = root / '.copienator' / 'return_sources.json'
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
atomic_update_json(path, lambda data: data.update({str(int(copy_id)): source.parent.name}), default_factory=dict)
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#23323a;background:#f3f5f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:3px}button{border:1px solid #c8d3ce;border-radius:7px;background:#fff;color:#244b40;padding:9px 13px;cursor:pointer;font-weight:550}button:hover:not(:disabled){background:#e9f1ed;border-color:#769b88}button:disabled{opacity:.45;cursor:default}.primary{background:#205c48;color:#fff;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:#fff}a{color:#21654e;text-decoration:none}a:hover{text-decoration:underline}header{display:flex;align-items:center;gap:32px;padding:20px 36px;border-bottom:1px solid #dbe3de;background:#fff}.brand{display:flex;align-items:center;gap:10px;font-size:23px;font-weight:800;color:#28684f}.brand span{font-size:16px;font-weight:650}.heading{flex:1;border-left:1px solid #dbe3de;padding-left:30px}h1{font-size:22px;margin:3px 0 0}h2{font-size:20px;margin:0 0 8px}h3{font-size:19px;margin:7px 0}p{line-height:1.55;margin:8px 0;color:#56666b}.eyebrow{font-size:11px;letter-spacing:.09em;color:#5c7470;font-weight:650}main{max-width:1540px;margin:auto;padding:30px 36px 60px}.toolbar{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.filters{display:flex;gap:12px;align-items:end}label{display:flex;flex-direction:column;gap:6px;color:#405650;font-size:12px;font-weight:600}input,select,textarea{border:1px solid #c5d1cb;border-radius:6px;background:#fff;padding:9px 10px;color:#263d34;font-size:14px;min-width:0}textarea{resize:vertical;min-height:76px;line-height:1.5}input:focus,textarea:focus,select:focus{outline:2px solid #80b69c;border-color:#4d8269}input[type=checkbox]{width:16px;height:16px;accent-color:#205c48}.check{display:flex;flex-direction:row;align-items:center;max-width:310px;font-size:12px;line-height:1.4}.publish{display:flex;align-items:center;gap:16px;background:#e8f0eb;border:1px solid #d0e0d5;border-radius:10px;padding:17px 20px;margin:20px 0}.publish>div{flex:1}.publish small{display:block;margin-top:5px;color:#5b6e64}.panel{padding:18px 20px;background:#fff;border:1px solid #d9e2dd;border-radius:9px;margin-bottom:14px}summary{cursor:pointer;font-weight:600}summary span{float:right;font-size:12px;font-weight:400;color:#65766f}.identity{border-top:1px solid #e1e7e3;padding:18px 0;display:grid;grid-template-columns:280px 1fr;gap:28px}.identity-name{display:flex;flex-direction:column;gap:10px;align-items:start}.identity-name label{width:100%}.general button{margin:5px 6px 0 0}.remark-row{display:flex;align-items:center;gap:6px;margin-bottom:8px}.remark-row textarea{flex:1}.instructions{width:100%;font-family:ui-monospace,monospace;min-height:140px;margin:10px 0}.panel details{margin-top:16px}.results{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.results span{color:#677872;font-size:12px}.answer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.answer{border:1px solid #d9e1dd;background:#fff;border-radius:10px;overflow:hidden}.card-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;gap:12px}.badge{font-size:11px;background:#edf1ee;color:#5a6e62;border-radius:4px;padding:5px 7px;white-space:nowrap}.warning{background:#fff2d8;color:#8a620b}.image-space{height:290px;overflow:hidden;background:#e7ece9;border-top:1px solid #e2e8e3;border-bottom:1px solid #e2e8e3;display:flex;justify-content:center;align-items:start;padding:12px}.image-button{position:relative;border:0;padding:0;background:#fff;width:100%;height:100%;overflow:hidden;border-radius:0}.image-button img{width:100%;display:block}.image-button span{position:absolute;bottom:8px;right:8px;background:#203d32e8;color:#fff;border-radius:5px;padding:7px 10px;font-size:12px}.answer-form{display:grid;grid-template-columns:88px 1fr;gap:15px;padding:18px 20px 10px}.card-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:8px 20px 20px}.card-actions a{font-size:12px}.empty{padding:32px;color:#77877e;font-style:italic;text-align:center}.load-more{display:block;margin:24px auto}.alert{background:#fff0eb;color:#8f3827;border:1px solid #e9b6a9;border-radius:7px;padding:12px;display:flex;gap:12px;align-items:center}.alert small{font-size:12px}.notice{color:#216044}.muted{font-size:12px}fieldset{padding:0;margin:0;border:0;min-width:0}.overlay{position:fixed;inset:0;background:#142d23b8;z-index:10;display:flex;align-items:center;justify-content:center;padding:24px}.modal{width:min(1300px,100%);max-height:94vh;overflow:auto;border-radius:12px;background:#f8faf8;padding:24px;box-shadow:0 24px 80px #001c2e55}.modal-head{display:flex;justify-content:space-between;align-items:center;gap:20px}.cut-controls{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.compare{display:grid;grid-template-columns:1fr 1fr;gap:24px}.compare>div{mLine truncated
|
||||
@@ -0,0 +1,15 @@
|
||||
(function(){const G=document.createElement("link").relList;if(G&&G.supports&&G.supports("modulepreload"))return;for(const q of document.querySelectorAll('link[rel="modulepreload"]'))y(q);new MutationObserver(q=>{for(const $ of q)if($.type==="childList")for(const rl of $.addedNodes)rl.tagName==="LINK"&&rl.rel==="modulepreload"&&y(rl)}).observe(document,{childList:!0,subtree:!0});function Y(q){const $={};return q.integrity&&($.integrity=q.integrity),q.referrerPolicy&&($.referrerPolicy=q.referrerPolicy),q.crossOrigin==="use-credentials"?$.credentials="include":q.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function y(q){if(q.ep)return;q.ep=!0;const $=Y(q);fetch(q.href,$)}})();var zs={exports:{}},nn={};var lv;function ih(){if(lv)return nn;lv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.fragment");function Y(y,q,$){var rl=null;if($!==void 0&&(rl=""+$),q.key!==void 0&&(rl=""+q.key),"key"in q){$={};for(var Gl in q)Gl!=="key"&&($[Gl]=q[Gl])}else $=q;return q=$.ref,{$$typeof:_,type:y,key:rl,ref:q!==void 0?q:null,props:$}}return nn.Fragment=G,nn.jsx=Y,nn.jsxs=Y,nn}var tv;function ch(){return tv||(tv=1,zs.exports=ih()),zs.exports}var d=ch(),_s={exports:{}},Z={};var uv;function fh(){if(uv)return Z;uv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.portal"),Y=Symbol.for("react.fragment"),y=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),$=Symbol.for("react.consumer"),rl=Symbol.for("react.context"),Gl=Symbol.for("react.forward_ref"),ul=Symbol.for("react.suspense"),Dl=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),M=Symbol.for("react.view_transition"),_l=Symbol.iterator;function Xl(s){return s===null||typeof s!="object"?null:(s=_l&&s[_l]||s["@@iterator"],typeof s=="function"?s:null)}var Ul={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},il=Object.assign,et={};function Ol(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}Ol.prototype.isReactComponent={},Ol.prototype.setState=function(s,E){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,E,"setState")},Ol.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function gt(){}gt.prototype=Ol.prototype;function Ql(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}var at=Ql.prototype=new gt;at.constructor=Ql,il(at,Ol.prototype),at.isPureReactComponent=!0;var xl=Array.isArray;function K(){}var F={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function Nl(s,E,D){var j=D.ref;return{$$typeof:_,type:s,key:E,ref:j!==void 0?j:null,props:D}}function pl(s,E){return Nl(s.type,E,s.props)}function Ll(s){return typeof s=="object"&&s!==null&&s.$$typeof===_}function jt(s){var E={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(D){return E[D]})}var L=/\/+/g;function hl(s,E){return typeof s=="object"&&s!==null&&s.key!=null?jt(""+s.key):E.toString(36)}function C(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(K,K):(s.status="pending",s.then(function(E){s.status==="pending"&&(s.status="fulfilled",s.value=E)},function(E){s.status==="pending"&&(s.status="rejected",s.reason=E)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function B(s,E,D,j,k){var al=typeof s;(al==="undefined"||al==="boolean")&&(s=null);var sl=!1;if(s===null)sl=!0;else switch(al){case"bigint":case"string":case"number":sl=!0;break;case"object":switch(s.$$typeof){case _:case G:sl=!0;break;case R:return sl=s._init,B(sl(s._payload),E,D,j,k)}}if(sl)return k=k(s),sl=j===""?"."+hl(s,0):j,xl(k)?(D="",sl!=null&&(D=sl.replace(L,"$&/")+"/"),B(k,E,D,"",function(nu){return nu})):k!=null&&(Ll(k)&&(k=pl(k,D+(k.key==null||s&&s.key===k.key?"":(""+k.key).replace(L,"$&/")+"/")+sl)),E.push(k)),1;sl=0;var x=j===""?".":j+":";if(xl(s))for(var V=0;V<s.length;V++)j=s[V],al=x+hl(j,V),sl+=B(j,E,D,al,k);else if(V=Xl(s),typeof V=="function")for(s=V.call(s),V=0;!(j=s.next()).done;)j=j.value,al=x+hl(j,V++),sl+=B(j,E,D,al,k);else if(al==="object"){if(typeof s.then=="function")return B(C(s),E,D,j,k);throw E=String(s),Error("Objects are not valid as a React child (found: "+(E==="[object Object]"?"object with keys {"+Object.keys(s).join(", ")+"}":E)+"). If you meant to render a collection of children, use an array instead.")}return sl}function X(s,E,D){if(s==null)return s;var j=[],k=0;return B(s,j,"","",function(al){return E.call(D,al,k++)}),j}function cl(s){if(s._status===-1){var E=s._result,D=E();D.then(function(j){(s._status===0||s._status===-1)&&(s._status=1,s._result=j,D.status===void 0&&(D.status="fulfilled",D.value=j))},function(j){(s._status===0||s._status===-1)&&(s._status=2,s._result=j,D.statLine truncated
|
||||
at`)?" (<anonymous>)":-1<u.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Vi+l+js}var Zi=!1;function Li(l,t){if(!l||Zi)return"";Zi=!0;var u=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var e={DetermineComponentFrameRoot:function(){try{if(t){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(A){var r=A}Reflect.construct(l,[],z)}else{try{z.call()}catch(A){r=A}z=!1;try{var g=Object.getOwnPropertyDescriptor(l.prototype,"props");Object.defineProperty(l.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new l}finally{z&&(g!==void 0?Object.defineProperty(l.prototype,"props",g):delete l.prototype.props)}}}else{try{throw Error()}catch(A){r=A}(z=l())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(A){if(A&&r&&typeof A.stack=="string")return[A.stack,r.stack]}return[null,null]}};e.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(e.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(e.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=e.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
|
||||
`),m=c.split(`
|
||||
`);for(a=e=0;e<f.length&&!f[e].includes("DetermineComponentFrameRoot");)e++;for(;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;if(e===f.length||a===m.length)for(e=f.length-1,a=m.length-1;1<=e&&0<=a&&f[e]!==m[a];)a--;for(;1<=e&&0<=a;e--,a--)if(f[e]!==m[a]){if(e!==1||a!==1)do if(e--,a--,0>a||f[e]!==m[a]){var S=`
|
||||
`+f[e].replace(" at new "," at ");return l.displayName&&S.includes("<anonymous>")&&(S=S.replace("<anonymous>",l.displayName)),S}while(1<=e&&0<=a);break}}}finally{Zi=!1,Error.prepareStackTrace=u}return(u=l?l.displayName||l.name:"")?zu(u):""}function ov(l,t){switch(l.tag){case 26:case 27:case 5:return zu(l.type);case 16:return zu("Lazy");case 13:return l.child!==t&&t!==null?zu("Suspense Fallback"):zu("Suspense");case 19:return zu("SuspenseList");case 0:case 15:return Li(l.type,!1);case 11:return Li(l.type.render,!1);case 1:return Li(l.type,!0);case 31:return zu("Activity");case 30:return zu("ViewTransition");default:return""}}function Ms(l){try{var t="",u=null;do t+=ov(l,u),u=l,l=l.return;while(l);return t}catch(e){return`
|
||||
Error generating stack: `+e.message+`
|
||||
`+e.stack}}var Ki=Object.prototype.hasOwnProperty,Ji=_.unstable_scheduleCallback,wi=_.unstable_cancelCallback,rv=_.unstable_shouldYield,dv=_.unstable_requestPaint,St=_.unstable_now,vv=_.unstable_getCurrentPriorityLevel,Us=_.unstable_ImmediatePriority,ps=_.unstable_UserBlockingPriority,dn=_.unstable_NormalPriority,mv=_.unstable_LowPriority,Rs=_.unstable_IdlePriority,yv=_.log,hv=_.unstable_setDisableYieldValue,da=null,bt=null;function _u(l){if(typeof yv=="function"&&hv(l),bt&&typeof bt.setStrictMode=="function")try{bt.setStrictMode(da,l)}catch{}}var Tt=Math.clz32?Math.clz32:bv,gv=Math.log,Sv=Math.LN2;function bv(l){return l>>>=0,l===0?32:31-(gv(l)/Sv|0)|0}var vn=256,mn=262144,yn=4194304;function Wu(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&-l;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function hn(l,t,u){var e=l.pendingLanes;if(e===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=e&134217727;return c!==0?(e=c&~n,e!==0?a=Wu(e):(i&=c,i!==0?a=Wu(i):u||(u=c&~l,u!==0&&(a=Wu(u))))):(c=e&~n,c!==0?a=Wu(c):i!==0?a=Wu(i):u||(u=e&~l,u!==0&&(a=Wu(u)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,u=t&-t,n>=u||n===32&&(u&4194048)!==0)?t:a}function va(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function xs(l,t){(t&8)!==0&&(t|=t&32);var u=l.entangledLanes;if(u!==0)for(l=l.entanglements,u&=t;0<u;){var e=31-Tt(u),a=1<<e;t|=l[e],u&=~a}return t}function Tv(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hs(){var l=yn;return yn<<=1,(yn&62914560)===0&&(yn=4194304),l}function $i(l){for(var t=[],u=0;31>u;u++)t.push(l);return t}function ma(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Ev(l,t,u,e,a,n){var i=l.pendingLanes;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=u,l.entangledLanes&=u,l.errorRecoveryDisabledLanes&=u,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,m=l.hiddenUpdates;for(u=i&~u;0<u;){var S=31-Tt(u),z=1<<S;c[S]=0,f[S]=-1;var r=m[S];if(r!==null)for(m[S]=null,S=0;S<r.length;S++){var g=r[S];g!==null&&(g.lane&=-536870913)}u&=~z}e!==0&&qs(l,e,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function qs(l,t,u){l.pendingLanes|=t,l.suspendedLanes&=~t;var e=31-Tt(t);l.entangledLanes|=t,l.entanglements[e]=l.entanglements[e]|1073741824|u&261930}function Ys(l,t){var u=l.entangledLanes|=t;for(l=l.entanglements;u;){var e=31-Tt(u),a=1<<e;a&t|l[e]&t&&(l[e]|=t),u&=~a}}function Bs(l,t){var u=t&-t;return u=(u&42)!==0?1:Fi(u),(u&(l.suspendedLanes|t))!==0?0:u}function Fi(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function Wi(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Gs(){var l=V.p;return l!==0?l:(l=window.event,l===void 0?32:w0(l.type))}function Xs(l,t){var u=V.p;try{return V.p=l,t()}finally{V.p=u}}var iu=Math.random().toString(36).slice(2),Wl="__reactFiber$"+iu,rt="__reactProps$"+iu,Ee="__reactContainer$"+iu,Qs="__reactEvents$"+iu,zv="__reactListeners$"+iu,_v="__reactHandles$"+iu,Vs="__reactResources$"+iu,ya="__reactMarker$"+iu,gn="__reactLoad$"+iu;function Sn(l){delete l[Wl],delete l[rt],delete l[zv],delete l[_v]}function ku(l){var t;if(t=l[Wl])return t;for(var u=l.parentNode;u;){if(t=u[Ee]||u[Wl]){if(u=t.alternate,t.child!==null||u!==null&&u.child!==null)for(l=C0(l);l!==null;){if(u=l[Wl])return u;l=C0(l)}return t}l=u,u=l.parentNode}return null}function ze(l){if(l=l[Wl]||l[Ee]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ha(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(y(33))}function _e(l){var t=l[Vs];return t||(t=l[Vs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Kl(l){l[ya]=!0}function Zs(l){l[gn]=void 0}var Ls=new Set,Ks={};function Iu(l,t){Oe(l,t),Oe(l+"Capture",t)}function
Line truncated
|
||||
`).replace(oy,"")}function c0(l,t){return t=i0(t),i0(l)===t}function yl(l,t,u,e,a,n){switch(u){case"children":if(typeof e=="string")t==="body"||t==="textarea"&&e===""||Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")t!=="body"&&Ae(l,""+e);else return;break;case"className":Tn(l,"class",e);break;case"tabIndex":Tn(l,"tabindex",e);break;case"dir":case"role":case"viewBox":case"width":case"height":Tn(l,u,e);break;case"style":to(l,e,n);return;case"data":if(t!=="object"){Tn(l,"data",e);break}case"src":case"href":if(e===""&&(t!=="a"||u!=="href")){l.removeAttribute(u);break}if(e==null||typeof e=="function"||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"action":case"formAction":if(typeof e=="function"){l.setAttribute(u,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(u==="formAction"?(t!=="input"&&yl(l,t,"name",a.name,a,null),yl(l,t,"formEncType",a.formEncType,a,null),yl(l,t,"formMethod",a.formMethod,a,null),yl(l,t,"formTarget",a.formTarget,a,null)):(yl(l,t,"encType",a.encType,a,null),yl(l,t,"method",a.method,a,null),yl(l,t,"target",a.target,a,null)));if(e==null||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"onClick":e!=null&&(l.onclick=$t);return;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"multiple":l.multiple=e&&typeof e!="function"&&typeof e!="symbol";break;case"muted":l.muted=e&&typeof e!="function"&&typeof e!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(e==null||typeof e=="function"||typeof e=="boolean"||typeof e=="symbol"){l.removeAttribute("xlink:href");break}u=En(e),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",u);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":e&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,""):l.removeAttribute(u);break;case"capture":case"download":e===!0?l.setAttribute(u,""):e!==!1&&e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"cols":case"rows":case"size":case"span":e!=null&&typeof e!="function"&&typeof e!="symbol"&&!isNaN(e)&&1<=e?l.setAttribute(u,e):l.removeAttribute(u);break;case"rowSpan":case"start":e==null||typeof e=="function"||typeof e=="symbol"||isNaN(e)?l.removeAttribute(u):l.setAttribute(u,e);break;case"popover":P("beforetoggle",l),P("toggle",l),bn(l,"popover",e);break;case"xlinkActuate":cu(l,"http://www.w3.org/1999/xlink","xlink:actuate",e);break;case"xlinkArcrole":cu(l,"http://www.w3.org/1999/xlink","xlink:arcrole",e);break;case"xlinkRole":cu(l,"http://www.w3.org/1999/xlink","xlink:role",e);break;case"xlinkShow":cu(l,"http://www.w3.org/1999/xlink","xlink:show",e);break;case"xlinkTitle":cu(l,"http://www.w3.org/1999/xlink","xlink:title",e);break;case"xlinkType":cu(l,"http://www.w3.org/1999/xlink","xlink:type",e);break;case"xmlBase":cu(l,"http://www.w3.org/XML/1998/namespace","xml:base",e);break;case"xmlLang":cu(l,"http://www.w3.org/XML/1998/namespace","xml:lang",e);break;case"xmlSpace":cu(l,"http://www.w3.org/XML/1998/namespace","xml:space",e);break;case"is":bn(l,"is",e);break;case"innerText":case"textContent":return;default:if(!(2<u.length)||u[0]!=="o"&&u[0]!=="O"||u[1]!=="n"&&u[1]!=="N")u=jv.get(u)||u,bn(l,u,e);else return}fl=!0}function Pf(l,t,u,e,a,n){switch(u){case"style":to(l,e,n);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"children":if(typeof e=="string")Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")Ae(l,""+e);else return;break;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"onClick":e!=null
Line truncated
|
||||
`).map((O,N,s)=>O.trim()&&!O.trim().startsWith("###")?d.jsxs("div",{className:"draft-row",children:[d.jsx("code",{children:O}),d.jsx("button",{"aria-label":`Monter l’instruction ${N}`,disabled:N===0||s[N-1].startsWith("###"),onClick:()=>{const E=[...s];[E[N-1],E[N]]=[E[N],E[N-1]],Nl("draft","","",E.join(`
|
||||
`))},children:"↑"}),d.jsx("button",{"aria-label":`Descendre l’instruction ${N}`,disabled:N===s.length-1||!s[N+1].trim(),onClick:()=>{const E=[...s];[E[N+1],E[N]]=[E[N],E[N+1]],Nl("draft","","",E.join(`
|
||||
`))},children:"↓"}),d.jsx("button",{onClick:()=>Nl("draft","","",s.filter((E,D)=>D!==N).join(`
|
||||
`)),children:"Retirer"})]},N):null)}),d.jsxs("details",{children:[d.jsx("summary",{children:"Éditer les instructions en texte"}),d.jsx("textarea",{className:"instructions","aria-label":"Brouillon des transferts",value:C,onChange:O=>Nl("draft","","",O.target.value)})]}),d.jsx("button",{disabled:!M[Gt("draft")],onClick:()=>pl([Gt("draft")]),children:"Enregistrer le brouillon"}),d.jsxs("details",{children:[d.jsx("summary",{children:"Instructions actuellement appliquées"}),d.jsx("pre",{children:_.applied})]})]}),d.jsxs("div",{className:"results",children:[d.jsxs("h2",{children:[cl.length," réponse",cl.length!==1?"s":""]}),d.jsx("span",{children:!rl&&!ul?"Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification":"Images annotées après lecture manuscrite"})]}),d.jsx("div",{className:"answer-grid",children:cl.slice(0,R).map(({c:O,a:N})=>d.jsxs("article",{className:"answer",children:[d.jsxs("div",{className:"card-heading",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",O.id," · ",O.name]}),d.jsx("h3",{children:N.label})]}),d.jsx("span",{className:"badge",children:N.info.present?N.info.not_empty?N.info.touched?"Dans le PDF":"Hors PDF filtré":"Réponse vide":"Absente"})]}),d.jsx("div",{className:"image-space",children:N.image?d.jsxs("button",{className:"image-button","aria-label":`Agrandir copie ${O.id} ${N.label}`,onClick:()=>at({copy:O.id,label:N.label}),children:[d.jsx("img",{loading:"lazy",src:Gi(O.id,N.label,!1,_.revision),alt:`Réponse annotée — copie ${O.id}, ${N.label}`}),d.jsx("span",{children:"Agrandir ↗"})]}):d.jsx("p",{className:"empty",children:"Aucune image pour ce label."})}),d.jsxs("div",{className:"answer-form",children:[d.jsxs("label",{children:["Score",d.jsx("input",{inputMode:"decimal",value:Vl("score",O.id,N.label,N.score),onChange:s=>Nl("score",O.id,N.label,s.target.value)})]}),d.jsxs("label",{children:["Remarque personnelle",d.jsx("textarea",{placeholder:"Ajouter une remarque (LaTeX accepté)",value:Vl("remark",O.id,N.label,N.remark),onChange:s=>Nl("remark",O.id,N.label,s.target.value)})]})]}),d.jsxs("div",{className:"card-actions",children:[d.jsx("button",{disabled:!M[Gt("score",O.id,N.label)]&&!M[Gt("remark",O.id,N.label)],onClick:()=>pl([Gt("score",O.id,N.label),Gt("remark",O.id,N.label)]),children:"Enregistrer"}),d.jsx("button",{disabled:!N.editableBody,onClick:()=>gt({copy:O.id,label:N.label}),children:"Couper / transférer"}),d.jsx("a",{href:fn(O.id,"context",N.label),target:"_blank",rel:"noreferrer",children:"Contexte ↗"}),d.jsx("a",{href:fn(O.id,"question",N.label),target:"_blank",rel:"noreferrer",children:"Énoncé ↗"}),d.jsx("a",{href:fn(O.id,"solution",N.label),target:"_blank",rel:"noreferrer",children:"Corrigé ↗"}),d.jsx("a",{href:fn(O.id,"output"),target:"_blank",rel:"noreferrer",children:"PDF final ↗"})]})]},O.id+"|"+N.label))}),cl.length===0&&d.jsx("p",{className:"empty",children:"Aucune réponse ne correspond à ces filtres."}),cl.length>R&&d.jsx("button",{className:"load-more",onClick:()=>b(R+20),children:"Afficher les 20 suivantes"})]}),(_.job.log||_.job.running)&&d.jsxs("details",{className:"panel",open:_.job.status==="failed",children:[d.jsxs("summary",{children:["Journal de régénération · ",_.job.status==="failed"?"Échec / résultat incomplet":_.job.running?"En cours":"Terminée"]}),d.jsx("pre",{className:"log",children:_.job.log})]})]}),Ol&&d.jsx(Sh,{copy:Ol.copy,source:Ol.label,labels:_.labels,mode:_.mode,revision:_.revision,draft:C,close:()=>gt(null),accept:O=>{Nl("draft","","",O),gt(null),$("Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.")}}),Ql&&d.jsx(gh,{copy:Ql.copy,label:Ql.label,revision:_.revision,close:()=>at(null)})]})}function gh({copy:_,label:G,revision:Y,close:y}){const[q,$]=Sl.useState(100);return Sl.useEffect(()=>{const rl=Gl=>{Gl.key==="Escape"&&y()};return window.addEventListener("keydown",rl),()=>window.removeEventListener("keydown",rl)},[]),d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal viewer",role:"dialog","aria-modal":"true","aria-label":`Copie ${_} ${G}`,children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("h2",{children:["Copie ",_," · ",G]}),d.jsxs("label",{children:["Zoom ",d.jsx("input",{type:"range",min:"40",max:"200",value:q,onChange:rl=>$(Number(rl.target.value))}),q,"%"]}),d.jsx("button",{autoFocus:!0,onClick:y,children:"Fermer"})]}),d.jsx("div",{className:"zoom-scroll",children:d.jsx("img",{src:Gi(_,G,!1,Y),alt:`Copie ${_}, ${G}`,style:{width:q+"%",maxWidth:"none"}})})]})})}function Sh({copy:_,source:G,labels:Y,mode:y,revision:q,draft:$,close:rl,accept:Gl}){const[ul,Dl]=Sl.useState(Y.find(L=>L!==G)||""),[R,b]=Sl.useState("cut"),[M,_l]=Sl.useState(50),[Xl,Ul]=Sl.useState("1"),[il,et]=Sl.useState(">"),[Ol,gt]=Sl.useState(!1),[Ql,at]=Sl.useState(""),[xl,K]=Sl.useState(null),[F,Vl]=Sl.useState(!1),Nl=R==="cut"?`c{${M}}${Xl}${il}`:R,pl=`Copie${_} ${G} ${Nl} ${Ol?"|":""}${ul}${Ol?"":"|Line truncated
|
||||
`+pl+`
|
||||
`;Sl.useEffect(()=>K(null),[ul,R,M,Xl,il,Ol,$]);const jt=async()=>{Vl(!0),at("");try{K(await oa("preview",{copy:_,source:G,target:ul,text:Ll}))}catch(L){at(String(L))}finally{Vl(!1)}};return d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-label":"Couper ou transférer une réponse",children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",_," · ",y]}),d.jsx("h2",{children:"Couper ou transférer"})]}),d.jsx("button",{autoFocus:!0,onClick:rl,children:"Fermer"})]}),d.jsx("p",{children:"Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe."}),Ql&&d.jsx("p",{role:"alert",className:"alert",children:Ql}),d.jsxs("div",{className:"cut-controls",children:[d.jsxs("label",{children:["Destination",d.jsx("select",{value:ul,onChange:L=>Dl(L.target.value),children:Y.filter(L=>L!==G).map(L=>d.jsx("option",{children:L},L))})]}),d.jsxs("label",{children:["Opération",d.jsxs("select",{value:R,onChange:L=>b(L.target.value),children:[d.jsx("option",{value:"cut",children:"Couper la source"}),d.jsx("option",{value:"x>",children:"Déplacer et ajouter"}),d.jsx("option",{value:"->",children:"Copier et ajouter"}),d.jsx("option",{value:"xx",children:"Déplacer et remplacer"}),d.jsx("option",{value:"-x",children:"Copier et remplacer"}),d.jsx("option",{value:"xs",children:"Vider la source"}),d.jsx("option",{value:"sx",children:"Vider la cible"}),d.jsx("option",{value:"ss",children:"Conserver les deux"})]})]}),R==="cut"&&d.jsxs(d.Fragment,{children:[d.jsxs("label",{children:["Coupe : ",M,"%",d.jsx("input",{type:"range",min:"1",max:"99",step:"0.1",value:M,onChange:L=>_l(Number(L.target.value))})]}),d.jsxs("label",{children:["Conserver à la source",d.jsxs("select",{value:Xl,onChange:L=>Ul(L.target.value),children:[d.jsx("option",{value:"1",children:"La partie haute"}),d.jsx("option",{value:"2",children:"La partie basse"})]})]}),d.jsxs("label",{children:["À la destination",d.jsxs("select",{value:il,onChange:L=>et(L.target.value),children:[d.jsx("option",{value:">",children:"Ajouter le fragment"}),d.jsx("option",{value:"x",children:"Remplacer le corps"})]})]})]}),d.jsxs("label",{className:"check",children:[d.jsx("input",{type:"checkbox",checked:Ol,onChange:L=>gt(L.target.checked)}),"Placer avant la cible"]})]}),d.jsxs("div",{className:"compare",children:[d.jsxs("div",{children:[d.jsxs("h3",{children:["Source · ",G]}),d.jsxs("div",{className:"cut-image",onClick:L=>{if(R==="cut"){const hl=L.currentTarget.getBoundingClientRect();_l(Math.min(99,Math.max(1,Math.round((L.clientY-hl.top)/hl.height*1e3)/10)))}},children:[d.jsx("img",{src:Gi(_,G,!0,q),alt:"Corps source sans en-tête"}),R==="cut"&&d.jsx("div",{className:"cut-line",style:{top:M+"%"},children:d.jsxs("span",{children:[M,"%"]})})]})]}),d.jsxs("div",{children:[d.jsxs("h3",{children:["Cible · ",ul]}),d.jsx(bh,{copy:_,label:ul,revision:q},ul)]})]}),d.jsx("code",{className:"instruction-line",children:pl}),d.jsxs("div",{className:"modal-actions",children:[d.jsx("button",{disabled:F||!ul,onClick:jt,children:F?"Calcul de l’aperçu…":"Aperçu du résultat"}),d.jsx("button",{className:"primary",disabled:!xl||F,onClick:()=>Gl(Ll),children:"Ajouter au brouillon"})]}),xl&&d.jsxs(d.Fragment,{children:[d.jsx("h3",{children:"Résultat proposé après toutes les instructions du brouillon"}),d.jsx("div",{className:"compare",children:[G,ul].map(L=>d.jsxs("div",{children:[d.jsx("h4",{children:L}),xl[L]?d.jsx("img",{src:xl[L],alt:"Aperçu "+L}):d.jsx("p",{className:"empty",children:"Corps vide"})]},L))})]}),d.jsx("p",{className:"muted",children:"Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes »."})]})})}function bh({copy:_,label:G,revision:Y}){const[y,q]=Sl.useState(!1);return y?d.jsx("p",{className:"empty",children:"Pas de corps existant. Le transfert peut remplir cette cible."}):d.jsx("img",{src:Gi(_,G,!0,Y),alt:"Corps cible sans en-tête",onError:()=>q(!0)})}yh.createRoot(document.getElementById("root")).render(d.jsx(hh,{}));
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Copienator · Finalisation</title> <script type="module" crossorigin src="./assets/index-B8v-OSfB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B6ukjHTb.css">
|
||||
</head><body><div id="root"></div></body></html>
|
||||
+186
-4
@@ -279,6 +279,7 @@ class CopienatorApp(tk.Tk):
|
||||
self.step_by_id = {step.id: step for step in self.steps}
|
||||
self.state_store = StateStore()
|
||||
self.runner = ProcessRunner()
|
||||
self.web_session = None
|
||||
self.active_step_id: str | None = None
|
||||
self.current_step: StepDefinition | None = None
|
||||
self.arg_vars: dict[str, tk.Variable] = {}
|
||||
@@ -359,6 +360,7 @@ class CopienatorApp(tk.Tk):
|
||||
top, text="Ouvrir l’énoncé", command=self._open_statement_pdf
|
||||
)
|
||||
self.open_statement_button.grid(row=0, column=5, padx=(6, 0))
|
||||
ttk.Button(top, text="Finalisation web…", command=self._open_finalisation_web).grid(row=0, column=6, padx=(6, 0))
|
||||
ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=7, padx=(6, 0))
|
||||
|
||||
ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0))
|
||||
@@ -536,6 +538,107 @@ class CopienatorApp(tk.Tk):
|
||||
status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3))
|
||||
status.grid(row=3, column=0, sticky="ew")
|
||||
|
||||
def _open_finalisation_web(self) -> None:
|
||||
if self.web_session is not None or self.runner.running or self.active_step_id or self.batch_monitor.active:
|
||||
messagebox.showinfo("Traitement en cours", "Terminez le traitement ou la surveillance en cours avant d’ouvrir la finalisation web.")
|
||||
return
|
||||
if not self.evaluation:
|
||||
messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.")
|
||||
return
|
||||
self._save_current_form()
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("Finalisation web")
|
||||
dialog.transient(self)
|
||||
dialog.resizable(False, False)
|
||||
frame = ttk.Frame(dialog, padding=24)
|
||||
frame.pack(fill="both", expand=True)
|
||||
text = tk.StringVar(value="Choisissez le mode utilisé pour lire les annotations manuscrites.")
|
||||
ttk.Label(frame, textvariable=text, wraplength=500).pack(pady=(0, 12))
|
||||
mode = tk.StringVar(value=annotation_mode_for_reader(self.state_store.step("read_annotations")))
|
||||
choice = ttk.Combobox(frame, textvariable=mode, values=("BGnot", "Bnot"), state="readonly")
|
||||
choice.pack(fill="x", pady=8)
|
||||
url = tk.StringVar()
|
||||
link = ttk.Entry(frame, textvariable=url, state="readonly", width=65)
|
||||
link.pack(fill="x", pady=8)
|
||||
|
||||
def open_browser():
|
||||
import webbrowser
|
||||
if self.web_session:
|
||||
webbrowser.open(self.web_session.url)
|
||||
|
||||
def restore():
|
||||
session = self.web_session
|
||||
self.web_session = None
|
||||
dialog.grab_release()
|
||||
dialog.destroy()
|
||||
if session:
|
||||
data = session.service.state()
|
||||
reader = self.state_store.step("read_annotations")
|
||||
values = dict(reader.get("values", {}))
|
||||
values["update_score"] = True
|
||||
if data["dirty"] or session.service.job["status"] != "idle":
|
||||
self.state_store.invalidate_after(self._progression_ids("read_annotations"), "read_annotations")
|
||||
self.state_store.update_step("read_annotations", variant="standard" if session.service.mode == "Bnot" else "grouped")
|
||||
if data["dirty"] or session.service.job["status"] == "failed":
|
||||
self.state_store.update_step("read_annotations", status="stale", values=values)
|
||||
self.state_store.update_step("giving_names", status="stale")
|
||||
elif session.service.job["status"] == "success":
|
||||
self.state_store.update_step("read_annotations", status="success", values=values)
|
||||
self.state_store.update_step("giving_names", status="detected")
|
||||
self._populate_tree()
|
||||
self._render_step()
|
||||
self._update_controls()
|
||||
|
||||
def poll():
|
||||
session = self.web_session
|
||||
if session is None:
|
||||
return
|
||||
if not session.alive:
|
||||
restore()
|
||||
return
|
||||
running = session.service.job["running"]
|
||||
close_button.configure(state="disabled" if running else "normal")
|
||||
text.set("Régénération en cours. Le GUI reste verrouillé jusqu’à la fin." if running else
|
||||
"Finalisation ouverte dans le navigateur. Le GUI est verrouillé pendant cette session.")
|
||||
dialog.after(300, poll)
|
||||
|
||||
def stop():
|
||||
if self.web_session:
|
||||
try:
|
||||
self.web_session.request_stop()
|
||||
text.set("Fermeture du serveur…")
|
||||
except ValueError as exc:
|
||||
messagebox.showinfo("Traitement en cours", str(exc), parent=dialog)
|
||||
else:
|
||||
restore()
|
||||
|
||||
def start():
|
||||
try:
|
||||
from copienator.finalisation_server import WebSession
|
||||
self.web_session = WebSession(self.evaluation, mode.get())
|
||||
except Exception as exc:
|
||||
messagebox.showerror("Finalisation web", str(exc), parent=dialog)
|
||||
return
|
||||
choice.configure(state="disabled")
|
||||
start_button.configure(state="disabled")
|
||||
url.set(self.web_session.url)
|
||||
browser_button.configure(state="normal")
|
||||
close_button.configure(text="Arrêter le serveur et reprendre")
|
||||
open_browser()
|
||||
poll()
|
||||
|
||||
actions = ttk.Frame(frame)
|
||||
actions.pack(pady=12)
|
||||
start_button = ttk.Button(actions, text="Ouvrir", command=start)
|
||||
start_button.pack(side="left", padx=4)
|
||||
browser_button = ttk.Button(actions, text="Rouvrir le navigateur", command=open_browser, state="disabled")
|
||||
browser_button.pack(side="left", padx=4)
|
||||
close_button = ttk.Button(actions, text="Annuler", command=stop)
|
||||
close_button.pack(side="left", padx=4)
|
||||
dialog.protocol("WM_DELETE_WINDOW", stop)
|
||||
dialog.grab_set()
|
||||
dialog.focus_set()
|
||||
|
||||
def _show_diagnostics(self) -> None:
|
||||
checks = collect_diagnostics(
|
||||
self.show_personal_steps, self.api_key_var.get(), self.evaluation
|
||||
@@ -818,7 +921,7 @@ class CopienatorApp(tk.Tk):
|
||||
if not step:
|
||||
return
|
||||
self._rendering = True
|
||||
if step.id in {"personal_read", "edit_scores", "general_remarks"}:
|
||||
if step.id in {"personal_read", "edit_scores", "general_remarks", "remove_copy"}:
|
||||
self.command_box.grid_remove()
|
||||
else:
|
||||
self.command_box.grid()
|
||||
@@ -894,7 +997,7 @@ class CopienatorApp(tk.Tk):
|
||||
continue
|
||||
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
|
||||
if step.id == 'statement_persp' and spec.name == 'delete_persp':
|
||||
value = False # Deletion is a one-run choice, never a saved default.
|
||||
value = spec.default # Reset on entry; retries in this form keep completed results.
|
||||
if step.id == 'statement' and spec.name == 'restart':
|
||||
value = False
|
||||
if step.id == "final_score" and spec.name == "copy_merged_histogramme" and spec.name not in values:
|
||||
@@ -942,6 +1045,9 @@ class CopienatorApp(tk.Tk):
|
||||
ttk.Button(self.form, text='Actualiser les barèmes présents',
|
||||
command=self._refresh_persp_inventory).grid(row=row, column=1, sticky='w')
|
||||
row += 1
|
||||
ttk.Button(self.form, text='Ouvrir le dossier Persp',
|
||||
command=self._open_persp).grid(row=row, column=1, sticky='w', pady=(8, 0))
|
||||
row += 1
|
||||
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
|
||||
self._refresh_persp_inventory()
|
||||
|
||||
@@ -988,6 +1094,8 @@ class CopienatorApp(tk.Tk):
|
||||
row += 1
|
||||
|
||||
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 == "remove_copy":
|
||||
self.run_button.configure(text="Supprimer la copie sélectionnée")
|
||||
if step.id == "personal_create":
|
||||
self.run_button.configure(text="Marquer Réussie")
|
||||
if step.id == "personal_read":
|
||||
@@ -1015,6 +1123,31 @@ class CopienatorApp(tk.Tk):
|
||||
self.persp_inventory_label.configure(text=text)
|
||||
|
||||
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
|
||||
if step.id == "remove_copy":
|
||||
from copienator.copy_removal import available_copies
|
||||
self.removal_copy_var = tk.StringVar()
|
||||
ttk.Label(self.form, text="Copie à supprimer").grid(row=row, column=0, sticky="w")
|
||||
choices = available_copies(self.evaluation) if self.evaluation else []
|
||||
combo = ttk.Combobox(self.form, textvariable=self.removal_copy_var,
|
||||
values=choices, state="readonly")
|
||||
combo.grid(row=row, column=1, sticky="ew")
|
||||
self.removal_preview = tk.Text(self.form, height=7, wrap="word", state="disabled")
|
||||
self.removal_preview.grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=8)
|
||||
def preview(*_args):
|
||||
from copienator.copy_removal import removal_plan
|
||||
try:
|
||||
plan = removal_plan(self.evaluation, self.removal_copy_var.get())
|
||||
text = "Retirer :\n" + "\n".join(str(p.relative_to(plan.root)) for p in plan.removed)
|
||||
if plan.updated:
|
||||
text += "\nMettre à jour :\n" + "\n".join(str(p.relative_to(plan.root)) for p in plan.updated)
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
text = str(error)
|
||||
self.removal_preview.configure(state="normal")
|
||||
self.removal_preview.delete("1.0", "end")
|
||||
self.removal_preview.insert("1.0", text)
|
||||
self.removal_preview.configure(state="disabled")
|
||||
combo.bind("<<ComboboxSelected>>", preview)
|
||||
return row + 2
|
||||
if step.id == "statement_groups" or (
|
||||
step.id == "annotation" and self.variant_var.get() == "grouped"
|
||||
):
|
||||
@@ -1149,7 +1282,7 @@ class CopienatorApp(tk.Tk):
|
||||
summary = str(exc)
|
||||
ttk.Label(self.form, text=summary, wraplength=600, justify="left").grid(row=row, column=0, columnspan=2, sticky="w")
|
||||
return row + 1
|
||||
if step.id in {"review_persp", "correction"}:
|
||||
if step.id == "correction":
|
||||
row = self._correction_folder_buttons(row)
|
||||
copy_selection_steps = {
|
||||
"page_splitter",
|
||||
@@ -1332,6 +1465,11 @@ class CopienatorApp(tk.Tk):
|
||||
|
||||
def _open_persp(self) -> None:
|
||||
evaluation = self.evaluation
|
||||
if self.current_step and self.current_step.id == "statement_persp":
|
||||
target = self.arg_vars.get("target")
|
||||
value = str(target.get()).strip() if target else ""
|
||||
if value:
|
||||
evaluation = (self.repository / value).resolve()
|
||||
self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp")
|
||||
|
||||
def _open_label_groups(self) -> None:
|
||||
@@ -1617,6 +1755,8 @@ class CopienatorApp(tk.Tk):
|
||||
return True
|
||||
|
||||
def _run_current_step(self, *, syncthing_ready=False, review_command=None, orientation_repair=False) -> None:
|
||||
if self.web_session is not None:
|
||||
return
|
||||
step = self.current_step
|
||||
evaluation = self.evaluation
|
||||
if orientation_repair and (not step or step.id != 'labels'):
|
||||
@@ -1633,6 +1773,9 @@ class CopienatorApp(tk.Tk):
|
||||
if self.active_step_id or self.runner.running:
|
||||
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant d’en lancer un autre.")
|
||||
return
|
||||
if step.id == "remove_copy":
|
||||
self._remove_selected_copy()
|
||||
return
|
||||
if step.id == "refaire_selection":
|
||||
self._save_refaire_selection()
|
||||
return
|
||||
@@ -1788,7 +1931,8 @@ class CopienatorApp(tk.Tk):
|
||||
return
|
||||
self._save_current_form()
|
||||
# Score edits already invalidate downstream steps when Set saves a change.
|
||||
if self.current_step.id not in {"edit_scores", "general_remarks"}:
|
||||
if self.current_step.id not in {"edit_scores", "general_remarks"} and not (
|
||||
status == "skipped" and self.current_step.skip_on_forward):
|
||||
self._invalidate_after_step(self.current_step, self._current_variant().id)
|
||||
self.state_store.update_step(self.current_step.id, status=status)
|
||||
history: dict[str, object] = {
|
||||
@@ -1805,6 +1949,32 @@ class CopienatorApp(tk.Tk):
|
||||
f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}."
|
||||
)
|
||||
|
||||
def _remove_selected_copy(self) -> None:
|
||||
from copienator.copy_removal import removal_plan, remove_copy
|
||||
copy = self.removal_copy_var.get()
|
||||
try:
|
||||
plan = removal_plan(self.evaluation, copy)
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
messagebox.showerror("Suppression impossible", str(error))
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Supprimer une copie",
|
||||
f"Supprimer {copy} et ses artefacts ?\n\n"
|
||||
f"{len(plan.removed)} fichiers ou dossiers retirés ; {len(plan.updated)} fichiers partagés mis à jour.\n"
|
||||
"Les originaux seront sauvegardés dans BackUp avant toute modification.\n"
|
||||
"Les autres copies ne seront pas renumérotées. Les lots de correction existants "
|
||||
"devront être recréés ; les étapes suivantes seront à revalider.",
|
||||
):
|
||||
return
|
||||
try:
|
||||
backup = remove_copy(self.evaluation, copy)
|
||||
except Exception as error:
|
||||
messagebox.showerror("Suppression impossible", str(error))
|
||||
return
|
||||
self._mark_step("success", reason=f"{copy} supprimée ; sauvegarde : {backup}")
|
||||
self._render_step()
|
||||
self.info_var.set(f"{copy} supprimée — sauvegarde : {backup}")
|
||||
|
||||
def _validate_rename_step(self) -> None:
|
||||
if (not self.current_step or self.current_step.id != "rename"
|
||||
or not self.state_store.evaluation or self.active_step_id or self.runner.running):
|
||||
@@ -2149,6 +2319,7 @@ class CopienatorApp(tk.Tk):
|
||||
self._render_step()
|
||||
if step_id == 'statement_persp':
|
||||
self._refresh_persp_inventory()
|
||||
return
|
||||
if step_id == "statement" and self._latex_warning_paths:
|
||||
self.after_idle(self._show_current_latex_warnings)
|
||||
if step_id == "personal_read":
|
||||
@@ -2289,6 +2460,14 @@ class CopienatorApp(tk.Tk):
|
||||
target = max(0, min(len(ids) - 1, index + delta))
|
||||
if target == index:
|
||||
return
|
||||
while delta > 0 and target < len(ids) - 1 and self.step_by_id[ids[target]].skip_on_forward:
|
||||
skipped = ids[target]
|
||||
entry = self.state_store.step(skipped)
|
||||
if not entry.get("status"):
|
||||
self.state_store.update_step(skipped, status="skipped", visited=True)
|
||||
if self.tree.exists(skipped):
|
||||
self.tree.item(skipped, values=(STATUS_LABELS["skipped"],))
|
||||
target += 1
|
||||
self.tree.selection_set(ids[target])
|
||||
self.tree.see(ids[target])
|
||||
|
||||
@@ -2328,6 +2507,9 @@ class CopienatorApp(tk.Tk):
|
||||
self.tree.see("correction")
|
||||
|
||||
def _on_close(self) -> None:
|
||||
if self.web_session is not None:
|
||||
messagebox.showinfo("Finalisation web", "Arrêtez le serveur depuis la fenêtre de finalisation avant de fermer le GUI.")
|
||||
return
|
||||
if self.runner.running:
|
||||
if not messagebox.askyesno(
|
||||
"Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?"
|
||||
|
||||
@@ -13,109 +13,7 @@ from copienator import EvaluationWorkspace
|
||||
from copienator.platform import open_path, safe_filename
|
||||
|
||||
|
||||
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NameIssue:
|
||||
folder: Path
|
||||
name: str
|
||||
copy_id: str
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||||
"""Return unknown and duplicated names currently present in A Rendre."""
|
||||
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||||
parsed: list[tuple[Path, str, str]] = []
|
||||
if return_dir.is_dir():
|
||||
for folder in return_dir.iterdir():
|
||||
if not folder.is_dir() or folder.is_symlink():
|
||||
continue
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if match:
|
||||
parsed.append(
|
||||
(folder, match.group("name"), match.group("copy_id"))
|
||||
)
|
||||
|
||||
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||||
issues = []
|
||||
for folder, name, copy_id in parsed:
|
||||
reasons = []
|
||||
if name.casefold() == "unknown":
|
||||
reasons.append("nom inconnu")
|
||||
elif counts[name.casefold()] > 1:
|
||||
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||||
if reasons:
|
||||
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||||
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||||
|
||||
|
||||
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||||
workspace = EvaluationWorkspace(evaluation)
|
||||
filename = f"Copie{copy_id}.pdf"
|
||||
for candidate in (
|
||||
workspace.original_copies_dir / filename,
|
||||
workspace.copies_dir / filename,
|
||||
workspace.root / filename,
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||||
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||||
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||||
if folder.parent.name != "A Rendre":
|
||||
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||||
|
||||
entered_name = requested_name.strip()
|
||||
if not entered_name:
|
||||
raise ValueError("Saisissez un nom.")
|
||||
name = safe_filename(entered_name, fallback="")
|
||||
if not name:
|
||||
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||||
|
||||
copy_id = match.group("copy_id")
|
||||
destination = folder.with_name(f"{name} ({copy_id})")
|
||||
if destination != folder and os.path.lexists(destination):
|
||||
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||||
|
||||
assets: dict[str, Path] = {}
|
||||
for child in folder.iterdir():
|
||||
suffix = child.suffix.casefold()
|
||||
if suffix not in {".jpg", ".pdf"}:
|
||||
continue
|
||||
if suffix in assets:
|
||||
raise ValueError(
|
||||
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||||
)
|
||||
assets[suffix] = child
|
||||
|
||||
renames: list[tuple[Path, Path]] = []
|
||||
for suffix, source in assets.items():
|
||||
target = source.with_name(f"{name}{suffix}")
|
||||
if target != source and os.path.lexists(target):
|
||||
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||||
if target != source:
|
||||
renames.append((source, target))
|
||||
|
||||
completed: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for source, target in renames:
|
||||
source.rename(target)
|
||||
completed.append((source, target))
|
||||
if destination != folder:
|
||||
folder.rename(destination)
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
if os.path.lexists(target) and not os.path.lexists(source):
|
||||
target.rename(source)
|
||||
raise
|
||||
return destination
|
||||
from copienator.return_identity import (RETURN_FOLDER_PATTERN, NameIssue, find_name_issues, original_copy_path, rename_return_copy)
|
||||
|
||||
|
||||
class GivingNamesPanel(ttk.LabelFrame):
|
||||
|
||||
@@ -32,33 +32,7 @@ def score_path_for_id(evaluation: Path, copy_id: str) -> Path:
|
||||
return matches[0]
|
||||
|
||||
|
||||
def read_scores(path: Path) -> dict:
|
||||
scores = read_json(path)
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
|
||||
return scores
|
||||
|
||||
|
||||
def set_score(path: Path, label: str, value: str) -> str:
|
||||
try:
|
||||
number = Decimal(value.strip().replace(",", "."))
|
||||
except InvalidOperation:
|
||||
raise ValueError("Le score doit être un nombre.") from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise ValueError("Le score doit être un nombre positif ou nul.")
|
||||
previous = ""
|
||||
|
||||
def update(scores):
|
||||
nonlocal previous
|
||||
if not isinstance(scores, dict) or label not in scores:
|
||||
raise ValueError(f"Label absent de score.json : {label}")
|
||||
previous = str(scores[label])
|
||||
scores[label] = str(number)
|
||||
|
||||
# A Rendre/score.json is often a symlink to the annotation score file.
|
||||
# Resolve it before atomic replacement so the link continues to work.
|
||||
atomic_update_json(path.resolve(strict=True), update, default_factory=dict)
|
||||
return previous
|
||||
from copienator.score_editing import read_scores, set_score
|
||||
|
||||
|
||||
class ScoreEditorPanel(ttk.Frame):
|
||||
|
||||
+17
-20
@@ -53,6 +53,7 @@ class StepDefinition:
|
||||
personal: bool = False
|
||||
requires: tuple[str, ...] = ()
|
||||
artifacts: tuple[str, ...] = ()
|
||||
skip_on_forward: bool = False
|
||||
auto_start_first_visit: bool = False
|
||||
skip_for_live_correction: bool = False
|
||||
skip_without_manual_conflicts: bool = False
|
||||
@@ -194,27 +195,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
"statement_persp", "Prétraitement de l’énoncé", "Générer les barèmes manquants avec Gemini",
|
||||
"Génère uniquement les barèmes manquants dans Persp et conserve ceux déjà présents. "
|
||||
"statement_persp", "Prétraitement de l’énoncé", "Générer les barèmes avec Gemini",
|
||||
"Par défaut, supprime les barèmes actuels dans Persp et tous les régénère. "
|
||||
"Décochez Delete current Persp pour générer uniquement les barèmes manquants et conserver ceux déjà présents. "
|
||||
"Les fichiers vides ou contenant seulement le label sont considérés comme manquants. "
|
||||
"Après un échec, relancez cette étape pour compléter les barèmes. "
|
||||
"Cochez Delete current Persp pour supprimer les barèmes actuels au lancement et tous les régénérer.",
|
||||
"Après la génération, ouvrez Persp pour vérifier les résultats, puis passez à l’étape suivante.",
|
||||
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec('delete_persp', 'Delete current Persp', kind='bool',
|
||||
flag='--delete-persp', default=False,
|
||||
flag='--delete-persp', default=True,
|
||||
help='Supprime tous les fichiers actuels de Persp au lancement, y compris les barèmes modifiés manuellement. Puis régénère tous les barèmes.')),
|
||||
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
"review_persp",
|
||||
"Prétraitement de l’énoncé",
|
||||
"Relire les barèmes",
|
||||
"Étape manuelle facultative : vérifier et modifier les instructions de correction.",
|
||||
(manual("review"),),
|
||||
optional=True,
|
||||
requires=("Persp",),
|
||||
),
|
||||
StepDefinition(
|
||||
"rotate",
|
||||
"Prétraitement des copies",
|
||||
@@ -252,6 +245,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
),
|
||||
StepDefinition(
|
||||
"remove_copy",
|
||||
"Prétraitement des copies",
|
||||
"Supprimer une copie",
|
||||
"Étape facultative, ignorée au premier passage. Sélectionnez une copie pour retirer ses fichiers "
|
||||
"et artefacts. Chaque fichier supprimé ou modifié est sauvegardé dans BackUp, dans l’évaluation. "
|
||||
"Les autres copies gardent leur numéro. Les lots de correction partagés devront être recréés.",
|
||||
(CommandVariant("default", "Suppression avec sauvegarde", None, "manual"),),
|
||||
optional=True,
|
||||
skip_on_forward=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"page_splitter",
|
||||
"Prétraitement des copies",
|
||||
@@ -299,16 +303,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Découper une partie à gauche pour détection des labels",
|
||||
"Produit les images de la partie gauche des copies. "
|
||||
"La position est estimée automatiquement sur plusieurs pages de même largeur ; "
|
||||
"Les 10 % de bandes d’écriture les plus à gauche sont ignorées ; "
|
||||
"la découpe retire le maximum entre la moitié de la marge et la marge moins 2 cm. "
|
||||
"Si la marge est inférieure à 20 mm, elle commence au bord gauche. "
|
||||
"Deux pages consécutives sans marge imposent le bord gauche pour la suite. "
|
||||
"La bande est élargie de 10 %. "
|
||||
"Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. "
|
||||
"Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone "
|
||||
"de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. "
|
||||
"1 utilise les pages entières, Entrée valide, P/p revient à la copie précédente et ! signale une erreur. "
|
||||
"La validation efface uniquement le signalement de découpe, sans effacer celui des labels."
|
||||
f"{personnal_binding} "
|
||||
"Une copie précise peut être ciblée.",
|
||||
(python("default", "Découpe", "crop-labels"),),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Finalisation dans le navigateur
|
||||
|
||||
La finalisation web est une **alternative facultative** aux quatre panneaux de
|
||||
finalisation. Les panneaux existants et les commandes restent disponibles.
|
||||
Elle fonctionne sur cet ordinateur, sans hébergement ni nouvel appel à Gemini.
|
||||
|
||||
## Ouvrir
|
||||
|
||||
Après **Lire les annotations manuscrites**, cliquez sur **Finalisation web…**
|
||||
en haut du GUI, choisissez **Bnot** ou **BGnot**, puis **Ouvrir**. Le GUI reste
|
||||
verrouillé par une fenêtre modale tant que le serveur fonctionne. Le bouton
|
||||
**Arrêter le serveur et reprendre** ferme la session et recharge les éditeurs.
|
||||
Fermer seulement l’onglet du navigateur ne ferme pas le serveur.
|
||||
|
||||
La commande équivalente est :
|
||||
|
||||
```sh
|
||||
python -m copienator finalise-web CHEMIN_EVALUATION --annotation-dir BGnot
|
||||
```
|
||||
|
||||
`--no-browser` affiche le lien sans ouvrir automatiquement le navigateur.
|
||||
Les commandes utilisant le gestionnaire CLI commun respectent le verrou de
|
||||
l’évaluation. Une régénération en cours doit finir avant l’arrêt normal du serveur.
|
||||
Le verrou système est libéré à l’arrêt du processus ; le fichier de verrou
|
||||
restant sur disque n’empêche pas la prochaine ouverture.
|
||||
|
||||
## Vérifier et modifier
|
||||
|
||||
- **Copie** seule : toutes les réponses de cette copie.
|
||||
- **Label** seul : toutes les copies pour ce label.
|
||||
- Les deux : leur intersection. Sans filtre, la page présente toutes les réponses
|
||||
par lots de vingt, avec chargement différé des images.
|
||||
- Cliquez sur une réponse pour l’agrandir. Les liens donnent accès au contexte,
|
||||
à l’énoncé, au corrigé et au PDF final lorsqu’ils existent.
|
||||
- Modifiez scores et remarques personnelles sous les images. Les remarques
|
||||
acceptent le LaTeX utilisé dans le workflow existant.
|
||||
- **Identités et remarques générales** permet de corriger les noms, d’ouvrir
|
||||
l’originale et d’ajouter, supprimer ou réordonner les remarques de chaque copie.
|
||||
- **Couper / transférer** montre exclusivement les corps de réponse. Cliquez sur
|
||||
la source pour placer la coupe, ou réglez son pourcentage. Choisissez la cible,
|
||||
la partie conservée et le placement, puis vérifiez **Aperçu du résultat**.
|
||||
|
||||
Les saisies locales sont conservées lors d’un changement de filtre. Enregistrez
|
||||
les modifications avant de régénérer. Si une autre page a changé les fichiers,
|
||||
une sauvegarde périmée est refusée ; **Recharger les données** actualise les
|
||||
valeurs enregistrées en conservant les saisies locales à réexaminer.
|
||||
|
||||
## Brouillon, application et régénération
|
||||
|
||||
**Ajouter au brouillon** ne modifie pas les images. **Enregistrer le brouillon**
|
||||
écrit le même `annotation_modifications.txt` que le GUI. Le fichier
|
||||
`Bnot/annotation_modifications_applied.txt` ou son équivalent BGnot reste le
|
||||
relevé des instructions effectivement appliquées.
|
||||
|
||||
**Régénérer les sorties** conserve par défaut ces instructions appliquées. Cochez
|
||||
**Appliquer aussi le brouillon de transferts** pour remplacer les instructions
|
||||
appliquées par le brouillon enregistré. Retirer une ligne du brouillon puis
|
||||
l’appliquer annule le transfert correspondant. Les contraintes existantes restent
|
||||
valables : une instruction par source, une cible ne peut pas être source, aucun
|
||||
déplacement automatique des scores ou des en-têtes.
|
||||
|
||||
La régénération actualise les réponses, les documents combinés et **A Rendre**
|
||||
selon les options de sortie existantes, en conservant les noms personnalisés.
|
||||
Le journal indique les erreurs et les résultats incomplets. La chaîne s’arrête
|
||||
à la première étape non réussie ; les modifications enregistrées restent
|
||||
récupérables pour une nouvelle tentative. Les publications gardent les garanties
|
||||
transactionnelles des moteurs existants (par copie pour la lecture, ensemble des
|
||||
copies concernées pour les transferts), pas une transaction globale de toute
|
||||
l’évaluation.
|
||||
|
||||
## Compatibilité et état de lecture
|
||||
|
||||
Les scores restent dans le dossier d’annotations choisi ; les remarques utilisent
|
||||
`custom_remarks.json` et `general_remarks.json`. Les éditeurs existants utilisent
|
||||
la même source de scores, y compris lorsque **A Rendre** contient des copies de
|
||||
fichiers plutôt que des liens. La préparation des retours mémorise cette source
|
||||
et retrouve les dossiers renommés par leur identifiant de copie.
|
||||
|
||||
Les lecteurs enregistrent maintenant `review_snapshot.json` et des images
|
||||
`.review-note-*.png` : les données de correction après interprétation des cases,
|
||||
la géométrie et les couches manuscrites, avant ajout des remarques personnelles.
|
||||
Le navigateur utilise ces données pour reconstruire les en-têtes sans relire les
|
||||
PDF manuscrits ni accumuler les remarques. Les reprises sélectives fusionnent
|
||||
leurs seules questions avec l’état précédemment lu. Les baselines sans perte
|
||||
restent la référence des coupes et transferts.
|
||||
|
||||
Pour une ancienne évaluation sans cet état, une régénération web ordinaire peut
|
||||
le créer avec le lecteur existant et les scores conservés. En présence de BRnot,
|
||||
la régénération refuse cette conversion automatique : il faut disposer d’une
|
||||
lecture du passage principal puis de la reprise avec cette version pour conserver
|
||||
l’interprétation complète. L’affichage et les éditeurs restent accessibles.
|
||||
|
||||
Si vous importez de nouvelles annotations ou refaites la correction/découpe,
|
||||
repassez par **Lire les annotations manuscrites** dans le workflow existant avant
|
||||
de revenir au navigateur. `regenerate-reviewed` réutilise l’interprétation
|
||||
sauvegardée ; il ne recherche pas de nouvelles annotations.
|
||||
|
||||
Anot reste géré par le workflow existant. La finalisation web concerne Bnot et
|
||||
BGnot, les deux modes qui disposent de corps de réponses éditables.
|
||||
|
||||
## Développement
|
||||
|
||||
L’API FastAPI/Uvicorn est dans `copienator/finalisation_server.py`, les opérations
|
||||
dans `copienator/finalisation.py`. Le client React/TypeScript est dans `web/`.
|
||||
Le paquet Python inclut les fichiers construits dans `copienator/web_static/` :
|
||||
Node n’est pas nécessaire pour utiliser l’application installée.
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Le serveur écoute uniquement sur `127.0.0.1`, sur un port libre, et exige un jeton
|
||||
de session échangé contre un cookie HttpOnly. Les requêtes d’une autre origine
|
||||
sont refusées. Les chemins des documents sont déterminés à partir des identifiants
|
||||
validés, jamais fournis librement par le navigateur.
|
||||
@@ -31,11 +31,13 @@ Choisissez **Analyse avec Gemini**.
|
||||
**Ignorer le cache** recommence l’analyse Gemini : à réserver à une
|
||||
analyse devenue incorrecte ou obsolète.
|
||||
|
||||
### Relire les barèmes — facultatif
|
||||
### Générer les barèmes avec Gemini — facultatif, profil personnel
|
||||
|
||||
**Effet :** permet de vérifier et d’ajuster les instructions qui guideront la correction.
|
||||
**Effet :** régénère les barèmes dans `Persp`. L’option **Delete current Persp** est cochée par défaut ; décochez-la pour conserver les barèmes présents et compléter uniquement les manquants, notamment après un échec.
|
||||
|
||||
Ouvrez les barèmes dans `Persp`. Vérifiez les réponses acceptables, les raisonnements alternatifs et les exigences de justification ; corrigez les consignes trop sévères, ambiguës ou erronées. Enregistrez vos modifications avant de marquer l’étape terminée.
|
||||
Les groupes existants sont réunis dans leur ordre en requêtes de six questions au maximum, sans jamais couper un groupe. Un groupe de plus de six questions est envoyé seul. Quatre requêtes peuvent être traitées simultanément. Ces valeurs sont fixes et le fichier `label_groups` reste inchangé. Les résultats des requêtes réussies sont conservés même si une autre échoue.
|
||||
|
||||
L’étape reste ouverte après la génération. Cliquez sur **Ouvrir le dossier Persp**, sous les autres contrôles, pour vérifier les réponses acceptables, les raisonnements alternatifs et les exigences de justification. Enregistrez vos modifications, puis passez à l’étape suivante.
|
||||
|
||||
## Prétraitement des copies
|
||||
|
||||
@@ -51,6 +53,14 @@ Exécutez seulement si toutes les copies sont à l’envers.
|
||||
|
||||
Vérifiez qu’un PDF correspond à une copie et que le dossier ne contient pas de documents parasites. Exécutez puis contrôlez le nombre de copies.
|
||||
|
||||
### Supprimer une copie — facultatif
|
||||
|
||||
Cette étape suit le renommage et est ignorée lorsque vous avancez dans le parcours. Cliquez dessus dans la liste, ou revenez en arrière, pour l’utiliser.
|
||||
|
||||
Choisissez une copie, examinez les fichiers affichés, puis cliquez sur **Supprimer la copie sélectionnée** et confirmez. Les scans, labels, découpages, annotations et livrables de cette copie sont retirés sans renuméroter les autres. Les données de correction partagées sont mises à jour ; les anciennes requêtes par lots et les grilles de noms sont retirées pour éviter de réintroduire la copie.
|
||||
|
||||
Chaque fichier supprimé ou modifié est sauvegardé dans `BackUp/<date>-CopieXX-<identifiant>/files`, avec son chemin d’origine et un `manifest.json`. Le nettoyage final conserve ces sauvegardes. Les étapes suivantes passent à **À revalider** ; les lots de correction retirés doivent être recréés. Les fichiers déjà exportés hors de l’évaluation restent à gérer séparément.
|
||||
|
||||
### Séparer et réordonner les pages
|
||||
|
||||
**Effet :** Découpe les scans A3 en pages A4 correctement ordonnées.
|
||||
@@ -635,3 +645,12 @@ Vérifiez le **Passage principal**, puis exécutez. Contrôlez une question refa
|
||||
**Effet :** supprime définitivement les fichiers de travail et la progression de l’interface en conservant les livrables prévus.
|
||||
|
||||
Attendez la fin des relectures et reprises. Vérifiez les résultats de tous les élèves dans `A Rendre` et sauvegardez l’évaluation si vous voulez pouvoir reprendre le travail. Choisissez d’abord **Prévisualiser sans supprimer**, puis examinez la liste. Ne lancez **Nettoyage définitif** qu’une fois certain de ne plus avoir besoin des intermédiaires : les PDF traités, textes de l’énoncé, corrections, journaux et livrables de `A Rendre` sont conservés, mais une reprise exigera de régénérer les données supprimées.
|
||||
|
||||
## Alternative : finalisation dans le navigateur
|
||||
|
||||
Le bouton **Finalisation web…** ouvre une interface locale facultative après la
|
||||
lecture des annotations Bnot ou BGnot. Elle réunit les réponses filtrées par copie
|
||||
et/ou label, les scores, remarques, noms et coupes/transferts. Le GUI est verrouillé
|
||||
pendant la session ; les fichiers restent compatibles avec les panneaux existants.
|
||||
Voir [le guide de finalisation web](finalisation_web.md) pour l’enregistrement,
|
||||
l’application des brouillons, la régénération et les anciennes évaluations.
|
||||
+3
-1
@@ -8,6 +8,8 @@ version = "0.1.0"
|
||||
description = "Workflow assistant for preparing and correcting scanned exams"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"numpy",
|
||||
"opencv-python-headless>=4.8",
|
||||
"pandas",
|
||||
@@ -35,4 +37,4 @@ py-modules = ["default_config"]
|
||||
include = ["copienator*", "copienator_gui*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
copienator = ["data/*.txt"]
|
||||
copienator = ["data/*.txt", "web_static/*", "web_static/assets/*"]
|
||||
@@ -0,0 +1,140 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator.copy_removal import available_copies, removal_plan, remove_copy
|
||||
|
||||
|
||||
class CopyRemovalTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
for name in ('Copies/Copie24.pdf', 'Copies/Copie25.pdf', 'Copies Originales/Copie24.pdf',
|
||||
'Cutleft/Copie24_01.jpg', 'Copies/Copie24/Ex 1.pdf',
|
||||
'BGnot/Copie24/Concat.pdf', 'A Rendre/Antoine (24)/score.json',
|
||||
'.copienator/runs/old/Copies/Copie24.pdf',
|
||||
'BackUp/previous/Copie24.pdf'):
|
||||
self.write(name, b'original bytes')
|
||||
|
||||
def write(self, name, value):
|
||||
p = self.root / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(value)
|
||||
return p
|
||||
|
||||
def test_removes_all_copy_artifacts_and_preserves_neighbors_and_backups(self):
|
||||
self.write('Copies/Copie240.pdf', b'other copy')
|
||||
self.write('Copies/Copie24_01.json', b'{"name": "Antoine"}')
|
||||
self.write('.copienator/copy_errors.json', b'{"Copie24.pdf":{"plotting":"bad"}, "Copie25.pdf":{"plotting":"keep"}}')
|
||||
self.write('correction.json', b'{"Ex 1":[[{"id":"24","result":{"score":3}}, {"id":"25","result":{"score":4}}]]}')
|
||||
self.write('refaire.json', b'[["Copie24", []], ["Copie25", []]]')
|
||||
self.write('general_remarks.json', b'{"24":"remove", "25":"keep"}')
|
||||
self.write('batch_requests_flash.jsonl', b'old payload')
|
||||
self.write('correction_pending_responses.json', b'{"group": "cached raw response"}')
|
||||
self.write('manual_resolutions.txt', b'Copie24 Ex 1 -> Ex 2\nCopie25 Ex 1 -> Ex 2\n')
|
||||
before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}
|
||||
plan = removal_plan(self.root, 'Copie24')
|
||||
backup = remove_copy(self.root, 'Copie24')
|
||||
self.assertEqual(available_copies(self.root), ['Copie25', 'Copie240'])
|
||||
self.assertFalse((self.root/'correction_pending_responses.json').exists())
|
||||
self.assertEqual((self.root/'manual_resolutions.txt').read_text(), 'Copie25 Ex 1 -> Ex 2\n')
|
||||
for p in plan.removed:
|
||||
self.assertFalse(p.exists())
|
||||
for relative in [p.relative_to(self.root) for p in (*plan.removed, *plan.updated) if p.relative_to(self.root) in before]:
|
||||
self.assertEqual((backup / 'files' / relative).read_bytes(), before[relative])
|
||||
self.assertEqual((self.root / 'Copies/Copie25.pdf').read_bytes(), b'original bytes')
|
||||
self.assertEqual((self.root / 'BackUp/previous/Copie24.pdf').read_bytes(), b'original bytes')
|
||||
self.assertEqual(json.loads((self.root / 'correction.json').read_text())['Ex 1'][0], [{'id':'25','result':{'score':4}}])
|
||||
self.assertEqual(json.loads((self.root / 'refaire.json').read_text()), [['Copie25', []]])
|
||||
self.assertEqual(json.loads((self.root / 'general_remarks.json').read_text()), {'25':'keep'})
|
||||
self.assertEqual(json.loads((backup / 'manifest.json').read_text())['status'], 'complete')
|
||||
|
||||
def test_group_removal_preserves_other_students_coordinates(self):
|
||||
path = self.write('Par label/Ex 1/Group_1.json', b'[["24", 50, 100, 1, "Ex 1"], ["25", 160, 210, 1, "Ex 1"]]')
|
||||
Image.new('RGB', (100, 220), 'blue').save(path.with_suffix('.jpg'))
|
||||
remove_copy(self.root, 'Copie24')
|
||||
self.assertEqual(json.loads(path.read_text()), [['25',160,210,1,'Ex 1']])
|
||||
with Image.open(path.with_suffix('.jpg')) as image:
|
||||
self.assertGreater(image.getpixel((20,60))[0], 240)
|
||||
self.assertLess(image.getpixel((20,180))[0], 30)
|
||||
|
||||
def test_failure_restores_already_removed_files(self):
|
||||
from copienator.copy_removal import atomic_write_bytes
|
||||
self.write('refaire.json', b'[["Copie24", []]]')
|
||||
original = (self.root/'Copies/Copie24.pdf').read_bytes()
|
||||
def failing_write(path, data):
|
||||
if path == self.root/'refaire.json':
|
||||
raise OSError('simulated full disk')
|
||||
return atomic_write_bytes(path, data)
|
||||
with patch('copienator.copy_removal.atomic_write_bytes', side_effect=failing_write):
|
||||
with self.assertRaises(OSError):
|
||||
remove_copy(self.root, 'Copie24')
|
||||
self.assertEqual((self.root/'Copies/Copie24.pdf').read_bytes(), original)
|
||||
self.assertEqual((self.root/'refaire.json').read_bytes(), b'[["Copie24", []]]')
|
||||
|
||||
def test_cleanup_keeps_removal_backups(self):
|
||||
from copienator import EvaluationWorkspace
|
||||
from copienator.commands.clean import build_cleanup_plan
|
||||
self.write('correction.json', b'{}')
|
||||
self.write('A Rendre/Antoine (24)/Antoine.jpg', b'returned image')
|
||||
plan = build_cleanup_plan(EvaluationWorkspace(self.root))
|
||||
saved = self.root/'BackUp/previous/Copie24.pdf'
|
||||
self.assertIn(saved, plan.kept_files)
|
||||
self.assertNotIn(saved, plan.deleted_files)
|
||||
self.assertNotIn(saved.parent, plan.deleted_directories)
|
||||
|
||||
def test_invalid_selection_and_external_symlink(self):
|
||||
for invalid in ('../Copie24', 'Copie99', ''):
|
||||
with self.assertRaises(ValueError):
|
||||
removal_plan(self.root, invalid)
|
||||
with tempfile.TemporaryDirectory() as external:
|
||||
p = Path(external)/'Copie24.pdf'
|
||||
p.write_bytes(b'outside')
|
||||
(self.root/'external').symlink_to(external, target_is_directory=True)
|
||||
remove_copy(self.root, 'Copie24')
|
||||
self.assertEqual(p.read_bytes(), b'outside')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
|
||||
class CopyRemovalGuiTests(unittest.TestCase):
|
||||
def test_first_visit_skips_without_invalidating_then_revisit_can_remove(self):
|
||||
from copienator_gui.app import CopienatorApp
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
evaluation = root/'Eval'
|
||||
evaluation.mkdir()
|
||||
(evaluation/'Copie24.pdf').write_bytes(b'copy')
|
||||
app = CopienatorApp(root, False, evaluation)
|
||||
app.update()
|
||||
try:
|
||||
steps = [s.id for s in app.steps]
|
||||
self.assertEqual(steps[steps.index('rename')+1], 'remove_copy')
|
||||
app.state_store.update_step('page_splitter', status='success')
|
||||
step = app.step_by_id['remove_copy']
|
||||
app._move_selection_from('rename', 1)
|
||||
app.update()
|
||||
self.assertEqual(app.current_step.id, 'page_splitter')
|
||||
self.assertEqual(app.state_store.step('remove_copy')['status'], 'skipped')
|
||||
self.assertEqual(app.state_store.step('page_splitter')['status'], 'success')
|
||||
app.current_step = step
|
||||
app._render_step()
|
||||
app._handle_first_visit(step)
|
||||
app.removal_copy_var.set('Copie24')
|
||||
with patch('copienator_gui.app.messagebox.askyesno', return_value=False):
|
||||
app._run_current_step()
|
||||
self.assertTrue((evaluation/'Copie24.pdf').exists())
|
||||
with patch('copienator_gui.app.messagebox.askyesno', return_value=True):
|
||||
app._run_current_step()
|
||||
self.assertFalse((evaluation/'Copie24.pdf').exists())
|
||||
self.assertEqual(app.state_store.step('page_splitter')['status'], 'stale')
|
||||
self.assertEqual(len(list((evaluation/'BackUp').glob('*/manifest.json'))), 1)
|
||||
finally:
|
||||
for callback in app.tk.splitlist(app.tk.call('after', 'info')):
|
||||
app.after_cancel(callback)
|
||||
app.destroy()
|
||||
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO, StringIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.annotation_modifications import APPLIED, DRAFT, stage_baseline
|
||||
from copienator.commands import modify_annotations, giving_names
|
||||
from copienator.evaluation_lock import EvaluationLock
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.finalisation import Finalisation
|
||||
from copienator.finalisation_server import create_app
|
||||
from copienator.return_identity import rename_return_copy
|
||||
from copienator.score_editing import read_scores, set_score
|
||||
|
||||
|
||||
def seed(root, mode='Bnot'):
|
||||
(root / 'Copies' / 'Copie01').mkdir(parents=True, exist_ok=True)
|
||||
(root / 'labels').write_text('Ex 1\nEx 2\nAbsent\n')
|
||||
atomic_write_json(root / 'Copies' / 'Copie01.json', {'name': 'Unknown'})
|
||||
scores = {'Ex 1': '3', 'Ex 2': '4', 'Absent': ''}
|
||||
info = {label: {'present': label != 'Absent', 'not_empty': label != 'Absent', 'touched': False, 'score': score}
|
||||
for label, score in scores.items()}
|
||||
images = {'Ex 1': Image.new('RGB', (100, 120), 'blue'), 'Ex 2': Image.new('RGB', (100, 80), 'green')}
|
||||
output = root / mode / 'Copie01'
|
||||
with staged_files(output) as staging:
|
||||
atomic_write_json(staging / 'score.json', scores)
|
||||
atomic_write_json(staging / 'info.json', info)
|
||||
for label, image in images.items():
|
||||
image.save(staging / f'{label}.jpg')
|
||||
images['Ex 1'].save(staging / 'Concat.jpg')
|
||||
stage_baseline(root, mode, output, staging, images, {'Ex 1': 20, 'Ex 2': 10}, info, [], scores)
|
||||
|
||||
|
||||
class WebTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
seed(self.root)
|
||||
self.service = Finalisation(self.root, 'Bnot', 'worker-token')
|
||||
self.client = TestClient(create_app(self.service, 'browser-token'))
|
||||
self.addCleanup(self.client.close)
|
||||
self.client.get('/?token=browser-token')
|
||||
|
||||
def save(self, kind, value, copy='01', label='Ex 1'):
|
||||
response = self.client.post('/api/change', json={'kind': kind, 'value': value, 'copy': copy,
|
||||
'label': label, 'revision': self.service.revision()})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_session_origin_and_paths(self):
|
||||
outsider = TestClient(create_app(self.service, 'browser-token'))
|
||||
self.assertEqual(outsider.get('/api/state').status_code, 403)
|
||||
self.assertEqual(self.client.get('/api/state', headers={'Host': 'evil.example'}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/api/stop', headers={'Origin':'https://evil.example'}).status_code, 403)
|
||||
self.assertEqual(self.client.get('/api/image', params={'copy':'../', 'label':'Ex 1'}).status_code, 409)
|
||||
self.assertEqual(self.client.get('/api/image', params={'copy':'01', 'label':'../labels'}).status_code, 409)
|
||||
self.assertEqual(self.client.get('/api/document', params={'copy':'01', 'kind':'question','label':'Absent'}).status_code, 404)
|
||||
self.assertEqual(self.client.get('/').status_code, 200)
|
||||
|
||||
def test_browser_and_existing_editor_share_scores_and_remarks(self):
|
||||
for mode in ('Bnot', 'BGnot'):
|
||||
with self.subTest(mode=mode):
|
||||
seed(self.root, mode)
|
||||
self.service = Finalisation(self.root, mode, 'worker-token')
|
||||
self.client = TestClient(create_app(self.service, 'browser-token'))
|
||||
self.client.get('/?token=browser-token')
|
||||
self.save('score', '2,5')
|
||||
score = self.root / mode / 'Copie01/score.json'
|
||||
self.assertEqual(read_scores(score)['Ex 1'], '2.5')
|
||||
set_score(score, 'Ex 1', '1')
|
||||
self.assertEqual(self.service.state()['copies'][0]['answers'][0]['score'], '1')
|
||||
self.save('remark', 'Une remarque $x^2$.')
|
||||
self.save('general', ['Première', 'Deuxième'])
|
||||
self.save('general', ['Deuxième', 'Première'])
|
||||
self.assertEqual(self.service.state()['copies'][0]['general'], ['Deuxième','Première'])
|
||||
self.save('remark', '')
|
||||
self.assertEqual(self.service.state()['copies'][0]['answers'][0]['remark'], '')
|
||||
|
||||
def test_stale_tab_and_busy_writes_rejected(self):
|
||||
old = self.service.revision()
|
||||
self.save('score', '2')
|
||||
response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':old})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.service.job['running'] = True
|
||||
self.assertEqual(self.client.post('/api/stop').status_code, 409)
|
||||
response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':self.service.revision()})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.service.job['running'] = False
|
||||
|
||||
def test_draft_preview_apply_reapply_and_undo(self):
|
||||
text = '### Source: Bnot\nCopie01 Ex 1 c{40}1> Ex 2|\n'
|
||||
before = (self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes()
|
||||
self.save('draft', text)
|
||||
self.assertEqual((self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes(), before)
|
||||
self.assertFalse((self.root / 'Bnot' / APPLIED).exists())
|
||||
result = self.client.post('/api/preview', json={'copy':'01','source':'Ex 1','target':'Ex 2','text':text})
|
||||
self.assertEqual(result.status_code, 200, result.text)
|
||||
import base64
|
||||
rendered = Image.open(BytesIO(base64.b64decode(result.json()['Ex 1'].split(',')[1])))
|
||||
self.assertEqual(rendered.height, 40) # cut uses 100px body, not 120px image
|
||||
modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot')
|
||||
applied = (self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes()
|
||||
modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot')
|
||||
self.assertEqual((self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes(), applied)
|
||||
self.save('draft', '### Source: Bnot\n')
|
||||
self.assertEqual((self.root / 'Bnot' / APPLIED).read_text(), text)
|
||||
modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot')
|
||||
self.assertEqual(Image.open(self.root / 'Bnot/Copie01/Ex 1.jpg').height, 120)
|
||||
|
||||
def test_invalid_draft_preserves_previous_file(self):
|
||||
self.save('draft', '### Source: Bnot\n')
|
||||
for text in ('### Source: BGnot\n', '### Source: Bnot\nCopie01 Ex 1 -> Ex 2|\nCopie01 Ex 2 -> Absent|\n'):
|
||||
response = self.client.post('/api/change', json={'kind':'draft','value':text,'revision':self.service.revision()})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual((self.root / DRAFT).read_text(), '### Source: Bnot\n')
|
||||
|
||||
@patch('copienator.commands.giving_names.configuration.RETURN_ANSWERS_ENABLED', False)
|
||||
def test_names_and_fallback_scores_survive_both_workflows(self):
|
||||
workspace = EvaluationWorkspace(self.root)
|
||||
self.save('name', 'Alice')
|
||||
self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0)
|
||||
folder = rename_return_copy(self.root / 'A Rendre/Alice (01)', 'Alice corrigée')
|
||||
self.assertEqual(self.service.state()['copies'][0]['name'], 'Alice corrigée')
|
||||
self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0)
|
||||
self.assertEqual(list((self.root / 'A Rendre').iterdir()), [folder])
|
||||
score = folder / 'score.json'
|
||||
score.unlink()
|
||||
atomic_write_json(score, {'Ex 1':'3','Ex 2':'4','Absent':''})
|
||||
set_score(score, 'Ex 1', '1')
|
||||
self.assertEqual(read_scores(self.root / 'Bnot/Copie01/score.json')['Ex 1'], '1')
|
||||
self.save('score', '2')
|
||||
self.assertEqual(read_scores(score)['Ex 1'], '2')
|
||||
self.save('name', 'Alice finale')
|
||||
self.assertTrue((self.root / 'A Rendre/Alice finale (01)/Alice finale.jpg').is_file())
|
||||
|
||||
def test_worker_order_flags_and_failure(self):
|
||||
calls = []
|
||||
class Process:
|
||||
stdout = StringIO('done\n')
|
||||
def __init__(self, command, **kwargs):
|
||||
calls.append(command)
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def wait(self): return 0
|
||||
with patch('copienator.finalisation.subprocess.Popen', Process):
|
||||
self.service._regenerate(True)
|
||||
self.assertEqual([c[3] for c in calls], ['read-annotations','modify-annotations','giving-names'])
|
||||
self.assertTrue(any('return-answers-question' in v for v in calls[0]))
|
||||
self.assertEqual(self.service.job['status'], 'success')
|
||||
calls.clear()
|
||||
with patch.object(Process, 'wait', return_value=4), patch('copienator.finalisation.subprocess.Popen', Process):
|
||||
self.service._regenerate(True)
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(self.service.job['status'], 'failed')
|
||||
|
||||
def test_os_lock_blocks_cli_and_releases(self):
|
||||
with EvaluationLock(self.root):
|
||||
with self.assertRaises(CliError):
|
||||
with EvaluationLock(self.root):
|
||||
pass
|
||||
result = subprocess.run([sys.executable,'-m','copienator','giving-names',str(self.root),'Bnot'], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('utilisée', result.stderr)
|
||||
with EvaluationLock(self.root):
|
||||
pass
|
||||
|
||||
|
||||
class SnapshotTests(unittest.TestCase):
|
||||
def test_selective_redo_merges_raw_inputs_and_preserves_handwriting(self):
|
||||
from copienator.review_snapshot import stage, load
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
seed(root)
|
||||
output = root / 'Bnot/Copie01'
|
||||
data = {l: {'pdf_path': root / 'Copies/Copie01' / f'{l}.pdf',
|
||||
'result': {'score': 3, 'feedback': []}, 'coordinates': [0, 0]}
|
||||
for l in ('Ex 1', 'Ex 2')}
|
||||
ink = Image.new('RGBA', (100, 120), (255, 0, 0, 255))
|
||||
bnote = {'images': [{'label':'Ex 1', 'header_height':20}, {'label':'Ex 2','header_height':10}]}
|
||||
with staged_files(output) as staging:
|
||||
stage(output, staging, data, {'Ex 1': {'img': ink}}, engine='standard', bnote=bnote)
|
||||
data['Ex 2']['result']['score'] = 1
|
||||
with staged_files(output) as staging:
|
||||
stage(output, staging, data, {}, engine='grouped', selected={'Ex 2'}, force_filtered={'Ex 1'})
|
||||
saved, restored, notes = load(root, output)
|
||||
self.assertEqual(restored['Ex 1']['result']['score'], 3)
|
||||
self.assertEqual(restored['Ex 2']['result']['score'], 1)
|
||||
self.assertEqual(notes['Ex 1']['img'].getpixel((0,0)), (255,0,0,255))
|
||||
self.assertEqual(notes['Ex 1']['old_header_h'], 20)
|
||||
self.assertIn('Ex 1', saved['force_filtered'])
|
||||
self.assertEqual(restored['Ex 1']['pdf_path'], root / 'Copies/Copie01/Ex 1.pdf')
|
||||
|
||||
def test_cached_regeneration_does_not_accumulate_custom_remarks(self):
|
||||
from copienator.review_snapshot import stage, load
|
||||
from copienator.commands import regenerate_reviewed
|
||||
from copienator.commands import annotating
|
||||
from copienator.custom_remarks import set_remark
|
||||
for mode in ('Bnot', 'BGnot'):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
seed(root, mode)
|
||||
output = root / mode / 'Copie01'
|
||||
pdf = root / 'Copies/Copie01/Ex 1.pdf'
|
||||
pdf.write_bytes(b'placeholder')
|
||||
data = {'Ex 1': {'pdf_path': pdf, 'result': {'score': 3, 'feedback': []}, 'coordinates': [0,0]}}
|
||||
with staged_files(output) as staging:
|
||||
stage(output, staging, data, {}, engine='standard' if mode == 'Bnot' else 'grouped',
|
||||
bnote={'images':[{'label':'Ex 1','header_height':20}]})
|
||||
set_remark(root, '01', 'Ex 1', 'Une seule remarque')
|
||||
with patch.object(annotating, 'make_base_image', return_value=(Image.new('RGB',(100,100),'white'),0,0)), \
|
||||
patch.object(annotating, 'compose_label_image', return_value=(Image.new('RGB',(100,120),'white'),20)) as compose, \
|
||||
patch('copienator.commands.reading_grouped_annotations.get_extra_pdfs_as_images', return_value=[]):
|
||||
for _ in range(2):
|
||||
self.assertEqual(regenerate_reviewed.run(EvaluationWorkspace(root), mode), 0)
|
||||
feedback = compose.call_args.args[2]['feedback']
|
||||
self.assertEqual(len(feedback), 1)
|
||||
self.assertEqual(feedback[0]['text'], 'Une seule remarque')
|
||||
_, raw, _ = load(root, output)
|
||||
self.assertEqual(raw['Ex 1']['result']['feedback'], [])
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
|
||||
class WebGuiTests(unittest.TestCase):
|
||||
def test_modal_lock_and_return_to_existing_workflow(self):
|
||||
from copienator_gui.app import CopienatorApp
|
||||
from copienator.finalisation_server import WebSession
|
||||
import tkinter as tk
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
seed(root)
|
||||
app = CopienatorApp(Path.cwd(), False, root)
|
||||
self.addCleanup(app.destroy)
|
||||
service = Finalisation(root, 'Bnot', 'token')
|
||||
class FakeSession:
|
||||
url = 'http://127.0.0.1:12345/'
|
||||
alive = True
|
||||
def request_stop(self): self.alive = False
|
||||
session = FakeSession()
|
||||
session.service = service
|
||||
def descendants(widget):
|
||||
for child in widget.winfo_children():
|
||||
yield child
|
||||
yield from descendants(child)
|
||||
def button(text):
|
||||
return next(w for w in descendants(app) if w.winfo_class() == 'TButton' and w.cget('text') == text)
|
||||
with patch('copienator.finalisation_server.WebSession', return_value=session), patch('webbrowser.open'):
|
||||
app._open_finalisation_web()
|
||||
app.update()
|
||||
self.assertIsInstance(app.grab_current(), tk.Toplevel)
|
||||
button('Ouvrir').invoke()
|
||||
self.assertIs(app.web_session, session)
|
||||
self.assertEqual(str(button('Ouvrir').cget('state')), 'disabled')
|
||||
service.save({'kind':'score','copy':'01','label':'Ex 1','value':'1','revision':service.revision()})
|
||||
with patch.object(app.runner, 'start') as start:
|
||||
app._run_current_step()
|
||||
start.assert_not_called()
|
||||
button('Arrêter le serveur et reprendre').invoke()
|
||||
app.after(450, app.quit)
|
||||
app.mainloop()
|
||||
self.assertIsNone(app.web_session)
|
||||
self.assertIsNone(app.grab_current())
|
||||
self.assertEqual(app.state_store.step('read_annotations')['status'], 'stale')
|
||||
@@ -2530,8 +2530,8 @@ class WorkflowTests(unittest.TestCase):
|
||||
self.assertFalse(default_config.ALWAYS_CROP)
|
||||
self.assertTrue(config.ALWAYS_CROP)
|
||||
|
||||
def test_review_persp_has_shorter_title(self) -> None:
|
||||
self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes")
|
||||
def test_rubric_review_is_part_of_generation(self) -> None:
|
||||
self.assertNotIn("review_persp", self.steps)
|
||||
|
||||
def test_cutleft_title_and_shortcuts_are_documented(self) -> None:
|
||||
step = self.steps["cutleft"]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from pydantic import ValidationError
|
||||
|
||||
from copienator.commands import gemini_for_labels as labels
|
||||
|
||||
|
||||
class LabelSchemaTests(unittest.TestCase):
|
||||
def test_request_schema_and_parser_agree_on_success_and_recovery(self):
|
||||
cases = [
|
||||
({}, False),
|
||||
({'name': 'Student'}, False),
|
||||
({'name': 'Student', 'error': None, 'upside_down_pages': []}, False),
|
||||
({'name': 'Student', 'list': []}, True),
|
||||
({'name': 'Student', 'list': [
|
||||
{'box_2d': [1, 2, 3, 4], 'label': 'Ex 1'}]}, True),
|
||||
({'error': 'insufficient_crop'}, True),
|
||||
({'upside_down_pages': [1]}, True),
|
||||
({'error': 'insufficient_crop', 'upside_down_pages': [1]}, True),
|
||||
({'error': None}, False),
|
||||
({'upside_down_pages': []}, False),
|
||||
({'upside_down_pages': [0]}, False),
|
||||
({'error': 'other'}, False),
|
||||
({'name': 'Student', 'list': None}, False),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
image = Path(directory) / 'Copie01_01.jpg'
|
||||
image.write_bytes(b'image')
|
||||
for known_name, model in ((None, labels.AnnotationData), ('Student', labels.LabelData)):
|
||||
_, config = labels.generate_request(
|
||||
image, 'Ex 1', 'Student', [], [], known_name=known_name)
|
||||
schema = config.response_json_schema
|
||||
Draft202012Validator.check_schema(schema)
|
||||
validator = Draft202012Validator(schema)
|
||||
for payload, expected in cases + [({'list': []}, bool(known_name))]:
|
||||
with self.subTest(known_name=known_name, payload=payload):
|
||||
self.assertEqual(validator.is_valid(payload), expected)
|
||||
if expected:
|
||||
model.model_validate(payload)
|
||||
else:
|
||||
with self.assertRaises(ValidationError):
|
||||
model.model_validate(payload)
|
||||
@@ -188,3 +188,80 @@ class NameGridTests(unittest.TestCase):
|
||||
self.assertEqual(read_json(self.workspace.copies_dir / 'Copie01_01.json')['name'], 'Alice')
|
||||
for call in self.client.models.generate_content.call_args_list:
|
||||
self.assertNotIn('Alice', call.kwargs['contents'][0].parts[1].text)
|
||||
|
||||
|
||||
class WideNameGridTests(unittest.TestCase):
|
||||
setUp = NameGridTests.setUp
|
||||
crop = NameGridTests.crop
|
||||
identify = NameGridTests.identify
|
||||
|
||||
def test_recovered_name_is_used_by_label_detection(self):
|
||||
self.crop(1)
|
||||
self.client.models.generate_content.side_effect = [
|
||||
Mock(text='{"cells":[{"cell":1,"name":"Missing"}]}'),
|
||||
Mock(text='{"cells":[{"cell":1,"name":"Bob"}]}'),
|
||||
Mock(text='{"list":[]}'),
|
||||
]
|
||||
with patch('pdf2image.convert_from_path', return_value=[Image.new('RGB', (800, 1200))]):
|
||||
status = labels.run(self.workspace, [self.workspace.root], client=self.client,
|
||||
sleep=lambda _: None, max_workers=1)
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
self.assertEqual(read_json(self.workspace.copies_dir / 'Copie01_01.json')['name'], 'Bob')
|
||||
self.assertNotIn('name', self.client.models.generate_content.call_args.kwargs[
|
||||
'config'].response_json_schema['properties'])
|
||||
|
||||
def test_missing_names_are_collated_and_cached_before_label_fallback(self):
|
||||
for number in range(1, 7):
|
||||
self.crop(number)
|
||||
groups = {f'Copie{i:02d}': [] for i in range(1, 7)}
|
||||
self.client.models.generate_content.side_effect = [
|
||||
Mock(text=json.dumps({'cells': [{'cell': 1, 'name': 'Alice'}]})),
|
||||
Mock(text=json.dumps({'cells': [
|
||||
{'cell': 1, 'name': 'Bob', 'box_2d': [100, 700, 300, 950]},
|
||||
{'cell': 2, 'name': 'Missing'},
|
||||
{'cell': 3, 'name': 'Outsider'},
|
||||
{'cell': 4, 'name': 'Missing'}]})),
|
||||
Mock(text=json.dumps({'cells': [{'cell': 1, 'name': 'Alice'}]})),
|
||||
]
|
||||
def render(*args, **kwargs):
|
||||
self.assertEqual(kwargs['first_page'], 1)
|
||||
self.assertEqual(kwargs['last_page'], 1)
|
||||
page = Image.new('RGB', (1654, 2338), 'white')
|
||||
page.paste('red', (1400, 50, 1500, 100))
|
||||
return [page]
|
||||
with patch('pdf2image.convert_from_path', side_effect=render) as rendering:
|
||||
result = self.identify(groups)
|
||||
self.assertEqual(rendering.call_count, 5)
|
||||
self.assertEqual(result, {'Copie01': 'Alice', 'Copie02': 'Bob',
|
||||
'Copie03': 'Missing', 'Copie04': 'Missing',
|
||||
'Copie05': 'Missing', 'Copie06': 'Alice'})
|
||||
self.assertEqual(self.identify(groups), result)
|
||||
self.assertEqual(rendering.call_count, 5)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 3)
|
||||
wide = self.directory / 'Wide'
|
||||
manifest = read_json(wide / 'Grids' / 'manifest.json')
|
||||
self.assertEqual([[c['copy'] for c in g['cells']] for g in manifest],
|
||||
[['Copie02', 'Copie03', 'Copie04', 'Copie05'], ['Copie06']])
|
||||
with Image.open(wide / 'Copie02.jpg') as crop:
|
||||
self.assertEqual(crop.size, (1654, 584))
|
||||
self.assertGreater(crop.getpixel((1450, 75))[0], 200)
|
||||
self.assertLess(crop.getpixel((1450, 75))[1], 40)
|
||||
self.assertAlmostEqual(names.name_bottom_height_fraction(self.workspace, 'Copie02'),
|
||||
.3 * 584 / 2338)
|
||||
for request in self.client.models.generate_content.call_args_list[1:]:
|
||||
prompt = request.kwargs['contents'][0].parts[1].text
|
||||
self.assertIn('full-width top quarters', prompt)
|
||||
self.assertIn('1 columns and 4 rows', prompt)
|
||||
|
||||
def test_wide_failure_is_retried_on_resume(self):
|
||||
self.crop(1)
|
||||
self.client.models.generate_content.side_effect = [
|
||||
Mock(text='{"cells":[{"cell":1,"name":"Missing"}]}'), TimeoutError(),
|
||||
Mock(text='{"cells":[{"cell":1,"name":"Bob"}]}'),
|
||||
]
|
||||
with patch('pdf2image.convert_from_path', return_value=[Image.new('RGB', (800, 1200))]) as render:
|
||||
self.assertEqual(self.identify({'Copie01': []}), {'Copie01': 'Missing'})
|
||||
self.assertFalse((self.directory / 'Wide' / 'Copie01_result.json').exists())
|
||||
self.assertEqual(self.identify({'Copie01': []}), {'Copie01': 'Bob'})
|
||||
render.assert_called_once()
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 3)
|
||||
@@ -2,6 +2,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
@@ -82,9 +83,12 @@ class PersonalStatementTests(unittest.TestCase):
|
||||
def test_personal_choice_and_optional_steps_only_in_personal_profile(self):
|
||||
standard = {step.id: step for step in build_workflow(False)}
|
||||
enabled = {step.id: step for step in build_workflow(True)}
|
||||
self.assertEqual([variant.id for variant in standard["statement"].variants], ["gemini"])
|
||||
self.assertEqual([variant.id for variant in standard["statement"].variants],
|
||||
["gemini", "compile"])
|
||||
self.assertEqual([variant.id for variant in enabled["statement"].variants],
|
||||
["personal", "gemini", "compile"])
|
||||
self.assertEqual([variant.program for variant in enabled["statement"].variants],
|
||||
["statement-personal", "statement"])
|
||||
["statement-personal", "statement", "statement"])
|
||||
for ident in ("statement_groups", "statement_persp"):
|
||||
self.assertNotIn(ident, standard)
|
||||
self.assertTrue(enabled[ident].optional)
|
||||
@@ -92,6 +96,25 @@ class PersonalStatementTests(unittest.TestCase):
|
||||
|
||||
|
||||
class SelectiveGeminiTests(unittest.TestCase):
|
||||
def test_rubric_batches_preserve_groups_and_six_question_limit(self):
|
||||
for sizes, expected in (
|
||||
([2, 2, 2], [6]),
|
||||
([4, 3, 2, 1], [4, 6]),
|
||||
([2, 7, 1, 5], [2, 7, 6]),
|
||||
([], []),
|
||||
):
|
||||
with self.subTest(sizes=sizes):
|
||||
groups = [[f'{index}:{question}' for question in range(size)]
|
||||
for index, size in enumerate(sizes)]
|
||||
original = [group[:] for group in groups]
|
||||
batches = gemini.batch_rubric_groups(groups)
|
||||
self.assertEqual([len(batch) for batch in batches], expected)
|
||||
self.assertEqual([label for batch in batches for label in batch],
|
||||
[label for group in groups for label in group])
|
||||
for group in groups:
|
||||
self.assertTrue(any(all(label in batch for label in group) for batch in batches))
|
||||
self.assertEqual(groups, original)
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
@@ -149,15 +172,14 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
|
||||
def test_rubrics_replace_only_persp_using_normal_prompt(self):
|
||||
before = self.snapshot()
|
||||
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]),
|
||||
self.rubric_response(self.labels[2:])]
|
||||
self.client.models.generate_content.return_value = self.rubric_response(self.labels)
|
||||
self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS)
|
||||
after = self.snapshot()
|
||||
self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")},
|
||||
{key: value for key, value in after.items()
|
||||
if not key.startswith(("Persp/", "Cache/"))})
|
||||
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
|
||||
self.assertEqual(len(responses), 2)
|
||||
self.assertEqual(len(responses), 1)
|
||||
self.assertEqual((self.root / "Persp" / "Ex 1").read_bytes(), before['Persp/Ex 1'])
|
||||
for label in self.labels:
|
||||
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
|
||||
@@ -170,12 +192,34 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4)
|
||||
self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4)
|
||||
|
||||
def separate_requests(self, last):
|
||||
# A full six-question group cannot be combined with the final group.
|
||||
extra = [f'Context {index}' for index in range(4)]
|
||||
self.workspace.labels_file.write_text('\n'.join(self.labels + extra) + '\n')
|
||||
self.workspace.label_groups_file.write_text(
|
||||
', '.join(self.labels[:2] + extra) + '\n' + self.labels[2] + '\n')
|
||||
for label in extra:
|
||||
for directory in ('Text2', 'Sol2'):
|
||||
(self.root / directory / f'{label}.tex').write_text(label)
|
||||
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
|
||||
|
||||
def response_for_request(**kwargs):
|
||||
text = kwargs['contents'][0].parts[1].text
|
||||
if 'Question [Ex 2]' in text:
|
||||
if isinstance(last, Exception):
|
||||
raise last
|
||||
return last
|
||||
return self.rubric_response(self.labels[:2])
|
||||
|
||||
self.client.models.generate_content.side_effect = response_for_request
|
||||
|
||||
def test_incomplete_or_failed_rubrics_keep_completed_groups(self):
|
||||
before = self.snapshot()
|
||||
for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")):
|
||||
for label in self.labels[:2]:
|
||||
(self.root / 'Persp' / label).write_bytes(before[f'Persp/{label}'])
|
||||
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last]
|
||||
self.separate_requests(last)
|
||||
before = self.snapshot()
|
||||
with self.assertRaises((CliError, RuntimeError)):
|
||||
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
|
||||
after = self.snapshot()
|
||||
@@ -183,7 +227,8 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
|
||||
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
|
||||
self.assertEqual(after['Persp/Ex 1'], before['Persp/Ex 1'])
|
||||
self.assertEqual({key: value for key, value in before.items() if not key.startswith('Persp/')},
|
||||
self.assertEqual({key: value for key, value in before.items()
|
||||
if not key.startswith(('Persp/', 'Cache/'))},
|
||||
{key: value for key, value in after.items()
|
||||
if not key.startswith(('Persp/', 'Cache/'))})
|
||||
|
||||
@@ -195,7 +240,7 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
)
|
||||
self.client.models.generate_content.side_effect = [
|
||||
self.response({'rubrics': [{'label': label, 'rubric_content': expected} for label in group]})
|
||||
for group in (self.labels[:2], self.labels[2:])
|
||||
for group in (self.labels,)
|
||||
]
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
for label in self.labels:
|
||||
@@ -217,8 +262,8 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
def test_control_character_keeps_progress_and_saves_diagnostics(self):
|
||||
before = self.snapshot()
|
||||
broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]})
|
||||
self.client.models.generate_content.side_effect = [
|
||||
self.rubric_response(self.labels[:2]), broken]
|
||||
self.separate_requests(broken)
|
||||
before = self.snapshot()
|
||||
output = io.StringIO()
|
||||
with patch('sys.stdout', output), self.assertRaisesRegex(ValueError, 'control characters'):
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
@@ -255,19 +300,17 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
before = kept.read_bytes()
|
||||
(self.root / 'Persp' / self.labels[1]).write_text('')
|
||||
(self.root / 'Persp' / self.labels[2]).unlink()
|
||||
self.client.models.generate_content.side_effect = [
|
||||
self.rubric_response([self.labels[1]]), self.rubric_response([self.labels[2]])]
|
||||
self.client.models.generate_content.return_value = self.rubric_response(self.labels[1:])
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
self.assertEqual(kept.read_bytes(), before)
|
||||
call = self.client.models.generate_content.call_args_list[0]
|
||||
text = call.kwargs['contents'][0].parts[1].text
|
||||
self.assertIn('UNIQUEMENT pour les labels suivants : ' + self.labels[1] + '\n', text)
|
||||
self.assertIn('UNIQUEMENT pour les labels suivants : ' + ', '.join(self.labels[1:]) + '\n', text)
|
||||
self.assertIn('Question [' + self.labels[0] + ']', text)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 1)
|
||||
|
||||
def test_retry_requests_only_failed_group(self):
|
||||
self.client.models.generate_content.side_effect = [
|
||||
self.rubric_response(self.labels[:2]), RuntimeError('API unavailable')]
|
||||
self.separate_requests(RuntimeError('API unavailable'))
|
||||
with self.assertRaises(RuntimeError):
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
before = {label: (self.root / 'Persp' / label).read_bytes() for label in self.labels[:2]}
|
||||
@@ -287,13 +330,56 @@ class SelectiveGeminiTests(unittest.TestCase):
|
||||
client.assert_not_called()
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
def test_four_concurrent_requests_save_successes_and_retry_only_failure(self):
|
||||
labels = [f'Q{index}' for index in range(20)]
|
||||
groups = [labels[index:index + 4] for index in range(0, 20, 4)]
|
||||
self.workspace.labels_file.write_text('\n'.join(labels) + '\n')
|
||||
group_text = ''.join(', '.join(group) + '\n' for group in groups)
|
||||
self.workspace.label_groups_file.write_text(group_text)
|
||||
for label in labels:
|
||||
for directory in ('Text2', 'Sol2'):
|
||||
(self.root / directory / f'{label}.tex').write_text(label)
|
||||
barrier = threading.Barrier(4, timeout=10)
|
||||
lock = threading.Lock()
|
||||
active = 0
|
||||
peak = 0
|
||||
|
||||
def generate(client, context, *, workspace, expected_labels):
|
||||
nonlocal active, peak
|
||||
self.assertIn(expected_labels, groups)
|
||||
if expected_labels == groups[-1]:
|
||||
raise RuntimeError('API unavailable')
|
||||
with lock:
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
try:
|
||||
barrier.wait()
|
||||
return {label: 'Generated rubric' for label in expected_labels}
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
with patch.object(gemini, 'generate_rubrics', side_effect=generate) as requests:
|
||||
with self.assertRaisesRegex(RuntimeError, 'API unavailable'):
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
self.assertEqual(requests.call_count, 5)
|
||||
self.assertEqual(peak, 4)
|
||||
for label in labels[:16]:
|
||||
self.assertEqual((self.root / 'Persp' / label).read_text(), label + '\nGenerated rubric')
|
||||
self.assertFalse((self.root / 'Persp' / labels[-1]).exists())
|
||||
self.client.models.generate_content.return_value = self.rubric_response(groups[-1])
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 1)
|
||||
self.assertEqual(self.workspace.label_groups_file.read_text(), group_text)
|
||||
for label in labels:
|
||||
self.assertTrue((self.root / 'Persp' / label).is_file())
|
||||
|
||||
def test_delete_persp_forces_regeneration_and_removes_old_files(self):
|
||||
for label in self.labels:
|
||||
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
|
||||
self.client.models.generate_content.side_effect = [
|
||||
self.rubric_response(self.labels[:2]), self.rubric_response(self.labels[2:])]
|
||||
self.client.models.generate_content.return_value = self.rubric_response(self.labels)
|
||||
gemini.refine_existing(self.workspace, 'persp', api_client=self.client, delete_persp=True)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 1)
|
||||
self.assertEqual({path.name for path in (self.root / 'Persp').iterdir()}, set(self.labels))
|
||||
|
||||
def test_cli_delete_persp_dispatch(self):
|
||||
@@ -392,13 +478,26 @@ class PersonalStatementGuiTests(unittest.TestCase):
|
||||
app._refresh_persp_inventory()
|
||||
self.assertIn('Barèmes présents : 1/2', app.persp_inventory_label.cget('text'))
|
||||
self.assertIn('Manquants : Ex 2', app.persp_inventory_label.cget('text'))
|
||||
self.assertFalse(app.arg_vars['delete_persp'].get())
|
||||
app.arg_vars['delete_persp'].set(True)
|
||||
self.assertTrue(app.arg_vars['delete_persp'].get())
|
||||
self.assertIn('--delete-persp', app.command_var.get())
|
||||
app.arg_vars['delete_persp'].set(False)
|
||||
app._save_current_form()
|
||||
app._render_step()
|
||||
self.assertFalse(app.arg_vars['delete_persp'].get())
|
||||
self.assertNotIn('--delete-persp', app.command_var.get())
|
||||
self.assertTrue(app.arg_vars['delete_persp'].get())
|
||||
self.assertIn('--delete-persp', app.command_var.get())
|
||||
folder_button = next(child for child in app.form.winfo_children()
|
||||
if isinstance(child, ttk.Button)
|
||||
and child.cget('text') == 'Ouvrir le dossier Persp')
|
||||
with patch('copienator_gui.app.open_path') as opened:
|
||||
folder_button.invoke()
|
||||
opened.assert_called_once_with(evaluation / 'Persp')
|
||||
with patch.object(app, '_move_selection_from') as advance:
|
||||
app.active_step_id = 'statement_persp'
|
||||
app._finish_process(0, False)
|
||||
app.update()
|
||||
advance.assert_not_called()
|
||||
self.assertEqual(app.current_step.id, 'statement_persp')
|
||||
self.assertEqual(app.state_store.step('statement_persp')['status'], 'success')
|
||||
app.tree.selection_set('statement')
|
||||
app.update()
|
||||
app._select_variant(1)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Copienator · Finalisation</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+1254
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"name":"copienator-finalisation","private":true,"version":"1.0.0","type":"module","scripts":{"build":"tsc --noEmit && vite build"},"dependencies":{"react":"^19.2.0","react-dom":"^19.2.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","typescript":"^5.9.0","vite":"^7.2.0"}}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import './style.css';
|
||||
|
||||
type Answer = {label:string;score:string;remark:string;image:boolean;editableBody:boolean;info:{present?:boolean;not_empty?:boolean;touched?:boolean}};
|
||||
type Copy = {id:string;name:string;general:string[];answers:Answer[]};
|
||||
type Job = {running:boolean;status:string;log:string};
|
||||
type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];draft:string;applied:string;revision:string;dirty:boolean;job:Job};
|
||||
type Edit = {kind:string;copy:string;label:string;value:string|string[]};
|
||||
async function api(path:string, data?:unknown) {
|
||||
const response=await fetch('/api/'+path, data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
|
||||
if(!response.ok){let message;try{const payload=await response.json();message=typeof payload.detail==='string'?payload.detail:JSON.stringify(payload.detail)}catch{message=await response.text().catch(()=>response.statusText)}throw new Error(message||'Le serveur ne répond pas.');}
|
||||
return response.json();
|
||||
}
|
||||
const imageUrl=(copy:string,label:string,body=false,revision='')=>'/api/image?'+new URLSearchParams({copy,label,body:String(body),v:revision});
|
||||
const documentUrl=(copy:string,kind:string,label='')=>'/api/document?'+new URLSearchParams({copy,kind,label});
|
||||
const keyOf=(kind:string,copy='',label='')=>JSON.stringify([kind,copy,label]);
|
||||
|
||||
function App(){
|
||||
const [state,setState]=useState<State|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const [copyFilter,setCopyFilter]=useState(''),[labelFilter,setLabelFilter]=useState(''),[limit,setLimit]=useState(20);
|
||||
const [edits,setEdits]=useState<Record<string,Edit>>({}),[busy,setBusy]=useState(false),[applyDraft,setApplyDraft]=useState(false);
|
||||
const [cut,setCut]=useState<{copy:string;label:string}|null>(null),[zoom,setZoom]=useState<{copy:string;label:string}|null>(null);
|
||||
const [stopped,setStopped]=useState(false);
|
||||
const refresh=async()=>{try{setState(await api('state'));setError('')}catch(e){setError(String(e))}};
|
||||
useEffect(()=>{void refresh()},[]);
|
||||
useEffect(()=>{const protect=(e:BeforeUnloadEvent)=>{if(Object.keys(edits).length){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',protect);return()=>window.removeEventListener('beforeunload',protect)},[edits]);
|
||||
useEffect(()=>{if(!state?.job.running)return;const timer=setInterval(async()=>{try{const job:Job=await api('job');setState(s=>s?{...s,job}:s);if(!job.running){await refresh();setNotice(job.status==='success'?'Les sorties ont été régénérées.':'La régénération est incomplète. Consultez le journal.')}}catch(e){setError(String(e))}},1000);return()=>clearInterval(timer)},[state?.job.running]);
|
||||
const value=(kind:string,copy:string,label:string,fallback:string|string[])=>edits[keyOf(kind,copy,label)]?.value??fallback;
|
||||
const edit=(kind:string,copy:string,label:string,v:string|string[])=>setEdits(old=>({...old,[keyOf(kind,copy,label)]:{kind,copy,label,value:v}}));
|
||||
const save=async(keys:string[])=>{if(!state)return;setBusy(true);setError('');let latest=state;try{for(const key of keys){const change=edits[key];if(!change)continue;latest=await api('change',{...change,revision:latest.revision});setState(latest);setEdits(old=>{const next={...old};delete next[key];return next})}setNotice('Modifications enregistrées.')}catch(e){setError(String(e))}finally{setBusy(false)}};
|
||||
const regenerate=async()=>{if(!state)return;setBusy(true);setError('');try{const job=await api('regenerate',{revision:state.revision,apply_draft:applyDraft});setState({...state,job});setNotice('')}catch(e){setError(String(e))}finally{setBusy(false)}};
|
||||
const stop=async()=>{try{await api('stop',{});setStopped(true)}catch(e){setError(String(e))}};
|
||||
if(stopped)return <main className="end"><h1>Session terminée</h1><p>Vous pouvez reprendre le travail dans Copienator.</p></main>;
|
||||
if(!state)return <main className="end"><h1>Copienator</h1><p>{error||'Chargement de la finalisation…'}</p><button onClick={refresh}>Réessayer</button></main>;
|
||||
const disabled=busy||state.job.running;
|
||||
const pending=Object.keys(edits).length;
|
||||
const draft=value('draft','','',state.draft) as string;
|
||||
const draftDiff=state.draft.trim()!==state.applied.trim();
|
||||
const matches=state.copies.filter(c=>!copyFilter||Number(c.id)===Number(copyFilter));
|
||||
const cards=matches.flatMap(c=>c.answers.filter(a=>!labelFilter||a.label===labelFilter).map(a=>({c,a})));
|
||||
const names=new Map<string,number>();state.copies.forEach(c=>names.set(c.name.toLowerCase(),(names.get(c.name.toLowerCase())||0)+1));
|
||||
const issues=state.copies.filter(c=>c.name.toLowerCase()==='unknown'||(names.get(c.name.toLowerCase())||0)>1);
|
||||
return <>
|
||||
<header inert={!!cut||!!zoom}><div className="brand">C<span>Copienator</span></div><div className="heading"><div className="eyebrow">FINALISATION · {state.mode}</div><h1>{state.evaluation}</h1></div><button className="quiet" disabled={disabled||!!pending} onClick={stop}>Fermer et reprendre dans le GUI</button></header>
|
||||
<main inert={!!cut||!!zoom}>
|
||||
<section className="toolbar"><div><h2>Vérifier les copies</h2><p>Retrouvez une copie, une question, ou leur intersection.</p></div><div className="filters"><label>Copie<input type="text" inputMode="numeric" list="copies" placeholder="Toutes les copies" value={copyFilter} onChange={e=>{setCopyFilter(e.target.value.replace(/\D/g,''));setLimit(20)}}/></label><datalist id="copies">{state.copies.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</datalist><label>Label<select value={labelFilter} onChange={e=>{setLabelFilter(e.target.value);setLimit(20)}}><option value="">Tous les labels</option>{state.labels.map(l=><option key={l}>{l}</option>)}</select></label><button onClick={()=>{setCopyFilter('');setLabelFilter('');setLimit(20)}}>Réinitialiser</button></div></section>
|
||||
{error&&<div role="alert" className="alert">{error}<button onClick={refresh}>Recharger les données</button><small>Les saisies non enregistrées restent dans la page.</small></div>}
|
||||
{notice&&<p role="status" className="notice">{notice}</p>}
|
||||
<section className="publish"><div><strong>{state.job.running?'Régénération en cours…':pending?`${pending} modification(s) non enregistrée(s)`:state.dirty?'Enregistré · sorties à régénérer':'Modifications enregistrées'}</strong><small>Les sorties comprennent les images, PDF et le dossier A Rendre configurés.</small></div><button disabled={disabled||!pending} onClick={()=>save(Object.keys(edits))}>Tout enregistrer</button>{pending>0&&<button disabled={disabled} onClick={()=>setEdits({})}>Annuler les saisies</button>}<label className="check"><input type="checkbox" checked={applyDraft} disabled={disabled} onChange={e=>setApplyDraft(e.target.checked)}/>Appliquer aussi le brouillon de transferts{draftDiff?' (différent du résultat actuel)':''}</label><button className="primary" disabled={disabled||!!pending} onClick={regenerate}>Régénérer les sorties</button></section>
|
||||
<fieldset disabled={disabled}>
|
||||
<details className="panel"><summary>Identités et remarques générales <span>{issues.length} nom(s) à vérifier</span></summary><p>Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.</p>{matches.map(c=>{const general=value('general',c.id,'',c.general) as string[];const changeGeneral=(next:string[])=>edit('general',c.id,'',next);return <section className="identity" key={c.id}><div className="identity-name"><strong>Copie {c.id}</strong><label>Nom<input value={value('name',c.id,'',c.name) as string} onChange={e=>edit('name',c.id,'',e.target.value)}/></label>{issues.includes(c)&&<span className="badge warning">Nom à vérifier</span>}<a target="_blank" rel="noreferrer" href={documentUrl(c.id,'original')}>Voir l’originale ↗</a><button disabled={!edits[keyOf('name',c.id)]} onClick={()=>save([keyOf('name',c.id)])}>Enregistrer le nom</button></div><div className="general">{general.map((text,i)=><div className="remark-row" key={i}><textarea aria-label={`Remarque générale ${i+1}, copie ${c.id}`} value={text} onChange={e=>changeGeneral(general.map((v,j)=>i===j?e.target.value:v))}/><button disabled={i===0} aria-label="Monter" onClick={()=>{const a=[...general];[a[i-1],a[i]]=[a[i],a[i-1]];changeGeneral(a)}}>↑</button><button disabled={i===general.length-1} aria-label="Descendre" onClick={()=>{const a=[...general];[a[i+1],a[i]]=[a[i],a[i+1]];changeGeneral(a)}}>↓</button><button onClick={()=>changeGeneral(general.filter((_,j)=>j!==i))}>Supprimer</button></div>)}<button onClick={()=>changeGeneral([...general,''])}>+ Remarque générale</button><button disabled={!edits[keyOf('general',c.id)]} onClick={()=>save([keyOf('general',c.id)])}>Enregistrer les remarques</button></div></section>})}</details>
|
||||
<details className="panel"><summary>Transferts et coupes <span>{draftDiff?'Brouillon non appliqué':'Brouillon identique aux modifications appliquées'}</span></summary><p>Les en-têtes et scores restent à leur label. Enregistrer un brouillon ne l’applique pas. Pour annuler un transfert, retirez sa ligne puis régénérez en cochant « Appliquer aussi le brouillon ».</p><div className="draft-list">{draft.split('\n').map((line,index,lines)=>line.trim()&&!line.trim().startsWith('###')?<div className="draft-row" key={index}><code>{line}</code><button aria-label={`Monter l’instruction ${index}`} disabled={index===0||lines[index-1].startsWith('###')} onClick={()=>{const next=[...lines];[next[index-1],next[index]]=[next[index],next[index-1]];edit('draft','','',next.join('\n'))}}>↑</button><button aria-label={`Descendre l’instruction ${index}`} disabled={index===lines.length-1||!lines[index+1].trim()} onClick={()=>{const next=[...lines];[next[index+1],next[index]]=[next[index],next[index+1]];edit('draft','','',next.join('\n'))}}>↓</button><button onClick={()=>edit('draft','','',lines.filter((_,i)=>i!==index).join('\n'))}>Retirer</button></div>:null)}</div><details><summary>Éditer les instructions en texte</summary><textarea className="instructions" aria-label="Brouillon des transferts" value={draft} onChange={e=>edit('draft','','',e.target.value)}/></details><button disabled={!edits[keyOf('draft')]} onClick={()=>save([keyOf('draft')])}>Enregistrer le brouillon</button><details><summary>Instructions actuellement appliquées</summary><pre>{state.applied}</pre></details></details>
|
||||
<div className="results"><h2>{cards.length} réponse{cards.length!==1?'s':''}</h2><span>{!copyFilter&&!labelFilter?'Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification':'Images annotées après lecture manuscrite'}</span></div>
|
||||
<div className="answer-grid">{cards.slice(0,limit).map(({c,a})=><article className="answer" key={c.id+'|'+a.label}><div className="card-heading"><div><span className="eyebrow">COPIE {c.id} · {c.name}</span><h3>{a.label}</h3></div><span className="badge">{!a.info.present?'Absente':!a.info.not_empty?'Réponse vide':a.info.touched?'Dans le PDF':'Hors PDF filtré'}</span></div><div className="image-space">{a.image?<button className="image-button" aria-label={`Agrandir copie ${c.id} ${a.label}`} onClick={()=>setZoom({copy:c.id,label:a.label})}><img loading="lazy" src={imageUrl(c.id,a.label,false,state.revision)} alt={`Réponse annotée — copie ${c.id}, ${a.label}`}/><span>Agrandir ↗</span></button>:<p className="empty">Aucune image pour ce label.</p>}</div><div className="answer-form"><label>Score<input inputMode="decimal" value={value('score',c.id,a.label,a.score) as string} onChange={e=>edit('score',c.id,a.label,e.target.value)}/></label><label>Remarque personnelle<textarea placeholder="Ajouter une remarque (LaTeX accepté)" value={value('remark',c.id,a.label,a.remark) as string} onChange={e=>edit('remark',c.id,a.label,e.target.value)}/></label></div><div className="card-actions"><button disabled={!edits[keyOf('score',c.id,a.label)]&&!edits[keyOf('remark',c.id,a.label)]} onClick={()=>save([keyOf('score',c.id,a.label),keyOf('remark',c.id,a.label)])}>Enregistrer</button><button disabled={!a.editableBody} onClick={()=>setCut({copy:c.id,label:a.label})}>Couper / transférer</button><a href={documentUrl(c.id,'context',a.label)} target="_blank" rel="noreferrer">Contexte ↗</a><a href={documentUrl(c.id,'question',a.label)} target="_blank" rel="noreferrer">Énoncé ↗</a><a href={documentUrl(c.id,'solution',a.label)} target="_blank" rel="noreferrer">Corrigé ↗</a><a href={documentUrl(c.id,'output')} target="_blank" rel="noreferrer">PDF final ↗</a></div></article>)}</div>
|
||||
{cards.length===0&&<p className="empty">Aucune réponse ne correspond à ces filtres.</p>}{cards.length>limit&&<button className="load-more" onClick={()=>setLimit(limit+20)}>Afficher les 20 suivantes</button>}
|
||||
</fieldset>
|
||||
{(state.job.log||state.job.running)&&<details className="panel" open={state.job.status==='failed'}><summary>Journal de régénération · {state.job.status==='failed'?'Échec / résultat incomplet':state.job.running?'En cours':'Terminée'}</summary><pre className="log">{state.job.log}</pre></details>}
|
||||
</main>
|
||||
{cut&&<CutEditor copy={cut.copy} source={cut.label} labels={state.labels} mode={state.mode} revision={state.revision} draft={draft} close={()=>setCut(null)} accept={text=>{edit('draft','','',text);setCut(null);setNotice('Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.')}}/>}
|
||||
{zoom&&<Viewer copy={zoom.copy} label={zoom.label} revision={state.revision} close={()=>setZoom(null)}/>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Viewer({copy,label,revision,close}:{copy:string;label:string;revision:string;close:()=>void}){
|
||||
const [scale,setScale]=useState(100);
|
||||
useEffect(()=>{const f=(e:KeyboardEvent)=>{if(e.key==='Escape')close()};window.addEventListener('keydown',f);return()=>window.removeEventListener('keydown',f)},[]);
|
||||
return <div className="overlay"><section className="modal viewer" role="dialog" aria-modal="true" aria-label={`Copie ${copy} ${label}`}><div className="modal-head"><h2>Copie {copy} · {label}</h2><label>Zoom <input type="range" min="40" max="200" value={scale} onChange={e=>setScale(Number(e.target.value))}/>{scale}%</label><button autoFocus onClick={close}>Fermer</button></div><div className="zoom-scroll"><img src={imageUrl(copy,label,false,revision)} alt={`Copie ${copy}, ${label}`} style={{width:scale+'%',maxWidth:'none'}}/></div></section></div>;
|
||||
}
|
||||
|
||||
function CutEditor({copy,source,labels,mode,revision,draft,close,accept}:{copy:string;source:string;labels:string[];mode:string;revision:string;draft:string;close:()=>void;accept:(text:string)=>void}){
|
||||
const [target,setTarget]=useState(labels.find(l=>l!==source)||''),[operation,setOperation]=useState('cut'),[percent,setPercent]=useState(50),[keep,setKeep]=useState('1'),[merge,setMerge]=useState('>'),[before,setBefore]=useState(false),[error,setError]=useState(''),[preview,setPreview]=useState<Record<string,string|null>|null>(null),[working,setWorking]=useState(false);
|
||||
const operator=operation==='cut'?`c{${percent}}${keep}${merge}`:operation;
|
||||
const line=`Copie${copy} ${source} ${operator} ${before?'|':''}${target}${before?'':'|'}`;
|
||||
const candidate=draft.trimEnd()+'\n'+line+'\n';
|
||||
useEffect(()=>setPreview(null),[target,operation,percent,keep,merge,before,draft]);
|
||||
const check=async()=>{setWorking(true);setError('');try{setPreview(await api('preview',{copy,source,target,text:candidate}))}catch(e){setError(String(e))}finally{setWorking(false)}};
|
||||
return <div className="overlay"><section className="modal" role="dialog" aria-modal="true" aria-label="Couper ou transférer une réponse"><div className="modal-head"><div><span className="eyebrow">COPIE {copy} · {mode}</span><h2>Couper ou transférer</h2></div><button autoFocus onClick={close}>Fermer</button></div><p>Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe.</p>{error&&<p role="alert" className="alert">{error}</p>}<div className="cut-controls"><label>Destination<select value={target} onChange={e=>setTarget(e.target.value)}>{labels.filter(l=>l!==source).map(l=><option key={l}>{l}</option>)}</select></label><label>Opération<select value={operation} onChange={e=>setOperation(e.target.value)}><option value="cut">Couper la source</option><option value="x>">Déplacer et ajouter</option><option value="->">Copier et ajouter</option><option value="xx">Déplacer et remplacer</option><option value="-x">Copier et remplacer</option><option value="xs">Vider la source</option><option value="sx">Vider la cible</option><option value="ss">Conserver les deux</option></select></label>{operation==='cut'&&<><label>Coupe : {percent}%<input type="range" min="1" max="99" step="0.1" value={percent} onChange={e=>setPercent(Number(e.target.value))}/></label><label>Conserver à la source<select value={keep} onChange={e=>setKeep(e.target.value)}><option value="1">La partie haute</option><option value="2">La partie basse</option></select></label><label>À la destination<select value={merge} onChange={e=>setMerge(e.target.value)}><option value=">">Ajouter le fragment</option><option value="x">Remplacer le corps</option></select></label></>}<label className="check"><input type="checkbox" checked={before} onChange={e=>setBefore(e.target.checked)}/>Placer avant la cible</label></div><div className="compare"><div><h3>Source · {source}</h3><div className="cut-image" onClick={e=>{if(operation==='cut'){const r=e.currentTarget.getBoundingClientRect();setPercent(Math.min(99,Math.max(1,Math.round((e.clientY-r.top)/r.height*1000)/10)))}}}><img src={imageUrl(copy,source,true,revision)} alt="Corps source sans en-tête"/>{operation==='cut'&&<div className="cut-line" style={{top:percent+'%'}}><span>{percent}%</span></div>}</div></div><div><h3>Cible · {target}</h3><BodyImage key={target} copy={copy} label={target} revision={revision}/></div></div><code className="instruction-line">{line}</code><div className="modal-actions"><button disabled={working||!target} onClick={check}>{working?'Calcul de l’aperçu…':'Aperçu du résultat'}</button><button className="primary" disabled={!preview||working} onClick={()=>accept(candidate)}>Ajouter au brouillon</button></div>{preview&&<><h3>Résultat proposé après toutes les instructions du brouillon</h3><div className="compare">{[source,target].map(label=><div key={label}><h4>{label}</h4>{preview[label]?<img src={preview[label]!} alt={'Aperçu '+label}/>:<p className="empty">Corps vide</p>}</div>)}</div></>}<p className="muted">Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes ».</p></section></div>;
|
||||
}
|
||||
function BodyImage({copy,label,revision}:{copy:string;label:string;revision:string}){const [missing,setMissing]=useState(false);return missing?<p className="empty">Pas de corps existant. Le transfert peut remplir cette cible.</p>:<img src={imageUrl(copy,label,true,revision)} alt="Corps cible sans en-tête" onError={()=>setMissing(true)}/>}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
@@ -0,0 +1,3 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#23323a;background:#f3f5f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:3px}button{border:1px solid #c8d3ce;border-radius:7px;background:white;color:#244b40;padding:9px 13px;cursor:pointer;font-weight:550}button:hover:not(:disabled){background:#e9f1ed;border-color:#769b88}button:disabled{opacity:.45;cursor:default}.primary{background:#205c48;color:white;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:white}a{color:#21654e;text-decoration:none}a:hover{text-decoration:underline}header{display:flex;align-items:center;gap:32px;padding:20px 36px;border-bottom:1px solid #dbe3de;background:white}.brand{display:flex;align-items:center;gap:10px;font-size:23px;font-weight:800;color:#28684f}.brand span{font-size:16px;font-weight:650}.heading{flex:1;border-left:1px solid #dbe3de;padding-left:30px}h1{font-size:22px;margin:3px 0 0}h2{font-size:20px;margin:0 0 8px}h3{font-size:19px;margin:7px 0}p{line-height:1.55;margin:8px 0;color:#56666b}.eyebrow{font-size:11px;letter-spacing:.09em;color:#5c7470;font-weight:650}main{max-width:1540px;margin:auto;padding:30px 36px 60px}.toolbar{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.filters{display:flex;gap:12px;align-items:end}label{display:flex;flex-direction:column;gap:6px;color:#405650;font-size:12px;font-weight:600}input,select,textarea{border:1px solid #c5d1cb;border-radius:6px;background:white;padding:9px 10px;color:#263d34;font-size:14px;min-width:0}textarea{resize:vertical;min-height:76px;line-height:1.5}input:focus,textarea:focus,select:focus{outline:2px solid #80b69c;border-color:#4d8269}input[type=checkbox]{width:16px;height:16px;accent-color:#205c48}.check{display:flex;flex-direction:row;align-items:center;max-width:310px;font-size:12px;line-height:1.4}.publish{display:flex;align-items:center;gap:16px;background:#e8f0eb;border:1px solid #d0e0d5;border-radius:10px;padding:17px 20px;margin:20px 0}.publish>div{flex:1}.publish small{display:block;margin-top:5px;color:#5b6e64}.panel{padding:18px 20px;background:white;border:1px solid #d9e2dd;border-radius:9px;margin-bottom:14px}summary{cursor:pointer;font-weight:600}summary span{float:right;font-size:12px;font-weight:400;color:#65766f}.identity{border-top:1px solid #e1e7e3;padding:18px 0;display:grid;grid-template-columns:280px 1fr;gap:28px}.identity-name{display:flex;flex-direction:column;gap:10px;align-items:start}.identity-name label{width:100%}.general button{margin:5px 6px 0 0}.remark-row{display:flex;align-items:center;gap:6px;margin-bottom:8px}.remark-row textarea{flex:1}.instructions{width:100%;font-family:ui-monospace,monospace;min-height:140px;margin:10px 0}.panel details{margin-top:16px}.results{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.results span{color:#677872;font-size:12px}.answer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.answer{border:1px solid #d9e1dd;background:white;border-radius:10px;overflow:hidden}.card-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;gap:12px}.badge{font-size:11px;background:#edf1ee;color:#5a6e62;border-radius:4px;padding:5px 7px;white-space:nowrap}.warning{background:#fff2d8;color:#8a620b}.image-space{height:290px;overflow:hidden;background:#e7ece9;border-top:1px solid #e2e8e3;border-bottom:1px solid #e2e8e3;display:flex;justify-content:center;align-items:start;padding:12px}.image-button{position:relative;border:0;padding:0;background:white;width:100%;height:100%;overflow:hidden;border-radius:0}.image-button img{width:100%;display:block}.image-button span{position:absolute;bottom:8px;right:8px;background:#203d32e8;color:white;border-radius:5px;padding:7px 10px;font-size:12px}.answer-form{display:grid;grid-template-columns:88px 1fr;gap:15px;padding:18px 20px 10px}.card-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:8px 20px 20px}.card-actions a{font-size:12px}.empty{padding:32px;color:#77877e;font-style:italic;text-align:center}.load-more{display:block;margin:24px auto}.alert{background:#fff0eb;color:#8f3827;border:1px solid #e9b6a9;border-radius:7px;padding:12px;display:flex;gap:12px;align-items:center}.alert small{font-size:12px}.notice{color:#216044}.muted{font-size:12px}fieldset{padding:0;margin:0;border:0;min-width:0}.overlay{position:fixed;inset:0;background:#142d23b8;z-index:10;display:flex;align-items:center;justify-content:center;padding:24px}.modal{width:min(1300px,100%);max-height:94vh;overflow:auto;border-radius:12px;background:#f8faf8;padding:24px;box-shadow:0 24px 80px #001c2e55}.modal-head{display:flex;justify-content:space-between;align-items:center;gap:20px}.cut-controls{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.compare{display:grid;grid-template-columns:1fr 1fr;gap:24px}.compLine truncated
|
||||
|
||||
.draft-list{margin:16px 0}.draft-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #e2e8e3}.draft-row code{flex:1;overflow-wrap:anywhere}.draft-row button{padding:6px 9px}.publish>div{min-width:190px}@media(max-width:1000px){.publish>div{flex:1 0 100%}}
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"noEmit":true},"include":["src"]}
|
||||
@@ -0,0 +1,2 @@
|
||||
import {defineConfig} from 'vite';
|
||||
export default defineConfig({base:'./',build:{outDir:'../copienator/web_static',emptyOutDir:true}});
|
||||
Reference in new issue
Block a user