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
#+author: Sébastien Miquel
#+date: 14-03-2026
# Time-stamp: <20-08-26 11:37>
# Time-stamp: <22-08-26 09:58>
#+OPTIONS:
* Méta
@@ -21,23 +21,6 @@ pour faire corriger des copies par Gemini.
4. Ces annotations manuscrites sont lues et recompilées en une
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
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
Certains labels sont utilisés directement comme noms de fichiers et de
dossiers. Les labels contenant notamment =:= ne sont pas acceptés par
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.
Les labels de questions sont utilisés comme noms de fichiers. Les
labels contenant notamment =:= ne sont pas acceptés par Windows.
** 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).
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 :
#+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.
*** 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 :
@@ -99,15 +82,21 @@ Lancer l'assistant avec :
python gui.py
#+END_SRC
On peut aussi ouvrir directement une évaluation avec
=python gui.py Interro=. L'interface conserve l'état et l'historique
des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes
dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
On peut aussi ouvrir directement une évaluation avec =python gui.py
Interro=. L'interface conserve l'état et l'historique des étapes dans
=Interro/.copienator-gui.json=, et les sorties complètes dans
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
supprime aucun résultat ; les étapes suivantes sont seulement marquées
comme étant à revalider.
La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de
masquer les étapes propres au workflow personnel.
Après la réussite d'un script, l'interface sélectionne automatiquement
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,
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= ;
- =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
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"
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):
def __init__(
self,
@@ -354,6 +367,54 @@ class CopienatorApp(tk.Tk):
self._save_current_form()
self.current_step = 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:
step = self.current_step
@@ -601,15 +662,27 @@ class CopienatorApp(tk.Tk):
self._populate_tree()
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:
return
self._save_current_form()
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.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.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:
if self.current_step and self.current_step.optional:
@@ -652,6 +725,8 @@ class CopienatorApp(tk.Tk):
self.active_step_id = None
self._populate_tree()
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:
text = self.stdin_var.get()
@@ -703,12 +778,17 @@ class CopienatorApp(tk.Tk):
def _move_selection(self, delta: int) -> None:
if not self.current_step:
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]
try:
index = ids.index(self.current_step.id)
index = ids.index(step_id)
except ValueError:
return
target = max(0, min(len(ids) - 1, index + delta))
if target == index:
return
self.tree.selection_set(ids[target])
self.tree.see(ids[target])
+9
View File
@@ -46,6 +46,9 @@ class StepDefinition:
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:
@@ -201,6 +204,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target(),),
requires=("Copies",),
artifacts=("Copies/Copie*/*",),
auto_start_first_visit=True,
),
StepDefinition(
"grouping",
@@ -211,6 +215,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),),
requires=("Copies",),
artifacts=("Par label",),
auto_start_first_visit=True,
),
StepDefinition(
"verify_groups",
@@ -258,6 +263,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
artifacts=("batch_jobs.json",),
skip_for_live_correction=True,
),
StepDefinition(
"batch_status",
@@ -267,6 +273,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(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",
@@ -276,6 +283,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
(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",
@@ -295,6 +303,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arguments=(arg_target("Dossier de l’évaluation"),),
optional=True,
requires=("manual_resolutions.txt", "correction.json"),
skip_without_manual_conflicts=True,
),
StepDefinition(
"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_data import AnnotationLoadResult, load_annotation_data
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.runner import ProcessRunner
from copienator_gui.state import StateStore
@@ -169,6 +169,17 @@ class AtomicJsonTests(unittest.TestCase):
persisted = read_json(root / ".copienator-gui.json")
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):
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.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:
command = self.command(
"correction",