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 d’entré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 à l’envers.", ( 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 l’outil 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 l’intégration d’un 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 d’un 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 d’appels 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. L’identifiant 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 d’encodage 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", "Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.", (python("default", "Export", "export.py"),), arguments=( arg_target("Dossier de l’évaluation"), ArgumentSpec( "annotation_dir", "Dossier d’annotations", "choice", default="BGnot", choices=("BGnot", "Bnot", "Anot"), positional=True, ), 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( "annotation_dir", "Dossier d’annotations", "choice", default="BGnot", choices=("BGnot", "Bnot", "Anot"), positional=True, ), 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 d’annotations choisi.", (python("default", "Attribution des noms", "giving_names.py"),), arguments=( arg_target("Dossier de l’évaluation"), ArgumentSpec( "annotation_dir", "Dossier d’annotations", "choice", default="BGnot", choices=("BGnot", "Bnot", "Anot"), positional=True, ), ), artifacts=("A Rendre",), ), StepDefinition( "personal_create", "Étapes personnelles", "Créer l’interro 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 l’administration.", (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())