Miscs
This commit is contained in:
+12
@@ -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
|
||||
|
||||
@@ -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:
|
||||
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())
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,7 +425,21 @@ def process_copy_group(
|
||||
f"Error: {image_file.name} contained unknown labels: "
|
||||
f"{unknown}"
|
||||
)
|
||||
wrong_labels.extend(unknown)
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 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")
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
+79
-6
@@ -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,25 +1026,27 @@ 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 = []
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
module.process_copy_group(
|
||||
EvaluationWorkspace(evaluation),
|
||||
"Copie01",
|
||||
[image],
|
||||
client=client,
|
||||
labels_text="Ex 1\n",
|
||||
labels_text="Ex 1 : 1)\n",
|
||||
names_text="Student\n",
|
||||
valid_labels={"Ex 1"},
|
||||
valid_labels={"Ex 1 : 1)"},
|
||||
valid_names={"Student", "Unknown", "Continued"},
|
||||
overwrite=True,
|
||||
sleep=sleeps.append,
|
||||
@@ -1045,13 +1054,77 @@ class StandardCliTests(unittest.TestCase):
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user