From db4ed2ef316be96ee6de246e3b9628cfa3767a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Tue, 8 Sep 2026 12:04:18 +0200 Subject: [PATCH] =?UTF-8?q?start-gui.sh=20et=20am=C3=A9liorations=20divers?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Readme.org | 40 ++++++ copienator/commands/enonce_info.py | 11 +- copienator/commands/gemini_for_enonce.py | 142 +++++++++++++++---- copienator/commands/page_splitter.py | 37 +++-- copienator/commands/splitting_int.py | 10 +- copienator_gui/app.py | 122 ++++++++++++++++- copienator_gui/diagnostics.py | 2 +- copienator_gui/workflow.py | 20 ++- default_config.py | 1 + start-gui.sh | 39 ++++++ tests/test_gui_convenience.py | 75 +++++++++++ tests/test_gui_core.py | 2 +- tests/test_page_splitter_reverse.py | 81 +++++++++++ tests/test_statement_personal_flow.py | 165 +++++++++++++++++++++++ 14 files changed, 697 insertions(+), 50 deletions(-) create mode 100755 start-gui.sh create mode 100644 tests/test_gui_convenience.py create mode 100644 tests/test_page_splitter_reverse.py create mode 100644 tests/test_statement_personal_flow.py diff --git a/Readme.org b/Readme.org index d1517e1..446434a 100644 --- a/Readme.org +++ b/Readme.org @@ -173,6 +173,46 @@ Lancer l'assistant avec : python -m copienator gui #+END_SRC +Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi +l’interface, depuis n’importe quel répertoire. Il utilise le Python de +=.venv= s’il existe, sinon =python3= du PATH. On peut lui passer le dossier +d’évaluation : =./start-gui.sh Interro=. Sans argument, il ouvre le +sous-dossier immédiat non masqué le plus récemment modifié du répertoire +courant, en excluant =copienator=, =copienator_gui=, =tests=, =OLD=, +=__pycache__=, =build=, =dist=, =*.egg-info=, =venv=, =env= et +=node_modules=. S’il n’y a aucun dossier admissible, l’interface démarre +sans évaluation. Un chemin explicite reste utilisable même s’il est exclu +de la sélection automatique. + +=Recharger — vérifier à nouveau= relit les fichiers d’entrée du dossier. +Pour reprendre le découpage d’une copie, sélectionnez-la dans =Copies +détectées=, cliquez sur =Refaire la copie sélectionnée=, puis sur +=Exécuter=. Le découpage repart de l’original conservé. +Dans la fenêtre de découpage, =i= inverse l’ordre de toutes les pages +(dernière vers première) et reprend à la nouvelle première page. Les choix +de découpage déjà saisis sont effacés ; les rotations globales sont conservées. +Ce raccourci est configurable via =PAGE_SPLITTER_KB["reverse_pages"]=. +=Copier la commande= copie les commandes complètes (à lancer depuis la +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). + +Avec =SHOW_PERSONAL_STEPS = True=, =Analyser l’énoncé= propose le choix +entre Gemini et =Énoncés et solutions personnels (SHEETINFO)=. Ce dernier +lance =python -m copienator statement-personal Interro= : il lit +=enonce.tex=, utilise le service d’exercices sur =localhost:8080= pour +générer =Text=, =Sol=, =Text2=, =Sol2= et les barèmes personnels =Persp=, +et écrit un groupe par exercice dans =label_groups=. + +Deux boutons ouvrent ensuite des actions facultatives avec aperçu de +commande et bouton =Exécuter= : =Regrouper avec Gemini…= remplace seulement +=label_groups= (=statement Interro --groups-only=) ; =Remplacer Persp avec +Gemini…= régénère seulement les barèmes des groupes actuels +(=statement Interro --persp-only=), avec le même prompt que le parcours +Gemini complet. Une réponse incomplète ou un échec laisse les anciens +barèmes en place. On peut ignorer ces étapes et conserver les résultats +personnels. + On peut aussi ouvrir directement une évaluation avec =python -m copienator gui Interro=. L'interface conserve l'état et l'historique des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes dans diff --git a/copienator/commands/enonce_info.py b/copienator/commands/enonce_info.py index c405e1a..9104c2e 100644 --- a/copienator/commands/enonce_info.py +++ b/copienator/commands/enonce_info.py @@ -13,6 +13,7 @@ from copienator import ( CliError, EvaluationWorkspace, ExitCode, + atomic_write_text, evaluation_parser, execute, workspace_from_args, @@ -142,10 +143,11 @@ def save_split_content(text, path, base_fname, problem): def process_directory(workspace: EvaluationWorkspace) -> ExitCode: directory = str(workspace.root) # Find the first .tex file in the directory - tex_files = glob.glob(os.path.join(directory, "*.tex")) + enonce = workspace.root / "enonce.tex" + tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex"))) if not tex_files: print(f"No .tex file found in {directory}. Looking in /Staging/Interro/") - int_name = directory.removesuffix("/") + int_name = workspace.root.name tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex") if os.path.exists(tex_path): tex_file = tex_path @@ -172,6 +174,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp") current_ex_num = 1 had_errors = False + exercise_groups = [] # Read entirely to allow chunking with open(tex_file, 'r', encoding='utf-8') as f_in: @@ -267,6 +270,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: for label in block_labels: f_labels.write(f"{label}\n") + exercise_groups.append(block_labels) current_ex_num += 1 except WindowsLabelError: @@ -280,6 +284,8 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode: had_errors = True labels_staging.replace(labels_file) + atomic_write_text(workspace.label_groups_file, + "".join(", ".join(group) + "\n" for group in exercise_groups)) return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS @@ -293,4 +299,3 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index 4741062..ed23f38 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -22,6 +22,7 @@ from copienator import ( workspace_from_args, ) from copienator.platform import validate_windows_labels +from copienator.filesystem import staged_directory from copienator.utils import compile_to_pdf @@ -73,6 +74,9 @@ class RubricItem(BaseModel): class GroupRubrics(BaseModel): rubrics: list[RubricItem] +class LabelGroups(BaseModel): + groups: list[list[str]] + PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen. Ta tâche : Établir un barème de correction détaillé pour CHAQUE question. @@ -176,6 +180,104 @@ def find_file(folder: Path, base_name: str) -> Path | None: return path return None + +def generate_rubrics(client, group_context_text: str) -> dict[str, str]: + """Use the same rubric request for full and selective statement generation.""" + response = client.models.generate_content( + model=MODEL_ID, + contents=[types.Content(role="user", parts=[ + types.Part.from_text(text=PROMPT_4), + types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), + ])], + config=types.GenerateContentConfig( + system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.", + temperature=0.2, + response_mime_type="application/json", + response_json_schema=GroupRubrics.model_json_schema(), + ), + ) + rubrics = GroupRubrics.model_validate_json(response.text).rubrics + if len({item.label for item in rubrics}) != len(rubrics): + raise ValueError("Gemini returned duplicate rubric labels") + return {item.label: item.rubric_content for item in rubrics} + + +def validate_groups(groups: list[list[str]], labels: list[str]) -> None: + flattened = [label for group in groups for label in group] + if (not groups or any(not group for group in groups) + or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)): + raise CliError("Groups must contain every existing label exactly once") + + +def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode: + """Regroup or replace rubrics without regenerating statements or solutions.""" + labels = workspace.read_labels() + if not labels or len(labels) != len(set(labels)): + raise CliError("Generate unique question labels before refining the statement") + validate_windows_labels(labels) + questions = {} + for label in labels: + safe_label = label.replace("/", "_") + parts = [] + for directory, title in (("Text2", "Question"), ("Sol2", "Correction")): + path = workspace.root / directory / f"{safe_label}.tex" + if not path.is_file(): + raise CliError(f"Missing {path}; generate statements and solutions first") + parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}") + questions[label] = "\n".join(parts) + context = utils.enonce_total(workspace.root) + if api_client is None: + if not api_key: + raise CliError("GEMINI_API_KEY is not configured") + api_client = genai.Client(api_key=api_key) + + if mode == "groups": + response = api_client.models.generate_content( + model=MODEL_ID, + contents=[types.Content(role="user", parts=[types.Part.from_text(text=( + "Regroupe ces questions d’examen en groupes cohérents pour la correction " + "et l’annotation, selon leurs dépendances et leur contexte commun. " + "Ne mélange pas des exercices différents. Conserve l’ordre des questions. " + "Chaque label doit apparaître exactement une fois, sans modification. " + "Renvoie uniquement un objet JSON groups contenant des listes de labels.\n\n" + + context + "\n\n" + "\n\n".join(questions.values()) + ))])], + config=types.GenerateContentConfig( + temperature=0.1, response_mime_type="application/json", + response_json_schema=LabelGroups.model_json_schema(), + ), + ) + groups = LabelGroups.model_validate_json(response.text).groups + validate_groups(groups, labels) + atomic_write_text(workspace.label_groups_file, + "".join(", ".join(group) + "\n" for group in groups)) + print(f"Updated label_groups: {len(groups)} Gemini groups.") + elif mode == "persp": + if not workspace.label_groups_file.is_file(): + raise CliError("Generate label_groups before generating rubrics") + groups = [[label.strip() for label in line.split(",") if label.strip()] + for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines() + if line.strip()] + validate_groups(groups, labels) + with staged_directory(workspace.root / "Persp") as staging: + for group in groups: + print(f"Generating rubric (Persp) for group: {', '.join(group)}...") + group_content = ( + "Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n" + + context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : " + + ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group) + ) + rubrics = generate_rubrics(api_client, group_content) + if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()): + raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved") + for label, rubric in rubrics.items(): + (staging / label.replace("/", "_")).write_text( + f"{label}\n{rubric}", encoding="utf-8") + print("Replaced Persp with Gemini rubrics.") + else: + raise ValueError(f"Unknown statement refinement: {mode}") + return ExitCode.SUCCESS + def process_exam( workspace: EvaluationWorkspace, restart: bool = False, @@ -701,32 +803,9 @@ def process_exam( group_context_text = "\n\n---\n\n".join(group_text_parts) - contents_4 = [ - types.Content( - role="user", - parts=[ - types.Part.from_text(text=PROMPT_4), - types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), - ], - ) - ] - - config_4 = types.GenerateContentConfig( - system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.", - temperature=0.2, - response_mime_type="application/json", - response_json_schema=GroupRubrics.model_json_schema(), - ) - print(f"Generating rubric (Persp) for group: {', '.join(labels)}...") try: - response_r = client.models.generate_content( - model=MODEL_ID, - contents=contents_4, - config=config_4 - ) - rubrics_data = GroupRubrics.model_validate_json(response_r.text) - rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics} + rubrics_map = generate_rubrics(client, group_context_text) except Exception as e: # noqa: BLE001 - remote API boundary print(f"Error generating rubric for group {labels[0]}: {e}") processing_errors.append(str(e)) @@ -822,12 +901,23 @@ def process_exam( workspace.labels_file, "".join(f"{label}\n" for label in labels_list), ) + atomic_write_text( + workspace.label_groups_file, + "".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n" + for group in grouped_extraction.groups + if any(isinstance(item, QuestionItem) for item in group)), + ) return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS def build_parser() -> argparse.ArgumentParser: parser = evaluation_parser("Extract exam and solution code via Gemini") + actions = parser.add_mutually_exclusive_group() + actions.add_argument("--groups-only", action="store_true", + help="Regroup existing questions with Gemini; update only label_groups") + actions.add_argument("--persp-only", action="store_true", + help="Replace only Persp with Gemini rubrics for existing groups") parser.add_argument( "--restart", action="store_true", @@ -841,7 +931,9 @@ def main(argv: Sequence[str] | None = None) -> int: return execute( parser, argv, - lambda args: process_exam( + lambda args: refine_existing(workspace_from_args(args), + "groups" if args.groups_only else "persp") + if args.groups_only or args.persp_only else process_exam( workspace_from_args(args), restart=args.restart, ), diff --git a/copienator/commands/page_splitter.py b/copienator/commands/page_splitter.py index 1fd974d..eeb7563 100644 --- a/copienator/commands/page_splitter.py +++ b/copienator/commands/page_splitter.py @@ -12,7 +12,7 @@ from collections.abc import Sequence from pathlib import Path from tkinter import messagebox -import fitz # PyMuPDF +import pymupdf # PyMuPDF from PIL import Image, ImageDraw, ImageTk from pypdf import PdfReader, PdfWriter @@ -27,6 +27,9 @@ from copienator import ( ) from copienator.platform import launch_pdf_arranger +# Keep the new shortcut available with older personal configuration files. +PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB} + # --- Constants --- # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) CM_TO_POINTS = (1 / 2.54) * 72 @@ -112,7 +115,7 @@ class PDFPreviewer: self.page_settings = [] self.processing = False # Flag to prevent multiple finish calls try: - self.doc = fitz.open(self.pdf_path) + self.doc = pymupdf.open(self.pdf_path) except (OSError, RuntimeError, ValueError) as e: self.failed = True self._temporary_directory.cleanup() @@ -166,6 +169,7 @@ class PDFPreviewer: f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n" f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n" f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n" + f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n" ) self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT) @@ -192,6 +196,7 @@ class PDFPreviewer: "next_page": self.confirm_and_next_page, "discard_page": self.discard_page, "send_end": self.send_page_end, + "reverse_pages": self.reverse_pages, "restart_file": self.restart_current_file, "arranger": self.start_arranger, "prev_file": self.go_to_previous_file, @@ -271,7 +276,7 @@ class PDFPreviewer: self.current_zoom = min(zoom_x, zoom_y) * 0.98 # --- Render Page --- - mat = fitz.Matrix(self.current_zoom, self.current_zoom) + mat = pymupdf.Matrix(self.current_zoom, self.current_zoom) pix = page.get_pixmap(matrix=mat, alpha=False) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) @@ -302,7 +307,7 @@ class PDFPreviewer: # Re-open the file from disk to reset changes (like moved pages) try: - self.doc = fitz.open(self.pdf_path) + self.doc = pymupdf.open(self.pdf_path) except (OSError, RuntimeError, ValueError) as e: messagebox.showerror("Error", f"Failed to reopen PDF file: {e}") self.master.destroy() @@ -319,6 +324,16 @@ class PDFPreviewer: self.load_page() + def reverse_pages(self, event=None): + """Reverse the current document and discard earlier page decisions.""" + if self.processing or not len(self.doc): + return + self.doc.select(list(reversed(range(len(self.doc))))) + self.current_page_index = 0 + self.page_settings = [] + self._initialize_current_page_settings() + self.load_page() + def move_line_left(self, event=None): """Moves the split line to the left.""" self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2) @@ -465,7 +480,7 @@ class PDFPreviewer: self.file_rotation + self.global_rotation) % 360 if keep == "as_is": - doc_full = fitz.open() + doc_full = pymupdf.open() page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height) page_full.show_pdf_page(page_full.rect, self.doc, i) page_full.set_rotation(rotation) @@ -477,13 +492,13 @@ class PDFPreviewer: # --- Create Left Part --- if rotation == 0: - rect_left = fitz.Rect(0, 0, line_x, page.rect.height) + rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height) else: - rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height) + rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height) if (keep == "both" or keep == "left") and line_x > 0: - doc_left = fitz.open() + doc_left = pymupdf.open() page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height) page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left) page_left.set_rotation(rotation) @@ -494,11 +509,11 @@ class PDFPreviewer: # --- Create Right Part --- if rotation == 0: - rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height) + rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height) else: - rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height) + rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height) if (keep == "both" or keep == "right") and line_x < page.rect.width: - doc_right = fitz.open() + doc_right = pymupdf.open() page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height) page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right) page_right.set_rotation(rotation) diff --git a/copienator/commands/splitting_int.py b/copienator/commands/splitting_int.py index 6f4a107..6421cab 100644 --- a/copienator/commands/splitting_int.py +++ b/copienator/commands/splitting_int.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Sequence from pathlib import Path -import fitz +import pymupdf from pypdf import PdfReader, PdfWriter from copienator import utils @@ -72,7 +72,7 @@ def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]: def _save_cropped_page( - document: fitz.Document, + document: pymupdf.Document, page_number: int, x0: float, y0: float, @@ -82,14 +82,14 @@ def _save_cropped_page( ) -> None: page = document[page_number] rotated_rectangle = page.rect * page.transformation_matrix - visual_crop = fitz.Rect( + visual_crop = pymupdf.Rect( rotated_rectangle.x0 + x0, y0, rotated_rectangle.x0 + x1, y1, ) unrotated_clip = visual_crop * page.derotation_matrix - cropped = fitz.open() + cropped = pymupdf.open() try: target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height) target_page.show_pdf_page( @@ -110,7 +110,7 @@ def _render_split_outputs( staging: Path, ) -> set[str]: """Render every current answer into an otherwise empty staging directory.""" - document = fitz.open(input_pdf) + document = pymupdf.open(input_pdf) try: parsed = _parse_coordinates(coords_list) parts_by_label: defaultdict[str, list[Path]] = defaultdict(list) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 7b60ded..4c12f20 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -279,6 +279,8 @@ class CopienatorApp(tk.Tk): ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid( row=0, column=0, sticky="ew" ) + self.copy_command_button = ttk.Button(command_box, text="Copier la commande", command=self._copy_command) + self.copy_command_button.grid(row=1, column=0, sticky="w", pady=(5, 0)) buttons = ttk.Frame(self.detail) buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0)) @@ -304,7 +306,22 @@ class CopienatorApp(tk.Tk): foreground="#efefef", insertbackground="white", state="disabled", + takefocus=True, + exportselection=False, ) + self.console.bind("", self._copy_console_selection) + self.console.bind("", self._copy_console_selection) + self.console.bind("", self._select_console_all) + self.console.bind("", lambda _event: self.console.focus_set()) + self.console_menu = tk.Menu(self.console, tearoff=False) + self.console_menu.add_command(label="Copier la sélection", command=self._copy_console_selection) + self.console_menu.add_command(label="Tout sélectionner", command=self._select_console_all) + self.console_menu.add_command(label="Copier toute la sortie", command=self._copy_console_all) + self.console.bind("", self._show_console_menu) + if self.tk.call("tk", "windowingsystem") == "aqua": + self.console.bind("", self._copy_console_selection) + self.console.bind("", self._select_console_all) + self.console.bind("", self._show_console_menu) console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview) self.console.configure(yscrollcommand=console_scroll.set) self.console.grid(row=0, column=0, sticky="nsew") @@ -313,6 +330,10 @@ class CopienatorApp(tk.Tk): console_actions = ttk.Frame(console_frame) console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) console_actions.columnconfigure(1, weight=1) + clipboard_actions = ttk.Frame(console_actions) + clipboard_actions.grid(row=1, column=0, columnspan=6, sticky="w", pady=(5, 0)) + ttk.Button(clipboard_actions, text="Copier la sélection", command=self._copy_console_selection).pack(side="left") + ttk.Button(clipboard_actions, text="Copier toute la sortie", command=self._copy_console_all).pack(side="left", padx=6) ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6)) self.stdin_var = tk.StringVar() self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var) @@ -467,7 +488,13 @@ class CopienatorApp(tk.Tk): if not evaluation: return list(step.requires) missing = [] - for pattern in step.requires: + requirements = step.requires + if step.id == "statement": + variant = (self.variant_var.get() if self.current_step and self.current_step.id == step.id + else self.state_store.step(step.id).get("variant", "gemini")) + if variant == "personal": + requirements = ("enonce.tex",) + for pattern in requirements: if any(char in pattern for char in "*?["): exists = next(evaluation.glob(pattern), None) is not None else: @@ -475,8 +502,19 @@ class CopienatorApp(tk.Tk): exists = path.exists() if not exists: missing.append(pattern) + if step.id == "inputs" and not ((evaluation / "names").is_file() or (self.repository / "names").is_file()): + missing.append("names") return missing + def _reload_inputs(self) -> None: + if not self.state_store.evaluation or self.active_step_id or self.runner.running: + return + missing = self._missing_requirements(self.step_by_id["inputs"]) + self.state_store.update_step("inputs", status="ready" if missing else "success") + self._populate_tree() + self._render_step() + self.info_var.set("Fichiers revérifiés — " + ("manquants : " + ", ".join(missing) if missing else "tous les fichiers d’entrée sont présents.")) + def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None: selection = self.tree.selection() if not selection or selection[0].startswith("section:"): @@ -561,6 +599,10 @@ class CopienatorApp(tk.Tk): self.title_label.configure(text=step.title) missing = self._missing_requirements(step) description = step.description + if step.id == "statement" and saved_variant == "personal": + description = ("Lit les blocs SHEETINFO de enonce.tex et récupère les énoncés, solutions " + "et barèmes du service personnel (localhost:8080). Crée un groupe par exercice. " + "Les actions Gemini ci-dessous restent facultatives.") if missing: description += "\nPrérequis non détectés : " + ", ".join(missing) self.description_label.configure(text=description) @@ -633,6 +675,19 @@ class CopienatorApp(tk.Tk): self._update_controls() def _render_context_controls(self, step: StepDefinition, row: int) -> int: + if self.show_personal_steps and step.id in {"statement", "statement_groups", "statement_persp", "review_persp"}: + actions = ttk.LabelFrame(self.form, text="Après génération — facultatif", padding=7) + actions.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + for ident, title in (("statement_groups", "Regrouper avec Gemini…"), + ("statement_persp", "Remplacer Persp avec Gemini…")): + ttk.Button(actions, text=title, command=lambda target=ident: self._select_statement_action(target)).pack( + side="left", padx=(0, 6)) + row += 1 + if step.id == "inputs": + ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid( + row=row, column=0, columnspan=2, sticky="w", pady=(0, 8) + ) + return row + 1 if step.section == REFAIRE_SECTION: evaluation = self.evaluation if step.id in {"refaire_selection", "refaire_correct"}: @@ -675,7 +730,8 @@ class CopienatorApp(tk.Tk): ttk.Label(copies, text=summary, wraplength=570, justify="left").grid( row=0, column=0, columnspan=2, sticky="ew" ) - self.copy_var.set(names[0] if names else "") + if self.copy_var.get() not in names: + self.copy_var.set(names[0] if names else "") selector = ttk.Combobox( copies, textvariable=self.copy_var, @@ -689,6 +745,14 @@ class CopienatorApp(tk.Tk): command=self._open_selected_copy, state="normal" if names else "disabled", ).grid(row=1, column=1, padx=(6, 0), pady=(7, 0)) + if step.id == "page_splitter": + actions = ttk.Frame(copies) + actions.grid(row=2, column=0, columnspan=2, sticky="w", pady=(7, 0)) + ttk.Button(actions, text="Refaire la copie sélectionnée", command=self._redo_selected_pages, + state="normal" if names else "disabled").pack(side="left") + ttk.Button(actions, text="Cibler tout le dossier", command=self._target_all_pages).pack(side="left", padx=6) + ttk.Label(copies, text="La reprise utilise l’original conservé. Vérifiez la commande puis cliquez sur Exécuter.", + wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0)) row += 1 if step.id == "plotting": @@ -702,6 +766,14 @@ class CopienatorApp(tk.Tk): row += 1 return row + def _select_statement_action(self, step_id: str) -> None: + self._save_current_form() + target = self.arg_vars.get("target") + if target: + self.state_store.update_step(step_id, values={"target": target.get()}) + self.tree.selection_set(step_id) + self.tree.see(step_id) + def _refaire_restart_controls(self, row: int) -> int: controls = ttk.Frame(self.form) controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5)) @@ -780,6 +852,16 @@ class CopienatorApp(tk.Tk): def _open_selected_copy(self) -> None: self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie") + def _redo_selected_pages(self) -> None: + path = self.copy_paths.get(self.copy_var.get()) + if path and "target" in self.arg_vars: + self.arg_vars["target"].set(str(path)) + self.info_var.set(f"Reprise de {path.name} prête — cliquez sur Exécuter.") + + def _target_all_pages(self) -> None: + if "target" in self.arg_vars: + self.arg_vars["target"].set(self._evaluation_arg()) + def _open_desktop_path(self, path: Path | None, label: str) -> None: if path is None or not path.exists(): messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.") @@ -907,6 +989,7 @@ class CopienatorApp(tk.Tk): def _update_command_preview(self) -> None: if self._rendering or not self.current_step: return + self.copy_command_button.configure(state="disabled") if self.current_step.is_manual: self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.") return @@ -916,9 +999,44 @@ class CopienatorApp(tk.Tk): if len(commands) > 2: preview += f"\nPuis {len(commands) - 2} autres copies, successivement." self.command_var.set(preview) + self.copy_command_button.configure(state="normal") except (OSError, ValueError, TypeError) as exc: self.command_var.set(f"Arguments invalides : {exc}") + def _copy_text(self, text: str) -> None: + self.clipboard_clear() + self.clipboard_append(text) + + def _copy_command(self) -> None: + if not self.current_step or self.current_step.is_manual: + return + try: + commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()] + self._copy_text("\n".join(command_display(command) for command in commands)) + self.info_var.set("Commande(s) copiée(s) — à exécuter depuis " + str(self.repository)) + except (OSError, ValueError, TypeError) as exc: + self.info_var.set(f"Copie impossible : {exc}") + + def _copy_console_selection(self, _event=None) -> str: + if self.console.tag_ranges("sel"): + self._copy_text(self.console.get("sel.first", "sel.last")) + return "break" + + def _select_console_all(self, _event=None) -> str: + self.console.tag_add("sel", "1.0", "end-1c") + self.console.focus_set() + return "break" + + def _copy_console_all(self) -> None: + self._copy_text(self.console.get("1.0", "end-1c")) + + def _show_console_menu(self, event) -> str: + try: + self.console_menu.tk_popup(event.x_root, event.y_root) + finally: + self.console_menu.grab_release() + return "break" + def _browse_target_file(self, variable: tk.Variable) -> None: selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository) if selected: diff --git a/copienator_gui/diagnostics.py b/copienator_gui/diagnostics.py index bfb3cac..d03f81b 100644 --- a/copienator_gui/diagnostics.py +++ b/copienator_gui/diagnostics.py @@ -51,7 +51,7 @@ def collect_diagnostics( "pdf2image": "pdf2image", "reportlab": "reportlab", "img2pdf": "img2pdf", - "PyMuPDF": "fitz", + "PyMuPDF": "pymupdf", "ftfy": "ftfy", "ezodf": "ezodf", "Google GenAI": "google.genai", diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 2271fc6..0274670 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -92,8 +92,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Détecte les questions, leurs groupes, le corrigé et les indications de barème.", ( python("gemini", "Analyse avec Gemini", "statement"), - python("personal", "Alternative personnelle", "statement-personal"), - ), + ) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),) + if show_personal_steps else ()), arguments=( arg_target("Dossier de l’évaluation"), ArgumentSpec( @@ -107,6 +107,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: requires=("enonce.pdf", "enonce.tex", "correction.tex"), artifacts=("labels", "Text", "Sol", "Persp"), ), + 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.", + (CommandVariant("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),), + arguments=(arg_target("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.", + (CommandVariant("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),), + arguments=(arg_target("Dossier de l’évaluation"),), + optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"), + ), StepDefinition( "review_persp", "Prétraitement de l’énoncé", diff --git a/default_config.py b/default_config.py index bf9cd3c..0063e3c 100644 --- a/default_config.py +++ b/default_config.py @@ -41,6 +41,7 @@ PAGE_SPLITTER_KB = { "next_page": "s", "discard_page": "z", "send_end": "a", # Send this page to the end + "reverse_pages": "i", # Reverse page order and restart at the new first page "restart_file": "T", "arranger": "A", # Call `pdf arranger` software, if available "prev_file": "P", diff --git a/start-gui.sh b/start-gui.sh new file mode 100755 index 0000000..8d1b6d8 --- /dev/null +++ b/start-gui.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# With no arguments, choose the newest visible immediate subfolder by mtime. +if [[ $# -eq 0 ]]; then + newest="" + for candidate in "$PWD"/*/; do + [[ -d "$candidate" ]] || continue + folder="${candidate%/}" + folder="${folder##*/}" + case "$folder" in + copienator|copienator_gui|tests|OLD|__pycache__|build|dist|*.egg-info|venv|env|node_modules) + continue + ;; + esac + if [[ -z "$newest" || "$candidate" -nt "$newest" ]]; then + newest="$candidate" + fi + done + if [[ -n "$newest" ]]; then + set -- "${newest%/}" + fi +fi + +# Resolve the evaluation before changing to the project directory. +if [[ $# -gt 0 && "$1" != -* && "$1" != /* ]]; then + evaluation="$PWD/$1" + shift + set -- "$evaluation" "$@" +fi +repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd -- "$repository" + +if [[ -x "$repository/.venv/bin/python" ]]; then + python_command="$repository/.venv/bin/python" +else + python_command="python3" +fi +exec "$python_command" -m copienator_gui "$@" diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py new file mode 100644 index 0000000..75772d9 --- /dev/null +++ b/tests/test_gui_convenience.py @@ -0,0 +1,75 @@ +import os +import tempfile +import unittest +from pathlib import Path + +from copienator_gui.app import CopienatorApp +from copienator_gui.workflow import command_display +from copienator.commands.page_splitter import _selected_inputs +from copienator import EvaluationWorkspace + + +@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display") +class GuiConvenienceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.repository = Path(self.temp.name) + self.evaluation = self.repository / "Évaluation avec espaces" + self.evaluation.mkdir() + self.app = CopienatorApp(self.repository, False, self.evaluation) + self.app.update() + + def tearDown(self): + for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")): + self.app.after_cancel(callback) + self.app.destroy() + self.temp.cleanup() + + def test_reload_detects_added_and_removed_files_without_advancing(self): + for name in ("enonce.pdf", "enonce.tex", "correction.tex"): + (self.evaluation / name).touch() + self.app._reload_inputs() + self.assertIn("names", self.app.info_var.get()) + (self.repository / "names").touch() + self.app._reload_inputs() + self.app.update() + self.assertEqual(self.app.state_store.step("inputs")["status"], "success") + self.assertEqual(self.app.current_step.id, "inputs") + (self.evaluation / "enonce.pdf").unlink() + self.app._reload_inputs() + self.assertEqual(self.app.state_store.step("inputs")["status"], "ready") + self.assertIn("enonce.pdf", self.app.description_label.cget("text")) + + def test_redo_targets_selected_copy_and_copies_runnable_command(self): + for folder in ("Copies", "Copies Originales"): + (self.evaluation / folder).mkdir() + for name in ("Copie01.pdf", "Copie02.pdf"): + (self.evaluation / folder / name).touch() + self.app.tree.selection_set("page_splitter") + self.app.update() + self.app.copy_var.set("Copie02.pdf") + self.app._redo_selected_pages() + command = self.app._make_command() + target = Path(command[-1]) + self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf") + self.assertEqual(_selected_inputs(EvaluationWorkspace(self.evaluation), target), + [self.evaluation / "Copies Originales" / "Copie02.pdf"]) + self.app._copy_command() + self.assertEqual(self.app.clipboard_get(), command_display(command)) + self.app._target_all_pages() + self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg()) + + def test_console_selection_survives_output_and_is_read_only(self): + self.app._append_console("Première ligne\nDeuxième ligne\n") + self.app.console.tag_add("sel", "1.0", "1.end") + self.app._append_console("Suite\n") + self.app._copy_console_selection() + self.assertEqual(self.app.clipboard_get(), "Première ligne") + self.app._copy_console_all() + expected = "Première ligne\nDeuxième ligne\nSuite\n" + self.assertEqual(self.app.clipboard_get(), expected) + self.app.console.insert("end", "unwanted edit") + self.assertEqual(self.app.console.get("1.0", "end-1c"), expected) + self.app._select_console_all() + self.app._copy_console_selection() + self.assertEqual(self.app.clipboard_get(), expected) diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index ee36de3..97bef2b 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -812,7 +812,7 @@ class StandardCliTests(unittest.TestCase): evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" copy_pdf.parent.mkdir(parents=True) - document = module.fitz.open() + document = module.pymupdf.open() page = document.new_page(width=600, height=800) page.insert_text((100, 200), "Student answer") document.save(copy_pdf) diff --git a/tests/test_page_splitter_reverse.py b/tests/test_page_splitter_reverse.py new file mode 100644 index 0000000..a1ee969 --- /dev/null +++ b/tests/test_page_splitter_reverse.py @@ -0,0 +1,81 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock + +import pymupdf + +from copienator.commands.page_splitter import PDFPreviewer, PAGE_SPLITTER_KB + + +class ReversePagesTests(unittest.TestCase): + def test_reverse_restarts_with_fresh_settings_and_exports_reversed_pages(self): + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "copy.pdf" + with pymupdf.open() as document: + for index in range(3): + page = document.new_page(width=200 + 20 * index, height=300) + page.insert_text((30, 30), f"Page {index + 1}") + document.save(source) + original = source.read_bytes() + preview = PDFPreviewer.__new__(PDFPreviewer) + preview.doc = pymupdf.open(source) + self.addCleanup(lambda: None if preview.doc.is_closed else preview.doc.close()) + preview.current_page_index = 2 + preview.page_settings = [{"keep": "none"}, {"keep": "left"}] + preview.current_rotation = 180 + preview.file_rotation = 180 + preview.global_rotation = 180 + preview.processing = False + preview.load_page = Mock() + + preview.reverse_pages() + + self.assertEqual([page.get_text().strip() for page in preview.doc], + ["Page 3", "Page 2", "Page 1"]) + self.assertEqual(preview.current_page_index, 0) + self.assertEqual(preview.page_settings, []) + self.assertEqual(preview.current_line_x, 120) + self.assertEqual(preview.current_rotation, 0) + self.assertEqual((preview.file_rotation, preview.global_rotation), (180, 180)) + preview.load_page.assert_called_once_with() + self.assertEqual(source.read_bytes(), original) + + preview.reverse_pages() + self.assertEqual([page.get_text().strip() for page in preview.doc], + ["Page 1", "Page 2", "Page 3"]) + preview.reverse_pages() + + preview.base_name = "copy" + preview.split_dir = Path(temporary) / "split" + preview.reorder_dir = Path(temporary) / "reorder" + preview.final_file = Path(temporary) / "result.pdf" + preview.output_dir = None + preview.page_settings = [ + {"keep": "as_is", "rotation": 0, "line_x": page.rect.width / 2} + for page in preview.doc + ] + preview.split_pdf() + preview.reorder_pdfs() + preview.concate_files() + with pymupdf.open(preview.final_file) as result: + self.assertEqual([page.get_text().strip() for page in result], + ["Page 3", "Page 2", "Page 1"]) + + def test_single_page_can_restart_and_processing_ignores_shortcut(self): + preview = PDFPreviewer.__new__(PDFPreviewer) + preview.doc = pymupdf.open() + self.addCleanup(preview.doc.close) + preview.doc.new_page() + preview.processing = True + preview.load_page = Mock() + preview.reverse_pages() + preview.load_page.assert_not_called() + preview.processing = False + preview.reverse_pages() + self.assertEqual(preview.current_page_index, 0) + self.assertEqual(preview.page_settings, []) + preview.load_page.assert_called_once_with() + + def test_shortcut_available_with_personal_configuration(self): + self.assertEqual(PAGE_SPLITTER_KB["reverse_pages"], "i") diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py new file mode 100644 index 0000000..67cf2d3 --- /dev/null +++ b/tests/test_statement_personal_flow.py @@ -0,0 +1,165 @@ +import io +import json +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from copienator import CliError, EvaluationWorkspace, ExitCode +from copienator.commands import enonce_info as personal +from copienator.commands import gemini_for_enonce as gemini +from copienator_gui.app import CopienatorApp +from copienator_gui.workflow import build_workflow + + +class PersonalStatementTests(unittest.TestCase): + def test_personal_generation_uses_statement_and_groups_by_exercise(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "correction.tex").write_text("Not SHEETINFO") + blocks = [{"id": 42, "indexes": [{"indices": [1]}, {"indices": [2]}]}, + {"id": 99}] + (root / "enonce.tex").write_text("\n".join( + "%%SHEETINFO : " + json.dumps(block) for block in blocks)) + (root / "label_groups").write_text("obsolete groups") + urls = [] + + def fetch(url): + urls.append(url) + if "/emacs/" in url: + return io.BytesIO(b"Statement\n 1) Question\n###Solution\n 1) Answer\n###Rubric\n 1) Points") + return io.BytesIO(b"Selected exercise content") + + def compile_pdf(content, path): + Path(path).write_bytes(b"test PDF") + + with patch.object(personal.urllib.request, "urlopen", side_effect=fetch), patch.object( + personal, "compile_to_pdf", side_effect=compile_pdf + ): + self.assertEqual(personal.process_directory(EvaluationWorkspace(root)), ExitCode.SUCCESS) + self.assertEqual((root / "labels").read_text(), "Ex 1 : 1)\nEx 1 : 2)\nEx 2\n") + self.assertEqual((root / "label_groups").read_text(), "Ex 1 : 1), Ex 1 : 2)\nEx 2\n") + for folder in ("Text2", "Sol2"): + for label in ("Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"): + self.assertTrue((root / folder / f"{label}.tex").is_file()) + self.assertTrue((root / folder / f"{label}.pdf").is_file()) + self.assertIn("Rubric", (root / "Persp" / "Ex 1").read_text()) + self.assertTrue(any("/exo_q_text/42/1" in url for url in urls)) + + def test_personal_choice_and_optional_steps_only_in_personal_profile(self): + standard = {step.id: step for step in build_workflow(False)} + enabled = {step.id: step for step in build_workflow(True)} + self.assertEqual([variant.id for variant in standard["statement"].variants], ["gemini"]) + self.assertEqual([variant.program for variant in enabled["statement"].variants], + ["statement", "statement-personal"]) + for ident in ("statement_groups", "statement_persp"): + self.assertNotIn(ident, standard) + self.assertTrue(enabled[ident].optional) + self.assertFalse(enabled[ident].auto_start_first_visit) + + +class SelectiveGeminiTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.workspace = EvaluationWorkspace(self.root) + self.labels = ["Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"] + (self.root / "labels").write_text("\n".join(self.labels) + "\n") + (self.root / "label_groups").write_text(", ".join(self.labels[:2]) + "\nEx 2\n") + for folder in ("Text", "Sol", "Text2", "Sol2", "Persp"): + (self.root / folder).mkdir() + for label in self.labels: + suffix = ".tex" if folder.endswith("2") else "" + (self.root / folder / (label + suffix)).write_text(f"Personal {folder}: {label}") + (self.root / "Persp" / "Ex 1").write_text("Old aggregate rubric") + self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock())) + + def snapshot(self): + return {str(path.relative_to(self.root)): path.read_bytes() + for path in self.root.rglob("*") if path.is_file()} + + def response(self, value): + return SimpleNamespace(text=json.dumps(value)) + + def test_grouping_changes_only_groups_and_keeps_exact_labels(self): + before = self.snapshot() + self.client.models.generate_content.return_value = self.response({"groups": [[label] for label in self.labels]}) + self.assertEqual(gemini.refine_existing(self.workspace, "groups", api_client=self.client), ExitCode.SUCCESS) + after = self.snapshot() + self.assertEqual(after.pop("label_groups"), ("\n".join(self.labels) + "\n").encode()) + before.pop("label_groups") + self.assertEqual(after, before) + + def test_invalid_grouping_preserves_existing_groups(self): + before = self.snapshot() + for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]): + self.client.models.generate_content.return_value = self.response({"groups": groups}) + with self.assertRaises(CliError): + gemini.refine_existing(self.workspace, "groups", api_client=self.client) + self.assertEqual(self.snapshot(), before) + + def rubric_response(self, labels): + return self.response({"rubrics": [{"label": label, "rubric_content": "Barème Gemini sur 4 points"} + for label in labels]}) + + def test_rubrics_replace_only_persp_using_normal_prompt(self): + before = self.snapshot() + self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), + self.rubric_response(self.labels[2:])] + self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS) + after = self.snapshot() + self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")}, + {key: value for key, value in after.items() if not key.startswith("Persp/")}) + self.assertFalse((self.root / "Persp" / "Ex 1").exists()) + for label in self.labels: + self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text()) + for call in self.client.models.generate_content.call_args_list: + self.assertEqual(call.kwargs["contents"][0].parts[0].text, gemini.PROMPT_4) + + def test_incomplete_or_failed_rubrics_preserve_entire_persp(self): + before = self.snapshot() + for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")): + self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last] + with self.assertRaises((CliError, RuntimeError)): + gemini.refine_existing(self.workspace, "persp", api_client=self.client) + self.assertEqual(self.snapshot(), before) + + def test_cli_dispatches_selective_modes_without_full_extraction(self): + with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object( + gemini, "process_exam" + ) as full: + for flag, mode in (("--groups-only", "groups"), ("--persp-only", "persp")): + self.assertEqual(gemini.main([str(self.root), flag]), ExitCode.SUCCESS) + self.assertEqual(refine.call_args.args[1], mode) + full.assert_not_called() + + +@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display") +class PersonalStatementGuiTests(unittest.TestCase): + def test_personal_requirements_and_optional_button_command_previews(self): + with tempfile.TemporaryDirectory() as temporary: + evaluation = Path(temporary) + (evaluation / "enonce.tex").touch() + app = CopienatorApp(Path.cwd(), True, evaluation) + try: + app.update() + app.tree.selection_set("statement") + app.update() + app._select_variant(1) + self.assertIn("statement-personal", app.command_var.get()) + self.assertEqual(app._missing_requirements(app.current_step), []) + self.assertIn("SHEETINFO", app.description_label.cget("text")) + for ident, flag in (("statement_groups", "--groups-only"), ("statement_persp", "--persp-only")): + app._select_statement_action(ident) + app.update() + self.assertEqual(app.current_step.id, ident) + self.assertIn(flag, app.command_var.get()) + self.assertTrue(app.current_step.optional) + self.assertFalse(app.runner.running) + finally: + for callback in app.tk.splitlist(app.tk.call("after", "info")): + app.after_cancel(callback) + app.destroy()