diff --git a/Readme.org b/Readme.org index 446434a..9bf30cf 100644 --- a/Readme.org +++ b/Readme.org @@ -197,6 +197,18 @@ 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 la marge des labels=, =s= 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= +reprend ces copies à partir des originaux conservés. Le même bouton dans +=Découper la marge des labels= reprend leurs marges ; 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 +conserve le signalement. Les commandes =page-split= et =crop-labels= +acceptent aussi =--marked= pour traiter les copies signalées de l’évaluation. + 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 diff --git a/copienator/commands/cutleft.py b/copienator/commands/cutleft.py index 55f61f3..425b359 100644 --- a/copienator/commands/cutleft.py +++ b/copienator/commands/cutleft.py @@ -2,8 +2,8 @@ from __future__ import annotations import argparse import threading -import time import tkinter as tk +from tkinter import messagebox from collections.abc import Sequence from functools import lru_cache from pathlib import Path @@ -23,6 +23,7 @@ from copienator import ( workspace_from_target, ) from copienator.filesystem import staged_files +from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths DELIMITER_WIDTH = 5 DELIMITER_COLOR = (0, 0, 0) @@ -156,6 +157,11 @@ class ImageReviewer: ) -> None: self.files = files self.output_dir = output_dir + self.workspace = EvaluationWorkspace(output_dir.parent) + self.completed = False + self.had_errors = False + self.stop_prefetch = threading.Event() + self.current_result = None self.index = 0 self.current_shift = 0 self.default_max_per_file = default_max_per_file @@ -174,6 +180,8 @@ 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.protocol("WM_DELETE_WINDOW", self.on_close) self.root.bind("n", lambda _event: self.on_shift(50)) self.root.bind("N", lambda _event: self.on_shift(100)) self.root.bind("t", lambda _event: self.on_shift(-50)) @@ -194,20 +202,25 @@ class ImageReviewer: def prefetch_worker(self) -> None: processed_index = -1 - while True: + while not self.stop_prefetch.is_set(): target = self.index + 1 if target < len(self.files) and target != processed_index: - get_pdf_pages(self.files[target]) + try: + get_pdf_pages(self.files[target]) + except Exception: + pass # The foreground review reports and flags conversion errors. processed_index = target - time.sleep(0.05) + self.stop_prefetch.wait(0.05) def load_current_image(self) -> None: if self.index >= len(self.files): print("All files processed.") - self.root.destroy() + self.completed = True + self.on_close() return self.is_processing = False self.current_shift = 0 + self.current_result = None self.trigger_processing(self.files[self.index], self.current_shift) def trigger_processing(self, pdf_path: Path, shift: int) -> None: @@ -228,13 +241,13 @@ class ImageReviewer: def check_manual_queue(self, pdf_path: Path) -> None: try: result = self.manual_queue.get_nowait() + self.is_processing = False if result is None: print(f"Failed to process {pdf_path.name}, skipping.") - self.index += 1 - self.load_current_image() + self._mark_error(pdf_path, "Échec de la conversion pour la découpe des marges") + self._advance() else: self.handle_processing_result(result, pdf_path) - self.is_processing = False except Empty: self.root.after(100, lambda: self.check_manual_queue(pdf_path)) @@ -244,7 +257,7 @@ class ImageReviewer: pdf_path: Path, ) -> None: self.current_preview = result[0] - save_results(result, pdf_path, self.output_dir) + self.current_result = result self.update_display(pdf_path.name, result[2]) def update_display(self, filename: str, schema: dict[str, object]) -> None: @@ -258,7 +271,7 @@ class ImageReviewer: f"[{self.index + 1}/{len(self.files)}] {filename} | " f"Shift: {self.current_shift}px\nFiles: {schema['number_of_files']} | " f"Cols: {schema['columns_per_file']}\n" - "Enter: Next | n: +50 | N: +100 | t: -50 | " + "Enter: Save and next | s: flag error and skip | n: +50 | N: +100 | t: -50 | " "1: use single column" ), fg="black", @@ -272,8 +285,34 @@ class ImageReviewer: self.trigger_processing(self.files[self.index], self.current_shift) def on_next(self, _event: object) -> None: - if self.is_processing: + if self.is_processing or self.current_result is None: return + pdf_path = self.files[self.index] + try: + save_results(self.current_result, pdf_path, self.output_dir) + clear_copy_error(self.workspace, pdf_path) + except Exception as exc: + self._mark_error(pdf_path, f"Échec de l’enregistrement : {exc}") + messagebox.showerror("Enregistrement impossible", str(exc), parent=self.root) + return + self._advance() + + def _mark_error(self, pdf_path: Path, reason: str) -> None: + mark_copy_error(self.workspace, pdf_path, reason) + self.had_errors = True + print(f"[Copie signalée] {pdf_path.name}: {reason}") + + def on_skip(self, _event=None) -> None: + if self.is_processing or self.index >= len(self.files): + return + self._mark_error(self.files[self.index], "Problème repéré pendant la découpe des marges") + self._advance() + + def on_close(self) -> None: + self.stop_prefetch.set() + self.root.destroy() + + def _advance(self) -> None: self.index += 1 self.current_shift = 0 self.current_max_per_file = self.default_max_per_file @@ -304,23 +343,27 @@ def run( target: Path, *, fullpage: bool = False, + marked: bool = False, ) -> ExitCode: - files = _selected_files(workspace, target) + files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target) if not files: print("No PDF files found.") return ExitCode.SUCCESS workspace.cutleft_dir.mkdir(parents=True, exist_ok=True) _get_pdf_pages_cached.cache_clear() - ImageReviewer( + reviewer = ImageReviewer( files, workspace.cutleft_dir, default_max_per_file=1 if fullpage else 5, ) - return ExitCode.SUCCESS + if not reviewer.completed: + return ExitCode.INTERRUPTED + return ExitCode.PARTIAL if reviewer.had_errors else ExitCode.SUCCESS def build_parser() -> argparse.ArgumentParser: parser = target_parser("Interactively crop the label margin from PDF copies") + parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter") parser.add_argument( "--fullpage", action="store_true", @@ -334,11 +377,10 @@ def main(argv: Sequence[str] | None = None) -> int: def handle(args: argparse.Namespace) -> ExitCode: workspace, target = workspace_from_target(args) - return run(workspace, target, fullpage=args.fullpage) + return run(workspace, target, fullpage=args.fullpage, marked=args.marked) return execute(parser, argv, handle) if __name__ == "__main__": raise SystemExit(main()) - diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index ed23f38..a461ddd 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -190,6 +190,7 @@ def generate_rubrics(client, group_context_text: str) -> dict[str, str]: types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), ])], config=types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), 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", @@ -243,6 +244,7 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non + context + "\n\n" + "\n\n".join(questions.values()) ))])], config=types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=0.1, response_mime_type="application/json", response_json_schema=LabelGroups.model_json_schema(), ), @@ -337,6 +339,7 @@ def process_exam( ] config_1 = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=0.1, response_mime_type="application/json", response_json_schema=ExamQuestions.model_json_schema(), @@ -375,6 +378,7 @@ def process_exam( ] config_2 = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=0.1, response_mime_type="application/json", response_json_schema=ExamSolutions.model_json_schema(), @@ -411,6 +415,7 @@ def process_exam( ] config_3 = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=0.1, response_mime_type="application/json", response_json_schema=ExamContext.model_json_schema(), diff --git a/copienator/commands/gemini_for_labels.py b/copienator/commands/gemini_for_labels.py index 188342c..f0d6117 100644 --- a/copienator/commands/gemini_for_labels.py +++ b/copienator/commands/gemini_for_labels.py @@ -76,6 +76,8 @@ be missing. ##wrong_labels## +##wrong_label_text_context## + Here's a list of the names of the students, pick the one that matches the best or `\"Unknown\"` if you cannot read the name @@ -133,6 +135,8 @@ be missing. ##wrong_labels## +##wrong_label_text_context## + Since this copy isn't the first part of a sequence, simply set the name to `\"Continued\"`.""" @@ -147,7 +151,66 @@ class AnnotationData(BaseModel): ) -def generate_request(file, labels, names, context_labels, wrong_labels): +TEXT_CONTEXT_MAX_CHARS = 4000 + + +def _label_filename(path: Path) -> str: + return path.stem if path.suffix.casefold() in {".tex", ".txt"} else path.name + + +def _common_prefix_length(left: str, right: str) -> int: + left_folded = left.casefold() + right_folded = right.casefold() + limit = min(len(left_folded), len(right_folded)) + for index in range(limit): + if left_folded[index] != right_folded[index]: + return index + return limit + + +def closest_text_context( + workspace: EvaluationWorkspace, wrong_labels: list[str] +) -> tuple[Path | None, str]: + """Return a bounded excerpt from the Text file closest to an invalid label.""" + text_dir = workspace.root / "Text" + if not wrong_labels or not text_dir.is_dir(): + return None, "" + + ranked: list[tuple[int, str, Path]] = [] + for path in text_dir.iterdir(): + if not path.is_file() or path.suffix.casefold() == ".pdf": + continue + filename = _label_filename(path) + prefix_length = max( + _common_prefix_length(filename, wrong_label) + for wrong_label in wrong_labels + ) + if prefix_length: + ranked.append((prefix_length, filename.casefold(), path)) + + for _prefix_length, _filename, path in sorted( + ranked, key=lambda item: (-item[0], item[1]) + ): + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + if len(content) > TEXT_CONTEXT_MAX_CHARS: + content = content[:TEXT_CONTEXT_MAX_CHARS] + "\n[excerpt truncated]" + return path, content + return None, "" + + +def generate_request( + file, + labels, + names, + context_labels, + wrong_labels, + wrong_label_text_context="", + wrong_label_text_file: Path | None = None, + seed: int = 0, +): """Generates request for Gemini with context.""" image_path = Path(file) @@ -162,9 +225,36 @@ def generate_request(file, labels, names, context_labels, wrong_labels): text = my_prompt2.replace("##labels##", labels)\ .replace("##prev_context##", context_str) if wrong_labels: - text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.") + formatted_wrong_labels = "\n".join(f'- "{label}"' for label in wrong_labels) + text = text.replace( + "##wrong_labels##", + "On the previous request for this image, you answered with these " + "invalid labels:\n" + f"{formatted_wrong_labels}\n" + "They are wrong because they do not exactly match any label in the " + "valid list above.\n\n" + "CRITICAL RETRY CONSTRAINT: NEVER return any of the invalid labels " + "listed above again. Your answer must use only exact labels copied " + "verbatim from the valid list. If the handwriting resembles an " + "invalid label, choose the closest exact valid label instead.", + ) else: - text = text.replace("##wrong_labels##\n\n", "") + text = text.replace("##wrong_labels##", "") + + if wrong_label_text_context and wrong_label_text_file: + text = text.replace( + "##wrong_label_text_context##", + "Here is an excerpt from the exam text file whose name has the " + "longest prefix in common with the invalid label(s), " + f"`{wrong_label_text_file.name}`:\n\n" + "\n" + f"{wrong_label_text_context}\n" + "\n\n" + "Use this excerpt as extra context for identifying the handwritten " + "label, but return only an exact label from the valid list above.", + ) + else: + text = text.replace("##wrong_label_text_context##", "") contents = [ @@ -181,9 +271,10 @@ def generate_request(file, labels, names, context_labels, wrong_labels): ] generate_content_config = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=1.0, top_p=0.95, - seed=0, + seed=seed, max_output_tokens=65535, response_mime_type= "application/json", response_json_schema= AnnotationData.model_json_schema(), @@ -293,17 +384,30 @@ def process_copy_group( f"{len(accumulated_labels)} accumulated labels..." ) attempt = 0 + label_retry_count = 0 wrong_labels: list[str] = [] while True: if attempt > 0: sleep(10 * attempt) try: + text_context_file, text_context = closest_text_context( + workspace, wrong_labels + ) + if text_context_file: + print( + f"[{group_key}] Retry context for {image_file.name}: " + f"{text_context_file.relative_to(workspace.root)}" + ) + request_seed = max(0, label_retry_count - 1) contents, request_config = generate_request( image_file, labels_text, names_text, accumulated_labels, wrong_labels, + text_context, + text_context_file, + seed=request_seed, ) response = client.models.generate_content( model=MODEL_ID, @@ -321,9 +425,23 @@ def process_copy_group( f"Error: {image_file.name} contained unknown labels: " f"{unknown}" ) - wrong_labels.extend(unknown) - attempt += 1 - continue + unique_unknown = list(dict.fromkeys(unknown)) + if ( + label_retry_count >= 2 + and set(unique_unknown) == set(wrong_labels) + ): + for item in annotation.list: + if item.label in unique_unknown: + item.label = f"??{item.label}" + print( + f"Warning: {image_file.name} repeated the same unknown " + "label(s) on the third try; keeping them with a ?? prefix." + ) + else: + wrong_labels = unique_unknown + label_retry_count += 1 + attempt += 1 + continue if annotation.name not in valid_names: print( f"Error: {image_file.name} returned unknown name: " diff --git a/copienator/commands/page_splitter.py b/copienator/commands/page_splitter.py index eeb7563..a02b28d 100644 --- a/copienator/commands/page_splitter.py +++ b/copienator/commands/page_splitter.py @@ -26,6 +26,7 @@ from copienator import ( workspace_from_target, ) from copienator.platform import launch_pdf_arranger +from copienator.copy_errors import marked_copy_paths # Keep the new shortcut available with older personal configuration files. PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB} @@ -647,8 +648,8 @@ def _selected_inputs( return list(reversed(candidates)) -def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode: - inputs = _selected_inputs(workspace, target) +def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode: + inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target) if not inputs: print(f"No PDF files found in {target}") return ExitCode.SUCCESS @@ -659,7 +660,9 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode: def build_parser() -> argparse.ArgumentParser: - return target_parser("Interactively split and reorder scanned PDF pages") + parser = target_parser("Interactively split and reorder scanned PDF pages") + parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review") + return parser def main(argv: Sequence[str] | None = None) -> int: @@ -667,7 +670,7 @@ def main(argv: Sequence[str] | None = None) -> int: def handle(args: argparse.Namespace) -> ExitCode: workspace, target = workspace_from_target(args) - return run(workspace, target) + return run(workspace, target, marked=args.marked) return execute(parser, argv, handle) diff --git a/copienator/copy_errors.py b/copienator/copy_errors.py new file mode 100644 index 0000000..8209b7e --- /dev/null +++ b/copienator/copy_errors.py @@ -0,0 +1,48 @@ +"""Persistent copy flags shared by page splitting, margin review and the GUI.""" +from pathlib import Path + +from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json + + +def _path(workspace: EvaluationWorkspace) -> Path: + return workspace.metadata_dir / "copy_errors.json" + + +def _validate(value) -> dict[str, str]: + if not isinstance(value, dict) or any( + not isinstance(name, str) or not isinstance(reason, str) + or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf" + for name, reason in value.items() + ): + raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions") + return value + + +def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]: + return _validate(read_json(_path(workspace), default={})) + + +def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None: + def update(errors): + _validate(errors)[pdf.name] = reason + atomic_update_json(_path(workspace), update, default_factory=dict) + + +def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None: + if not _path(workspace).exists(): + return + def update(errors): + _validate(errors).pop(pdf.name, None) + atomic_update_json(_path(workspace), update, default_factory=dict) + + +def marked_copy_paths(workspace: EvaluationWorkspace, *, originals: bool = False) -> list[Path]: + directories = ([workspace.original_copies_dir, workspace.copies_dir, workspace.root] + if originals else [workspace.copies_dir]) + result = [] + for name in sorted(copy_errors(workspace), key=str.casefold): + path = next((directory / name for directory in directories if (directory / name).is_file()), None) + if path is None: + raise CliError(f"Marked copy not found: {name}") + result.append(path) + return result diff --git a/copienator/prompting.py b/copienator/prompting.py index 9c8d971..9823442 100644 --- a/copienator/prompting.py +++ b/copienator/prompting.py @@ -182,6 +182,7 @@ def generate_request(input_dir, file, full_label): ] generate_content_config = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=1.0, top_p=0.95, seed=0, @@ -259,6 +260,7 @@ Commentaires d'origine : ] config = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), temperature=1.0, response_mime_type="application/json", response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema() @@ -288,7 +290,10 @@ Voici les labels possibles. Ta réponse doit être l'un d'entre eux : contents = [types.Content(role="user", parts=[ types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"), types.Part.from_text(text=prompt)])] - config = types.GenerateContentConfig(temperature=1.0) + config = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), + temperature=1.0, + ) return contents, config def request_for_additional_answer(pdf_path, label, enonce, labels_txt): @@ -316,6 +321,9 @@ d'entre eux : types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"), types.Part.from_text(text=prompt) ])] - config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json") + config = types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), + temperature=1.0, + response_mime_type="application/json", + ) return contents, config - diff --git a/copienator_gui/app.py b/copienator_gui/app.py index 4c12f20..22bf1fc 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -8,7 +8,8 @@ from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any -from copienator import EvaluationWorkspace, ExitCode, atomic_write_json +from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json +from copienator.copy_errors import copy_errors from copienator.platform import ( WindowsLabelError, add_platform_executable_paths, @@ -205,25 +206,29 @@ class CopienatorApp(tk.Tk): top = ttk.Frame(self, padding=(10, 8)) top.grid(row=0, column=0, sticky="ew") - top.columnconfigure(2, weight=1) + top.columnconfigure(5, weight=1) ttk.Button(top, text="Charger", command=self._load_evaluation).grid( row=0, column=0, padx=(0, 8) ) ttk.Label(top, text="Évaluation").grid(row=0, column=1, sticky="w", padx=(0, 8)) - path_entry = ttk.Entry(top, textvariable=self.evaluation_var) - path_entry.grid(row=0, column=2, sticky="ew") - path_entry.bind("", lambda _event: self._load_evaluation()) - ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid( - row=0, column=3, padx=6 + self.evaluation_entry = ttk.Entry(top, textvariable=self.evaluation_var, width=42) + self.evaluation_entry.grid(row=0, column=2, sticky="w") + self.evaluation_entry.bind("", lambda _event: self._load_evaluation()) + self.open_evaluation_button = ttk.Button( + top, text="Ouvrir le dossier", command=self._open_evaluation_folder ) - ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0)) + self.open_evaluation_button.grid(row=0, column=3, padx=(6, 0)) + ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid( + row=0, column=4, padx=6 + ) + ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=6, padx=(6, 0)) ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid( row=1, column=1, sticky="w", pady=(7, 0) ) environment = ttk.Frame(top) - environment.grid(row=1, column=2, columnspan=3, sticky="e", pady=(7, 0)) + environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0)) ttk.Checkbutton( environment, text="Utiliser le proxy HTTPS", @@ -403,6 +408,9 @@ class CopienatorApp(tk.Tk): self.evaluation_var.set(selected) self._load_evaluation() + def _open_evaluation_folder(self) -> None: + self._open_desktop_path(self.evaluation, "dossier d’évaluation") + def _toggle_proxy(self) -> None: self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled") @@ -581,10 +589,12 @@ class CopienatorApp(tk.Tk): return self._rendering = True redo = step.section == REFAIRE_SECTION - self.console.configure(height=8 if redo else 14) - self.rowconfigure(1, weight=4 if redo else 3) - self.rowconfigure(2, weight=1 if redo else 2) - self.form_canvas.configure(height=(340 if step.id == "refaire_selection" else 260) if redo else 200) + expanded_form = redo or step.id in {"page_splitter", "cutleft", "labels"} + self.console.configure(height=8 if expanded_form else 14) + self.rowconfigure(1, weight=4 if expanded_form else 3) + self.rowconfigure(2, weight=1 if expanded_form else 2) + form_height = (340 if step.id == "refaire_selection" else 260) if redo else (300 if expanded_form else 200) + self.form_canvas.configure(height=form_height) self.form_canvas.yview_moveto(0) for child in self.form.winfo_children(): child.destroy() @@ -667,6 +677,15 @@ class CopienatorApp(tk.Tk): row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8) ) ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4)) + row += 1 + + if self.show_personal_steps and step.id == "statement": + 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=(10, 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)) self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter")) self.skip_button.configure(state="normal" if step.optional else "disabled") @@ -675,13 +694,11 @@ 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)) + if step.id == "rename": + self.validate_rename_button = ttk.Button( + self.form, text="Valider sans renommer", command=self._validate_rename_step + ) + self.validate_rename_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)) row += 1 if step.id == "inputs": ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid( @@ -714,7 +731,7 @@ class CopienatorApp(tk.Tk): return row + 1 if step.id in {"review_persp", "correction"}: row = self._correction_folder_buttons(row) - if step.section == "Prétraitement des copies": + if step.id in {"page_splitter", "cutleft", "labels"}: evaluation = self.evaluation paths = copy_pdf_paths(evaluation) if evaluation else [] self.copy_paths = {path.name: path for path in paths} @@ -745,14 +762,36 @@ 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": + if step.id in {"page_splitter", "cutleft", "labels"}: 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, + label = "Refaire la copie sélectionnée" if step.id == "page_splitter" else "Cibler la copie sélectionnée" + ttk.Button(actions, text=label, command=self._target_selected_copy, 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.", + help_text = ( + "La reprise utilise l’original conservé. " + if step.id == "page_splitter" + else "Seule la copie ciblée sera analysée par Gemini. " + if step.id == "labels" + else "Seule la copie ciblée sera traitée. " + ) + ttk.Label(copies, text=help_text + "Vérifiez la commande puis cliquez sur Exécuter.", wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0)) + if step.id in {"page_splitter", "cutleft"}: + try: + marked = copy_errors(EvaluationWorkspace(evaluation)) if evaluation else {} + marked_summary = ", ".join(marked) or "Aucune copie signalée." + except (CliError, OSError, ValueError, RuntimeError) as exc: + marked = {} + marked_summary = f"Lecture des copies signalées impossible : {exc}" + self.marked_copies_button = ttk.Button( + copies, text=f"Traiter les copies signalées ({len(marked)})", command=self._run_marked_copies, + state="normal" if marked and not self.active_step_id and not self.runner.running else "disabled", + ) + self.marked_copies_button.grid(row=4, column=0, columnspan=2, sticky="w", pady=(7, 0)) + ttk.Label(copies, text=marked_summary, wraplength=570).grid( + row=5, column=0, columnspan=2, sticky="w") row += 1 if step.id == "plotting": @@ -852,16 +891,28 @@ 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: + def _target_selected_copy(self) -> None: path = self.copy_paths.get(self.copy_var.get()) if path and "target" in self.arg_vars: + if "marked" in self.arg_vars: + self.arg_vars["marked"].set(False) self.arg_vars["target"].set(str(path)) - self.info_var.set(f"Reprise de {path.name} prête — cliquez sur Exécuter.") + self.info_var.set(f"Copie ciblée : {path.name} — cliquez sur Exécuter.") def _target_all_pages(self) -> None: if "target" in self.arg_vars: + if "marked" in self.arg_vars: + self.arg_vars["marked"].set(False) self.arg_vars["target"].set(self._evaluation_arg()) + def _run_marked_copies(self) -> None: + if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft"} + or self.active_step_id or self.runner.running or not self.state_store.evaluation): + return + self.arg_vars["target"].set(self._evaluation_arg()) + self.arg_vars["marked"].set(True) + self._run_current_step() + 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.") @@ -1186,6 +1237,19 @@ class CopienatorApp(tk.Tk): f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}." ) + def _validate_rename_step(self) -> None: + if (not self.current_step or self.current_step.id != "rename" + or not self.state_store.evaluation or self.active_step_id or self.runner.running): + return + self._save_current_form() + self.state_store.update_step("rename", status="success") + self.state_store.add_history({ + "step": "rename", "status": "success", "manual": True, + "reason": "Noms des PDF validés sans renommer", + }) + self._populate_tree() + self.info_var.set("Renommage validé — aucun fichier modifié.") + def _skip_step(self) -> None: if self.current_step and self.current_step.optional: step_id = self.current_step.id @@ -1314,6 +1378,12 @@ class CopienatorApp(tk.Tk): def _update_controls(self) -> None: running = bool(self.active_step_id) or self.runner.running + if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft"}: + self.marked_copies_button.configure(state="disabled") + if self.current_step and self.current_step.id == "rename": + self.validate_rename_button.configure( + state="disabled" if running or not self.state_store.evaluation else "normal" + ) self.run_button.configure(state="disabled" if running or not self.current_step else "normal") self.skip_button.configure(state="normal" if not running and self.current_step and self.current_step.optional else "disabled") self.interrupt_button.configure(state="normal" if running else "disabled") diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 0274670..4cd5285 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -173,7 +173,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Séparer et réordonner les pages", "Ouvre l’outil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.", (python("default", "Séparation des pages", "page-split"),), - arguments=(arg_target(),), + arguments=(arg_target(), ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked")), artifacts=("Copies", "Copies Originales"), ), StepDefinition( @@ -185,6 +185,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: arguments=( arg_target(), ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"), + ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked"), ), requires=("Copies",), artifacts=("Cutleft",), diff --git a/start-gui.sh b/start-gui.sh index 8d1b6d8..b214a90 100755 --- a/start-gui.sh +++ b/start-gui.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +# Applications opened from a file manager do not inherit variables exported by +# an interactive shell (for example GEMINI_API_KEY from ~/.zshrc). Re-enter +# through the user's shell once so its startup file can provide those values. +if [[ -z "${GEMINI_API_KEY:-}" && -z "${COPIENATOR_GUI_SHELL_LOADED:-}" && -x "${SHELL:-}" ]]; then + export COPIENATOR_GUI_SHELL_LOADED=1 + exec "$SHELL" -ic 'exec "$@"' copienator-gui-shell "$0" "$@" +fi + # With no arguments, choose the newest visible immediate subfolder by mtime. if [[ $# -eq 0 ]]; then newest="" diff --git a/tests/test_gui_convenience.py b/tests/test_gui_convenience.py index 75772d9..2684eb4 100644 --- a/tests/test_gui_convenience.py +++ b/tests/test_gui_convenience.py @@ -2,6 +2,8 @@ import os import tempfile import unittest from pathlib import Path +from tkinter import ttk +from unittest.mock import patch from copienator_gui.app import CopienatorApp from copienator_gui.workflow import command_display @@ -40,6 +42,16 @@ class GuiConvenienceTests(unittest.TestCase): self.assertEqual(self.app.state_store.step("inputs")["status"], "ready") self.assertIn("enonce.pdf", self.app.description_label.cget("text")) + def test_compact_evaluation_input_and_open_folder_button(self): + self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42) + self.assertEqual( + int(self.app.open_evaluation_button.grid_info()["column"]), + int(self.app.evaluation_entry.grid_info()["column"]) + 1, + ) + with patch("copienator_gui.app.open_path") as opened: + self.app.open_evaluation_button.invoke() + opened.assert_called_once_with(self.evaluation) + def test_redo_targets_selected_copy_and_copies_runnable_command(self): for folder in ("Copies", "Copies Originales"): (self.evaluation / folder).mkdir() @@ -48,7 +60,7 @@ class GuiConvenienceTests(unittest.TestCase): self.app.tree.selection_set("page_splitter") self.app.update() self.app.copy_var.set("Copie02.pdf") - self.app._redo_selected_pages() + self.app._target_selected_copy() command = self.app._make_command() target = Path(command[-1]) self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf") @@ -59,6 +71,36 @@ class GuiConvenienceTests(unittest.TestCase): self.app._target_all_pages() self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg()) + def test_label_detection_can_target_one_copy(self): + copies = self.evaluation / "Copies" + copies.mkdir() + for name in ("Copie01.pdf", "Copie02.pdf"): + (copies / name).touch() + + self.app.tree.selection_set("labels") + self.app.update() + self.assertEqual(self.app.copy_var.get(), "Copie01.pdf") + self.app.copy_var.set("Copie02.pdf") + self.app._target_selected_copy() + + command = self.app._make_command() + self.assertEqual(Path(command[-1]), copies / "Copie02.pdf") + + def button_texts(widget): + return [ + text + for child in widget.winfo_children() + for text in ( + [child.cget("text")] + if isinstance(child, ttk.Button) + else button_texts(child) + ) + ] + + controls = button_texts(self.app.form) + self.assertIn("Cibler la copie sélectionnée", controls) + self.assertIn("Cibler tout le dossier", controls) + 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") @@ -73,3 +115,25 @@ class GuiConvenienceTests(unittest.TestCase): self.app._select_console_all() self.app._copy_console_selection() self.assertEqual(self.app.clipboard_get(), expected) + + def test_validate_rename_preserves_files_and_downstream_status(self): + pdf = self.evaluation / "Copie01.pdf" + pdf.write_bytes(b"unchanged PDF") + self.app.state_store.update_step("rename", status="stale") + self.app.state_store.update_step("page_splitter", status="success") + self.app.tree.selection_set("rename") + self.app.update() + with patch.object(self.app.runner, "start") as start: + self.app.validate_rename_button.invoke() + start.assert_not_called() + self.assertEqual(self.app.state_store.step("rename")["status"], "success") + self.assertEqual(self.app.state_store.step("page_splitter")["status"], "success") + self.assertEqual(pdf.read_bytes(), b"unchanged PDF") + self.assertEqual(list(self.evaluation.glob("*.pdf")), [pdf]) + self.app.state_store.update_step("rename", status="stale") + self.app.active_step_id = "page_splitter" + self.app._update_controls() + self.assertIn("disabled", self.app.validate_rename_button.state()) + self.app._validate_rename_step() + self.assertEqual(self.app.state_store.step("rename")["status"], "stale") + self.app.active_step_id = None diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 97bef2b..9cb8861 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -10,7 +10,7 @@ import tempfile import time import unittest from concurrent.futures import ThreadPoolExecutor -from contextlib import redirect_stderr +from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch @@ -714,6 +714,8 @@ class StandardCliTests(unittest.TestCase): copy_pdf.parent.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") with patch.object(module, "ImageReviewer") as reviewer: + reviewer.return_value.completed = True + reviewer.return_value.had_errors = False self.assertEqual(module.main([str(copy_pdf), "--fullpage"]), 0) files, output_dir = reviewer.call_args.args[:2] self.assertEqual(files, [copy_pdf]) @@ -1011,6 +1013,11 @@ class StandardCliTests(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) + text_dir = evaluation / "Text" + text_dir.mkdir() + (text_dir / "Ex 1").write_text("Short match", encoding="utf-8") + closest_text = text_dir / "Ex 1 : 1)" + closest_text.write_text("Relevant statement text", encoding="utf-8") image = evaluation / "Cutleft" / "Copie01_01.jpg" image.parent.mkdir() image.write_bytes(b"image") @@ -1019,39 +1026,105 @@ class StandardCliTests(unittest.TestCase): Mock( text=( '{"name":"Student","list":' - '[{"box_2d":[1,2,3,4],"label":"Wrong"}]}' + '[{"box_2d":[1,2,3,4],"label":"Ex 1 : l)"}]}' ) ), Mock( text=( '{"name":"Student","list":' - '[{"box_2d":[1,2,3,4],"label":"Ex 1"}]}' + '[{"box_2d":[1,2,3,4],"label":"Ex 1 : 1)"}]}' ) ), ] sleeps = [] - module.process_copy_group( - EvaluationWorkspace(evaluation), - "Copie01", - [image], - client=client, - labels_text="Ex 1\n", - names_text="Student\n", - valid_labels={"Ex 1"}, - valid_names={"Student", "Unknown", "Continued"}, - overwrite=True, - sleep=sleeps.append, - target_interval=0, - ) + output = io.StringIO() + with redirect_stdout(output): + module.process_copy_group( + EvaluationWorkspace(evaluation), + "Copie01", + [image], + client=client, + labels_text="Ex 1 : 1)\n", + names_text="Student\n", + valid_labels={"Ex 1 : 1)"}, + valid_names={"Student", "Unknown", "Continued"}, + overwrite=True, + sleep=sleeps.append, + target_interval=0, + ) self.assertEqual(client.models.generate_content.call_count, 2) self.assertIn(10, sleeps) + retry_contents = client.models.generate_content.call_args_list[1].kwargs[ + "contents" + ] + retry_prompt = retry_contents[0].parts[1].text + self.assertIn('"Ex 1 : l)"', retry_prompt) + self.assertIn("CRITICAL RETRY CONSTRAINT: NEVER return", retry_prompt) + self.assertIn("Relevant statement text", retry_prompt) + self.assertIn("`Ex 1 : 1)`", retry_prompt) + self.assertIn( + "Retry context for Copie01_01.jpg: Text/Ex 1 : 1)", + output.getvalue(), + ) self.assertEqual( read_json(evaluation / "Copies" / "Copie01_01.json")["list"][0][ "label" ], - "Ex 1", + "Ex 1 : 1)", ) + def test_label_detection_marks_a_thrice_repeated_unknown_label(self) -> None: + module = self.modules["gemini_for_labels"] + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) / "Exam" + (evaluation / "Copies").mkdir(parents=True) + (evaluation / "Text").mkdir() + (evaluation / "Text" / "Ex 10").write_text( + "Question 1 text", encoding="utf-8" + ) + image = evaluation / "Cutleft" / "Copie42_03.jpg" + image.parent.mkdir() + image.write_bytes(b"image") + repeated = Mock( + text=( + '{"name":"Continued","list":' + '[{"box_2d":[1,2,3,4],"label":"Ex 10 : 1)a)"}]}' + ) + ) + client = Mock() + client.models.generate_content.side_effect = [ + repeated, + repeated, + repeated, + ] + + output = io.StringIO() + with redirect_stdout(output): + generated = module.process_copy_group( + EvaluationWorkspace(evaluation), + "Copie42", + [image], + client=client, + labels_text="Ex 10 : 1)\nEx 10 : 2)\nEx 10 : 3)\n", + names_text="Student\n", + valid_labels={"Ex 10 : 1)", "Ex 10 : 2)", "Ex 10 : 3)"}, + valid_names={"Student", "Unknown", "Continued"}, + overwrite=True, + sleep=lambda _seconds: None, + target_interval=0, + ) + + self.assertEqual(generated, 1) + self.assertEqual(client.models.generate_content.call_count, 3) + seeds = [ + call.kwargs["config"].seed + for call in client.models.generate_content.call_args_list + ] + self.assertEqual(seeds, [0, 0, 1]) + result = read_json(evaluation / "Copies" / "Copie42_03.json") + self.assertEqual(result["list"][0]["label"], "??Ex 10 : 1)a)") + self.assertIn("keeping them with a ?? prefix", output.getvalue()) + def test_correction_overwrite_keeps_previous_state_until_a_commit(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_marked_copies.py b/tests/test_marked_copies.py new file mode 100644 index 0000000..1e745fa --- /dev/null +++ b/tests/test_marked_copies.py @@ -0,0 +1,197 @@ +import os +import tempfile +import threading +import unittest +from pathlib import Path +from queue import Queue +from unittest.mock import Mock, patch + +from PIL import Image + +from copienator import CliError, EvaluationWorkspace, ExitCode +from copienator.copy_errors import copy_errors, clear_copy_error, mark_copy_error, marked_copy_paths +from copienator.commands import cutleft, page_splitter +from copienator_gui.app import CopienatorApp + + +class MarkedCopiesTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.workspace = EvaluationWorkspace(Path(self.temp.name)) + self.workspace.copies_dir.mkdir() + self.workspace.original_copies_dir.mkdir() + self.files = [self.workspace.copies_dir / name for name in ("Copie01.pdf", "Copie02.pdf")] + for path in self.files: + path.write_bytes(b"processed") + (self.workspace.original_copies_dir / path.name).write_bytes(b"original") + + def reviewer(self): + review = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer) + review.workspace = self.workspace + review.files = self.files + review.output_dir = self.workspace.cutleft_dir + review.index = 0 + review.is_processing = False + review.had_errors = False + review.completed = False + review.current_shift = 50 + review.default_max_per_file = 5 + review.current_max_per_file = 1 + review.root = Mock() + review.stop_prefetch = threading.Event() + review.load_current_image = Mock() + review.update_display = Mock() + image = Image.new("RGB", (8, 8), "white") + review.current_result = (image, [image], {"total_pages": 1, "columns_per_file": [1]}) + return review + + def test_marks_survive_reload_and_resolve_originals_only_for_splitting(self): + mark_copy_error(self.workspace, self.files[1], "Wrong page order") + reloaded = EvaluationWorkspace(self.workspace.root) + self.assertEqual(marked_copy_paths(reloaded), [self.files[1]]) + original = self.workspace.original_copies_dir / self.files[1].name + self.assertEqual(marked_copy_paths(reloaded, originals=True), [original]) + self.files[1].unlink() + with self.assertRaises(CliError): + marked_copy_paths(reloaded) + self.assertEqual(marked_copy_paths(reloaded, originals=True), [original]) + clear_copy_error(reloaded, original) + self.assertEqual(copy_errors(self.workspace), {}) + + def test_skip_flags_and_advances_without_replacing_existing_crop(self): + review = self.reviewer() + review.output_dir.mkdir() + previous = review.output_dir / "Copie01_01.jpg" + previous.write_bytes(b"previous crop") + review.handle_processing_result(review.current_result, self.files[0]) + review.on_skip() + self.assertEqual(previous.read_bytes(), b"previous crop") + self.assertIn("Copie01.pdf", copy_errors(self.workspace)) + self.assertEqual(review.index, 1) + self.assertEqual(review.current_max_per_file, 5) + self.assertTrue(review.had_errors) + review.load_current_image.assert_called_once_with() + + def test_accept_saves_and_clears_only_current_flag(self): + for path in self.files: + mark_copy_error(self.workspace, path, "Review needed") + review = self.reviewer() + review.on_next(None) + self.assertTrue((review.output_dir / "Copie01_01.jpg").is_file()) + self.assertEqual(set(copy_errors(self.workspace)), {"Copie02.pdf"}) + self.assertEqual(review.index, 1) + + def test_failed_save_and_window_close_preserve_flag(self): + mark_copy_error(self.workspace, self.files[0], "Review needed") + review = self.reviewer() + with patch.object(cutleft, "save_results", side_effect=OSError("disk full")), patch.object( + cutleft.messagebox, "showerror" + ): + review.on_next(None) + self.assertEqual(review.index, 0) + self.assertIn("Copie01.pdf", copy_errors(self.workspace)) + review.on_close() + self.assertFalse(review.completed) + self.assertTrue(review.stop_prefetch.is_set()) + self.assertIn("Copie01.pdf", copy_errors(self.workspace)) + + def test_processing_blocks_skip_and_failed_conversion_is_flagged(self): + review = self.reviewer() + review.is_processing = True + review.on_skip() + self.assertEqual(review.index, 0) + self.assertEqual(copy_errors(self.workspace), {}) + review.manual_queue = Queue() + review.manual_queue.put(None) + review.load_current_image.side_effect = lambda: setattr(review, "is_processing", True) + review.check_manual_queue(self.files[0]) + self.assertIn("Copie01.pdf", copy_errors(self.workspace)) + self.assertEqual(review.index, 1) + self.assertTrue(review.is_processing) # Still loading the next copy. + + def test_cli_splitting_preserves_flags_and_cropping_reports_partial_or_interrupted(self): + mark_copy_error(self.workspace, self.files[1], "Wrong order") + with patch.object(page_splitter.tk, "Tk"), patch.object(page_splitter, "PDFPreviewer") as preview: + preview.return_value.failed = False + self.assertEqual(page_splitter.main([str(self.workspace.root), "--marked"]), 0) + self.assertEqual(preview.call_args.args[2], [self.workspace.original_copies_dir / "Copie02.pdf"]) + self.assertIn("Copie02.pdf", copy_errors(self.workspace)) + with patch.object(cutleft, "ImageReviewer") as reviewer: + for completed, errors, expected in ((True, True, ExitCode.PARTIAL), + (False, False, ExitCode.INTERRUPTED), + (True, False, ExitCode.SUCCESS)): + reviewer.return_value.completed = completed + reviewer.return_value.had_errors = errors + self.assertEqual(cutleft.main([str(self.workspace.root), "--marked"]), expected) + self.assertEqual(reviewer.call_args.args[0], [self.files[1]]) + + +@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display") +class MarkedCopiesGuiTests(unittest.TestCase): + setUp = MarkedCopiesTests.setUp + + def test_keyboard_skip_then_accept_and_retry_clears_flag(self): + real_tk = cutleft.tk.Tk + + def review_with_keys(files, keys): + sent = [] + + def make_root(): + root = real_tk() + + def send_when_ready(): + info = root.winfo_children()[-1].cget("text") + index = len(sent) + if index < len(keys) and info.startswith(f"[{index + 1}/{len(files)}]"): + root.focus_force() + sent.append(keys[index]) + root.event_generate(keys[index]) + if len(sent) < len(keys): + root.after(10, send_when_ready) + + root.after(20, send_when_ready) + root.after(3000, root.destroy) # Bound a failed keyboard test. + return root + + with patch.object(cutleft.tk, "Tk", side_effect=make_root), patch.object( + cutleft, "get_pdf_pages", return_value=[Image.new("RGB", (600, 300), "white")] + ), patch.object(cutleft, "OUTPUT_SIZE", (400, 200)): + reviewer = cutleft.ImageReviewer(files, self.workspace.cutleft_dir) + self.assertEqual(sent, keys) + self.assertTrue(reviewer.completed) + return reviewer + + 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()) + self.assertTrue((self.workspace.cutleft_dir / "Copie02_01.jpg").exists()) + second = review_with_keys(marked_copy_paths(self.workspace), [""]) + self.assertFalse(second.had_errors) + self.assertEqual(copy_errors(self.workspace), {}) + self.assertTrue((self.workspace.cutleft_dir / "Copie01_01.jpg").exists()) + + def test_both_buttons_run_marked_and_single_copy_target_clears_filter(self): + mark_copy_error(self.workspace, self.files[1], "Review needed") + app = CopienatorApp(Path.cwd(), False, self.workspace.root) + try: + app.update() + for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels")): + app.tree.selection_set(ident) + app.update() + self.assertIn("(1)", app.marked_copies_button.cget("text")) + with patch.object(app, "_run_current_step") as run: + app.marked_copies_button.invoke() + run.assert_called_once_with() + self.assertIn(command, app._make_command()) + self.assertIn("--marked", app._make_command()) + self.assertEqual(app.arg_vars["target"].get(), app._evaluation_arg()) + app.copy_var.set("Copie01.pdf") + app._target_selected_copy() + self.assertNotIn("--marked", app._make_command()) + self.assertIn(str(self.files[0]), app._make_command()) + finally: + for callback in app.tk.splitlist(app.tk.call("after", "info")): + app.after_cancel(callback) + app.destroy() diff --git a/tests/test_statement_personal_flow.py b/tests/test_statement_personal_flow.py index 67cf2d3..982da9a 100644 --- a/tests/test_statement_personal_flow.py +++ b/tests/test_statement_personal_flow.py @@ -118,6 +118,7 @@ class SelectiveGeminiTests(unittest.TestCase): 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) + self.assertTrue(call.kwargs["config"].automatic_function_calling.disable) def test_incomplete_or_failed_rubrics_preserve_entire_persp(self): before = self.snapshot()