start-gui.sh et améliorations diverses

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