Améliorations GUI
This commit is contained in:
+24
-44
@@ -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
@@ -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])
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user