Files
Copies/copienator_gui/app.py
T

2314 lines
109 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import os
import queue
import re
import shutil
import sys
import tkinter as tk
from dataclasses import replace
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.commands.resolve_manual import pending_manual_conflicts
from copienator.copy_errors import copy_errors
from copienator.orientation import reports as orientation_reports
from copienator.filesystem import staged_files
from copienator.platform import (
WindowsLabelError,
add_platform_executable_paths,
open_path,
validate_windows_labels,
)
from copienator.utils import LATEX_WARNING_MARKER
from copienator.rubrics import present_rubric_labels
from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor
from .giving_names import GivingNamesPanel, find_name_issues
from .scores import ScoreEditorPanel
from .notifications import notify_desktop
from .syncthing import SyncthingPanel
from .manual_resolution import ManualResolutionPanel
from .annotation_modifications import AnnotationModificationsPanel, annotation_mode_for_reader
from .recorrection import prepare_selection_file
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
RefaireSelection,
available_copies,
load_selection,
resolve_layout,
validate_selection,
)
from .refaire_sessions import RESTART_WARNING, begin_pass
from .runner import ProcessRunner
from .state import StateStore
from .workflow import (
CommandVariant,
StepDefinition,
build_command,
build_workflow,
command_display,
evaluation_argument,
value_for_default,
)
STATUS_LABELS = {
"ready": "Prête",
"running": "En cours",
"success": "Réussie",
"partial": "Partielle",
"failed": "Échouée",
"interrupted": "Interrompue",
"skipped": "Ignorée",
"stale": "À revalider",
"detected": "Détectée",
}
DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128"
CORRECTION_PROGRESS_RE = re.compile(
r"\[Progression correction\] Groupes traités : (\d+)/(\d+)"
)
PERSONAL_INTERRO_SOURCE = Path("/home/sebastien/Prépa/Staging/Interro")
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
ANNOTATION_VARIANT_DIRECTORIES = {
"simple": "Anot",
"checks": "Bnot",
"grouped": "BGnot",
}
ANNOTATION_VARIANT_READERS = {
"checks": "standard",
"grouped": "grouped",
}
def get_personal_interro_files(
evaluation: Path, source_directory: Path = PERSONAL_INTERRO_SOURCE
) -> tuple[Path, Path, Path]:
"""Copy an Interro project's statement sources into the evaluation."""
match = re.fullmatch(r"Interro(\d+)", evaluation.name)
if match is None:
raise ValueError("Le dossier d’évaluation doit s’appeler Interro{id}, avec un identifiant numérique.")
project_name = evaluation.name
source_to_destination = (
(source_directory / f"{project_name}.pdf", "enonce.pdf"),
(source_directory / f"{project_name}.tex", "enonce.tex"),
(source_directory / f"{project_name}c.tex", "correction.tex"),
)
missing = [source.name for source, _destination in source_to_destination if not source.is_file()]
if missing:
raise FileNotFoundError(
"Fichier(s) source introuvable(s) dans "
f"{source_directory} : {', '.join(missing)}"
)
with staged_files(evaluation) as staging:
for source, destination_name in source_to_destination:
shutil.copy2(source, staging / destination_name)
return tuple(evaluation / name for _source, name in source_to_destination)
class Tooltip:
"""Small delayed tooltip for Tk and ttk widgets."""
def __init__(self, widget: tk.Widget, text: str, delay_ms: int = 450) -> None:
self.widget = widget
self.text = text
self.delay_ms = delay_ms
self._after_id: str | None = None
self.window: tk.Toplevel | None = None
widget.bind("<Enter>", self._schedule, add="+")
widget.bind("<Leave>", self.hide, add="+")
widget.bind("<ButtonPress>", self.hide, add="+")
def _schedule(self, _event: tk.Event | None = None) -> None:
self.hide()
self._after_id = self.widget.after(self.delay_ms, self.show)
def show(self) -> None:
self._after_id = None
if self.window is not None or not self.widget.winfo_exists():
return
self.window = tk.Toplevel(self.widget)
self.window.wm_overrideredirect(True)
self.window.attributes("-topmost", True)
x = self.widget.winfo_rootx() + 12
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
self.window.wm_geometry(f"+{x}+{y}")
tk.Label(
self.window,
text=self.text,
justify="left",
wraplength=430,
background="#fffbd8",
foreground="#202020",
relief="solid",
borderwidth=1,
padx=8,
pady=5,
).pack()
def hide(self, _event: tk.Event | None = None) -> None:
if self._after_id is not None:
try:
self.widget.after_cancel(self._after_id)
except tk.TclError:
pass
self._after_id = None
if self.window is not None:
self.window.destroy()
self.window = None
def attach_tooltip(widget: tk.Widget, text: str) -> Tooltip:
tooltip = Tooltip(widget, text)
# Keep the tooltip easy to inspect and alive for as long as its widget.
widget._copienator_tooltip = tooltip # type: ignore[attr-defined]
return tooltip
def copy_pdf_paths(evaluation: Path) -> list[Path]:
"""List the most relevant version of each scanned copy."""
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
for location in locations:
if not location.is_dir():
continue
copies = sorted(
(
path
for path in location.glob("*.pdf")
if not EvaluationWorkspace(evaluation).is_statement_pdf(evaluation / path.name)
),
key=lambda path: path.name.casefold(),
)
if copies:
return copies
return []
def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]:
return tuple(
name for name in ANNOTATION_DIRECTORIES if (evaluation / name).is_dir()
)
def plotting_shortcut_lines() -> list[str]:
try:
from copienator.configuration import PLOTTING_KB
except (ImportError, AttributeError):
from default_config import PLOTTING_KB
labels = (
("OK", "valider et passer à la suivante"),
("previous", "revenir à la précédente"),
("edit", "éditer le fichier JSON"),
("open pdf", "ouvrir la copie traitée"),
("open original pdf", "ouvrir la copie originale"),
("open eval", "ouvrir l’énoncé"),
)
display_names = {"<Return>": "Entrée", "<Escape>": "Échap"}
lines = [
f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}"
for action, description in labels
]
lines.append("! : signaler une erreur et passer à la copie suivante")
lines.append("Échap : fermer la fenêtre")
return lines
def build_runner_environment(
base: dict[str, str], api_key: str, proxy: str, use_proxy: bool
) -> dict[str, str]:
environment = add_platform_executable_paths(base)
environment["PYTHONUNBUFFERED"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
if api_key.strip():
environment["GEMINI_API_KEY"] = api_key.strip()
environment.pop("HTTPS_PROXY", None)
environment.pop("https_proxy", None)
if use_proxy and proxy.strip():
environment["HTTPS_PROXY"] = proxy.strip()
return environment
def process_status(return_code: int, interrupted: bool = False) -> str:
if interrupted or return_code == ExitCode.INTERRUPTED:
return "interrupted"
if return_code == ExitCode.SUCCESS:
return "success"
if return_code == ExitCode.PARTIAL:
return "partial"
return "failed"
def has_manual_conflicts(path: Path) -> bool:
"""Return whether a manual-resolution file contains an instruction."""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return False
except (OSError, UnicodeError):
# If the file cannot be inspected, keep the step visible rather than
# silently claiming that there is nothing to resolve.
return True
if any(line.strip() and not line.lstrip().startswith("###") for line in lines):
return True
# An emptied manual file still needs execution to acknowledge deletions.
try:
results = read_json(path.parent / "correction.json", default={})
return bool(pending_manual_conflicts(results))
except (OSError, ValueError, TypeError, KeyError, AttributeError):
return True
class CopienatorApp(tk.Tk):
def __init__(
self,
repository: Path,
show_personal_steps: bool,
initial_evaluation: Path | None = None,
) -> None:
super().__init__()
self.repository = repository.resolve()
self.show_personal_steps = show_personal_steps
self.steps = build_workflow(show_personal_steps)
self.step_by_id = {step.id: step for step in self.steps}
self.state_store = StateStore()
self.runner = ProcessRunner()
self.active_step_id: str | None = None
self.current_step: StepDefinition | None = None
self.arg_vars: dict[str, tk.Variable] = {}
self.copy_paths: dict[str, Path] = {}
self._rendering = False
self.refaire_panel: RefaireSelection | None = None
self.giving_names_panel: GivingNamesPanel | None = None
self.score_editor_panel: ScoreEditorPanel | None = None
self.complete_giving_names_button: ttk.Button | None = None
self.label_groups_button: ttk.Button | None = None
self.pending_refaire_commands: list[list[str]] = []
self.refaire_command_index = 0
self._correction_progress_buffer = ""
self._latex_warning_buffer = ""
self._latex_warning_paths: list[Path] = []
self.title("Copienator — assistant de correction")
self.geometry("1180x820")
self.minsize(900, 640)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.evaluation_var = tk.StringVar()
self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", ""))
self.proxy_var = tk.StringVar(value=DEFAULT_HTTPS_PROXY)
self.use_proxy_var = tk.BooleanVar(value=False)
self.verbose_var = tk.BooleanVar(value=False)
self.copy_var = tk.StringVar()
self.variant_var = tk.StringVar()
self.extra_var = tk.StringVar()
self.command_var = tk.StringVar(value="Sélectionnez une étape.")
self.info_var = tk.StringVar(value="Choisissez un dossier d’évaluation.")
self.batch_watch_status = tk.StringVar(value="Vérification automatique arrêtée.")
self.batch_monitor = BatchMonitor(
self, self._batch_results_ready, self._batch_watch_status_changed, self._append_console
)
self._build_ui(show_personal_steps)
self.extra_var.trace_add("write", lambda *_args: self._update_command_preview())
self.verbose_var.trace_add("write", lambda *_args: self._update_command_preview())
self.after(60, self._poll_runner)
if initial_evaluation and initial_evaluation.is_dir():
self.evaluation_var.set(str(initial_evaluation.resolve()))
self._load_evaluation()
else:
self._populate_tree()
@property
def evaluation(self) -> Path | None:
value = self.evaluation_var.get().strip()
return Path(value).expanduser().resolve() if value else None
def _build_ui(self, show_personal_steps: bool) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=3)
self.rowconfigure(2, weight=2)
top = ttk.Frame(self, padding=(10, 8))
top.grid(row=0, column=0, sticky="ew")
top.columnconfigure(6, weight=1)
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(
row=0, column=0, padx=(0, 8)
)
ttk.Label(top, text="Évaluation").grid(row=0, column=1, sticky="w", padx=(0, 8))
self.evaluation_entry = ttk.Entry(top, textvariable=self.evaluation_var, width=42)
self.evaluation_entry.grid(row=0, column=2, sticky="w")
self.evaluation_entry.bind("<Return>", lambda _event: self._load_evaluation())
self.browse_evaluation_button = ttk.Button(
top, text="Parcourir…", command=self._browse_evaluation
)
self.browse_evaluation_button.grid(row=0, column=3, padx=(6, 0))
self.open_evaluation_button = ttk.Button(
top, text="Ouvrir le dossier", command=self._open_evaluation_folder
)
self.open_evaluation_button.grid(row=0, column=4, padx=(12, 0))
self.open_statement_button = ttk.Button(
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="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))
ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid(
row=1, column=1, sticky="w", pady=(7, 0)
)
environment = ttk.Frame(top)
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
proxy_toggle = ttk.Checkbutton(
environment,
text="Utiliser le proxy HTTPS",
variable=self.use_proxy_var,
command=self._toggle_proxy,
)
proxy_toggle.pack(side="left", padx=(0, 6))
attach_tooltip(
proxy_toggle,
"Active le proxy HTTPS configuré dans le champ voisin pour les commandes lancées par le GUI.",
)
self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
self.proxy_entry.pack(side="left")
attach_tooltip(
self.proxy_entry,
"Adresse du proxy HTTPS transmise aux commandes lorsque l’option de proxy est activée.",
)
verbose_toggle = ttk.Checkbutton(
environment,
text="Détails d’erreur ⓘ",
variable=self.verbose_var,
)
verbose_toggle.pack(side="left", padx=(10, 0))
attach_tooltip(
verbose_toggle,
"Ajoute --verbose aux commandes compatibles afin d’afficher la trace complète lorsqu’une erreur inattendue survient.",
)
profile = "standard + personnel" if show_personal_steps else "standard"
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
main_pane = ttk.Panedwindow(self, orient="horizontal")
main_pane.grid(row=1, column=0, sticky="nsew", padx=10)
navigation = ttk.Frame(main_pane, padding=(0, 5, 6, 5))
self.tree = ttk.Treeview(navigation, columns=("status",), show="tree headings", selectmode="browse")
self.tree.heading("#0", text="Étape")
self.tree.heading("status", text="État")
self.tree.column("#0", minwidth=230, width=280)
self.tree.column("status", minwidth=90, width=105, anchor="center")
tree_scroll = ttk.Scrollbar(navigation, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=tree_scroll.set)
self.tree.pack(side="left", fill="both", expand=True)
tree_scroll.pack(side="right", fill="y")
self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
main_pane.add(navigation, weight=1)
self.detail = ttk.Frame(main_pane, padding=(12, 6, 4, 4))
self.detail.columnconfigure(0, weight=1)
self.detail.rowconfigure(3, weight=1)
self.title_label = ttk.Label(self.detail, text="Sélectionnez une étape", font=("TkDefaultFont", 15, "bold"))
self.title_label.grid(row=0, column=0, sticky="w")
self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left")
self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8))
ttk.Style(self).configure("MissingPrerequisite.TLabel", foreground="#c62828")
self.missing_requirements_label = ttk.Label(
self.detail,
text="",
style="MissingPrerequisite.TLabel",
wraplength=680,
justify="left",
)
self.missing_requirements_label.grid(row=2, column=0, sticky="ew", pady=(0, 8))
self.missing_requirements_label.grid_remove()
form_container = ttk.Frame(self.detail)
form_container.grid(row=3, column=0, sticky="nsew")
form_container.columnconfigure(0, weight=1)
form_container.rowconfigure(0, weight=1)
self.form_canvas = tk.Canvas(form_container, highlightthickness=0, width=1, height=1)
self.form_canvas.grid(row=0, column=0, sticky="nsew")
form_scroll = ttk.Scrollbar(form_container, orient="vertical", command=self.form_canvas.yview)
form_scroll.grid(row=0, column=1, sticky="ns")
self.form_canvas.configure(yscrollcommand=form_scroll.set)
self.form = ttk.Frame(self.form_canvas)
form_window = self.form_canvas.create_window((0, 0), window=self.form, anchor="nw")
self.form.bind("<Configure>", lambda _event: self.form_canvas.configure(scrollregion=self.form_canvas.bbox("all")))
self.form_canvas.bind("<Configure>", lambda event: self.form_canvas.itemconfigure(form_window, width=event.width))
self.form.columnconfigure(1, weight=1)
command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6)
self.command_box = command_box
command_box.grid(row=4, column=0, sticky="ew", pady=(8, 5))
command_box.columnconfigure(0, weight=1)
ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid(
row=0, column=0, sticky="ew"
)
self.copy_command_button = ttk.Button(command_box, text="Copier la commande", command=self._copy_command)
self.copy_command_button.grid(row=1, column=0, sticky="w", pady=(5, 0))
buttons = ttk.Frame(self.detail)
buttons.grid(row=5, column=0, sticky="ew", pady=(5, 0))
self.previous_button = ttk.Button(buttons, text="← Précédente", command=lambda: self._move_selection(-1))
self.previous_button.pack(side="left")
self.next_button = ttk.Button(buttons, text="Suivante →", command=lambda: self._move_selection(1))
self.next_button.pack(side="left", padx=6)
self.skip_button = ttk.Button(buttons, text="Marquer ignorée", command=self._skip_step)
self.skip_button.pack(side="right")
self.run_button = ttk.Button(buttons, text="Exécuter", command=self._run_current_step)
self.run_button.pack(side="right", padx=6)
main_pane.add(self.detail, weight=3)
console_frame = ttk.LabelFrame(self, text="Sortie en temps réel", padding=(7, 5))
console_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(6, 8))
console_frame.columnconfigure(0, weight=1)
console_frame.rowconfigure(0, weight=1)
self.console = tk.Text(
console_frame,
height=14,
wrap="word",
background="#171717",
foreground="#efefef",
insertbackground="white",
state="disabled",
takefocus=True,
exportselection=False,
)
self.console.bind("<Control-c>", self._copy_console_selection)
self.console.bind("<Control-Shift-C>", self._copy_console_selection)
self.console.bind("<Control-a>", self._select_console_all)
self.console.bind("<Button-1>", lambda _event: self.console.focus_set())
self.console_menu = tk.Menu(self.console, tearoff=False)
self.console_menu.add_command(label="Copier la sélection", command=self._copy_console_selection)
self.console_menu.add_command(label="Tout sélectionner", command=self._select_console_all)
self.console_menu.add_command(label="Copier toute la sortie", command=self._copy_console_all)
self.console.bind("<Button-3>", self._show_console_menu)
if self.tk.call("tk", "windowingsystem") == "aqua":
self.console.bind("<Command-c>", self._copy_console_selection)
self.console.bind("<Command-a>", self._select_console_all)
self.console.bind("<Button-2>", self._show_console_menu)
console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview)
self.console.configure(yscrollcommand=console_scroll.set)
self.console.grid(row=0, column=0, sticky="nsew")
console_scroll.grid(row=0, column=1, sticky="ns")
console_actions = ttk.Frame(console_frame)
console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
console_actions.columnconfigure(1, weight=1)
clipboard_actions = ttk.Frame(console_actions)
clipboard_actions.grid(row=1, column=0, columnspan=6, sticky="w", pady=(5, 0))
ttk.Button(clipboard_actions, text="Copier la sélection", command=self._copy_console_selection).pack(side="left")
ttk.Button(clipboard_actions, text="Copier toute la sortie", command=self._copy_console_all).pack(side="left", padx=6)
self.latex_warning_button = ttk.Button(
clipboard_actions,
text="Problèmes LaTeX…",
command=self._show_current_latex_warnings,
)
self.latex_warning_button.pack(side="left", padx=6)
self.latex_warning_button.pack_forget()
ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
self.stdin_var = tk.StringVar()
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
self.stdin_entry.grid(row=0, column=1, sticky="ew")
self.stdin_entry.bind("<Return>", lambda _event: self._send_input())
self.send_button = ttk.Button(console_actions, text="Envoyer", command=self._send_input, state="disabled")
self.send_button.grid(row=0, column=2, padx=5)
self.interrupt_button = ttk.Button(
console_actions, text="Interrompre", command=self._interrupt, state="disabled"
)
self.interrupt_button.grid(row=0, column=3, padx=5)
self.force_button = ttk.Button(
console_actions, text="Forcer l’arrêt", command=self._force_stop, state="disabled"
)
self.force_button.grid(row=0, column=4)
ttk.Button(console_actions, text="Effacer la console", command=self._clear_console).grid(
row=0, column=5, padx=(8, 0)
)
status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3))
status.grid(row=3, column=0, sticky="ew")
def _show_diagnostics(self) -> None:
checks = collect_diagnostics(
self.show_personal_steps, self.api_key_var.get(), self.evaluation
)
dialog = tk.Toplevel(self)
dialog.title("Diagnostic Copienator")
dialog.geometry("760x480")
dialog.transient(self)
dialog.columnconfigure(0, weight=1)
dialog.rowconfigure(1, weight=1)
required_missing = sum(1 for check in checks if check.required and not check.ok)
summary = (
"Tous les prérequis obligatoires sont disponibles."
if required_missing == 0
else f"{required_missing} prérequis obligatoire(s) manquant(s)."
)
ttk.Label(dialog, text=summary, padding=10, font=("TkDefaultFont", 11, "bold")).grid(
row=0, column=0, sticky="w"
)
tree = ttk.Treeview(dialog, columns=("state", "need", "detail"), show="headings")
tree.heading("state", text="État")
tree.heading("need", text="Niveau")
tree.heading("detail", text="Composant et détail")
tree.column("state", width=85, anchor="center")
tree.column("need", width=100, anchor="center")
tree.column("detail", width=530)
for check in checks:
tree.insert(
"",
"end",
values=(
"OK" if check.ok else "Manquant",
"Obligatoire" if check.required else "Optionnel",
f"{check.name} — {check.detail}",
),
)
tree.grid(row=1, column=0, sticky="nsew", padx=10)
ttk.Button(dialog, text="Fermer", command=dialog.destroy).grid(row=2, column=0, pady=10)
def _browse_evaluation(self) -> None:
selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository)
if selected:
self.evaluation_var.set(selected)
self._load_evaluation()
def _open_evaluation_folder(self) -> None:
self._open_desktop_path(self.evaluation, "dossier d’évaluation")
def _open_statement_pdf(self) -> None:
evaluation = self.evaluation
statement = EvaluationWorkspace(evaluation).statement_pdf_file if evaluation else None
self._open_desktop_path(statement, "fichier enonce.pdf")
def _toggle_proxy(self) -> None:
self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled")
def _load_evaluation(self) -> None:
if self.active_step_id or self.runner.running:
messagebox.showinfo("Traitement en cours", "Attendez la fin du traitement avant de changer d’évaluation.")
return
evaluation = self.evaluation
if not evaluation or not evaluation.is_dir():
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
return
self.batch_monitor.stop()
self._reset_latex_warnings()
self._save_current_form()
self.state_store.load(evaluation)
for step in self.steps:
if self.state_store.step(step.id).get("status") == "running":
self.state_store.update_step(step.id, status="interrupted")
self._populate_tree()
workspace = EvaluationWorkspace(evaluation)
missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not workspace.resolve(name).exists()]
names_found = (evaluation / "names").exists() or (self.repository / "names").exists()
details = []
if missing:
details.append("manquants : " + ", ".join(missing))
if not names_found:
details.append("fichier names introuvable")
if not self.api_key_var.get():
details.append("clé Gemini non renseignée")
self.info_var.set(
f"Évaluation chargée : {evaluation}"
+ (" — " + " ; ".join(details) if details else " — prérequis principaux détectés")
)
first = self.steps[0].id if self.steps else None
if first:
self.tree.selection_set(first)
self.tree.see(first)
def _populate_tree(self) -> None:
selected = self.current_step.id if self.current_step else None
opened = {self.tree.item(item, "text"): self.tree.item(item, "open") for item in self.tree.get_children()}
self.tree.delete(*self.tree.get_children())
section_items: dict[str, str] = {}
show_batches = self._has_batch_correction()
for step in self.steps:
if step.skip_for_live_correction and not show_batches:
continue
if step.section not in section_items:
section_id = f"section:{len(section_items)}"
section_items[step.section] = section_id
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=opened.get(step.section, step.section != REFAIRE_SECTION))
status = self._step_status(step)
suffix = " (facultative)" if step.optional else ""
self.tree.insert(
section_items[step.section],
"end",
iid=step.id,
text=step.title + suffix,
values=(STATUS_LABELS.get(status, status),),
)
if selected and self.tree.exists(selected):
self.tree.selection_set(selected)
elif selected and self.tree.exists("correction"):
self.tree.selection_set("correction")
def _has_batch_correction(self) -> bool:
evaluation = self.state_store.evaluation
if evaluation is None:
return False
correction = self.state_store.step("correction")
if correction.get("batch_started") or correction.get("last_run_variant") in {"batch", "hybrid", "integrate"}:
return True
for name in ("batch_requests_flash.jsonl", "batch_requests_pro.jsonl",
"batch_jobs.json", "batched_correction_result.jsonl"):
path = evaluation / name
try:
if path.is_file() and path.stat().st_size > 0:
return True
except OSError:
continue
return False
def _step_status(self, step: StepDefinition) -> str:
if self.state_store.evaluation:
saved = self.state_store.step(step.id).get("status")
if saved:
return str(saved)
if self._artifacts_exist(step):
return "detected"
if step.id == "inputs" and not self._missing_requirements(step):
return "detected"
return "ready"
def _artifacts_exist(self, step: StepDefinition) -> bool:
evaluation = self.evaluation
if not evaluation or not step.artifacts:
return False
for pattern in step.artifacts:
if any(char in pattern for char in "*?["):
if next(evaluation.glob(pattern), None) is not None:
return True
elif (evaluation / pattern).exists():
return True
return False
def _missing_requirements(self, step: StepDefinition) -> list[str]:
evaluation = self.evaluation
if not evaluation:
return list(step.requires)
missing = []
requirements = step.requires
if step.id == "statement":
variant = (self.variant_var.get() if self.current_step and self.current_step.id == step.id
else self.state_store.step(step.id).get("variant", "gemini"))
if variant == "personal":
requirements = ("enonce.tex",)
for pattern in requirements:
if any(char in pattern for char in "*?["):
exists = next(evaluation.glob(pattern), None) is not None
else:
workspace = EvaluationWorkspace(evaluation)
path = workspace.annotation_dir("refaire") if pattern == "BRnot" else workspace.resolve(pattern)
exists = path.exists()
if not exists:
missing.append(pattern)
if step.id == "inputs" and not ((evaluation / "names").is_file() or (self.repository / "names").is_file()):
missing.append("names")
return missing
def _reload_inputs(self) -> None:
if not self.state_store.evaluation or self.active_step_id or self.runner.running:
return
missing = self._missing_requirements(self.step_by_id["inputs"])
self.state_store.update_step("inputs", status="ready" if missing else "success")
self._populate_tree()
self._render_step()
self.info_var.set("Fichiers revérifiés — " + ("manquants : " + ", ".join(missing) if missing else "tous les fichiers d’entrée sont présents."))
def _get_input_files(self) -> None:
evaluation = self.evaluation
if (
not self.show_personal_steps
or not evaluation
or not self.state_store.evaluation
or self.active_step_id
or self.runner.running
):
return
try:
copied = get_personal_interro_files(evaluation)
except (OSError, ValueError) as exc:
messagebox.showerror("Récupération impossible", str(exc))
return
missing = self._missing_requirements(self.step_by_id["inputs"])
if missing:
self.state_store.update_step("inputs", status="ready")
self._populate_tree()
self._render_step()
messagebox.showwarning(
"Prérequis encore manquant",
"Les trois fichiers ont été copiés, mais l’étape ne peut pas encore "
"être terminée :\n\n" + "\n".join(missing),
)
return
self._mark_step("success", automatic=True, reason="Fichiers d’entrée récupérés")
self.info_var.set(
"Fichiers récupérés : " + ", ".join(path.name for path in copied)
)
self._move_selection_from("inputs", 1)
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
selection = self.tree.selection()
if not selection or selection[0].startswith("section:"):
return
step = self.step_by_id.get(selection[0])
if not step:
return
if self.current_step and self.current_step.id != step.id:
self._save_current_form()
self.current_step = step
self._render_step()
self._handle_first_visit(step)
def _handle_first_visit(self, step: StepDefinition) -> None:
if not self.state_store.evaluation:
return
entry = self.state_store.step(step.id)
if entry.get("visited"):
return
# Persist this before scheduling an action so selection callbacks cannot
# trigger the same automatic behavior twice.
self.state_store.update_step(step.id, visited=True)
if step.skip_without_manual_conflicts:
evaluation = self.evaluation
conflicts = evaluation / "manual_resolutions.txt" if evaluation else None
if conflicts is None or not has_manual_conflicts(conflicts):
self.after_idle(
lambda step_id=step.id: self._automatic_skip(
step_id, "aucun conflit manuel détecté"
)
)
return
if step.auto_start_first_visit and not entry.get("status") and not self._artifacts_exist(step):
self.after_idle(lambda step_id=step.id: self._automatic_start(step_id))
def _automatic_start(self, step_id: str) -> None:
if self.runner.running or not self.current_step or self.current_step.id != step_id:
return
self.info_var.set(f"{self.current_step.title} : démarrage automatique.")
self._run_current_step()
def _automatic_skip(self, step_id: str, reason: str) -> None:
if self.runner.running or not self.current_step or self.current_step.id != step_id:
return
self._mark_step("skipped", automatic=True, reason=reason)
self._move_selection_from(step_id, 1)
def _render_step(self) -> None:
step = self.current_step
if not step:
return
self._rendering = True
if step.id in {"personal_read", "edit_scores"}:
self.command_box.grid_remove()
else:
self.command_box.grid()
redo = step.section == REFAIRE_SECTION
expanded_form = redo or step.id in {
"page_splitter",
"crop_blank_margins",
"cutleft",
"labels",
"plotting",
"manual_resolution",
"modify_annotations",
"giving_names",
"edit_scores",
"statement_persp",
}
self.console.configure(height=8 if expanded_form else 14)
self.rowconfigure(1, weight=4 if expanded_form else 3)
self.rowconfigure(2, weight=1 if expanded_form else 2)
form_height = (340 if step.id == "refaire_selection" else 260) if redo else (300 if expanded_form else 200)
self.form_canvas.configure(height=form_height)
self.form_canvas.yview_moveto(0)
for child in self.form.winfo_children():
child.destroy()
self.arg_vars.clear()
self.refaire_panel = None
self.giving_names_panel = None
self.score_editor_panel = None
self.complete_giving_names_button = None
self.label_groups_button = None
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
saved_variant = entry.get("variant", step.variants[0].id)
if saved_variant not in {variant.id for variant in step.variants}:
saved_variant = step.variants[0].id
self.variant_var.set(saved_variant)
self.extra_var.set(str(entry.get("extra", "")))
self.title_label.configure(text=step.title)
missing = self._missing_requirements(step)
description = step.description
if step.id == "statement" and saved_variant == "personal":
description = ("Lit les blocs SHEETINFO de enonce.tex et récupère les énoncés, solutions "
"et barèmes du service personnel (localhost:8080). Crée un groupe par exercice. "
"Les deux étapes Gemini suivantes restent facultatives.")
self.description_label.configure(text=description)
if missing:
self.missing_requirements_label.configure(
text="Prérequis non détectés : " + ", ".join(missing)
)
self.missing_requirements_label.grid()
else:
self.missing_requirements_label.grid_remove()
row = self._render_context_controls(step, 0)
if len(step.variants) > 1:
ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8))
labels = [variant.label for variant in step.variants]
combo = ttk.Combobox(self.form, values=labels, state="readonly")
variant_index = [variant.id for variant in step.variants].index(saved_variant)
combo.current(variant_index)
combo.grid(row=row, column=1, sticky="ew", pady=4)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select_variant(combo.current()))
row += 1
values = entry.get("values", {}) if isinstance(entry.get("values", {}), dict) else {}
variant = self._current_variant()
evaluation_arg = self._evaluation_arg()
for spec in step.arguments:
if step.section == REFAIRE_SECTION and spec.name != "workers":
continue
if spec.variants and variant.id not in spec.variants:
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.
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:
value = bool(self.state_store.step("personal_read").get("wsnm_run", False))
if step.id == "modify_annotations" and spec.name == "annotation_dir" and spec.name not in values:
reader = self.state_store.step("read_annotations")
value = annotation_mode_for_reader(reader)
choices = spec.choices
if spec.name == "annotation_dir" and step.id in {"export", "import", "giving_names"}:
choices, value = self._annotation_directory_choices(step.id)
argument_label = ttk.Label(self.form, text=f"{spec.label} ⓘ")
argument_label.grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
attach_tooltip(argument_label, spec.help)
if spec.kind == "bool":
variable: tk.Variable = tk.BooleanVar(value=bool(value))
widget = ttk.Checkbutton(self.form, variable=variable)
widget.grid(row=row, column=1, sticky="w", pady=4)
elif spec.kind == "choice":
variable = tk.StringVar(value=str(value))
widget = ttk.Combobox(self.form, textvariable=variable, values=choices, state="readonly")
widget.grid(row=row, column=1, sticky="ew", pady=4)
else:
variable = tk.StringVar(value=str(value))
field = ttk.Frame(self.form)
field.grid(row=row, column=1, sticky="ew", pady=4)
field.columnconfigure(0, weight=1)
widget = ttk.Entry(field, textvariable=variable)
widget.grid(row=0, column=0, sticky="ew")
if spec.kind == "path":
ttk.Button(field, text="Fichier…", command=lambda var=variable: self._browse_target_file(var)).grid(
row=0, column=1, padx=(5, 0)
)
ttk.Button(field, text="Dossier…", command=lambda var=variable: self._browse_target_dir(var)).grid(
row=0, column=2, padx=(5, 0)
)
self.arg_vars[spec.name] = variable
variable.trace_add("write", lambda *_args: self._update_command_preview())
attach_tooltip(widget, spec.help)
row += 1
if step.id == 'statement_persp':
self.persp_inventory_label = ttk.Label(self.form, wraplength=680, justify='left')
self.persp_inventory_label.grid(row=row, column=0, columnspan=2, sticky='ew', pady=8)
row += 1
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
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
self._refresh_persp_inventory()
if step.id == "prepare_recorrection":
self.edit_recorrection_button = ttk.Button(
self.form, text="Ouvrir refaire.json (avec exemples)",
command=self._edit_recorrection,
)
self.edit_recorrection_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
row += 1
if step.id in {"manual_resolution", "modify_annotations"}:
def manual_evaluation():
target = self.arg_vars.get("target")
value = target.get().strip() if target else ""
return (self.repository / value).resolve() if value else self.evaluation
if step.id == "modify_annotations":
self.manual_panel = AnnotationModificationsPanel(
self.form, manual_evaluation,
lambda: self.arg_vars["annotation_dir"].get(),
)
self.arg_vars["annotation_dir"].trace_add("write", lambda *_: self.manual_panel.reload())
else:
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
if "target" in self.arg_vars:
self.arg_vars["target"].trace_add("write", lambda *_args: self.manual_panel.reload())
row += 1
show_extra = bool(step.extra_arguments_help) and (
not step.extra_arguments_variants
or variant.id in step.extra_arguments_variants
)
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION:
extra_label = ttk.Label(self.form, text="Arguments supplémentaires ⓘ")
extra_label.grid(
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
)
extra_entry = ttk.Entry(self.form, textvariable=self.extra_var)
extra_entry.grid(row=row, column=1, sticky="ew", pady=(10, 4))
attach_tooltip(extra_label, step.extra_arguments_help)
attach_tooltip(extra_entry, step.extra_arguments_help)
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 == "personal_create":
self.run_button.configure(text="Marquer Réussie")
if step.id == "personal_read":
self.run_button.configure(text="Marquer réussie")
self.skip_button.configure(state="normal" if step.optional else "disabled")
self._rendering = False
self._update_command_preview()
self._update_controls()
def _refresh_persp_inventory(self) -> None:
if not self.current_step or self.current_step.id != 'statement_persp':
return
target = self.arg_vars.get('target')
value = str(target.get()).strip() if target else ''
root = (self.repository / value).resolve() if value else self.evaluation
try:
labels = EvaluationWorkspace(root).read_labels() if root else []
present = present_rubric_labels(root, labels) if root else []
missing = [label for label in labels if label not in set(present)]
text = (f'Barèmes présents : {len(present)}/{len(labels)} — Manquants : {len(missing)}\n'
+ 'Présents : ' + (', '.join(present) or 'aucun') + '\n'
+ 'Manquants : ' + (', '.join(missing) or 'aucun'))
except (OSError, ValueError) as error:
text = f'Inventaire Persp indisponible : {error}'
self.persp_inventory_label.configure(text=text)
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
if step.id == "statement_groups" or (
step.id == "annotation" and self.variant_var.get() == "grouped"
):
controls = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
self.label_groups_button = ttk.Button(
controls,
text="Ouvrir les groupes de questions",
command=self._open_label_groups,
)
self.label_groups_button.pack(side="left")
ttk.Label(
controls,
text=("Une ligne = un groupe. Vérifiez les groupes actuels avant de lancer Gemini."
if step.id == "statement_groups" else
"Une ligne = un groupe. Vérifiez et enregistrez le fichier avant d’exécuter BGnot."),
wraplength=470,
justify="left",
).pack(side="left", padx=(10, 0))
return row + 1
if step.id == "personal_read":
controls = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
self.review_buttons = []
for command in ("re", "wsn", "we", "mei", "wsnm"):
button = ttk.Button(
controls, text=f"gestion_classe {command}",
command=lambda command=command: self._run_current_step(review_command=command),
)
button.pack(side="left", padx=(0, 6))
self.review_buttons.append(button)
return row + 1
if step.id == "export":
self.syncthing_panel = SyncthingPanel(self.form)
self.syncthing_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
return row + 1
if step.id == "batch_status":
controls = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
self.watch_batches_button = ttk.Button(
controls, text="Vérifier toutes les 5 minutes", command=self._start_batch_watch
)
self.watch_batches_button.pack(side="left")
self.stop_watch_batches_button = ttk.Button(
controls, text="Arrêter les vérifications", command=self.batch_monitor.stop
)
self.stop_watch_batches_button.pack(side="left", padx=8)
ttk.Label(self.form, textvariable=self.batch_watch_status).grid(
row=row + 1, column=0, columnspan=2, sticky="w"
)
ttk.Label(self.form, text="Gardez Copienator ouvert. Une notification de bureau sera envoyée lorsque tous les résultats seront prêts.",
wraplength=650).grid(row=row + 2, column=0, columnspan=2, sticky="w", pady=8)
return row + 3
if step.id == "rename":
self.validate_rename_button = ttk.Button(
self.form, text="Valider sans renommer", command=self._validate_rename_step
)
self.validate_rename_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8))
row += 1
if step.id == "inputs":
ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid(
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
)
row += 1
if self.show_personal_steps:
self.get_input_files_button = ttk.Button(
self.form, text="Get the files", command=self._get_input_files
)
self.get_input_files_button.grid(
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
)
row += 1
return row
if step.id == "edit_scores" and self.evaluation:
self.score_editor_panel = ScoreEditorPanel(
self.form, self.evaluation, self._score_changed, self._remark_changed
)
self.score_editor_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
row += 1
if step.id == "giving_names" and self.evaluation:
self.giving_names_panel = GivingNamesPanel(
self.form, self.evaluation
)
self.giving_names_panel.grid(
row=row,
column=0,
columnspan=2,
sticky="ew",
pady=(0, 8),
)
row += 1
self.complete_giving_names_button = ttk.Button(
self.form,
text="Marquer terminée",
command=self._complete_giving_names,
)
self.complete_giving_names_button.grid(
row=row,
column=0,
columnspan=2,
sticky="w",
pady=(0, 8),
)
row += 1
if step.section == REFAIRE_SECTION:
evaluation = self.evaluation
if step.id in {"refaire_selection", "refaire_correct"}:
row = self._correction_folder_buttons(row)
row = self._refaire_restart_controls(row)
if step.id == "refaire_selection" and evaluation:
choices, preferred = self._annotation_directory_choices("export")
draft = self.state_store.step(step.id).get("values", {})
self.refaire_panel = RefaireSelection(self.form, evaluation, draft, choices, preferred)
self.refaire_panel.grid(row=row, column=0, columnspan=2, sticky="nsew")
else:
try:
entries = self._refaire_selection()
summary = f"{len(entries)} copie(s) sélectionnée(s) :\n" + " ; ".join(f"{name} : {', '.join(labels) or 'toute la copie'}" for name, labels in entries[:5])
if len(entries) > 5:
summary += f" ; et {len(entries) - 5} autres (voir la sélection)."
source = self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "")
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
layout = resolve_layout(entries, EvaluationWorkspace(evaluation).read_labels(), choice)
summary += f"\nPassage principal : {source} — PDF à vérifier : {'par question' if layout == 'grouped' else 'par copie'}"
except (OSError, ValueError, TypeError) as exc:
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"}:
row = self._correction_folder_buttons(row)
copy_selection_steps = {
"page_splitter",
"crop_blank_margins",
"cutleft",
"labels",
"plotting",
"splitting",
}
if step.id in copy_selection_steps:
evaluation = self.evaluation
paths = copy_pdf_paths(evaluation) if evaluation else []
self.copy_paths = {path.name: path for path in paths}
names = list(self.copy_paths)
copies = ttk.LabelFrame(self.form, text="Copies détectées", padding=7)
copies.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
copies.columnconfigure(0, weight=1)
summary = (
f"{len(names)} copie(s) : {', '.join(names)}"
if names
else "Aucune copie PDF détectée."
)
ttk.Label(copies, text=summary, wraplength=570, justify="left").grid(
row=0, column=0, columnspan=2, sticky="ew"
)
if self.copy_var.get() not in names:
self.copy_var.set(names[0] if names else "")
selector = ttk.Combobox(
copies,
textvariable=self.copy_var,
values=names,
state="readonly" if names else "disabled",
)
selector.grid(row=1, column=0, sticky="ew", pady=(7, 0))
ttk.Button(
copies,
text="Afficher la copie",
command=self._open_selected_copy,
state="normal" if names else "disabled",
).grid(row=1, column=1, padx=(6, 0), pady=(7, 0))
if step.id in copy_selection_steps:
actions = ttk.Frame(copies)
actions.grid(row=2, column=0, columnspan=2, sticky="w", pady=(7, 0))
label = "Refaire la copie sélectionnée" if step.id == "page_splitter" else "Cibler la copie sélectionnée"
ttk.Button(actions, text=label, command=self._target_selected_copy,
state="normal" if names else "disabled").pack(side="left")
ttk.Button(actions, text="Cibler tout le dossier", command=self._target_all_pages).pack(side="left", padx=6)
help_text = (
"La reprise utilise l’original conservé. "
if step.id == "page_splitter"
else "Seule la copie ciblée sera analysée par Gemini. "
if step.id == "labels"
else "Seule la copie ciblée sera vérifiée. "
if step.id == "plotting"
else "Seule la copie ciblée sera traitée. "
)
ttk.Label(copies, text=help_text + "Vérifiez la commande puis cliquez sur Exécuter.",
wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
if step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
try:
marked = copy_errors(EvaluationWorkspace(evaluation)) if evaluation else {}
marked_summary = ", ".join(marked) or "Aucune copie signalée."
except (CliError, OSError, ValueError, RuntimeError) as exc:
marked = {}
marked_summary = f"Lecture des copies signalées impossible : {exc}"
self.marked_copies_button = ttk.Button(
copies, text=f"Traiter les copies signalées ({len(marked)})", command=self._run_marked_copies,
state="normal" if marked and not self.active_step_id and not self.runner.running else "disabled",
)
self.marked_copies_button.grid(row=4, column=0, columnspan=2, sticky="w", pady=(7, 0))
ttk.Label(copies, text=marked_summary, wraplength=570).grid(
row=5, column=0, columnspan=2, sticky="w")
if step.id == 'labels' and evaluation:
try:
orientation = orientation_reports(EvaluationWorkspace(evaluation))
except (OSError, ValueError, TypeError) as exc:
orientation = {}
self._append_console(f'Rapports d’orientation illisibles : {exc}\n')
if orientation:
lines = []
for name, report in orientation.items():
pages = sorted({p for values in report.get('images', {}).values() for p in values})
lines.append(f'{name} : pages à l’envers {pages}' if pages else f'{name} : orientation à vérifier')
if report.get('warning'):
lines.append(report['warning'])
ttk.Label(copies, text='\n'.join(lines), wraplength=570).grid(
row=6, column=0, columnspan=2, sticky='w')
orientation_actions = ttk.Frame(copies)
orientation_actions.grid(row=7, column=0, columnspan=2, sticky='w', pady=7)
state = 'disabled' if self.active_step_id or self.runner.running else 'normal'
ttk.Button(orientation_actions, text='Signaler ces copies', state=state,
command=self._flag_orientation_copies).pack(side='left')
ttk.Button(orientation_actions, text='Corriger automatiquement l’orientation', state=state,
command=lambda: self._run_current_step(orientation_repair=True)).pack(side='left', padx=6)
row += 1
if step.id == "plotting":
shortcuts = ttk.LabelFrame(self.form, text="Raccourcis clavier", padding=7)
shortcuts.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
ttk.Label(
shortcuts,
text="\n".join(plotting_shortcut_lines()),
justify="left",
).grid(row=0, column=0, sticky="w")
row += 1
return row
def _refaire_restart_controls(self, row: int) -> int:
controls = ttk.Frame(self.form)
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
running = bool(self.active_step_id) or self.runner.running
ttk.Button(controls, text="Nouvelle reprise", command=lambda: self._start_refaire_pass(False),
state="disabled" if running else "normal").pack(side="left", padx=(0, 6))
ttk.Button(controls, text="Refaire la même sélection", command=lambda: self._start_refaire_pass(True),
state="disabled" if running else "normal").pack(side="left")
ttk.Label(self.form, text="« Nouvelle reprise » : à utiliser après avoir importé les résultats et mis à jour les copies finales.",
wraplength=580, justify="left").grid(row=row + 1, column=0, columnspan=2, sticky="w", pady=(0, 6))
return row + 2
def _start_refaire_pass(self, keep_selection: bool) -> None:
workspace = self.state_store.workspace
if workspace is None:
messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.")
return
if self.active_step_id or self.runner.running:
messagebox.showwarning("Traitement en cours", "Attendez la fin du traitement avant de commencer une reprise.")
return
try:
if keep_selection:
load_selection(workspace.root)
title = "Refaire la même sélection" if keep_selection else "Nouvelle reprise"
message = RESTART_WARNING
if self.state_store.step("refaire_merge").get("status") != "success":
message += "\n\nLa reprise actuelle n’est pas marquée comme fusionnée. Les résultats non fusionnés ne seront pas intégrés aux copies finales."
message += "\n\nLes fichiers de la reprise précédente seront conservés. Continuer ?"
if not messagebox.askyesno(title, message, icon="warning", default="no"):
return
self._save_current_form()
updated, ident = begin_pass(workspace, self.state_store.data, keep_selection=keep_selection)
self.state_store.data = updated
# Do not save the old form over the fresh pass during navigation.
self.current_step = None
self.refaire_panel = None
self.arg_vars.clear()
self._populate_tree()
self.tree.selection_set("refaire_selection")
self.tree.see("refaire_selection")
self.info_var.set(f"Nouvelle reprise : {ident}. Enregistrez la sélection pour continuer.")
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Nouvelle reprise impossible", str(exc))
def _correction_folder_buttons(self, row: int) -> int:
buttons = ttk.Frame(self.form)
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
for directory, label in (("Sol", "Corrigés (Sol)"), ("Persp", "Consignes de notation (Persp)")):
ttk.Button(buttons, text=label,
command=lambda name=directory: self._open_desktop_path(self.evaluation / name if self.evaluation else None, f"dossier {name}")).pack(side="left", padx=(0, 6))
return row + 1
def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]:
evaluation = self.evaluation
detected = detected_annotation_directories(evaluation) if evaluation else ()
choices = detected or ANNOTATION_DIRECTORIES
if step_id == "giving_names":
choices = ANNOTATION_DIRECTORIES
if step_id in {"export", "giving_names"}:
annotation = self.state_store.step("annotation")
variant = annotation.get("last_run_variant", annotation.get("variant", "grouped"))
preferred = ANNOTATION_VARIANT_DIRECTORIES.get(str(variant), "BGnot")
else:
export = self.state_store.step("export")
last_values = export.get("last_run_values", export.get("values", {}))
preferred = (
str(last_values.get("annotation_dir", "BGnot"))
if isinstance(last_values, dict)
else "BGnot"
)
return choices, preferred if preferred in choices else choices[0]
def _open_persp(self) -> None:
evaluation = self.evaluation
self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp")
def _open_label_groups(self) -> None:
evaluation = self.evaluation
if self.current_step and self.current_step.id == "statement_groups":
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 / "label_groups" if evaluation else None,
"fichier label_groups",
)
def _open_selected_copy(self) -> None:
self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie")
def _target_selected_copy(self) -> None:
path = self.copy_paths.get(self.copy_var.get())
if path and "target" in self.arg_vars:
if "marked" in self.arg_vars:
self.arg_vars["marked"].set(False)
self.arg_vars["target"].set(str(path))
self.info_var.set(f"Copie ciblée : {path.name} — cliquez sur Exécuter.")
def _target_all_pages(self) -> None:
if "target" in self.arg_vars:
if "marked" in self.arg_vars:
self.arg_vars["marked"].set(False)
self.arg_vars["target"].set(self._evaluation_arg())
def _run_marked_copies(self) -> None:
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft", "labels", "plotting"}
or self.active_step_id or self.runner.running or not self.state_store.evaluation):
return
self.arg_vars["target"].set(self._evaluation_arg())
self.arg_vars["marked"].set(True)
self._run_current_step()
def _flag_orientation_copies(self) -> None:
if not self.evaluation or self.active_step_id or self.runner.running:
return
from copienator.commands.repair_orientation import run
run(EvaluationWorkspace(self.evaluation), self.evaluation, flag=True)
self.info_var.set('Copies signalées : reprenez les étapes précédentes avec « Traiter les copies signalées ».')
self._render_step()
def _open_desktop_path(self, path: Path | None, label: str) -> None:
if path is None or not path.exists():
messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.")
return
try:
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture impossible", str(exc))
def _edit_recorrection(self) -> None:
if self.evaluation is None:
return
try:
path = prepare_selection_file(self.evaluation)
except OSError as exc:
messagebox.showerror("Préparation impossible", str(exc))
return
self._open_desktop_path(path, "fichier refaire.json")
def _select_variant(self, index: int) -> None:
if not self.current_step or index < 0:
return
self._save_current_form()
self.variant_var.set(self.current_step.variants[index].id)
if self.state_store.evaluation:
self.state_store.update_step(self.current_step.id, variant=self.variant_var.get())
self._render_step()
def _current_variant(self) -> CommandVariant:
assert self.current_step is not None
selected = self.variant_var.get()
return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0])
def _values(self) -> dict[str, object]:
if self.current_step and self.current_step.section == REFAIRE_SECTION:
if self.current_step.id == "refaire_selection" and self.refaire_panel:
return self.refaire_panel.values()
return {
"target": self._evaluation_arg(),
"annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot"),
**{name: variable.get() for name, variable in self.arg_vars.items()},
}
return {name: variable.get() for name, variable in self.arg_vars.items()}
def _save_current_form(self) -> None:
if self._rendering or not self.current_step or not self.state_store.evaluation:
return
saved_values = self.state_store.step(self.current_step.id).get("values", {})
merged_values = dict(saved_values) if isinstance(saved_values, dict) else {}
merged_values.update(self._values())
self.state_store.update_step(
self.current_step.id,
variant=self.variant_var.get() or self.current_step.variants[0].id,
values=merged_values,
extra=(
self.extra_var.get()
if self.current_step.extra_arguments_help
else ""
),
)
def _evaluation_arg(self) -> str:
evaluation = self.evaluation
return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>"
def _progression_ids(self, step_id: str) -> list[str]:
redo = step_id.startswith("refaire_")
show_batches = self._has_batch_correction()
return [step.id for step in self.steps if step.id.startswith("refaire_") == redo
and (not step.skip_for_live_correction or show_batches)]
def _refaire_selection(self) -> list[list]:
evaluation = self.evaluation
if not evaluation or not self.state_store.evaluation:
raise ValueError("Chargez d’abord une évaluation.")
entry = self.state_store.step("refaire_selection")
if entry.get("status") != "success":
raise ValueError("Enregistrez d’abord les copies et les questions à refaire.")
values = entry.get("values", {})
selection = load_selection(evaluation)
if selection != validate_selection(values.get("selection"), available_copies(evaluation), EvaluationWorkspace(evaluation).read_labels()):
raise ValueError("La sélection a changé. Enregistrez-la avant de continuer.")
source = values.get("annotation_dir")
if source not in ANNOTATION_DIRECTORIES or not (evaluation / source).is_dir():
raise ValueError("Choisissez un dossier existant pour le passage principal.")
return selection
def _save_refaire_selection(self) -> None:
if not self.refaire_panel or not self.evaluation:
return
try:
if self.state_store.step("refaire_merge").get("status") == "success":
messagebox.showinfo("Reprise terminée", "Utilisez « Nouvelle reprise » ou « Refaire la même sélection » pour commencer un autre passage.")
return
values = self.refaire_panel.values()
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
source = values["annotation_dir"]
if source not in detected_annotation_directories(self.evaluation):
raise ValueError("Choisissez un dossier existant pour le passage principal.")
workspace = EvaluationWorkspace(self.evaluation)
output = workspace.annotation_dir("refaire")
if output.is_dir() and any(output.iterdir()) and workspace.refaire_file.is_file():
from copienator import read_json
if read_json(workspace.refaire_file) != entries:
raise ValueError("Des PDF de vérification existent déjà. Importez et fusionnez leurs résultats, puis utilisez « Nouvelle reprise » pour changer de sélection.")
if workspace.refaire_session_dir is not None:
atomic_write_json(workspace.refaire_session_dir / "refaire.json", entries)
atomic_write_json(workspace.refaire_session_dir / "session.json", {"id": workspace.refaire_session_id, "values": values})
atomic_write_json(self.evaluation / "refaire.json", entries)
self._mark_step("success")
self._move_selection_from("refaire_selection", 1)
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
def _refaire_commands(self) -> list[list[str]]:
assert self.current_step is not None
selection = self._refaire_selection()
values = self._values()
targets = [self._evaluation_arg()]
if self.current_step.id in {"refaire_review", "refaire_split"}:
copies = available_copies(self.evaluation)
targets = [str(copies[name]) for name, _labels in selection]
variant = self._current_variant()
if self.current_step.id == "refaire_annotate":
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
layout = resolve_layout(selection, EvaluationWorkspace(self.evaluation).read_labels(), choice)
variant = replace(variant, program="annotate-grouped" if layout == "grouped" else "annotate-checks")
return [build_command(
self.repository,
self.current_step,
variant,
{**values, "target": target},
self._evaluation_arg(),
verbose=bool(self.verbose_var.get()),
) for target in targets]
def _make_command(self) -> list[str]:
if not self.current_step:
return []
if self.current_step.section == REFAIRE_SECTION:
return self._refaire_commands()[0]
allow_extra = bool(self.current_step.extra_arguments_help) and (
not self.current_step.extra_arguments_variants
or self._current_variant().id in self.current_step.extra_arguments_variants
)
return build_command(
self.repository,
self.current_step,
self._current_variant(),
self._values(),
self._evaluation_arg(),
self.extra_var.get() if allow_extra else "",
verbose=bool(self.verbose_var.get()),
)
def _update_command_preview(self) -> None:
if self._rendering or not self.current_step:
return
self.copy_command_button.configure(state="disabled")
if self.current_step.is_manual:
self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.")
return
try:
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
preview = "\n".join(command_display(command) for command in commands[:2])
if len(commands) > 2:
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
self.command_var.set(preview)
self.copy_command_button.configure(state="normal")
except (OSError, ValueError, TypeError) as exc:
self.command_var.set(f"Arguments invalides : {exc}")
def _copy_text(self, text: str) -> None:
self.clipboard_clear()
self.clipboard_append(text)
def _copy_command(self) -> None:
if not self.current_step or self.current_step.is_manual:
return
try:
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
self._copy_text("\n".join(command_display(command) for command in commands))
self.info_var.set("Commande(s) copiée(s) — à exécuter depuis " + str(self.repository))
except (OSError, ValueError, TypeError) as exc:
self.info_var.set(f"Copie impossible : {exc}")
def _copy_console_selection(self, _event=None) -> str:
if self.console.tag_ranges("sel"):
self._copy_text(self.console.get("sel.first", "sel.last"))
return "break"
def _select_console_all(self, _event=None) -> str:
self.console.tag_add("sel", "1.0", "end-1c")
self.console.focus_set()
return "break"
def _copy_console_all(self) -> None:
self._copy_text(self.console.get("1.0", "end-1c"))
def _show_console_menu(self, event) -> str:
try:
self.console_menu.tk_popup(event.x_root, event.y_root)
finally:
self.console_menu.grab_release()
return "break"
def _browse_target_file(self, variable: tk.Variable) -> None:
selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository)
if selected:
variable.set(selected)
def _browse_target_dir(self, variable: tk.Variable) -> None:
selected = filedialog.askdirectory(initialdir=self.evaluation or self.repository)
if selected:
variable.set(selected)
def _validate_arguments(self) -> bool:
if not self.current_step:
return False
variant = self._current_variant()
for spec in self.current_step.arguments:
if spec.variants and variant.id not in spec.variants:
continue
value = self.arg_vars.get(spec.name)
if spec.kind == "int" and value and str(value.get()).strip():
try:
int(str(value.get()))
except ValueError:
messagebox.showerror("Argument invalide", f"« {spec.label} » doit être un entier.")
return False
try:
self._make_command()
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Arguments invalides", str(exc))
return False
return True
def _run_current_step(self, *, syncthing_ready=False, review_command=None, orientation_repair=False) -> None:
step = self.current_step
evaluation = self.evaluation
if orientation_repair and (not step or step.id != 'labels'):
return
if review_command is not None and (
not step or step.id != "personal_read" or review_command not in {"re", "wsn", "we", "mei", "wsnm"}
):
return
if not step or not evaluation or not self.state_store.evaluation:
messagebox.showerror("Évaluation absente", "Chargez d’abord un dossier d’évaluation.")
return
if step.id == "batch_status" and self.batch_monitor.active:
return
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 == "refaire_selection":
self._save_refaire_selection()
return
if step.id == "prepare_recorrection":
try:
load_selection(evaluation)
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
return
self._mark_step("success")
self._select_refaire_correction()
return
if step.section == REFAIRE_SECTION:
try:
self._refaire_selection()
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Sélection à vérifier", str(exc))
return
if (step.is_manual or step.id == "personal_create"
or (step.id == "personal_read" and review_command is None)):
self._mark_step("success")
if step.id == "edit_scores":
entry = self.state_store.step("read_annotations")
values = dict(entry.get("values", {}))
values["update_score"] = True
self.state_store.update_step("read_annotations", status="stale", values=values)
self._populate_tree()
self.tree.selection_set("read_annotations")
self.tree.see("read_annotations")
else:
self._move_selection_from(step.id, 1)
return
if os.name == "nt" and step.id != "statement":
labels_path = evaluation / "labels"
if labels_path.is_file():
labels = [
line.strip()
for line in labels_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
try:
validate_windows_labels(labels)
except WindowsLabelError as exc:
messagebox.showerror("Labels incompatibles avec Windows", str(exc))
return
if not self._validate_arguments():
return
missing = self._missing_requirements(step)
if missing and not messagebox.askyesno(
"Prérequis non détectés",
"Les éléments suivants n’ont pas été trouvés :\n\n"
+ "\n".join(missing)
+ "\n\nLancer tout de même la commande ?",
):
return
variant = self._current_variant()
command = self._make_command()
if orientation_repair:
command = [sys.executable, '-m', 'copienator', 'repair-orientation', str(evaluation)]
if step.id == "personal_read":
command = ["gestion_classe", review_command]
if review_command not in {"mei", "wsnm"}:
evaluation_name = str(self._values().get("evaluation_name", evaluation.name)).strip()
if evaluation_name:
command.append(evaluation_name)
if variant.dangerous and not messagebox.askyesno(
"Nettoyage irréversible" if step.id == "clean" else "Confirmation requise",
variant.danger_warning
or "Cette commande réinitialise ou supprime des résultats de correction. Continuer ?",
icon="warning",
):
return
if step.id == "export" and not syncthing_ready:
def continue_export():
if self.current_step is step and self.evaluation == evaluation:
self._run_current_step(syncthing_ready=True)
self.syncthing_panel.ensure_started(continue_export)
return
self._save_current_form()
run_values = self._values()
self._invalidate_after_step(step, variant.id)
self.state_store.update_step(
step.id,
status="running",
command=command_display(command),
last_run_variant=variant.id,
last_run_values=run_values,
)
self.active_step_id = step.id
if step.id == "statement":
self._reset_latex_warnings()
if step.id == "correction":
self._correction_progress_buffer = ""
self.info_var.set("Correction : préparation des groupes…")
workspace = self.state_store.workspace
assert workspace is not None
log_path = workspace.log_path(step.id)
environment = build_runner_environment(
os.environ,
self.api_key_var.get(),
self.proxy_var.get(),
self.use_proxy_var.get(),
)
self.pending_refaire_commands = self._refaire_commands()[1:] if step.section == REFAIRE_SECTION else []
self.refaire_command_index = 1
self._append_console(f"\n$ {command_display(command)}\n")
try:
self.runner.start(command, self.repository, environment, log_path)
if step.id == "personal_read" and review_command == "wsnm" and not self.state_store.step(step.id).get("wsnm_run"):
self.state_store.update_step(step.id, wsnm_run=True)
values = dict(self.state_store.step("final_score").get("values", {}))
values["copy_merged_histogramme"] = True
self.state_store.update_step("final_score", values=values)
if step.id == 'statement_persp':
self.arg_vars['delete_persp'].set(False)
self._save_current_form()
if step.id == 'statement' and 'restart' in self.arg_vars:
self.arg_vars['restart'].set(False)
self._save_current_form()
if step.id == "correction" and variant.id in {"batch", "hybrid"}:
self.state_store.update_step("correction", batch_started=True)
except (OSError, RuntimeError) as exc:
self.state_store.update_step(step.id, status="failed")
self.state_store.add_history(
{"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)}
)
self.active_step_id = None
self.pending_refaire_commands = []
self._append_console(f"Impossible de lancer la commande : {exc}\n")
messagebox.showerror("Échec du lancement", str(exc))
self._populate_tree()
self._update_controls()
def _invalidate_after_step(self, step: StepDefinition, variant_id: str) -> None:
# Checking remote job status does not change any downstream input.
if step.id == "batch_status":
return
ordered_ids = self._progression_ids(step.id)
if step.id == "correction" and variant_id in {"live", "refaire", "integrate"}:
# These modes reuse correction inputs without generating new batches.
ordered_ids = [step_id for step_id in ordered_ids
if not self.step_by_id[step_id].skip_for_live_correction]
self.state_store.invalidate_after(ordered_ids, step.id)
def _mark_step(
self, status: str, *, automatic: bool = False, reason: str | None = None
) -> None:
if not self.current_step or not self.state_store.evaluation or self.active_step_id:
return
self._save_current_form()
# Score edits already invalidate downstream steps when Set saves a change.
if self.current_step.id != "edit_scores":
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] = {
"step": self.current_step.id,
"status": status,
"manual": not automatic,
}
if reason:
history["reason"] = reason
self.state_store.add_history(history)
self._populate_tree()
detail = f" ({reason})" if reason else ""
self.info_var.set(
f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}."
)
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):
return
self._save_current_form()
self.state_store.update_step("rename", status="success")
self.state_store.add_history({
"step": "rename", "status": "success", "manual": True,
"reason": "Noms des PDF validés sans renommer",
})
self._populate_tree()
self.info_var.set("Renommage validé — aucun fichier modifié.")
def _remark_changed(self, copy_id: str, label: str, previous: str, current: str) -> None:
self._score_changed(copy_id, label, previous, current, remark=True)
def _score_changed(self, copy_id: str, label: str, previous: str, current: str,
*, remark: bool = False) -> None:
step = self.step_by_id["edit_scores"]
self._invalidate_after_step(step, "default")
self.state_store.update_step(step.id, status="detected")
self.state_store.update_step("read_annotations", status="stale")
self.state_store.add_history({
"step": step.id, "copy_id": copy_id, "label": label,
("previous_remark" if remark else "previous_score"): previous,
("remark" if remark else "score"): current, "manual": True,
})
# Refresh statuses without rebuilding the form between score edits.
for item in self.steps:
if self.tree.exists(item.id):
status = self._step_status(item)
self.tree.item(item.id, values=(STATUS_LABELS.get(status, status),))
def _complete_giving_names(self) -> None:
if (
not self.current_step
or self.current_step.id != "giving_names"
or not self.evaluation
or self.active_step_id
or self.runner.running
):
return
return_dir = EvaluationWorkspace(self.evaluation).return_dir
if not return_dir.is_dir():
messagebox.showerror(
"A Rendre absent",
"Exécutez d’abord l’attribution des noms.",
)
return
issues = find_name_issues(self.evaluation)
if issues and not messagebox.askyesno(
"Noms encore à vérifier",
f"{len(issues)} copie(s) ont encore un nom inconnu ou dupliqué. "
"Marquer quand même l’étape comme terminée ?",
icon="warning",
):
return
self._mark_step("success")
self._move_selection_from("giving_names", 1)
def _skip_step(self) -> None:
if self.current_step and self.current_step.optional:
step_id = self.current_step.id
self._mark_step("skipped")
self._move_selection_from(step_id, 1)
def _poll_runner(self) -> None:
while True:
try:
event, payload = self.runner.events.get_nowait()
except queue.Empty:
break
if event == "output":
output = str(payload)
self._track_correction_progress(output)
self._track_latex_warnings(output)
self._append_console(output)
elif event == "input_echo":
self._append_console(f"> {payload}")
elif event == "runner_error":
self._append_console(f"\nErreur du lanceur : {payload}\n")
elif event == "finished":
return_code, interrupted = payload
self._finish_process(int(return_code), bool(interrupted))
self.after(60, self._poll_runner)
def _track_correction_progress(self, output: str) -> None:
if self.active_step_id != "correction":
return
self._correction_progress_buffer = (
self._correction_progress_buffer + output
)[-512:]
matches = list(CORRECTION_PROGRESS_RE.finditer(
self._correction_progress_buffer
))
if not matches:
return
completed, total = (int(value) for value in matches[-1].groups())
percent = round(100 * completed / total) if total else 100
self.info_var.set(
f"Correction : {completed} groupe(s) traité(s) sur {total} "
f"({percent} %)."
)
def _reset_latex_warnings(self) -> None:
self._latex_warning_buffer = ""
self._latex_warning_paths = []
if hasattr(self, "latex_warning_button"):
self.latex_warning_button.pack_forget()
def _track_latex_warnings(self, output: str, *, flush: bool = False) -> None:
if self.active_step_id != "statement":
return
combined = self._latex_warning_buffer + output
lines = combined.splitlines(keepends=True)
self._latex_warning_buffer = ""
if lines and not lines[-1].endswith(("\n", "\r")) and not flush:
self._latex_warning_buffer = lines.pop()
for line in lines:
marker_at = line.find(LATEX_WARNING_MARKER)
if marker_at < 0:
continue
raw_path = line[marker_at + len(LATEX_WARNING_MARKER):].strip()
if not raw_path:
continue
path = Path(raw_path).expanduser()
if not path.is_absolute() and self.evaluation:
path = self.evaluation / path
path = path.resolve()
if path not in self._latex_warning_paths:
self._latex_warning_paths.append(path)
if self._latex_warning_paths:
self.latex_warning_button.pack(side="left", padx=6)
def _show_current_latex_warnings(self) -> None:
if not self._latex_warning_paths:
messagebox.showinfo("Compilation LaTeX", "Aucun problème de compilation détecté.")
return
dialog = tk.Toplevel(self)
dialog.title("Problèmes de compilation LaTeX")
dialog.geometry("900x380")
dialog.transient(self)
dialog.columnconfigure(0, weight=1)
dialog.rowconfigure(1, weight=1)
ttk.Label(
dialog,
text=(
f"{len(self._latex_warning_paths)} fichier(s) n’ont pas été compilés "
"correctement. Sélectionnez-en un pour ouvrir ses éléments."
),
padding=10,
wraplength=850,
justify="left",
).grid(row=0, column=0, sticky="ew")
paths = tk.Listbox(dialog, exportselection=False)
for pdf_path in self._latex_warning_paths:
try:
label = str(pdf_path.relative_to(self.evaluation)) if self.evaluation else str(pdf_path)
except ValueError:
label = str(pdf_path)
paths.insert("end", label)
paths.grid(row=1, column=0, sticky="nsew", padx=10)
paths.selection_set(0)
actions = ttk.Frame(dialog, padding=10)
actions.grid(row=2, column=0, sticky="ew")
def selected_pdf() -> Path:
selection = paths.curselection()
index = selection[0] if selection else 0
return self._latex_warning_paths[index]
def open_folder() -> None:
self._open_desktop_path(selected_pdf().parent, "dossier de compilation")
def open_source() -> None:
self._open_desktop_path(selected_pdf().with_suffix(".tex"), "fichier source LaTeX")
def open_pdf() -> None:
self._open_desktop_path(selected_pdf(), "fichier PDF compilé")
ttk.Button(actions, text="Ouvrir le dossier", command=open_folder).pack(side="left")
ttk.Button(actions, text="Ouvrir le .tex", command=open_source).pack(side="left", padx=6)
ttk.Button(actions, text="Ouvrir le PDF", command=open_pdf).pack(side="left")
ttk.Button(actions, text="Fermer", command=dialog.destroy).pack(side="right")
def _batch_watch_status_changed(self, message: str) -> None:
self.batch_watch_status.set(message)
self._update_controls()
def _start_batch_watch(self) -> None:
if self.active_step_id or self.runner.running or not self.state_store.evaluation:
return
step = self.step_by_id["batch_status"]
command = build_command(self.repository, step, step.variants[0], {},
str(self.state_store.evaluation))
environment = build_runner_environment(
os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()
)
self.batch_monitor.start(command, self.repository, environment,
self.state_store.workspace.log_path("batch_status_watch"))
def _batch_results_ready(self) -> None:
evaluation = self.state_store.evaluation
if evaluation is None:
return
self.state_store.update_step("batch_status", status="success", return_code=0)
self._populate_tree()
message = f"{evaluation.name} : tous les résultats batch sont prêts à récupérer."
self.info_var.set(message)
self._notify_completion("Copienator — batchs terminés", message)
if (self.current_step and self.current_step.id == "batch_status"
and not self.active_step_id and not self.runner.running):
self._move_selection_from("batch_status", 1)
def _notify_completion(self, title: str, message: str) -> None:
try:
notify_desktop(title, message)
except (OSError, RuntimeError) as exc:
self.bell()
self._append_console(f"Notification de bureau indisponible : {exc}\n{message}\n")
def _finish_process(self, return_code: int, interrupted: bool) -> None:
step_id = self.active_step_id
if not step_id:
return
if step_id == "statement":
self._track_latex_warnings("", flush=True)
status = process_status(return_code, interrupted)
if status == "success" and self.pending_refaire_commands:
command = self.pending_refaire_commands.pop(0)
self.refaire_command_index += 1
self._append_console(f"\n$ {command_display(command)}\n")
self.state_store.update_step(step_id, command=command_display(command))
try:
self.runner.start(command, self.repository,
build_runner_environment(os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()),
self.state_store.workspace.log_path(f"{step_id}_{self.refaire_command_index}"))
self._update_controls()
return
except (OSError, RuntimeError) as exc:
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
status, return_code = "failed", 1
self.pending_refaire_commands = []
if step_id in {"refaire_merge", "modify_annotations"} and status == "success":
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
entry = self.state_store.step(downstream)
if entry.get("status") in {"success", "detected", "skipped"}:
self.state_store.update_step(downstream, status="stale")
if step_id == "clean" and status == "success":
self._append_console(
f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n"
)
self.info_var.set(
"Nettoyage terminé : la progression précédente a été supprimée."
)
self.active_step_id = None
# Do not recreate .copienator-gui.json after clean removed it.
self.state_store = StateStore()
self._populate_tree()
self._update_controls()
messagebox.showinfo(
"Nettoyage terminé",
"Les fichiers intermédiaires et la progression du GUI ont été "
"supprimés. Les copies traitées, les fichiers textuels de l’énoncé, "
"correction.json, les journaux et les livrables de A Rendre ont "
"été conservés.",
)
return
stored_status = (
"detected"
if step_id in {"giving_names", "personal_read"} and status == "success"
else status
)
self.state_store.update_step(
step_id, status=stored_status, return_code=return_code
)
if (step_id == "statement" and status == "success"
and self.state_store.step(step_id).get("last_run_variant") == "gemini"):
reason = "Barèmes déjà générés par l’analyse Gemini de l’énoncé."
self.state_store.update_step("statement_persp", status="skipped")
self.state_store.add_history({
"step": "statement_persp", "status": "skipped",
"manual": False, "reason": reason,
})
self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n")
if step_id == "read_annotations" and status == "success":
reader = self.state_store.step(step_id)
mode = annotation_mode_for_reader(reader)
values = dict(self.state_store.step("modify_annotations").get("values", {}))
values["annotation_dir"] = mode
self.state_store.update_step("modify_annotations", values=values)
if step_id == "annotation" and status == "success":
generation_variant = self.state_store.step("annotation").get(
"last_run_variant"
)
reader_variant = ANNOTATION_VARIANT_READERS.get(
str(generation_variant)
)
if reader_variant:
self.state_store.update_step(
"read_annotations", variant=reader_variant
)
self.state_store.add_history(
{
"step": step_id,
"command": self.state_store.step(step_id).get("command", ""),
"status": status,
"return_code": return_code,
}
)
title = self.step_by_id[step_id].title
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n")
self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).")
if step_id == "statement" and self._latex_warning_paths:
count = len(self._latex_warning_paths)
self.info_var.set(
f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}) — "
f"{count} problème(s) LaTeX."
)
self.active_step_id = None
self._populate_tree()
self._update_controls()
if step_id == 'labels' and self.current_step and self.current_step.id == 'labels':
self._render_step()
if step_id == 'statement_persp':
self._refresh_persp_inventory()
if step_id == "statement" and self._latex_warning_paths:
self.after_idle(self._show_current_latex_warnings)
if step_id == "personal_read":
if status == "success":
self.info_var.set("Commande terminée. Poursuivez la relecture, puis cliquez sur « Marquer réussie ».")
return
if step_id == "giving_names" and status == "success":
if self.giving_names_panel:
self.giving_names_panel.reload()
self.info_var.set(
"A Rendre a été préparé. Vérifiez les noms, puis cliquez "
"sur « Marquer terminée »."
)
return
notified_completions = {
"labels": "détection des labels terminée",
"correction": "étape de correction terminée",
"annotation": "génération des copies annotées terminée",
"read_annotations": "lecture des annotations manuscrites terminée",
}
if step_id in notified_completions and self.state_store.evaluation:
completion = notified_completions[step_id]
outcome = STATUS_LABELS[status].lower()
self._notify_completion(
f"Copienator — {completion} ({outcome})",
f"{self.state_store.evaluation.name} : {completion} — {outcome} "
f"(code {return_code}).",
)
if status == "success":
if step_id == "batch_status" and self.batch_monitor.active:
self.batch_monitor.stop()
self._batch_results_ready()
return
if step_id == "fetch_batches":
self.after_idle(self._select_batch_integration)
elif (step_id == "correction"
and self.state_store.step(step_id).get("last_run_variant") == "integrate"):
self.after_idle(self._select_after_batch_integration)
elif step_id == "manual_resolution":
self.after_idle(self._select_refaire_correction)
else:
self.after_idle(
lambda completed_id=step_id: self._move_selection_from(
completed_id, 1
)
)
def _send_input(self) -> None:
text = self.stdin_var.get()
try:
self.runner.send_input(text)
except RuntimeError as exc:
messagebox.showinfo("Aucune saisie attendue", str(exc))
return
self.stdin_var.set("")
def _interrupt(self) -> None:
graceful_correction = self.active_step_id == "correction"
question = (
"Arrêter de lancer de nouveaux appels Gemini ? Les appels déjà en "
"cours pourront se terminer et leurs résultats seront sauvegardés."
if graceful_correction
else "Envoyer une interruption au script en cours ?"
)
if self.runner.running and messagebox.askyesno("Interrompre", question):
try:
self.runner.interrupt()
self.info_var.set(
"Arrêt demandé : attente des appels Gemini en cours…"
if graceful_correction
else "Interruption demandée. Utilisez « Forcer l’arrêt » si le script ne répond pas."
)
except OSError as exc:
messagebox.showerror("Interruption impossible", str(exc))
def _force_stop(self) -> None:
if self.runner.running and messagebox.askyesno(
"Forcer l’arrêt", "Forcer l’arrêt du script et de ses processus enfants ?", icon="warning"
):
try:
self.runner.force_stop()
except OSError as exc:
messagebox.showerror("Arrêt impossible", str(exc))
def _append_console(self, text: str) -> None:
self.console.configure(state="normal")
self.console.insert("end", text)
self.console.see("end")
self.console.configure(state="disabled")
def _clear_console(self) -> None:
self.console.configure(state="normal")
self.console.delete("1.0", "end")
self.console.configure(state="disabled")
def _update_controls(self) -> None:
running = bool(self.active_step_id) or self.runner.running
if self.current_step and self.current_step.id == "personal_read":
for button in self.review_buttons:
button.configure(state="disabled" if running or not self.state_store.evaluation else "normal")
if self.current_step and self.current_step.id == "batch_status":
self.watch_batches_button.configure(state="disabled" if running or self.batch_monitor.active or not self.state_store.evaluation else "normal")
self.stop_watch_batches_button.configure(state="normal" if self.batch_monitor.active else "disabled")
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft", "labels", "plotting"}:
self.marked_copies_button.configure(state="disabled")
if self.current_step and self.current_step.id == "rename":
self.validate_rename_button.configure(
state="disabled" if running or not self.state_store.evaluation else "normal"
)
watching_current = bool(self.current_step and self.current_step.id == "batch_status"
and self.batch_monitor.active)
self.run_button.configure(state="disabled" if running or not self.current_step or watching_current else "normal")
self.skip_button.configure(state="normal" if not running and self.current_step and self.current_step.optional else "disabled")
self.interrupt_button.configure(state="normal" if running else "disabled")
self.force_button.configure(state="normal" if running else "disabled")
self.send_button.configure(state="normal" if running else "disabled")
self.stdin_entry.configure(state="normal" if running else "disabled")
if self.complete_giving_names_button is not None:
self.complete_giving_names_button.configure(
state="disabled" if running else "normal"
)
if self.score_editor_panel is not None:
self.score_editor_panel.set_busy(running)
def _move_selection(self, delta: int) -> None:
if not self.current_step:
return
self._move_selection_from(self.current_step.id, delta)
def _move_selection_from(self, step_id: str, delta: int) -> None:
ids = self._progression_ids(step_id)
try:
index = ids.index(step_id)
except ValueError:
return
target = max(0, min(len(ids) - 1, index + delta))
if target == index:
return
self.tree.selection_set(ids[target])
self.tree.see(ids[target])
def _select_batch_integration(self) -> None:
"""Prepare integration after batch results have been retrieved."""
if self.runner.running or self.active_step_id or not self.state_store.evaluation:
return
self._save_current_form()
self.state_store.update_step("correction", variant="integrate")
self.tree.selection_set("correction")
self.tree.see("correction")
def _select_after_batch_integration(self) -> None:
"""Continue integration with conflict resolution or correction cleanup."""
evaluation = self.state_store.evaluation
if self.runner.running or self.active_step_id or evaluation is None:
return
if has_manual_conflicts(evaluation / "manual_resolutions.txt"):
target = "manual_resolution"
else:
self.state_store.update_step("manual_resolution", status="success")
self.state_store.add_history({
"step": "manual_resolution", "status": "success",
"manual": False, "reason": "Aucun conflit manuel après l’intégration batch.",
})
self._populate_tree()
target = "post_correction"
self.tree.selection_set(target)
self.tree.see(target)
def _select_refaire_correction(self) -> None:
"""Return from manual conflict resolution to the main redo correction."""
if self.runner.running or self.active_step_id or not self.state_store.evaluation:
return
self.state_store.update_step("correction", variant="refaire")
self.tree.selection_set("correction")
self.tree.see("correction")
def _on_close(self) -> None:
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 ?"
):
return
try:
self.runner.force_stop()
except OSError:
pass
self._save_current_form()
self.batch_monitor.stop()
self.destroy()