diff --git a/Readme.org b/Readme.org index 5eee572..437708f 100644 --- a/Readme.org +++ b/Readme.org @@ -197,11 +197,12 @@ racine du projet). Dans la console, les boutons de copie et le clic droit permettent de copier la sélection ou toute la sortie ; =Ctrl+C= et =Ctrl+A= sont également disponibles (=Cmd= sous macOS). -Pendant =Découper une partie à gauche pour détection des labels=, =n= décale -la zone de 50 px vers la droite, =N= de 100 px, =t= de 50 px vers la -gauche et =l= l’élargit de 50 px. =1= utilise les pages entières. =s= -signale la copie en erreur et passe à la suivante ; =Entrée= valide et -enregistre la découpe affichée. +Pendant =Découper une partie à gauche pour détection des labels=, les +flèches gauche et droite déplacent la zone de 50 px ; les flèches haut et +bas l’élargissent ou la réduisent de 50 px. Avec le workflow personnel, +=t/s/r/n= reproduisent respectivement gauche/réduire/élargir/droite. +=1= utilise les pages entières. =!= signale la copie en erreur et passe à +la suivante ; =Entrée= valide et enregistre la découpe affichée. Une copie ignorée conserve ses anciennes découpes. Les signalements sont conservés dans =.copienator/copy_errors.json=, même après fermeture. Dans =Séparer et réordonner les pages=, =Traiter les copies signalées= @@ -209,7 +210,7 @@ reprend ces copies à partir des originaux conservés. Le même bouton dans =Découper une partie à gauche pour détection des labels= reprend leur découpage ; chaque signalement est effacé seulement après validation et enregistrement avec =Entrée=. -Fermer la fenêtre, appuyer à nouveau sur =s= ou rencontrer une erreur +Fermer la fenêtre, appuyer à nouveau sur =!= ou rencontrer une erreur conserve le signalement. Les commandes =page-split= et =crop-labels= acceptent aussi =--marked= pour traiter les copies signalées de l’évaluation. diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index 883a048..97966b6 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -24,6 +24,7 @@ from copienator import ( ) from copienator.filesystem import staged_files from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths +from copienator.configuration import SHOW_PERSONAL_STEPS DELIMITER_WIDTH = 5 DELIMITER_COLOR = (0, 0, 0) @@ -32,6 +33,9 @@ CROP_SHIFT_STEP = 50 CROP_WIDTH_STEP = 50 pdf_cache_lock = threading.Lock() +# The requested spelling is kept as the public switch for this alternate layout. +personnal_binding = bool(SHOW_PERSONAL_STEPS) + def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]: """Distribute pages into balanced chunks no larger than max_per_file.""" @@ -189,13 +193,17 @@ class ImageReviewer: self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold")) self.label_info.pack(pady=5) self.root.bind("", self.on_next) - self.root.bind("s", self.on_skip) + self.root.bind("!", self.on_skip) self.root.protocol("WM_DELETE_WINDOW", self.on_close) - self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP)) - self.root.bind("N", lambda _event: self.on_shift(2 * CROP_SHIFT_STEP)) - self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) - self.root.bind("l", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) - self.root.bind("L", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP)) + self.root.bind("", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) + self.root.bind("", lambda _event: self.on_shift(CROP_SHIFT_STEP)) + self.root.bind("", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) + self.root.bind("", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP)) + if personnal_binding: + self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) + self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP)) + self.root.bind("r", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) + self.root.bind("s", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP)) self.root.bind("1", lambda _event: self.on_set_max_pages(1)) Thread(target=self.prefetch_worker, daemon=True).start() @@ -283,6 +291,11 @@ class ImageReviewer: tk_image = ImageTk.PhotoImage(self.current_preview) self.label_img.configure(image=tk_image) self.label_img.image = tk_image + personal_instructions = ( + "\nt/s/r/n also available: left / narrower / wider / right" + if personnal_binding + else "" + ) self.label_info.configure( text=( f"[{self.index + 1}/{len(self.files)}] {filename} | " @@ -290,8 +303,9 @@ class ImageReviewer: f"Extra width: {self.current_width_offset}px\n" f"Files: {schema['number_of_files']} | " f"Cols: {schema['columns_per_file']}\n" - "Enter: Save and next | s: flag error and skip | n: +50 | N: +100 | t: -50 | " - "l: widen by 50 | Shift+l: undo widening by 50 | 1: use full pages" + "Arrows: left/right move by 50px | up/down widen/narrow by 50px\n" + "Enter: Save and next | !: flag error and skip | 1: use full pages" + f"{personal_instructions}" ), fg="black", ) diff --git a/copienator/configuration.py b/copienator/configuration.py index 1a6554d..09b8e9f 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -33,6 +33,7 @@ else: # Keep new optional settings compatible with older personal configuration files. ALWAYS_CROP = False +SHOW_PERSONAL_STEPS = False RETURN_JPEG_ENABLED = True RETURN_PDF_ENABLED = True RETURN_ANSWERS_ENABLED = False diff --git a/copienator_gui/app.py b/copienator_gui/app.py index eeb4158..93ce183 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -1749,6 +1749,15 @@ class CopienatorApp(tk.Tk): self.state_store.update_step( step_id, status=stored_status, return_code=return_code ) + if (step_id == "statement" and status == "success" + and self.state_store.step(step_id).get("last_run_variant") == "gemini"): + reason = "Barèmes déjà générés par l’analyse Gemini de l’énoncé." + self.state_store.update_step("statement_persp", status="skipped") + self.state_store.add_history({ + "step": "statement_persp", "status": "skipped", + "manual": False, "reason": reason, + }) + self._append_console(f"Remplacer les barèmes par Gemini : ignorée — {reason}\n") if step_id == "annotation" and status == "success": generation_variant = self.state_store.step("annotation").get( "last_run_variant" diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 9872673..91bb474 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -88,6 +88,12 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: manual = lambda ident, label="Étape manuelle": CommandVariant( ident, label, None, "manual" ) + personnal_binding = ( + "\nWorkflow personnel : les raccourcis t/s/r/n sont aussi disponibles " + "(t : gauche, s : réduire, r : élargir, n : droite)." + if show_personal_steps + else "" + ) return_answer_arguments = () if configuration.RETURN_ANSWERS_ENABLED: @@ -136,7 +142,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "statement", "Prétraitement de l’énoncé", "Analyser l’énoncé", - "Détecte les questions, leurs groupes, le corrigé et les indications de barème.", + "Avec Gemini : extrait les questions, les contextes et les solutions." + "Propose, sur la base des labels, des groupement de questions. Ces groupes seront utilisés lors des requêtes de correction." + "Génère aussi des barèmes détaillés de chaque question. ", ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),) if show_personal_steps else ()) + (python("gemini", "Analyse avec Gemini", "statement"),), @@ -156,16 +164,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: ), 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.", + "Facultatif. Demande à Gemini de regrouper les questions selon leurs dépendances et leur contexte commun. " + "L’analyse Gemini initiale regroupe d’après la structure des labels ; cette étape peut donc proposer un autre découpage. " + "Ignorez cette étape si les groupes actuels vous conviennent.", (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.", + "statement_persp", "Prétraitement de l’énoncé", "Générer les barèmes par Gemini", + "Facultatif. Inutile après l'analyse de l'énoncé par Gemini, puisque les barèmes ont déjà été généré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"), @@ -261,9 +269,11 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "cutleft", "Prétraitement des copies", "Découper une partie à gauche pour détection des labels", - "Produit les images de la partie gauche des copies. Dans la fenêtre de découpe : " - "n décale la zone de 50 px vers la droite, N de 100 px, t de 50 px vers la gauche, " - "l l’élargit de 50 px, 1 utilise les pages entières, Entrée valide et s signale une erreur. " + "Produit les images de la partie gauche des copies. " + "Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone " + "de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. " + "1 utilise les pages entières, Entrée valide et ! signale une erreur." + f"{personnal_binding} " "Une copie précise peut être ciblée.", (python("default", "Découpe", "crop-labels"),), arguments=( diff --git a/default_config.py b/default_config.py index 7e8ffad..b7dd44f 100644 --- a/default_config.py +++ b/default_config.py @@ -28,11 +28,12 @@ FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf") FINAL_SCORE_FONT_PATH = None # Modèle pour des choses très légères +# Le modèle 3.5-flash-lite est un peu moins bon pour la duplication de contexte pour des questions, dans l'analyse de l'énoncé. # MODEL_LITE_ID = "gemini-3.5-flash-lite" MODEL_LITE_ID = "gemini-3.8-flash" # Modèle pour identifier visuellement les labels -# MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche moyennement, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit +# MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche plutôt mal, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit MODEL_FOR_LABEL_ID = "gemini-3.8-flash" # Modèle pour des choses normales diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index ac3c907..c28be6a 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -81,6 +81,24 @@ class GuiConvenienceTests(unittest.TestCase): style.lookup("MissingPrerequisite.TLabel", "foreground"), "#c62828" ) + def test_only_successful_gemini_analysis_skips_rubric_replacement(self): + for variant, code, interrupted, expected in ( + ("gemini", 0, False, "skipped"), + ("personal", 0, False, "ready"), + ("gemini", 4, False, "ready"), + ("gemini", 1, False, "ready"), + ("gemini", 0, True, "ready"), + ): + with self.subTest(variant=variant, code=code, interrupted=interrupted): + self.app.state_store.update_step("statement_persp", status="ready") + self.app.state_store.update_step("statement_groups", status="ready") + self.app.state_store.update_step("statement", last_run_variant=variant) + self.app.active_step_id = "statement" + with patch.object(self.app, "after_idle"): + self.app._finish_process(code, interrupted) + self.assertEqual(self.app.state_store.step("statement_persp")["status"], expected) + self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready") + def test_compact_evaluation_input_and_open_folder_button(self): self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42) self.assertEqual( diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index b19c946..dfe272a 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -2245,10 +2245,13 @@ class WorkflowTests(unittest.TestCase): step.title, "Découper une partie à gauche pour détection des labels", ) - for shortcut in ("n", "N", "t", "l", "1", "Entrée", "s"): + for shortcut in ("←", "→", "↑", "↓", "1", "Entrée", "!", "t/s/r/n"): with self.subTest(shortcut=shortcut): self.assertIn(shortcut, step.description) + standard = next(step for step in build_workflow(False) if step.id == "cutleft") + self.assertNotIn("t/s/r/n", standard.description) + def test_export_has_shorter_title_and_annotation_argument(self) -> None: self.assertEqual(self.steps["export"].title, "Exporter") command = self.command( diff --git a/tests/test_marked_copies.py b/tests/test_marked_copies.py index eaee78f..b5e6093 100644 --- a/tests/test_marked_copies.py +++ b/tests/test_marked_copies.py @@ -107,6 +107,11 @@ class MarkedCopiesTests(unittest.TestCase): self.assertEqual(review.current_width_offset, 50) review.trigger_processing.assert_called_once_with(self.files[0], 50) + def test_personal_crop_binding_follows_personal_workflow_option(self): + from copienator.configuration import SHOW_PERSONAL_STEPS + + self.assertEqual(cutleft.personnal_binding, bool(SHOW_PERSONAL_STEPS)) + def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self): page = Image.new("RGB", (900, 300), "white") with patch.object(cutleft, "get_pdf_pages", return_value=[page]): @@ -188,7 +193,7 @@ class MarkedCopiesGuiTests(unittest.TestCase): self.assertTrue(reviewer.completed) return reviewer - first = review_with_keys(self.files, ["", ""]) + first = review_with_keys(self.files, ["", ""]) self.assertTrue(first.had_errors) self.assertEqual(set(copy_errors(self.workspace)), {"Copie01.pdf"}) self.assertFalse((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())