Améliorations GUI

This commit is contained in:
2026-08-22 11:49:13 +02:00
parent 3a8d0fe3ff
commit 81dc658640
4 changed files with 159 additions and 49 deletions
+24 -44
View File
@@ -1,7 +1,7 @@
#+title: Script #+title: Script
#+author: Sébastien Miquel #+author: Sébastien Miquel
#+date: 14-03-2026 #+date: 14-03-2026
# Time-stamp: <20-08-26 11:37> # Time-stamp: <22-08-26 09:58>
#+OPTIONS: #+OPTIONS:
* Méta * Méta
@@ -21,23 +21,6 @@ pour faire corriger des copies par Gemini.
4. Ces annotations manuscrites sont lues et recompilées en une 4. Ces annotations manuscrites sont lues et recompilées en une
version de la copie pour l'élève. version de la copie pour l'élève.
** Disclaimer
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
fait peu d'efforts pour le rendre universel et simple à l'emploi.
Plusieurs parties de mon workflow sont spécifiques à mes
représentations internes des sujets d'examens et/ou à mon environment.
L'utilisation de ce système nécessite une familiarité avec python et
la ligne de commande unix ; cette familiarité n'est probablement pas
suffisante en l'état, mais en théorie il devrait être possible de le
faire tourner sur le dossier =Example= qui contient une copie
initiale, les fichiers correspondant au sujet de l'interro, et des
examples du rendu final (dans le sous dossier =BGnot=).
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
de ce système n'est pas une priorité.
** Limitations ** Limitations
Pour l'instant, la correction est faite question par question : le LLM Pour l'instant, la correction est faite question par question : le LLM
@@ -47,13 +30,8 @@ question précédente ou autre.
*** Noms de labels sous Windows *** Noms de labels sous Windows
Certains labels sont utilisés directement comme noms de fichiers et de Les labels de questions sont utilisés comme noms de fichiers. Les
dossiers. Les labels contenant notamment =:= ne sont pas acceptés par labels contenant notamment =:= ne sont pas acceptés par Windows.
Windows. Sous Windows, les scripts refusent ces labels avant de
poursuivre et affichent la liste des valeurs à corriger. Aucune
conversion silencieuse n'est appliquée ; utiliser par exemple
=Ex 1 - a)= au lieu de =Ex 1 : a)=. Le diagnostic du GUI permet de les
repérer avant de lancer une étape.
** Requirements ** Requirements
@@ -75,9 +53,6 @@ pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab im
Il faut créer une clef API pour Gemini (pas facile). Il faut créer une clef API pour Gemini (pas facile).
NB : Lors de la création, google offre (offrait ?) 300€ d'utilisation,
mais seulement pendant les trois mois à venir.
Puis ajouter =GEMINI_API_KEY= à l'environnement avec : Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
#+BEGIN_SRC bash #+BEGIN_SRC bash
@@ -91,7 +66,15 @@ ou éventuellement, la renseigner directement dans le fichier
Copier `default_config.py` en `config.py`. Éventuellement le modifier. Copier `default_config.py` en `config.py`. Éventuellement le modifier.
*** Interface graphique ** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les
noms/prénoms des élèves, un par ligne
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
suite)
3. Suivre les instructions suivantes
** Interface graphique
Lancer l'assistant avec : Lancer l'assistant avec :
@@ -99,15 +82,21 @@ Lancer l'assistant avec :
python gui.py python gui.py
#+END_SRC #+END_SRC
On peut aussi ouvrir directement une évaluation avec On peut aussi ouvrir directement une évaluation avec =python gui.py
=python gui.py Interro=. L'interface conserve l'état et l'historique Interro=. L'interface conserve l'état et l'historique des étapes dans
des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes =Interro/.copienator-gui.json=, et les sorties complètes dans
dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
supprime aucun résultat ; les étapes suivantes sont seulement marquées supprime aucun résultat ; les étapes suivantes sont seulement marquées
comme étant à revalider. comme étant à revalider.
La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de Après la réussite d'un script, l'interface sélectionne automatiquement
masquer les étapes propres au workflow personnel. l'étape suivante. =Découper les réponses par question= et =Regrouper
les réponses= démarrent automatiquement lors de leur première visite.
Avec le mode =Correction immédiate=, les trois étapes batch sont alors
marquées =Ignorée= à leur première visite. L'étape de résolution
manuelle est traitée de la même façon si =manual_resolutions.txt= est
absent, vide ou ne contient que des commentaires. Ces automatismes ne
se répètent pas lors d'un retour en arrière.
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX, Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
@@ -182,15 +171,6 @@ scripts migrés vers cette convention sont actuellement :
- =reading_annotations.py= et =reading_grouped_annotations.py= ; - =reading_annotations.py= et =reading_grouped_annotations.py= ;
- =export.py=, =import.py= et =giving_names.py=. - =export.py=, =import.py= et =giving_names.py=.
** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les
noms/prénoms des élèves, un par ligne
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
suite)
3. Suivre les instructions suivantes
* Étapes et Script * Étapes et Script
Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI
+84 -4
View File
@@ -46,6 +46,19 @@ def process_status(return_code: int, interrupted: bool = False) -> str:
return "failed" return "failed"
def has_manual_conflicts(path: Path) -> bool:
"""Return whether a manual-resolution file contains an instruction."""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return False
except (OSError, UnicodeError):
# If the file cannot be inspected, keep the step visible rather than
# silently claiming that there is nothing to resolve.
return True
return any(line.strip() and not line.lstrip().startswith("###") for line in lines)
class CopienatorApp(tk.Tk): class CopienatorApp(tk.Tk):
def __init__( def __init__(
self, self,
@@ -354,6 +367,54 @@ class CopienatorApp(tk.Tk):
self._save_current_form() self._save_current_form()
self.current_step = step self.current_step = step
self._render_step() self._render_step()
self._handle_first_visit(step)
def _handle_first_visit(self, step: StepDefinition) -> None:
if not self.state_store.evaluation:
return
entry = self.state_store.step(step.id)
if entry.get("visited"):
return
# Persist this before scheduling an action so selection callbacks cannot
# trigger the same automatic behavior twice.
self.state_store.update_step(step.id, visited=True)
if step.skip_for_live_correction:
correction = self.state_store.step("correction")
if correction.get("variant", "live") == "live":
self.after_idle(
lambda step_id=step.id: self._automatic_skip(
step_id, "correction immédiate sélectionnée"
)
)
return
if step.skip_without_manual_conflicts:
evaluation = self.evaluation
conflicts = evaluation / "manual_resolutions.txt" if evaluation else None
if conflicts is None or not has_manual_conflicts(conflicts):
self.after_idle(
lambda step_id=step.id: self._automatic_skip(
step_id, "aucun conflit manuel détecté"
)
)
return
if step.auto_start_first_visit and not entry.get("status") and not self._artifacts_exist(step):
self.after_idle(lambda step_id=step.id: self._automatic_start(step_id))
def _automatic_start(self, step_id: str) -> None:
if self.runner.running or not self.current_step or self.current_step.id != step_id:
return
self.info_var.set(f"{self.current_step.title} : démarrage automatique.")
self._run_current_step()
def _automatic_skip(self, step_id: str, reason: str) -> None:
if self.runner.running or not self.current_step or self.current_step.id != step_id:
return
self._mark_step("skipped", automatic=True, reason=reason)
self._move_selection_from(step_id, 1)
def _render_step(self) -> None: def _render_step(self) -> None:
step = self.current_step step = self.current_step
@@ -601,15 +662,27 @@ class CopienatorApp(tk.Tk):
self._populate_tree() self._populate_tree()
self._update_controls() self._update_controls()
def _mark_step(self, status: str) -> None: def _mark_step(
self, status: str, *, automatic: bool = False, reason: str | None = None
) -> None:
if not self.current_step or not self.state_store.evaluation: if not self.current_step or not self.state_store.evaluation:
return return
self._save_current_form() self._save_current_form()
self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id) self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id)
self.state_store.update_step(self.current_step.id, status=status) self.state_store.update_step(self.current_step.id, status=status)
self.state_store.add_history({"step": self.current_step.id, "status": status, "manual": True}) history: dict[str, object] = {
"step": self.current_step.id,
"status": status,
"manual": not automatic,
}
if reason:
history["reason"] = reason
self.state_store.add_history(history)
self._populate_tree() self._populate_tree()
self.info_var.set(f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}.") detail = f" ({reason})" if reason else ""
self.info_var.set(
f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}."
)
def _skip_step(self) -> None: def _skip_step(self) -> None:
if self.current_step and self.current_step.optional: if self.current_step and self.current_step.optional:
@@ -652,6 +725,8 @@ class CopienatorApp(tk.Tk):
self.active_step_id = None self.active_step_id = None
self._populate_tree() self._populate_tree()
self._update_controls() self._update_controls()
if status == "success":
self.after_idle(lambda completed_id=step_id: self._move_selection_from(completed_id, 1))
def _send_input(self) -> None: def _send_input(self) -> None:
text = self.stdin_var.get() text = self.stdin_var.get()
@@ -703,12 +778,17 @@ class CopienatorApp(tk.Tk):
def _move_selection(self, delta: int) -> None: def _move_selection(self, delta: int) -> None:
if not self.current_step: if not self.current_step:
return return
self._move_selection_from(self.current_step.id, delta)
def _move_selection_from(self, step_id: str, delta: int) -> None:
ids = [step.id for step in self.steps] ids = [step.id for step in self.steps]
try: try:
index = ids.index(self.current_step.id) index = ids.index(step_id)
except ValueError: except ValueError:
return return
target = max(0, min(len(ids) - 1, index + delta)) target = max(0, min(len(ids) - 1, index + delta))
if target == index:
return
self.tree.selection_set(ids[target]) self.tree.selection_set(ids[target])
self.tree.see(ids[target]) self.tree.see(ids[target])
+9
View File
@@ -46,6 +46,9 @@ class StepDefinition:
personal: bool = False personal: bool = False
requires: tuple[str, ...] = () requires: tuple[str, ...] = ()
artifacts: tuple[str, ...] = () artifacts: tuple[str, ...] = ()
auto_start_first_visit: bool = False
skip_for_live_correction: bool = False
skip_without_manual_conflicts: bool = False
@property @property
def is_manual(self) -> bool: def is_manual(self) -> bool:
@@ -201,6 +204,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target(),), arguments=(arg_target(),),
requires=("Copies",), requires=("Copies",),
artifacts=("Copies/Copie*/*",), artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
), ),
StepDefinition( StepDefinition(
"grouping", "grouping",
@@ -211,6 +215,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",), requires=("Copies",),
artifacts=("Par label",), artifacts=("Par label",),
auto_start_first_visit=True,
), ),
StepDefinition( StepDefinition(
"verify_groups", "verify_groups",
@@ -258,6 +263,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
artifacts=("batch_jobs.json",), artifacts=("batch_jobs.json",),
skip_for_live_correction=True,
), ),
StepDefinition( StepDefinition(
"batch_status", "batch_status",
@@ -267,6 +273,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "État des batchs", "batch_status.py"),), (python("default", "État des batchs", "batch_status.py"),),
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),), arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
optional=True, optional=True,
skip_for_live_correction=True,
), ),
StepDefinition( StepDefinition(
"fetch_batches", "fetch_batches",
@@ -276,6 +283,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(python("default", "Récupération", "fetch_batched_results.py"),), (python("default", "Récupération", "fetch_batched_results.py"),),
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
skip_for_live_correction=True,
), ),
StepDefinition( StepDefinition(
"post_correction", "post_correction",
@@ -295,6 +303,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
requires=("manual_resolutions.txt", "correction.json"), requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True,
), ),
StepDefinition( StepDefinition(
"annotation", "annotation",
+42 -1
View File
@@ -31,7 +31,7 @@ from copienator import (
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
from copienator.filesystem import staged_directory, staged_files from copienator.filesystem import staged_directory, staged_files
from copienator_gui.app import process_status from copienator_gui.app import has_manual_conflicts, process_status
from copienator_gui.diagnostics import collect_diagnostics from copienator_gui.diagnostics import collect_diagnostics
from copienator_gui.runner import ProcessRunner from copienator_gui.runner import ProcessRunner
from copienator_gui.state import StateStore from copienator_gui.state import StateStore
@@ -169,6 +169,17 @@ class AtomicJsonTests(unittest.TestCase):
persisted = read_json(root / ".copienator-gui.json") persisted = read_json(root / ".copienator-gui.json")
self.assertEqual(persisted["steps"]["labels"]["status"], "success") self.assertEqual(persisted["steps"]["labels"]["status"], "success")
def test_invalidation_preserves_first_visit_marker(self) -> None:
with tempfile.TemporaryDirectory() as directory:
store = StateStore()
store.load(Path(directory))
store.update_step("splitting", status="success", visited=True)
store.invalidate_after(["plotting", "splitting"], "plotting")
self.assertEqual(store.step("splitting")["status"], "stale")
self.assertTrue(store.step("splitting")["visited"])
class AnnotationDataTests(unittest.TestCase): class AnnotationDataTests(unittest.TestCase):
def test_loader_indexes_coordinates_without_mutating_correction(self) -> None: def test_loader_indexes_coordinates_without_mutating_correction(self) -> None:
@@ -1366,6 +1377,36 @@ class WorkflowTests(unittest.TestCase):
self.assertNotIn("update_ods", standard_ids) self.assertNotIn("update_ods", standard_ids)
self.assertIn("update_ods", personal_ids) self.assertIn("update_ods", personal_ids)
def test_first_visit_automation_is_declared_on_expected_steps(self) -> None:
auto_start = {
step.id for step in self.steps.values() if step.auto_start_first_visit
}
skip_for_live = {
step.id for step in self.steps.values() if step.skip_for_live_correction
}
skip_without_conflicts = {
step.id
for step in self.steps.values()
if step.skip_without_manual_conflicts
}
self.assertEqual(auto_start, {"splitting", "grouping"})
self.assertEqual(
skip_for_live, {"submit_batches", "batch_status", "fetch_batches"}
)
self.assertEqual(skip_without_conflicts, {"manual_resolution"})
def test_manual_conflicts_ignore_blank_and_comment_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "manual_resolutions.txt"
self.assertFalse(has_manual_conflicts(path))
path.write_text("\n### Instructions\n ### exemple\n", encoding="utf-8")
self.assertFalse(has_manual_conflicts(path))
path.write_text(
"### Instructions\nCopie01 Ex 1 -> Ex 2\n", encoding="utf-8"
)
self.assertTrue(has_manual_conflicts(path))
def test_live_correction_arguments(self) -> None: def test_live_correction_arguments(self) -> None:
command = self.command( command = self.command(
"correction", "correction",