863 lines
37 KiB
Python
863 lines
37 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
|
||
|
||
from copienator import configuration
|
||
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
|
||
false_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 = "un dossier d’évaluation ou un fichier à traiter") -> ArgumentSpec:
|
||
return ArgumentSpec(
|
||
"target",
|
||
"Cible",
|
||
kind="path",
|
||
default=EVALUATION,
|
||
positional=True,
|
||
help=f"La cible peut être {help_text.rstrip('.')}.",
|
||
)
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
return_answer_arguments = ()
|
||
if configuration.RETURN_ANSWERS_ENABLED:
|
||
return_answer_arguments = (
|
||
ArgumentSpec(
|
||
"return_answers_context",
|
||
"Inclure le contexte dans answers",
|
||
"bool",
|
||
"--return-answers-context",
|
||
"--no-return-answers-context",
|
||
default=configuration.RETURN_ANSWERS_CONTEXT,
|
||
help="Inclut les pages de contexte applicables avant chaque réponse individuelle publiée dans answers.",
|
||
),
|
||
ArgumentSpec(
|
||
"return_answers_question",
|
||
"Inclure l’énoncé dans answers",
|
||
"bool",
|
||
"--return-answers-question",
|
||
"--no-return-answers-question",
|
||
default=configuration.RETURN_ANSWERS_QUESTION,
|
||
help="Inclut l’énoncé actuel avant chaque réponse individuelle publiée dans answers.",
|
||
),
|
||
ArgumentSpec(
|
||
"return_answers_solution",
|
||
"Inclure la correction dans answers",
|
||
"bool",
|
||
"--return-answers-solution",
|
||
"--no-return-answers-solution",
|
||
default=configuration.RETURN_ANSWERS_SOLUTION,
|
||
help="Inclut la correction actuelle avant chaque réponse individuelle publiée dans answers.",
|
||
),
|
||
)
|
||
|
||
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("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||
if show_personal_steps else ())
|
||
+ (python("gemini", "Analyse avec Gemini", "statement"),),
|
||
arguments=(
|
||
arg_target("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"restart",
|
||
"Ignorer le cache (--restart)",
|
||
kind="bool",
|
||
flag="--restart",
|
||
help="Ignore les résultats Gemini mis en cache et recommence entièrement l’analyse de l’énoncé.",
|
||
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("le 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("le 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 à l’envers.",
|
||
(
|
||
CommandVariant(
|
||
"rotate",
|
||
"Rotation",
|
||
"copies",
|
||
"python",
|
||
("rotate",),
|
||
fixed_args_before_positionals=True,
|
||
supports_verbose=True,
|
||
),
|
||
),
|
||
arguments=(arg_target("le 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("le 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-split"),),
|
||
arguments=(
|
||
arg_target(),
|
||
ArgumentSpec(
|
||
"marked",
|
||
"Copies signalées uniquement",
|
||
"bool",
|
||
"--marked",
|
||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||
),
|
||
),
|
||
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("le dossier de l’évaluation ou un PDF du dossier Copies"),
|
||
ArgumentSpec(
|
||
"workers",
|
||
"Copies traitées en parallèle",
|
||
"int",
|
||
"--workers",
|
||
default=5,
|
||
help="Fixe le nombre maximal de copies rognées simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||
),
|
||
),
|
||
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",
|
||
help="Désactive la découpe habituelle de la marge gauche et transmet chaque page entière à la détection des labels.",
|
||
),
|
||
ArgumentSpec(
|
||
"marked",
|
||
"Copies signalées uniquement",
|
||
"bool",
|
||
"--marked",
|
||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||
),
|
||
),
|
||
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",
|
||
help="Relance la détection même lorsqu’un fichier JSON de labels existe déjà pour la copie.",
|
||
),
|
||
),
|
||
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("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"workers",
|
||
"PDF traités en parallèle",
|
||
"int",
|
||
"--workers",
|
||
default=5,
|
||
help="Fixe le nombre maximal de PDF de réponse analysés simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||
),
|
||
),
|
||
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("le 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 l’intégration d’un batch.",
|
||
(
|
||
python("live", "Correction immédiate", "correct"),
|
||
python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
|
||
python("hybrid", "Batch à partir d’un 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("le dossier de l’évaluation ou une image Group_X.jpg"),
|
||
ArgumentSpec(
|
||
"overwrite",
|
||
"Écraser les corrections existantes",
|
||
"bool",
|
||
"--overwrite",
|
||
help="Relance les corrections demandées même lorsqu’un résultat existe déjà.",
|
||
variants=("live", "batch", "hybrid", "refaire", "integrate"),
|
||
),
|
||
ArgumentSpec(
|
||
"limit",
|
||
"Limite d’appels Pro",
|
||
"int",
|
||
"--limit",
|
||
help="Limite le nombre d’appels au modèle Pro pendant cette exécution. Laissez ce champ vide pour ne pas imposer de limite.",
|
||
variants=("live", "hybrid", "refaire"),
|
||
),
|
||
ArgumentSpec(
|
||
"batch_from",
|
||
"Premier label envoyé en batch",
|
||
"text",
|
||
"--batch-from",
|
||
help="Indique le premier label traité en batch ; les labels précédents sont corrigés immédiatement.",
|
||
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("le dossier de l’évaluation"),),
|
||
optional=True,
|
||
artifacts=("batch_jobs.json",),
|
||
skip_for_live_correction=True,
|
||
),
|
||
StepDefinition(
|
||
"batch_status",
|
||
"Correction",
|
||
"Consulter l’état des batchs",
|
||
"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"),),
|
||
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("le dossier de l’évaluation"),),
|
||
optional=True,
|
||
skip_for_live_correction=True,
|
||
),
|
||
StepDefinition(
|
||
"post_correction",
|
||
"Correction",
|
||
"Nettoyer la correction",
|
||
"Sauvegarde correction.json dans correction_precleanup.json, puis corrige certains problèmes d’encodage 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",),
|
||
),
|
||
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("le 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("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"overwrite",
|
||
"Écraser les sorties",
|
||
"bool",
|
||
"--overwrite",
|
||
help="Remplace les annotations déjà générées dans le dossier de sortie sélectionné.",
|
||
),
|
||
ArgumentSpec(
|
||
"refaire",
|
||
"Mode refaire",
|
||
"bool",
|
||
"--refaire",
|
||
help="Génère uniquement les copies et questions inscrites dans refaire.json, dans le dossier réservé à la reprise.",
|
||
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("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"annotation_dir",
|
||
"Dossier d’annotations",
|
||
"choice",
|
||
default="BGnot",
|
||
choices=("BGnot", "Bnot", "Anot"),
|
||
help="Choisissez le dossier d’annotations à exporter : groupées, avec cases, ou simples.",
|
||
positional=True,
|
||
),
|
||
ArgumentSpec(
|
||
"refaire",
|
||
"Mode refaire",
|
||
"bool",
|
||
"--refaire",
|
||
help="Exporte les annotations du passage de reprise BRnot au lieu du dossier principal.",
|
||
),
|
||
),
|
||
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("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"annotation_dir",
|
||
"Dossier d’annotations",
|
||
"choice",
|
||
default="BGnot",
|
||
choices=("BGnot", "Bnot", "Anot"),
|
||
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
|
||
positional=True,
|
||
),
|
||
ArgumentSpec(
|
||
"refaire",
|
||
"Mode refaire",
|
||
"bool",
|
||
"--refaire",
|
||
help="Importe les annotations manuscrites dans BRnot pour le passage de reprise.",
|
||
),
|
||
),
|
||
),
|
||
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("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"update_score",
|
||
"Régénérer avec les nouveaux énoncés/corrigés et les score.json",
|
||
"bool",
|
||
"--update-score",
|
||
help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite.",
|
||
),
|
||
ArgumentSpec(
|
||
"refaire",
|
||
"Mode refaire",
|
||
"bool",
|
||
"--refaire",
|
||
help="Lit les annotations du passage de reprise BRnot et les fusionne avec les copies principales.",
|
||
variants=("grouped",),
|
||
),
|
||
ArgumentSpec(
|
||
"annotation_dir",
|
||
"Passage principal du mode refaire",
|
||
"choice",
|
||
"--annotation-dir",
|
||
default="BGnot",
|
||
choices=("BGnot", "Bnot", "Anot"),
|
||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||
variants=("grouped",),
|
||
),
|
||
) + return_answer_arguments,
|
||
),
|
||
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"),),
|
||
arguments=(
|
||
arg_target("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"annotation_dir",
|
||
"Dossier d’annotations",
|
||
"choice",
|
||
default="BGnot",
|
||
choices=("BGnot", "Bnot", "Anot"),
|
||
help="Choisissez le dossier d’annotations utilisé pour construire les fichiers nommés dans A Rendre.",
|
||
positional=True,
|
||
),
|
||
ArgumentSpec(
|
||
"update",
|
||
"Mettre à jour uniquement les images de answers",
|
||
"bool",
|
||
"--update",
|
||
help="Met à jour uniquement les images du dossier answers de chaque élève existant, en identifiant la copie par le numéro final entre parenthèses et sans modifier le nom du dossier ni les autres fichiers.",
|
||
),
|
||
),
|
||
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", supports_verbose=False),),
|
||
arguments=(
|
||
arg_target("le dossier de l’évaluation"),
|
||
ArgumentSpec(
|
||
"sum",
|
||
"Écrire seulement la somme",
|
||
"bool",
|
||
"--sum",
|
||
help="Écrit uniquement la note totale de chaque élève dans le fichier ODS, sans détailler les scores par question.",
|
||
),
|
||
),
|
||
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",
|
||
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
|
||
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
|
||
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.",
|
||
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
||
arguments=(arg_target("le 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,
|
||
),
|
||
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, PDF, score.json et info.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, PDF et "
|
||
"score.json et info.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("le 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 s’ouvre à 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 d’export 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 d’import.", 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"),
|
||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||
),)
|
||
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] = []
|
||
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
|
||
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)
|
||
elif not bool(value) and spec.false_flag:
|
||
options.append(spec.false_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())
|