start-gui.sh et améliorations diverses
This commit is contained in:
+40
@@ -173,6 +173,46 @@ Lancer l'assistant avec :
|
||||
python -m copienator gui
|
||||
#+END_SRC
|
||||
|
||||
Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi
|
||||
l’interface, depuis n’importe quel répertoire. Il utilise le Python de
|
||||
=.venv= s’il existe, sinon =python3= du PATH. On peut lui passer le dossier
|
||||
d’évaluation : =./start-gui.sh Interro=. Sans argument, il ouvre le
|
||||
sous-dossier immédiat non masqué le plus récemment modifié du répertoire
|
||||
courant, en excluant =copienator=, =copienator_gui=, =tests=, =OLD=,
|
||||
=__pycache__=, =build=, =dist=, =*.egg-info=, =venv=, =env= et
|
||||
=node_modules=. S’il n’y a aucun dossier admissible, l’interface démarre
|
||||
sans évaluation. Un chemin explicite reste utilisable même s’il est exclu
|
||||
de la sélection automatique.
|
||||
|
||||
=Recharger — vérifier à nouveau= relit les fichiers d’entrée du dossier.
|
||||
Pour reprendre le découpage d’une copie, sélectionnez-la dans =Copies
|
||||
détectées=, cliquez sur =Refaire la copie sélectionnée=, puis sur
|
||||
=Exécuter=. Le découpage repart de l’original conservé.
|
||||
Dans la fenêtre de découpage, =i= inverse l’ordre de toutes les pages
|
||||
(dernière vers première) et reprend à la nouvelle première page. Les choix
|
||||
de découpage déjà saisis sont effacés ; les rotations globales sont conservées.
|
||||
Ce raccourci est configurable via =PAGE_SPLITTER_KB["reverse_pages"]=.
|
||||
=Copier la commande= copie les commandes complètes (à lancer depuis la
|
||||
racine du projet). Dans la console, les boutons de copie et le clic droit
|
||||
permettent de copier la sélection ou toute la sortie ; =Ctrl+C= et
|
||||
=Ctrl+A= sont également disponibles (=Cmd= sous macOS).
|
||||
|
||||
Avec =SHOW_PERSONAL_STEPS = True=, =Analyser l’énoncé= propose le choix
|
||||
entre Gemini et =Énoncés et solutions personnels (SHEETINFO)=. Ce dernier
|
||||
lance =python -m copienator statement-personal Interro= : il lit
|
||||
=enonce.tex=, utilise le service d’exercices sur =localhost:8080= pour
|
||||
générer =Text=, =Sol=, =Text2=, =Sol2= et les barèmes personnels =Persp=,
|
||||
et écrit un groupe par exercice dans =label_groups=.
|
||||
|
||||
Deux boutons ouvrent ensuite des actions facultatives avec aperçu de
|
||||
commande et bouton =Exécuter= : =Regrouper avec Gemini…= remplace seulement
|
||||
=label_groups= (=statement Interro --groups-only=) ; =Remplacer Persp avec
|
||||
Gemini…= régénère seulement les barèmes des groupes actuels
|
||||
(=statement Interro --persp-only=), avec le même prompt que le parcours
|
||||
Gemini complet. Une réponse incomplète ou un échec laisse les anciens
|
||||
barèmes en place. On peut ignorer ces étapes et conserver les résultats
|
||||
personnels.
|
||||
|
||||
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
|
||||
Interro=. L'interface conserve l'état et l'historique des étapes dans
|
||||
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import validate_windows_labels
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
|
||||
@@ -73,6 +74,9 @@ class RubricItem(BaseModel):
|
||||
class GroupRubrics(BaseModel):
|
||||
rubrics: list[RubricItem]
|
||||
|
||||
class LabelGroups(BaseModel):
|
||||
groups: list[list[str]]
|
||||
|
||||
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
|
||||
Ta tâche :
|
||||
Établir un barème de correction détaillé pour CHAQUE question.
|
||||
@@ -176,6 +180,104 @@ def find_file(folder: Path, base_name: str) -> Path | None:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
|
||||
"""Use the same rubric request for full and selective statement generation."""
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=[types.Content(role="user", parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
])],
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
),
|
||||
)
|
||||
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
|
||||
if len({item.label for item in rubrics}) != len(rubrics):
|
||||
raise ValueError("Gemini returned duplicate rubric labels")
|
||||
return {item.label: item.rubric_content for item in rubrics}
|
||||
|
||||
|
||||
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
|
||||
flattened = [label for group in groups for label in group]
|
||||
if (not groups or any(not group for group in groups)
|
||||
or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)):
|
||||
raise CliError("Groups must contain every existing label exactly once")
|
||||
|
||||
|
||||
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
|
||||
"""Regroup or replace rubrics without regenerating statements or solutions."""
|
||||
labels = workspace.read_labels()
|
||||
if not labels or len(labels) != len(set(labels)):
|
||||
raise CliError("Generate unique question labels before refining the statement")
|
||||
validate_windows_labels(labels)
|
||||
questions = {}
|
||||
for label in labels:
|
||||
safe_label = label.replace("/", "_")
|
||||
parts = []
|
||||
for directory, title in (("Text2", "Question"), ("Sol2", "Correction")):
|
||||
path = workspace.root / directory / f"{safe_label}.tex"
|
||||
if not path.is_file():
|
||||
raise CliError(f"Missing {path}; generate statements and solutions first")
|
||||
parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}")
|
||||
questions[label] = "\n".join(parts)
|
||||
context = utils.enonce_total(workspace.root)
|
||||
if api_client is None:
|
||||
if not api_key:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
api_client = genai.Client(api_key=api_key)
|
||||
|
||||
if mode == "groups":
|
||||
response = api_client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
|
||||
"Regroupe ces questions d’examen en groupes cohérents pour la correction "
|
||||
"et l’annotation, selon leurs dépendances et leur contexte commun. "
|
||||
"Ne mélange pas des exercices différents. Conserve l’ordre des questions. "
|
||||
"Chaque label doit apparaître exactement une fois, sans modification. "
|
||||
"Renvoie uniquement un objet JSON groups contenant des listes de labels.\n\n"
|
||||
+ context + "\n\n" + "\n\n".join(questions.values())
|
||||
))])],
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1, response_mime_type="application/json",
|
||||
response_json_schema=LabelGroups.model_json_schema(),
|
||||
),
|
||||
)
|
||||
groups = LabelGroups.model_validate_json(response.text).groups
|
||||
validate_groups(groups, labels)
|
||||
atomic_write_text(workspace.label_groups_file,
|
||||
"".join(", ".join(group) + "\n" for group in groups))
|
||||
print(f"Updated label_groups: {len(groups)} Gemini groups.")
|
||||
elif mode == "persp":
|
||||
if not workspace.label_groups_file.is_file():
|
||||
raise CliError("Generate label_groups before generating rubrics")
|
||||
groups = [[label.strip() for label in line.split(",") if label.strip()]
|
||||
for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()]
|
||||
validate_groups(groups, labels)
|
||||
with staged_directory(workspace.root / "Persp") as staging:
|
||||
for group in groups:
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(group)}...")
|
||||
group_content = (
|
||||
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n"
|
||||
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
|
||||
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group)
|
||||
)
|
||||
rubrics = generate_rubrics(api_client, group_content)
|
||||
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()):
|
||||
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved")
|
||||
for label, rubric in rubrics.items():
|
||||
(staging / label.replace("/", "_")).write_text(
|
||||
f"{label}\n{rubric}", encoding="utf-8")
|
||||
print("Replaced Persp with Gemini rubrics.")
|
||||
else:
|
||||
raise ValueError(f"Unknown statement refinement: {mode}")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
def process_exam(
|
||||
workspace: EvaluationWorkspace,
|
||||
restart: bool = False,
|
||||
@@ -701,32 +803,9 @@ def process_exam(
|
||||
|
||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||
|
||||
contents_4 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_4 = types.GenerateContentConfig(
|
||||
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
)
|
||||
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||
try:
|
||||
response_r = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_4,
|
||||
config=config_4
|
||||
)
|
||||
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
||||
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
||||
rubrics_map = generate_rubrics(client, group_context_text)
|
||||
except Exception as e: # noqa: BLE001 - remote API boundary
|
||||
print(f"Error generating rubric for group {labels[0]}: {e}")
|
||||
processing_errors.append(str(e))
|
||||
@@ -822,12 +901,23 @@ def process_exam(
|
||||
workspace.labels_file,
|
||||
"".join(f"{label}\n" for label in labels_list),
|
||||
)
|
||||
atomic_write_text(
|
||||
workspace.label_groups_file,
|
||||
"".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n"
|
||||
for group in grouped_extraction.groups
|
||||
if any(isinstance(item, QuestionItem) for item in group)),
|
||||
)
|
||||
|
||||
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Extract exam and solution code via Gemini")
|
||||
actions = parser.add_mutually_exclusive_group()
|
||||
actions.add_argument("--groups-only", action="store_true",
|
||||
help="Regroup existing questions with Gemini; update only label_groups")
|
||||
actions.add_argument("--persp-only", action="store_true",
|
||||
help="Replace only Persp with Gemini rubrics for existing groups")
|
||||
parser.add_argument(
|
||||
"--restart",
|
||||
action="store_true",
|
||||
@@ -841,7 +931,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: process_exam(
|
||||
lambda args: refine_existing(workspace_from_args(args),
|
||||
"groups" if args.groups_only else "persp")
|
||||
if args.groups_only or args.persp_only else process_exam(
|
||||
workspace_from_args(args),
|
||||
restart=args.restart,
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 d’entrée sont présents."))
|
||||
|
||||
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
|
||||
selection = self.tree.selection()
|
||||
if not selection or selection[0].startswith("section:"):
|
||||
@@ -561,6 +599,10 @@ class CopienatorApp(tk.Tk):
|
||||
self.title_label.configure(text=step.title)
|
||||
missing = self._missing_requirements(step)
|
||||
description = step.description
|
||||
if step.id == "statement" and saved_variant == "personal":
|
||||
description = ("Lit les blocs SHEETINFO de enonce.tex et récupère les énoncés, solutions "
|
||||
"et barèmes du service personnel (localhost:8080). Crée un groupe par exercice. "
|
||||
"Les actions Gemini ci-dessous restent facultatives.")
|
||||
if missing:
|
||||
description += "\nPrérequis non détectés : " + ", ".join(missing)
|
||||
self.description_label.configure(text=description)
|
||||
@@ -633,6 +675,19 @@ class CopienatorApp(tk.Tk):
|
||||
self._update_controls()
|
||||
|
||||
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
|
||||
if self.show_personal_steps and step.id in {"statement", "statement_groups", "statement_persp", "review_persp"}:
|
||||
actions = ttk.LabelFrame(self.form, text="Après génération — facultatif", padding=7)
|
||||
actions.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
for ident, title in (("statement_groups", "Regrouper avec Gemini…"),
|
||||
("statement_persp", "Remplacer Persp avec Gemini…")):
|
||||
ttk.Button(actions, text=title, command=lambda target=ident: self._select_statement_action(target)).pack(
|
||||
side="left", padx=(0, 6))
|
||||
row += 1
|
||||
if step.id == "inputs":
|
||||
ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid(
|
||||
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
|
||||
)
|
||||
return row + 1
|
||||
if step.section == REFAIRE_SECTION:
|
||||
evaluation = self.evaluation
|
||||
if step.id in {"refaire_selection", "refaire_correct"}:
|
||||
@@ -675,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 l’original conservé. Vérifiez la commande puis cliquez sur Exécuter.",
|
||||
wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
|
||||
row += 1
|
||||
|
||||
if step.id == "plotting":
|
||||
@@ -702,6 +766,14 @@ class CopienatorApp(tk.Tk):
|
||||
row += 1
|
||||
return row
|
||||
|
||||
def _select_statement_action(self, step_id: str) -> None:
|
||||
self._save_current_form()
|
||||
target = self.arg_vars.get("target")
|
||||
if target:
|
||||
self.state_store.update_step(step_id, values={"target": target.get()})
|
||||
self.tree.selection_set(step_id)
|
||||
self.tree.see(step_id)
|
||||
|
||||
def _refaire_restart_controls(self, row: int) -> int:
|
||||
controls = ttk.Frame(self.form)
|
||||
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
|
||||
@@ -780,6 +852,16 @@ class CopienatorApp(tk.Tk):
|
||||
def _open_selected_copy(self) -> None:
|
||||
self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie")
|
||||
|
||||
def _redo_selected_pages(self) -> None:
|
||||
path = self.copy_paths.get(self.copy_var.get())
|
||||
if path and "target" in self.arg_vars:
|
||||
self.arg_vars["target"].set(str(path))
|
||||
self.info_var.set(f"Reprise de {path.name} prête — cliquez sur Exécuter.")
|
||||
|
||||
def _target_all_pages(self) -> None:
|
||||
if "target" in self.arg_vars:
|
||||
self.arg_vars["target"].set(self._evaluation_arg())
|
||||
|
||||
def _open_desktop_path(self, path: Path | None, label: str) -> None:
|
||||
if path is None or not path.exists():
|
||||
messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.")
|
||||
@@ -907,6 +989,7 @@ class CopienatorApp(tk.Tk):
|
||||
def _update_command_preview(self) -> None:
|
||||
if self._rendering or not self.current_step:
|
||||
return
|
||||
self.copy_command_button.configure(state="disabled")
|
||||
if self.current_step.is_manual:
|
||||
self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.")
|
||||
return
|
||||
@@ -916,9 +999,44 @@ class CopienatorApp(tk.Tk):
|
||||
if len(commands) > 2:
|
||||
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
|
||||
self.command_var.set(preview)
|
||||
self.copy_command_button.configure(state="normal")
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
self.command_var.set(f"Arguments invalides : {exc}")
|
||||
|
||||
def _copy_text(self, text: str) -> None:
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(text)
|
||||
|
||||
def _copy_command(self) -> None:
|
||||
if not self.current_step or self.current_step.is_manual:
|
||||
return
|
||||
try:
|
||||
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
|
||||
self._copy_text("\n".join(command_display(command) for command in commands))
|
||||
self.info_var.set("Commande(s) copiée(s) — à exécuter depuis " + str(self.repository))
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
self.info_var.set(f"Copie impossible : {exc}")
|
||||
|
||||
def _copy_console_selection(self, _event=None) -> str:
|
||||
if self.console.tag_ranges("sel"):
|
||||
self._copy_text(self.console.get("sel.first", "sel.last"))
|
||||
return "break"
|
||||
|
||||
def _select_console_all(self, _event=None) -> str:
|
||||
self.console.tag_add("sel", "1.0", "end-1c")
|
||||
self.console.focus_set()
|
||||
return "break"
|
||||
|
||||
def _copy_console_all(self) -> None:
|
||||
self._copy_text(self.console.get("1.0", "end-1c"))
|
||||
|
||||
def _show_console_menu(self, event) -> str:
|
||||
try:
|
||||
self.console_menu.tk_popup(event.x_root, event.y_root)
|
||||
finally:
|
||||
self.console_menu.grab_release()
|
||||
return "break"
|
||||
|
||||
def _browse_target_file(self, variable: tk.Variable) -> None:
|
||||
selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository)
|
||||
if selected:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user