Initial commit for finalisation web interface
This commit is contained in:
1 parent
b295f871a7
commit
c36642e8de
33 files changed
+2745
-138
No files matched your search
@@ -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 ?"
|
||||
|
||||
@@ -13,109 +13,7 @@ from copienator import EvaluationWorkspace
|
||||
from copienator.platform import open_path, safe_filename
|
||||
|
||||
|
||||
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NameIssue:
|
||||
folder: Path
|
||||
name: str
|
||||
copy_id: str
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||||
"""Return unknown and duplicated names currently present in A Rendre."""
|
||||
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||||
parsed: list[tuple[Path, str, str]] = []
|
||||
if return_dir.is_dir():
|
||||
for folder in return_dir.iterdir():
|
||||
if not folder.is_dir() or folder.is_symlink():
|
||||
continue
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if match:
|
||||
parsed.append(
|
||||
(folder, match.group("name"), match.group("copy_id"))
|
||||
)
|
||||
|
||||
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||||
issues = []
|
||||
for folder, name, copy_id in parsed:
|
||||
reasons = []
|
||||
if name.casefold() == "unknown":
|
||||
reasons.append("nom inconnu")
|
||||
elif counts[name.casefold()] > 1:
|
||||
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||||
if reasons:
|
||||
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||||
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||||
|
||||
|
||||
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||||
workspace = EvaluationWorkspace(evaluation)
|
||||
filename = f"Copie{copy_id}.pdf"
|
||||
for candidate in (
|
||||
workspace.original_copies_dir / filename,
|
||||
workspace.copies_dir / filename,
|
||||
workspace.root / filename,
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||||
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||||
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||||
if folder.parent.name != "A Rendre":
|
||||
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||||
|
||||
entered_name = requested_name.strip()
|
||||
if not entered_name:
|
||||
raise ValueError("Saisissez un nom.")
|
||||
name = safe_filename(entered_name, fallback="")
|
||||
if not name:
|
||||
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||||
|
||||
copy_id = match.group("copy_id")
|
||||
destination = folder.with_name(f"{name} ({copy_id})")
|
||||
if destination != folder and os.path.lexists(destination):
|
||||
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||||
|
||||
assets: dict[str, Path] = {}
|
||||
for child in folder.iterdir():
|
||||
suffix = child.suffix.casefold()
|
||||
if suffix not in {".jpg", ".pdf"}:
|
||||
continue
|
||||
if suffix in assets:
|
||||
raise ValueError(
|
||||
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||||
)
|
||||
assets[suffix] = child
|
||||
|
||||
renames: list[tuple[Path, Path]] = []
|
||||
for suffix, source in assets.items():
|
||||
target = source.with_name(f"{name}{suffix}")
|
||||
if target != source and os.path.lexists(target):
|
||||
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||||
if target != source:
|
||||
renames.append((source, target))
|
||||
|
||||
completed: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for source, target in renames:
|
||||
source.rename(target)
|
||||
completed.append((source, target))
|
||||
if destination != folder:
|
||||
folder.rename(destination)
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
if os.path.lexists(target) and not os.path.lexists(source):
|
||||
target.rename(source)
|
||||
raise
|
||||
return destination
|
||||
from copienator.return_identity import (RETURN_FOLDER_PATTERN, NameIssue, find_name_issues, original_copy_path, rename_return_copy)
|
||||
|
||||
|
||||
class GivingNamesPanel(ttk.LabelFrame):
|
||||
|
||||
@@ -32,33 +32,7 @@ def score_path_for_id(evaluation: Path, copy_id: str) -> Path:
|
||||
return matches[0]
|
||||
|
||||
|
||||
def read_scores(path: Path) -> dict:
|
||||
scores = read_json(path)
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
|
||||
return scores
|
||||
|
||||
|
||||
def set_score(path: Path, label: str, value: str) -> str:
|
||||
try:
|
||||
number = Decimal(value.strip().replace(",", "."))
|
||||
except InvalidOperation:
|
||||
raise ValueError("Le score doit être un nombre.") from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise ValueError("Le score doit être un nombre positif ou nul.")
|
||||
previous = ""
|
||||
|
||||
def update(scores):
|
||||
nonlocal previous
|
||||
if not isinstance(scores, dict) or label not in scores:
|
||||
raise ValueError(f"Label absent de score.json : {label}")
|
||||
previous = str(scores[label])
|
||||
scores[label] = str(number)
|
||||
|
||||
# A Rendre/score.json is often a symlink to the annotation score file.
|
||||
# Resolve it before atomic replacement so the link continues to work.
|
||||
atomic_update_json(path.resolve(strict=True), update, default_factory=dict)
|
||||
return previous
|
||||
from copienator.score_editing import read_scores, set_score
|
||||
|
||||
|
||||
class ScoreEditorPanel(ttk.Frame):
|
||||
|
||||
Reference in new issue
Block a user