Refaire fixes and GUI support

This commit is contained in:
2026-09-06 19:06:42 +02:00
parent a9897606ac
commit 2ff1a9b7b9
11 changed files with 1664 additions and 116 deletions
+184 -24
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import os
import queue
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 ExitCode
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json
from copienator.platform import (
WindowsLabelError,
add_platform_executable_paths,
@@ -16,6 +17,14 @@ from copienator.platform import (
)
from .diagnostics import collect_diagnostics
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
RefaireSelection,
available_copies,
load_selection,
resolve_layout,
validate_selection,
)
from .runner import ProcessRunner
from .state import StateStore
from .workflow import (
@@ -154,6 +163,9 @@ class CopienatorApp(tk.Tk):
self.arg_vars: dict[str, tk.Variable] = {}
self.copy_paths: dict[str, Path] = {}
self._rendering = False
self.refaire_panel: RefaireSelection | None = None
self.pending_refaire_commands: list[list[str]] = []
self.refaire_command_index = 0
self.title("Copienator — assistant de correction")
self.geometry("1180x820")
@@ -245,8 +257,19 @@ class CopienatorApp(tk.Tk):
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))
self.form = ttk.Frame(self.detail)
self.form.grid(row=2, column=0, sticky="nsew")
form_container = ttk.Frame(self.detail)
form_container.grid(row=2, 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)
@@ -362,6 +385,9 @@ class CopienatorApp(tk.Tk):
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.")
@@ -392,13 +418,14 @@ class CopienatorApp(tk.Tk):
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] = {}
for step in self.steps:
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=True)
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(
@@ -513,9 +540,16 @@ class CopienatorApp(tk.Tk):
if not step:
return
self._rendering = True
redo = step.section == REFAIRE_SECTION
self.console.configure(height=8 if redo else 14)
self.rowconfigure(1, weight=4 if redo else 3)
self.rowconfigure(2, weight=1 if redo else 2)
self.form_canvas.configure(height=(340 if step.id == "refaire_selection" else 260) if redo else 200)
self.form_canvas.yview_moveto(0)
for child in self.form.winfo_children():
child.destroy()
self.arg_vars.clear()
self.refaire_panel = 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}:
@@ -544,6 +578,8 @@ class CopienatorApp(tk.Tk):
variant = self._current_variant()
evaluation_arg = self._evaluation_arg()
for spec in step.arguments:
if step.section == REFAIRE_SECTION:
continue
if spec.variants and variant.id not in spec.variants:
continue
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
@@ -582,27 +618,44 @@ class CopienatorApp(tk.Tk):
row += 1
row += 1
if not step.is_manual:
if not step.is_manual and step.section != REFAIRE_SECTION:
ttk.Label(self.form, text="Arguments supplémentaires").grid(
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
)
ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4))
self.run_button.configure(text="Marquer terminée" if step.is_manual else "Exécuter")
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
self.skip_button.configure(state="normal" if step.optional else "disabled")
self._rendering = False
self._update_command_preview()
self._update_controls()
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
if step.id == "review_persp":
ttk.Button(
self.form,
text="Ouvrir le dossier Persp",
command=self._open_persp,
).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)
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)
if step.section == "Prétraitement des copies":
evaluation = self.evaluation
paths = copy_pdf_paths(evaluation) if evaluation else []
@@ -646,6 +699,14 @@ class CopienatorApp(tk.Tk):
row += 1
return row
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 ()
@@ -696,6 +757,10 @@ class CopienatorApp(tk.Tk):
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")}
return {name: variable.get() for name, variable in self.arg_vars.items()}
def _save_current_form(self) -> None:
@@ -715,9 +780,62 @@ class CopienatorApp(tk.Tk):
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_")
return [step.id for step in self.steps if step.id.startswith("refaire_") == redo]
def _refaire_selection(self) -> list[list]:
evaluation = self.evaluation
if not evaluation or not self.state_store.evaluation:
raise ValueError("Chargez dabord une évaluation.")
entry = self.state_store.step("refaire_selection")
if entry.get("status") != "success":
raise ValueError("Enregistrez dabord 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:
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.")
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()) 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]
return build_command(
self.repository,
self.current_step,
@@ -731,11 +849,15 @@ class CopienatorApp(tk.Tk):
if self._rendering or not self.current_step:
return
if self.current_step.is_manual:
self.command_var.set("Étape manuelle — aucune commande ne sera exécutée.")
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:
self.command_var.set(command_display(self._make_command()))
except ValueError as exc:
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)
except (OSError, ValueError, TypeError) as exc:
self.command_var.set(f"Arguments invalides : {exc}")
def _browse_target_file(self, variable: tk.Variable) -> None:
@@ -764,7 +886,7 @@ class CopienatorApp(tk.Tk):
return False
try:
self._make_command()
except ValueError as exc:
except (OSError, ValueError, TypeError) as exc:
messagebox.showerror("Arguments invalides", str(exc))
return False
return True
@@ -775,11 +897,22 @@ class CopienatorApp(tk.Tk):
if not step or not evaluation or not self.state_store.evaluation:
messagebox.showerror("Évaluation absente", "Chargez dabord un dossier d’évaluation.")
return
if self.runner.running:
if self.active_step_id or self.runner.running:
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant den lancer un autre.")
return
if step.id == "refaire_selection":
self._save_refaire_selection()
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:
self._mark_step("success")
if step.section == REFAIRE_SECTION:
self._move_selection_from(step.id, 1)
return
if os.name == "nt" and step.id != "statement":
labels_path = evaluation / "labels"
@@ -816,7 +949,7 @@ class CopienatorApp(tk.Tk):
self._save_current_form()
run_values = self._values()
ordered_ids = [item.id for item in self.steps]
ordered_ids = self._progression_ids(step.id)
self.state_store.invalidate_after(ordered_ids, step.id)
self.state_store.update_step(
step.id,
@@ -837,6 +970,8 @@ class CopienatorApp(tk.Tk):
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)
@@ -846,6 +981,7 @@ class CopienatorApp(tk.Tk):
{"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()
@@ -854,10 +990,10 @@ class CopienatorApp(tk.Tk):
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:
if not self.current_step or not self.state_store.evaluation or self.active_step_id:
return
self._save_current_form()
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
self.state_store.invalidate_after(self._progression_ids(self.current_step.id), self.current_step.id)
self.state_store.update_step(self.current_step.id, status=status)
history: dict[str, object] = {
"step": self.current_step.id,
@@ -875,7 +1011,10 @@ class CopienatorApp(tk.Tk):
def _skip_step(self) -> None:
if self.current_step and self.current_step.optional:
step_id = self.current_step.id
self._mark_step("skipped")
if step_id.startswith("refaire_"):
self._move_selection_from(step_id, 1)
def _poll_runner(self) -> None:
while True:
@@ -899,6 +1038,26 @@ class CopienatorApp(tk.Tk):
if not step_id:
return
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 == "refaire_merge" 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"
@@ -977,8 +1136,9 @@ class CopienatorApp(tk.Tk):
self.console.configure(state="disabled")
def _update_controls(self) -> None:
running = self.runner.running
running = bool(self.active_step_id) or self.runner.running
self.run_button.configure(state="disabled" if running or not self.current_step 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")
@@ -990,7 +1150,7 @@ class CopienatorApp(tk.Tk):
self._move_selection_from(self.current_step.id, delta)
def _move_selection_from(self, step_id: str, delta: int) -> None:
ids = [step.id for step in self.steps]
ids = self._progression_ids(step_id)
try:
index = ids.index(step_id)
except ValueError: