This commit is contained in:
2026-09-08 16:41:04 +02:00
parent db4ed2ef31
commit bf05272797
14 changed files with 725 additions and 75 deletions
+12
View File
@@ -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
+58 -16
View File
@@ -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("<Return>", 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 lenregistrement : {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())
+5
View File
@@ -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(),
+125 -7
View File
@@ -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"
"<exam_text_excerpt>\n"
f"{wrong_label_text_context}\n"
"</exam_text_excerpt>\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: "
+7 -4
View File
@@ -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)
+48
View File
@@ -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
+11 -3
View File
@@ -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
+96 -26
View File
@@ -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("<Return>", 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("<Return>", 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 loriginal conservé. Vérifiez la commande puis cliquez sur Exécuter.",
help_text = (
"La reprise utilise loriginal 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} nexiste 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")
+2 -1
View File
@@ -173,7 +173,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Séparer et réordonner les pages",
"Ouvre loutil 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",),
+8
View File
@@ -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=""
+65 -1
View File
@@ -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
+90 -17
View File
@@ -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:
+197
View File
@@ -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, ["<KeyPress-s>", "<Return>"])
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), ["<Return>"])
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()
+1
View File
@@ -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()