Files
Copies/copienator_gui/workflow.py
T
2026-08-22 11:55:17 +02:00

527 lines
20 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 shlex
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
EVALUATION = "${evaluation}"
@dataclass(frozen=True)
class ArgumentSpec:
name: str
label: str
kind: str = "text" # text, int, bool, choice, path
flag: str | None = None
default: object = ""
choices: tuple[str, ...] = ()
help: str = ""
positional: bool = False
variants: tuple[str, ...] = ()
@dataclass(frozen=True)
class CommandVariant:
id: str
label: str
program: str | None
kind: str = "python" # python, shell, external, manual
fixed_args: tuple[str, ...] = ()
fixed_args_before_positionals: bool = False
dangerous: bool = False
@dataclass(frozen=True)
class StepDefinition:
id: str
section: str
title: str
description: str
variants: tuple[CommandVariant, ...]
arguments: tuple[ArgumentSpec, ...] = ()
optional: bool = False
personal: bool = False
requires: tuple[str, ...] = ()
artifacts: tuple[str, ...] = ()
auto_start_first_visit: bool = False
skip_for_live_correction: bool = False
skip_without_manual_conflicts: bool = False
@property
def is_manual(self) -> bool:
return all(variant.kind == "manual" for variant in self.variants)
def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec:
return ArgumentSpec(
"target",
"Cible",
kind="path",
default=EVALUATION,
positional=True,
help=help_text,
)
def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
python = lambda ident, label, script, **kwargs: CommandVariant(
ident, label, script, "python", **kwargs
)
manual = lambda ident, label="Étape manuelle": CommandVariant(
ident, label, None, "manual"
)
steps = [
StepDefinition(
"inputs",
"Préparation",
"Vérifier les fichiers dentrée",
"Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex. "
"Le fichier names doit être présent dans l’évaluation ou à la racine du projet.",
(manual("check"),),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
),
StepDefinition(
"statement",
"Prétraitement de l’énoncé",
"Analyser l’énoncé",
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
(
python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"),
python("personal", "Alternative enonce_info.py", "enonce_info.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"restart",
"Ignorer le cache (--restart)",
kind="bool",
flag="--restart",
variants=("gemini",),
),
),
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
artifacts=("labels", "Text", "Sol", "Persp"),
),
StepDefinition(
"review_persp",
"Prétraitement de l’énoncé",
"Relire les barèmes",
"Étape manuelle facultative : vérifier et modifier les instructions de correction.",
(manual("review"),),
optional=True,
requires=("Persp",),
),
StepDefinition(
"rotate",
"Prétraitement des copies",
"Retourner toutes les copies",
"Rotation facultative de 180° lorsque les scans sont à lenvers.",
(
CommandVariant(
"rotate",
"Rotation",
"copies_tools.py",
"python",
("rotate",),
fixed_args_before_positionals=True,
),
),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
),
StepDefinition(
"rename",
"Prétraitement des copies",
"Renommer les PDF en CopieXX.pdf",
"Renomme les PDF du dossier d’évaluation dans leur ordre courant.",
(
CommandVariant(
"rename",
"Renommage",
"copies_tools.py",
"python",
("rename",),
fixed_args_before_positionals=True,
),
),
arguments=(arg_target("Dossier de l’évaluation"),),
),
StepDefinition(
"page_splitter",
"Prétraitement des copies",
"Séparer et réordonner les pages",
"Ouvre loutil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
(python("default", "Séparation des pages", "page_splitter.py"),),
arguments=(arg_target(),),
artifacts=("Copies", "Copies Originales"),
),
StepDefinition(
"cutleft",
"Prétraitement des copies",
"Découper la marge des labels",
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.",
(python("default", "Découpe", "cutleft.py"),),
arguments=(
arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
),
requires=("Copies",),
artifacts=("Cutleft",),
),
StepDefinition(
"labels",
"Labels et regroupement",
"Détecter les labels avec Gemini",
"Identifie les labels dans les images produites par la découpe gauche.",
(python("default", "Détection des labels", "gemini_for_labels.py"),),
arguments=(
arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
),
requires=("labels", "Copies", "Cutleft"),
artifacts=("Copies/*.json",),
),
StepDefinition(
"plotting",
"Labels et regroupement",
"Vérifier visuellement les labels",
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.",
(python("default", "Vérification", "plotting.py"),),
arguments=(arg_target(),),
requires=("labels", "Cutleft"),
artifacts=("Copies/Copie*.json",),
),
StepDefinition(
"splitting",
"Labels et regroupement",
"Découper les réponses par question",
"Découpe les copies à partir des coordonnées de labels vérifiées.",
(python("default", "Découpage", "splitting_int.py"),),
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
),
StepDefinition(
"grouping",
"Labels et regroupement",
"Regrouper les réponses",
"Regroupe les réponses portant le même label pour préparer les requêtes.",
(python("default", "Regroupement", "grouping.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
auto_start_first_visit=True,
),
StepDefinition(
"verify_groups",
"Labels et regroupement",
"Vérifier les groupes produits",
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.",
(python("default", "Vérification des groupes", "verify_groups.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("Copies", "Par label"),
),
StepDefinition(
"correction",
"Correction",
"Lancer ou intégrer la correction",
"Choisir une correction immédiate, batch, hybride, une recorrection, ou lintégration dun batch.",
(
python("live", "Correction immédiate", "correction.py"),
python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correction.py"),
python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)),
python(
"integrate",
"Intégrer les résultats batch",
"correction.py",
fixed_args=("--deal-with-batched",),
),
python("reset", "Réinitialiser les corrections", "correction.py", fixed_args=("--reset",), dangerous=True),
),
arguments=(
arg_target("Évaluation ou image Group_X.jpg"),
ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)),
ArgumentSpec("limit", "Limite dappels Pro", "int", "--limit", variants=("live",)),
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
),
requires=("Par label", "Persp", "labels"),
artifacts=("correction.json", "batch_requests_*.jsonl"),
),
StepDefinition(
"submit_batches",
"Correction",
"Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "submit_batches.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
artifacts=("batch_jobs.json",),
skip_for_live_correction=True,
),
StepDefinition(
"batch_status",
"Correction",
"Consulter l’état des batchs",
"Affiche les jobs Gemini en cours. Lidentifiant de téléchargement est facultatif.",
(python("default", "État des batchs", "batch_status.py"),),
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
optional=True,
skip_for_live_correction=True,
),
StepDefinition(
"fetch_batches",
"Correction",
"Récupérer les résultats batch",
"Télécharge et rassemble les réponses des jobs terminés.",
(python("default", "Récupération", "fetch_batched_results.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
skip_for_live_correction=True,
),
StepDefinition(
"post_correction",
"Correction",
"Nettoyer la correction",
"Corrige certains problèmes dencodage et prépare le texte pour LaTeX.",
(python("default", "Post-correction", "post-correction.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("correction.json",),
),
StepDefinition(
"manual_resolution",
"Correction",
"Résoudre les conflits manuels",
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
(python("default", "Résolution", "resolve_manual.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True,
),
StepDefinition(
"annotation",
"Génération des annotations",
"Générer les copies annotées",
"Les trois modes sont exclusifs pour un parcours donné.",
(
python("simple", "Annotations simples (Anot)", "annotating.py"),
python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"),
python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)),
),
requires=("correction.json",),
artifacts=("Anot", "Bnot", "BGnot"),
),
StepDefinition(
"export",
"Génération des annotations",
"Exporter vers la tablette",
"Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.",
(python("default", "Export", "export.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
),
optional=True,
),
StepDefinition(
"tablet",
"Correction manuscrite",
"Annoter les PDF sur la tablette",
"Étape manuelle : enregistrer chaque résultat sous le nom Concat_annotated.pdf.",
(manual("tablet"),),
),
StepDefinition(
"import",
"Correction manuscrite",
"Importer les annotations manuscrites",
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
(python("default", "Import", "import.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
),
),
StepDefinition(
"read_annotations",
"Finalisation",
"Lire les annotations manuscrites",
"Le mode doit correspondre au mode choisi lors de la génération des annotations.",
(
python("standard", "Lecture Bnot", "reading_annotations.py"),
python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
),
),
StepDefinition(
"giving_names",
"Finalisation",
"Attribuer les noms et préparer A Rendre",
"Crée le dossier A Rendre à partir du dossier dannotations choisi.",
(python("default", "Attribution des noms", "giving_names.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"choice",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
positional=True,
),
),
artifacts=("A Rendre",),
),
StepDefinition(
"personal_create",
"Étapes personnelles",
"Créer linterro dans gestion_classe",
"Exécute gestion_classe ne.",
(CommandVariant("default", "gestion_classe ne", "gestion_classe", "external", ("ne",)),),
personal=True,
),
StepDefinition(
"personal_scale",
"Étapes personnelles",
"Renseigner le barème",
"Exécute gestion_classe we.",
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),),
personal=True,
),
StepDefinition(
"update_ods",
"Étapes personnelles",
"Mettre à jour le fichier ODS",
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
(python("default", "Mise à jour ODS", "update_ods.py"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
),
personal=True,
),
StepDefinition(
"personal_read",
"Étapes personnelles",
"Relire avec gestion_classe",
"Exécute gestion_classe re.",
(CommandVariant("default", "gestion_classe re", "gestion_classe", "external", ("re",)),),
personal=True,
),
StepDefinition(
"personal_sent",
"Étapes personnelles",
"Marquer comme envoyé",
"Exécute gestion_classe wsent.",
(CommandVariant("default", "gestion_classe wsent", "gestion_classe", "external", ("wsent",)),),
personal=True,
),
StepDefinition(
"final_score",
"Étapes personnelles",
"Ajouter le score final",
"Génère les fichiers de diffusion avec le score final.",
(python("default", "Score final", "add_final_score.py"),),
arguments=(arg_target("Dossier de l’évaluation"),),
personal=True,
),
StepDefinition(
"personal_deploy",
"Étapes personnelles",
"Déployer et publier les copies",
"Étape manuelle : déployer miqmacs-copies-assets puis mettre à jour les copies depuis ladministration.",
(manual("deploy"),),
personal=True,
),
StepDefinition(
"personal_print",
"Étapes personnelles",
"Imprimer une copie",
"Étape manuelle via le lecteur PDF.",
(manual("print"),),
personal=True,
optional=True,
),
]
return [step for step in steps if show_personal_steps or not step.personal]
def value_for_default(value: object, evaluation_arg: str) -> object:
return evaluation_arg if value == EVALUATION else value
def build_command(
repository: Path,
step: StepDefinition,
variant: CommandVariant,
values: dict[str, object],
evaluation_arg: str,
extra_arguments: str = "",
) -> list[str]:
if variant.kind == "manual" or not variant.program:
return []
program_path = repository / variant.program
if variant.kind == "python":
command = [sys.executable, "-u", str(program_path)]
elif variant.kind == "shell":
command = [str(program_path)]
else:
command = [variant.program]
positionals: list[str] = []
options: list[str] = []
for spec in step.arguments:
if spec.variants and variant.id not in spec.variants:
continue
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
if spec.kind == "bool":
if bool(value) and spec.flag:
options.append(spec.flag)
continue
if value is None or str(value).strip() == "":
continue
rendered = str(value)
if spec.positional:
positionals.append(rendered)
elif spec.flag:
options.extend((spec.flag, rendered))
if variant.fixed_args_before_positionals:
command.extend(variant.fixed_args)
command.extend(positionals)
if not variant.fixed_args_before_positionals:
command.extend(variant.fixed_args)
command.extend(options)
if extra_arguments.strip():
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
return command
def command_display(command: Iterable[str]) -> str:
if os.name == "nt":
return " ".join(f'"{part}"' if " " in part else part for part in command)
return shlex.join(command)
def evaluation_argument(repository: Path, evaluation: Path) -> str:
try:
return str(evaluation.resolve().relative_to(repository.resolve()))
except ValueError:
return str(evaluation.resolve())