543 lines
21 KiB
Python
543 lines
21 KiB
Python
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())
|