Compare commits

..
5 Commits
Author SHA1 Message Date
sebastien 5a54b5ac2b better name finding (use top part, full width) 2026-11-02 11:56:24 +01:00
sebastien 9d0e40cc4f Require label lists only on success. 2026-11-02 11:49:44 +01:00
sebastien 28e3b5bfb9 Faster Persp generation 2026-11-02 11:43:57 +01:00
sebastien 213576f83d Copy removal 2026-11-02 11:35:21 +01:00
sebastien c36642e8de Initial commit for finalisation web interface 2026-11-01 08:41:37 +01:00
43 changed files with 3582 additions and 227 deletions

No files matched your search

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