Initial commit for finalisation web interface

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

No files matched your search

+108
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
@@ -1617,6 +1720,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'):
@@ -2328,6 +2433,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):