miscs (Interro02) : horizontal cutting resolution

This commit is contained in:
2026-09-14 22:20:46 +02:00
parent 5080274e8f
commit 0a86403ca6
28 changed files with 1447 additions and 59 deletions
+84 -2
View File
@@ -21,6 +21,9 @@ from copienator.platform import (
)
from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor
from .notifications import notify_desktop
from .manual_resolution import ManualResolutionPanel
from .refaire import SECTION as REFAIRE_SECTION
from .refaire import (
RefaireSelection,
@@ -274,6 +277,10 @@ class CopienatorApp(tk.Tk):
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())
@@ -543,6 +550,7 @@ class CopienatorApp(tk.Tk):
if not evaluation or not evaluation.is_dir():
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
return
self.batch_monitor.stop()
self._save_current_form()
self.state_store.load(evaluation)
for step in self.steps:
@@ -744,7 +752,7 @@ class CopienatorApp(tk.Tk):
return
self._rendering = True
redo = step.section == REFAIRE_SECTION
expanded_form = redo or step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}
expanded_form = redo or step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels", "manual_resolution"}
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)
@@ -830,6 +838,18 @@ class CopienatorApp(tk.Tk):
attach_tooltip(widget, spec.help)
row += 1
if step.id == "manual_resolution":
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
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
@@ -852,6 +872,23 @@ class CopienatorApp(tk.Tk):
self._update_controls()
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
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
@@ -1299,6 +1336,8 @@ 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 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 den lancer un autre.")
return
@@ -1448,6 +1487,39 @@ class CopienatorApp(tk.Tk):
self._finish_process(int(return_code), bool(interrupted))
self.after(60, self._poll_runner)
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)
try:
notify_desktop("Copienator — batchs terminés", message)
except (OSError, RuntimeError) as exc:
self.bell()
self._append_console(f"Notification de bureau indisponible : {exc}\n{message}\n")
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 _finish_process(self, return_code: int, interrupted: bool) -> None:
step_id = self.active_step_id
if not step_id:
@@ -1509,6 +1581,10 @@ class CopienatorApp(tk.Tk):
self._populate_tree()
self._update_controls()
if status == "success":
if step_id == "batch_status" and self.batch_monitor.active:
self.batch_monitor.stop()
self._batch_results_ready()
return
self.after_idle(lambda completed_id=step_id: self._move_selection_from(completed_id, 1))
def _send_input(self) -> None:
@@ -1552,13 +1628,18 @@ class CopienatorApp(tk.Tk):
def _update_controls(self) -> None:
running = bool(self.active_step_id) or self.runner.running
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"}:
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"
)
self.run_button.configure(state="disabled" if running or not self.current_step 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")
@@ -1593,4 +1674,5 @@ class CopienatorApp(tk.Tk):
except OSError:
pass
self._save_current_form()
self.batch_monitor.stop()
self.destroy()
+81
View File
@@ -0,0 +1,81 @@
"""Non-blocking, cancellable batch checks while the desktop GUI is open."""
import queue
from .runner import ProcessRunner
CHECK_INTERVAL_MS = 5 * 60 * 1000
class BatchMonitor:
def __init__(self, scheduler, on_ready, on_status, on_output):
self.scheduler = scheduler
self.on_ready = on_ready
self.on_status = on_status
self.on_output = on_output
self.active = False
self.timer = None
self.runner = None
def start(self, command, cwd, environment, log_path):
if self.active:
return
self.arguments = (command, cwd, environment, log_path)
self.active = True
self._check()
def stop(self):
self.active = False
if self.timer is not None:
self.scheduler.after_cancel(self.timer)
self.timer = None
if self.runner is not None:
try:
self.runner.force_stop()
except OSError as exc:
self.on_output(f"Arrêt de la vérification : {exc}\n")
self.runner = None
self.on_status("Vérification automatique arrêtée.")
def _later(self):
self.timer = self.scheduler.after(CHECK_INTERVAL_MS, self._check)
def _check(self):
self.timer = None
if not self.active:
return
self.runner = ProcessRunner()
self.on_status("Vérification des batchs en cours…")
try:
self.runner.start(*self.arguments)
except (OSError, RuntimeError) as exc:
self.on_output(f"Vérification impossible : {exc}\n")
self.runner = None
self.on_status("Échec de la vérification. Nouvel essai dans 5 minutes.")
self._later()
return
self.timer = self.scheduler.after(100, self._poll)
def _poll(self):
self.timer = None
if not self.active or self.runner is None:
return
while True:
try:
event, payload = self.runner.events.get_nowait()
except queue.Empty:
break
if event in {"output", "runner_error"}:
self.on_output(str(payload))
elif event == "finished":
code, interrupted = payload
self.runner = None
if code == 0 and not interrupted:
self.active = False
self.on_status("Tous les résultats batch sont prêts.")
self.on_ready()
else:
self.on_status("Résultats pas encore prêts. Nouvelle vérification dans 5 minutes.")
self._later()
return
self.timer = self.scheduler.after(100, self._poll)
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
import pymupdf
from PIL import Image, ImageTk
from copienator.pdf_cut import cut_position
PAGE_GAP = 28
SNAP_PIXELS = 12
def percentage_at_y(y: float, heights: list[float], scale: float) -> float:
"""Convert canvas y to document height, snapping across inter-page gaps."""
top = 0.0
cumulative = 0.0
total = sum(heights)
for index, height in enumerate(heights):
bottom = top + height * scale
if index + 1 < len(heights) and bottom - SNAP_PIXELS <= y <= bottom + PAGE_GAP + SNAP_PIXELS:
return (cumulative + height) / total * 100
if y <= bottom:
return max(0.0, min(100.0, (cumulative + (y - top) / scale) / total * 100))
cumulative += height
top = bottom + PAGE_GAP
return 100.0
def y_at_percentage(percent: float, heights: list[float], scale: float) -> float:
if percent <= 0:
return 0.0
if percent >= 100:
return sum(heights) * scale + (len(heights) - 1) * PAGE_GAP
index, offset = cut_position(heights, percent)
top = sum(heights[:index]) * scale + index * PAGE_GAP
return top - PAGE_GAP / 2 if index and offset == 0 else top + offset * scale
def cut_operator(percent: float, keep: int, mode: str) -> str:
value = f"{percent:.6f}".rstrip("0").rstrip(".")
return f"c{{{value}}}{keep}{mode}"
class CutHelper(tk.Toplevel):
def __init__(self, parent, path: Path, on_accept, initial=None):
# Load the source before creating a window so invalid PDFs leave no dialog.
with pymupdf.open(path) as document:
if not len(document):
raise ValueError("Le PDF est vide.")
self.heights = [page.rect.height for page in document]
width = min(850, parent.winfo_screenwidth() - 100)
self.scale = min(1.5, width / max(page.rect.width for page in document))
rendered = []
for page in document:
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
colorspace=pymupdf.csRGB)
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
super().__init__(parent)
self.title(f"Cut — {path.name}")
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
self.transient(parent.winfo_toplevel())
self.on_accept = on_accept
self.percent = initial[0] if initial else 50.0
self.keep = tk.IntVar(value=initial[1] if initial else 1)
self.mode = tk.StringVar(value=initial[2] if initial else ">")
self.caption = tk.StringVar()
ttk.Label(self, text="Déplacez la barre rouge. Entrée : afficher la commande ; Échap : annuler.",
wraplength=width).pack(anchor="w", padx=8, pady=5)
controls = ttk.Frame(self)
controls.pack(fill="x", padx=8)
ttk.Label(controls, text="Conserver à la source :").pack(side="left")
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left")
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left")
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left")
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
viewport = ttk.Frame(self)
viewport.pack(fill="both", expand=True)
self.canvas = tk.Canvas(viewport, background="#555555", highlightthickness=0)
scrollbar = ttk.Scrollbar(viewport, command=self.canvas.yview)
self.canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
self.canvas.pack(fill="both", expand=True)
self.images = [ImageTk.PhotoImage(image, master=self) for image in rendered]
top = 0.0
self.width = width
for index, image in enumerate(self.images):
self.canvas.create_image(0, top, anchor="nw", image=image)
top += self.heights[index] * self.scale
if index + 1 < len(self.images):
top += PAGE_GAP
self.canvas.configure(scrollregion=(0, 0, width, top))
self.bar = self.canvas.create_line(0, 0, width, 0, fill="#ff3030", width=4)
self.canvas.bind("<Button-1>", self.move_bar)
self.canvas.bind("<B1-Motion>", self.move_bar)
self.canvas.bind("<Button-4>", lambda event: self.canvas.yview_scroll(-3, "units"))
self.canvas.bind("<Button-5>", lambda event: self.canvas.yview_scroll(3, "units"))
self.canvas.bind("<MouseWheel>", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units"))
self.bind("<Return>", self.accept)
self.bind("<Escape>", lambda event: self.destroy())
self.keep.trace_add("write", lambda *_: self.draw_bar())
self.mode.trace_add("write", lambda *_: self.draw_bar())
self.draw_bar()
self.canvas.yview_moveto(max(0, (y_at_percentage(self.percent, self.heights, self.scale) - 200) / top))
self.focus_set()
self.grab_set()
def move_bar(self, event):
self.percent = percentage_at_y(self.canvas.canvasy(event.y), self.heights, self.scale)
self.draw_bar()
def draw_bar(self):
y = y_at_percentage(self.percent, self.heights, self.scale)
self.canvas.coords(self.bar, 0, y, self.width, y)
text = cut_operator(self.percent, self.keep.get(), self.mode.get())
if 0 < self.percent < 100:
index, offset = cut_position(self.heights, self.percent)
text += f" — entre les pages {index} et {index + 1}" if offset == 0 else f" — page {index + 1}"
self.caption.set(text)
def accept(self, event=None):
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
rounded = float(operator.split("{")[1].split("}")[0])
if not 0 < rounded < 100:
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
return
self.destroy()
self.on_accept(operator)
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import tkinter as tk
from pathlib import Path
from tkinter import messagebox, ttk
from copienator import CliError
from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text
from copienator.platform import open_path
from .cut_helper import CutHelper
HELP = """La correction propose x> pour un mauvais label et -> pour une réponse supplémentaire lorsque le PDF cible existe déjà. Vérifiez les deux PDF avant de choisir.
Format : Copie01 Label source OP Label cible|
Conservez Copie suivi du numéro et les labels exacts (sans .pdf). Les espaces dans les labels sont acceptés ; entourez lopérateur despaces.
-> : fusionner dans la cible, conserver la source.
x> : fusionner dans la cible, archiver la source.
-x : remplacer la cible par une copie de la source, conserver la source.
xx : remplacer la cible par une copie de la source, archiver la source.
ss : conserver les deux PDF sans fusion.
sx : conserver la source, archiver la cible, sans fusion.
xs : archiver la source, conserver la cible, sans fusion.
c{43}1> : couper la source à 43 %, conserver le début et ajouter la fin à la cible.
c{43}2x : couper la source à 43 %, conserver la fin et remplacer la cible par le début.
1 conserve le début, 2 conserve la fin ; > fusionne lautre partie avec la cible, x la remplace. Le pourcentage porte sur la hauteur cumulée des pages visibles. Les décimales sont acceptées. Une seule coupe par label source est autorisée.
Cut permet de placer la coupe, avec accrochage entre les pages. Entrée ferme laperçu et affiche la commande à recopier ci-dessus : aucun fichier nest modifié. Une séparation entre pages conserve les pages entières. La source complète est archivée en _old ; les deux labels modifiés sont générés en _new et ajoutés à refaire.json.
Pour les fusions : « Source x> Cible| » place la cible avant la source ; « Source x> |Cible » place la source avant la cible. Même règle avec -> et c{…}1>/c{…}2> (pour la partie transférée) ; sans |, la cible vient en premier.
Vous pouvez changer lopérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne ne résout pas son conflit.
Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter. Seul le fichier enregistré est appliqué. Larchivage utilise le suffixe _old ; les PDF créés utilisent _new. Après succès, manual_resolutions.txt est supprimé et correction.json est mis à jour. Si des PDF sont créés, refaire.json est généré : relancez la correction avec --refaire."""
class ManualResolutionPanel(ttk.Frame):
def __init__(self, parent, get_evaluation):
super().__init__(parent)
self.get_evaluation = get_evaluation
self.pdf_buttons = []
self.cut_buttons = []
actions = ttk.Frame(self)
actions.pack(fill="x")
self.editor_button = ttk.Button(
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
)
self.editor_button.pack(side="left")
ttk.Button(actions, text="Recharger", command=self.reload).pack(side="left", padx=6)
self.cut_result = tk.StringVar()
result = ttk.Frame(self)
result.pack(fill="x", pady=4)
ttk.Entry(result, textvariable=self.cut_result, state="readonly").pack(side="left", fill="x", expand=True)
ttk.Button(result, text="Copier la commande Cut", command=self.copy_cut_command).pack(side="left", padx=6)
self.status = ttk.Label(self, wraplength=650)
self.status.pack(fill="x", pady=4)
preview = ttk.Frame(self)
preview.pack(fill="both", expand=True)
self.text = tk.Text(preview, height=10, width=50, wrap="word", state="disabled")
scroll = ttk.Scrollbar(preview, command=self.text.yview)
self.text.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.text.pack(fill="both", expand=True)
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
self.reload()
def manual_path(self) -> Path | None:
evaluation = self.get_evaluation()
return evaluation / "manual_resolutions.txt" if evaluation else None
def open_editor(self):
path = self.manual_path()
if path is None or not path.is_file():
self.reload()
return
try:
# .txt is opened in the desktop's associated text editor.
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture impossible", str(exc))
def open_pdf(self, evaluation, copy_id, label):
path = get_actual_pdf(evaluation / "Copies", copy_id, label)
try:
if not path.is_file():
raise FileNotFoundError(f"PDF introuvable : {path}")
open_path(path)
except (OSError, RuntimeError) as exc:
messagebox.showerror("Ouverture du PDF", str(exc))
def reload(self):
for button in self.pdf_buttons + self.cut_buttons:
button.destroy()
self.pdf_buttons.clear()
self.cut_buttons.clear()
self.cut_result.set("")
self.text.configure(state="normal")
self.text.delete("1.0", "end")
path = self.manual_path()
self.editor_button.configure(state="disabled")
try:
if path is None:
raise FileNotFoundError("Chargez une évaluation.")
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
else:
self.editor_button.configure(state="normal")
malformed = []
for number, raw in enumerate(content.splitlines(keepends=True), 1):
self.text.insert("end", raw.rstrip("\r\n"))
try:
instructions = parse_instruction_text(raw)
except CliError:
malformed.append(str(number))
else:
if instructions:
instruction = instructions[0]
for title, label in (("PDF source", instruction.old_label),
("PDF cible", instruction.new_label)):
button = ttk.Button(
self.text, text=title,
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
)
self.pdf_buttons.append(button)
self.text.window_create("end", window=button, padx=8)
button = ttk.Button(self.text, text="Cut",
command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
self.cut_buttons.append(button)
self.text.window_create("end", window=button, padx=8)
if raw.endswith("\n"):
self.text.insert("end", "\n")
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
f"{len(self.pdf_buttons) // 2} instruction(s)"
)
self.status.configure(text=str(path) + detail)
finally:
self.text.configure(state="disabled")
def copy_cut_command(self):
if self.cut_result.get():
self.clipboard_clear()
self.clipboard_append(self.cut_result.get())
def open_cut(self, evaluation, instruction):
source = get_actual_pdf(evaluation / "Copies", instruction.copy_id, instruction.old_label)
def accepted(operator):
target = ("|" + instruction.new_label) if instruction.pipe_first else (instruction.new_label + "|")
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
try:
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
CutHelper(self, source, accepted, initial)
except (OSError, RuntimeError, ValueError) as exc:
messagebox.showerror("Ouverture du PDF", str(exc))
+37
View File
@@ -0,0 +1,37 @@
"""Launch native desktop notifications without blocking Tk."""
import base64
import json
import os
import subprocess
import sys
from copienator.platform import find_executable
def notify_desktop(title: str, message: str) -> None:
if sys.platform.startswith("linux"):
executable = find_executable("notify-send")
if not executable:
raise RuntimeError("Installez notify-send (libnotify) pour les notifications de bureau.")
command = [executable, "--app-name=Copienator", "--", title, message]
elif sys.platform == "darwin":
command = ["/usr/bin/osascript", "-e",
f"display notification {json.dumps(message, ensure_ascii=False)} with title {json.dumps(title, ensure_ascii=False)}"]
elif os.name == "nt":
# Encode the script and quote strings as literals; no shell interpolation.
quote = lambda value: "'" + value.replace("'", "''") + "'"
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$notice = New-Object System.Windows.Forms.NotifyIcon;"
"$notice.Icon = [System.Drawing.SystemIcons]::Information;"
"$notice.Visible = $true;"
f"$notice.ShowBalloonTip(10000, {quote(title)}, {quote(message)}, "
"[System.Windows.Forms.ToolTipIcon]::Info);"
"Start-Sleep -Seconds 12; $notice.Dispose()"
)
command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden",
"-EncodedCommand", base64.b64encode(script.encode("utf-16-le")).decode("ascii")]
else:
raise RuntimeError("Notifications de bureau indisponibles sur ce système.")
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+6 -19
View File
@@ -352,7 +352,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"bool",
"--overwrite",
help="Relance les corrections demandées même lorsquun résultat existe déjà.",
variants=("live", "batch", "hybrid", "refaire"),
variants=("live", "batch", "hybrid", "refaire", "integrate"),
),
ArgumentSpec(
"limit",
@@ -394,24 +394,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"batch_status",
"Correction",
"Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
"Vérifie les jobs enregistrés pour l’évaluation. Passe à la récupération uniquement lorsque tous ont réussi et que leurs résultats sont disponibles ; sinon, reste sur cette étape.",
(python("default", "État des batchs", "batch-status"),),
arguments=(
ArgumentSpec(
"download",
"Télécharger le job",
"text",
"--download",
help="Saisissez lidentifiant complet dun job Gemini terminé pour télécharger son fichier de résultats.",
),
ArgumentSpec(
"output",
"Fichier JSONL de destination",
"path",
"--output",
help="Choisissez le fichier JSONL dans lequel enregistrer le job téléchargé. Ce champ nest utilisé que si un identifiant de job est fourni.",
),
),
optional=True,
skip_for_live_correction=True,
),
@@ -429,7 +413,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"post_correction",
"Correction",
"Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
"Sauvegarde correction.json dans correction_precleanup.json, puis corrige certains problèmes dencodage et prépare le texte pour LaTeX. La sauvegarde est remplacée à chaque nettoyage.",
(python("default", "Post-correction", "post-correction"),),
arguments=(arg_target("le dossier de l’évaluation"),),
requires=("correction.json",),
@@ -788,6 +772,9 @@ def build_command(
positionals: list[str] = []
options: list[str] = []
if step.id == "batch_status":
# Use the loaded evaluation without introducing another input field.
options.extend(("--evaluation", evaluation_arg))
for spec in step.arguments:
if spec.variants and variant.id not in spec.variants:
continue