Files
Copies/copienator_gui/workflow.py
T

723 lines
30 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
from copienator.configuration import ALWAYS_CROP
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
danger_warning: str | None = None
supports_verbose: 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
extra_arguments_help: str = ""
extra_arguments_variants: tuple[str, ...] = ()
@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]:
def python(ident, label, script, **kwargs):
supports_verbose = kwargs.pop("supports_verbose", True)
return CommandVariant(
ident,
label,
script,
"python",
supports_verbose=supports_verbose,
**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", "statement"),
) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
if show_personal_steps else ()),
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(
"statement_groups", "Prétraitement de l’énoncé", "Regrouper les questions avec Gemini",
"Facultatif après la génération : remplace les groupes par exercice par des groupes "
"proposés par Gemini, en conservant les labels, les énoncés, les solutions et les barèmes.",
(python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
),
StepDefinition(
"statement_persp", "Prétraitement de l’énoncé", "Remplacer les barèmes par Gemini",
"Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. "
"Les énoncés et les solutions personnels sont conservés.",
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
),
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",
"python",
("rotate",),
fixed_args_before_positionals=True,
supports_verbose=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",
"python",
("rename",),
fixed_args_before_positionals=True,
supports_verbose=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-split"),),
arguments=(arg_target(), ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked")),
artifacts=("Copies", "Copies Originales"),
),
StepDefinition(
"crop_blank_margins",
"Prétraitement des copies",
"Rogner les zones vides",
"Facultatif : détecte les zones vides en haut et en bas malgré les lignes et les perforations, "
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
(python("default", "Rognage des zones vides", "crop-margins"),),
arguments=(arg_target("Dossier de l’évaluation ou PDF dans Copies"),
ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)),
optional=True,
requires=("Copies",),
auto_start_first_visit=ALWAYS_CROP,
),
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", "crop-labels"),),
arguments=(
arg_target(),
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked"),
),
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", "labels"),),
arguments=(
arg_target(),
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
),
requires=("labels", "Copies", "Cutleft"),
artifacts=("Copies/*.json",),
extra_arguments_help=(
"PDF de copie ou images Cutleft supplémentaires de cette évaluation, "
"séparés par des espaces. Mettez entre guillemets les chemins contenant des espaces."
),
),
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", "review-labels"),),
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", "split-answers"),),
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
),
StepDefinition(
"crop_exercise_bottoms",
"Labels et regroupement",
"Rogner le bas des réponses",
"Facultatif après le découpage par labels : rogne uniquement les grands espaces "
"vides au bas des réponses. Les PDF modifiés sont remplacés, les originaux sont "
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"workers",
"PDF traités en parallèle",
"int",
"--workers",
default=5,
),
),
optional=True,
requires=("Copies/Copie*/*.pdf",),
auto_start_first_visit=ALWAYS_CROP,
),
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", "group-answers"),),
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
auto_start_first_visit=True,
),
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", "correct"),
python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
python("hybrid", "Batch à partir dun label", "correct"),
python("refaire", "Recorrection depuis refaire.json", "correct", fixed_args=("--refaire",)),
python(
"integrate",
"Intégrer les résultats batch",
"correct",
fixed_args=("--deal-with-batched",),
),
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
),
arguments=(
arg_target("Évaluation ou image Group_X.jpg"),
ArgumentSpec(
"overwrite",
"Écraser les corrections existantes",
"bool",
"--overwrite",
variants=("live", "batch", "hybrid", "refaire"),
),
ArgumentSpec(
"limit",
"Limite dappels Pro",
"int",
"--limit",
variants=("live", "hybrid", "refaire"),
),
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
),
requires=("Par label", "Persp", "labels"),
artifacts=("correction.json", "batch_requests_*.jsonl"),
extra_arguments_help=(
"Images Group_X.jpg supplémentaires de cette évaluation, séparées par des espaces. "
"Mettez entre guillemets les chemins contenant des espaces."
),
extra_arguments_variants=("live", "batch", "hybrid", "refaire"),
),
StepDefinition(
"submit_batches",
"Correction",
"Envoyer les batchs",
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
(python("default", "Envoi", "batch-submit"),),
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"),),
arguments=(
ArgumentSpec("download", "Télécharger le job", "text", "--download"),
ArgumentSpec(
"output",
"Fichier JSONL de destination",
"path",
"--output",
help="Utilisé avec un identifiant de téléchargement.",
),
),
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", "batch-fetch"),),
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"),),
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"),),
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)", "annotate-simple"),
python("checks", "Annotations avec cases (Bnot)", "annotate-checks"),
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks", "grouped")),
),
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"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"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"),),
arguments=(
arg_target("Dossier de l’évaluation"),
ArgumentSpec(
"annotation_dir",
"Dossier dannotations",
"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", "read-annotations"),
python("grouped", "Lecture BGnot", "read-grouped"),
),
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",)),
ArgumentSpec(
"annotation_dir",
"Passage principal du mode refaire",
"choice",
"--annotation-dir",
default="BGnot",
choices=("BGnot", "Bnot", "Anot"),
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"),),
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", supports_verbose=False),),
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", supports_verbose=False),),
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,
),
StepDefinition(
"clean",
"Archivage",
"Nettoyer les fichiers intermédiaires",
"Supprime définitivement les fichiers permettant de reprendre le parcours. "
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
"les journaux, ainsi que les images et score.json de A Rendre.",
(
python(
"default",
"Nettoyage définitif",
"clean",
fixed_args=("--yes",),
dangerous=True,
danger_warning=(
"Le nettoyage est irréversible.\n\n"
"Toute la progression du GUI et les fichiers intermédiaires "
"seront supprimés. Il ne sera plus possible de reprendre une "
"étape sans régénérer ses données.\n\n"
"Les PDF traités, les fichiers textuels de l’énoncé, "
"correction.json, les journaux, ainsi que les images et "
"score.json de A Rendre seront conservés.\n\n"
"Continuer ?"
),
),
python(
"dry_run",
"Prévisualiser sans supprimer",
"clean",
fixed_args=("--dry-run",),
),
),
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("Copies", "correction.json", "A Rendre"),
),
]
steps[-1:-1] = build_refaire_workflow()
return [step for step in steps if show_personal_steps or not step.personal]
def build_refaire_workflow() -> list[StepDefinition]:
from .refaire import SECTION
selection = StepDefinition(
"refaire_selection", SECTION, "Choisir les copies et les questions",
"Sélectionnez les copies et les questions à refaire, puis enregistrez la sélection. "
"Le passage principal doit être terminé ; conservez ses annotations.",
(CommandVariant("default", "Sélection", None, "manual"),),
requires=("Copies", "labels", "correction.json"),
)
definitions = [
("review", "Reprendre le découpage", "Vérifiez et ajustez les labels des copies sélectionnées. Chaque copie souvre à son tour. Fermez la fenêtre pour passer à la suivante.", "review-labels", (), True),
("split", "Redécouper les réponses", "À exécuter après une modification du découpage. Traite toutes les copies sélectionnées ; vérifiez les fichiers _new et _old en cas de résolution manuelle.", "split-answers", (), True),
("correct", "Refaire la correction", "Relance la correction des seules questions sélectionnées. Peut être ignorée pour corriger manuellement les résultats.", "correct", ("--refaire",), True),
("annotate", "Préparer les copies à vérifier", "Génère les questions sélectionnées avec des cases dans BRnot, par question ou par copie selon la sélection. Remplace le précédent passage dans BRnot.", "annotate-checks", ("--refaire", "--overwrite"), False),
("export", "Exporter vers la tablette", "Exporte BRnot vers EXPORT_DIR. Retirez les anciens fichiers dexport avant le transfert.", "export", ("--refaire",), True),
("tablet", "Vérifier sur la tablette", "Annotez les PDF exportés, puis placez les retours dans IMPORT_DIR sans changer leur nom (nom de groupe ou Copie01.pdf…). Retournez aussi les PDF sans modification. Retirez les anciens fichiers dimport.", None, (), False),
("import", "Importer les copies vérifiées", "Importe les PDF retournés dans BRnot. Vous pouvez ignorer cette étape si les fichiers Concat_annotated.pdf y sont déjà en place.", "import", ("--refaire",), True),
("merge", "Mettre à jour les copies finales", "Fusionne les questions refaites avec le reste de chaque copie dans le dossier du passage principal. Relancez ensuite la préparation de A Rendre, le calcul des notes et la diffusion.", "read-grouped", ("--refaire",), False),
]
steps = [selection]
for suffix, title, description, program, flags, optional in definitions:
arguments = (arg_target(),) if program else ()
if suffix == "merge":
arguments += (ArgumentSpec("annotation_dir", "Passage principal", "choice", "--annotation-dir", default="BGnot", choices=("BGnot", "Bnot", "Anot")),)
requirements = ("refaire.json", "Copies", "labels", "correction.json")
if suffix in {"export", "tablet", "import", "merge"}:
requirements += ("BRnot",)
steps.append(StepDefinition(
f"refaire_{suffix}", SECTION, title, description,
(CommandVariant(
"default",
title,
program,
"python" if program else "manual",
flags,
supports_verbose=program is not None,
),),
arguments=arguments, optional=optional, requires=requirements,
))
return steps
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 = "",
verbose: bool = False,
) -> list[str]:
if variant.kind == "manual" or not variant.program:
return []
program_path = repository / variant.program
if variant.kind == "python":
command = [sys.executable, "-u", "-m", "copienator", variant.program]
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 verbose and variant.supports_verbose:
command.append("--verbose")
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())