Refaire fixes and GUI support
This commit is contained in:
+184
-24
@@ -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 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:
|
||||
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 d’abord 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 d’en 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:
|
||||
|
||||
Reference in New Issue
Block a user