diff --git a/Readme.org b/Readme.org index fba825e..cfada12 100644 --- a/Readme.org +++ b/Readme.org @@ -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 diff --git a/copienator_gui/app.py b/copienator_gui/app.py index fd566e7..67b698d 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -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]) diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 47a76cc..384e5d2 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -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", diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 7710a73..7e28009 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -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",