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
|
python -m copienator gui
|
||||||
#+END_SRC
|
#+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
|
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=. L'interface conserve l'état et l'historique des étapes dans
|
||||||
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
=Interro/.copienator-gui.json=, et les sorties complètes dans
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from copienator import (
|
|||||||
CliError,
|
CliError,
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
|
atomic_write_text,
|
||||||
evaluation_parser,
|
evaluation_parser,
|
||||||
execute,
|
execute,
|
||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
@@ -142,10 +143,11 @@ def save_split_content(text, path, base_fname, problem):
|
|||||||
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||||
directory = str(workspace.root)
|
directory = str(workspace.root)
|
||||||
# Find the first .tex file in the directory
|
# 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:
|
if not tex_files:
|
||||||
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
|
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")
|
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
|
||||||
if os.path.exists(tex_path):
|
if os.path.exists(tex_path):
|
||||||
tex_file = 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")
|
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
|
||||||
current_ex_num = 1
|
current_ex_num = 1
|
||||||
had_errors = False
|
had_errors = False
|
||||||
|
exercise_groups = []
|
||||||
|
|
||||||
# Read entirely to allow chunking
|
# Read entirely to allow chunking
|
||||||
with open(tex_file, 'r', encoding='utf-8') as f_in:
|
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:
|
for label in block_labels:
|
||||||
f_labels.write(f"{label}\n")
|
f_labels.write(f"{label}\n")
|
||||||
|
exercise_groups.append(block_labels)
|
||||||
current_ex_num += 1
|
current_ex_num += 1
|
||||||
|
|
||||||
except WindowsLabelError:
|
except WindowsLabelError:
|
||||||
@@ -280,6 +284,8 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
|||||||
had_errors = True
|
had_errors = True
|
||||||
|
|
||||||
labels_staging.replace(labels_file)
|
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
|
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
@@ -293,4 +299,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from copienator import (
|
|||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.platform import validate_windows_labels
|
from copienator.platform import validate_windows_labels
|
||||||
|
from copienator.filesystem import staged_directory
|
||||||
from copienator.utils import compile_to_pdf
|
from copienator.utils import compile_to_pdf
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +74,9 @@ class RubricItem(BaseModel):
|
|||||||
class GroupRubrics(BaseModel):
|
class GroupRubrics(BaseModel):
|
||||||
rubrics: list[RubricItem]
|
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.
|
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
|
||||||
Ta tâche :
|
Ta tâche :
|
||||||
Établir un barème de correction détaillé pour CHAQUE question.
|
É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 path
|
||||||
return None
|
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(
|
def process_exam(
|
||||||
workspace: EvaluationWorkspace,
|
workspace: EvaluationWorkspace,
|
||||||
restart: bool = False,
|
restart: bool = False,
|
||||||
@@ -701,32 +803,9 @@ def process_exam(
|
|||||||
|
|
||||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
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)}...")
|
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||||
try:
|
try:
|
||||||
response_r = client.models.generate_content(
|
rubrics_map = generate_rubrics(client, group_context_text)
|
||||||
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}
|
|
||||||
except Exception as e: # noqa: BLE001 - remote API boundary
|
except Exception as e: # noqa: BLE001 - remote API boundary
|
||||||
print(f"Error generating rubric for group {labels[0]}: {e}")
|
print(f"Error generating rubric for group {labels[0]}: {e}")
|
||||||
processing_errors.append(str(e))
|
processing_errors.append(str(e))
|
||||||
@@ -822,12 +901,23 @@ def process_exam(
|
|||||||
workspace.labels_file,
|
workspace.labels_file,
|
||||||
"".join(f"{label}\n" for label in labels_list),
|
"".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
|
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = evaluation_parser("Extract exam and solution code via Gemini")
|
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(
|
parser.add_argument(
|
||||||
"--restart",
|
"--restart",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -841,7 +931,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
return execute(
|
return execute(
|
||||||
parser,
|
parser,
|
||||||
argv,
|
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),
|
workspace_from_args(args),
|
||||||
restart=args.restart,
|
restart=args.restart,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from collections.abc import Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
import pymupdf # PyMuPDF
|
||||||
from PIL import Image, ImageDraw, ImageTk
|
from PIL import Image, ImageDraw, ImageTk
|
||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ from copienator import (
|
|||||||
)
|
)
|
||||||
from copienator.platform import launch_pdf_arranger
|
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 ---
|
# --- Constants ---
|
||||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||||
CM_TO_POINTS = (1 / 2.54) * 72
|
CM_TO_POINTS = (1 / 2.54) * 72
|
||||||
@@ -112,7 +115,7 @@ class PDFPreviewer:
|
|||||||
self.page_settings = []
|
self.page_settings = []
|
||||||
self.processing = False # Flag to prevent multiple finish calls
|
self.processing = False # Flag to prevent multiple finish calls
|
||||||
try:
|
try:
|
||||||
self.doc = fitz.open(self.pdf_path)
|
self.doc = pymupdf.open(self.pdf_path)
|
||||||
except (OSError, RuntimeError, ValueError) as e:
|
except (OSError, RuntimeError, ValueError) as e:
|
||||||
self.failed = True
|
self.failed = True
|
||||||
self._temporary_directory.cleanup()
|
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('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('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('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)
|
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
|
||||||
@@ -192,6 +196,7 @@ class PDFPreviewer:
|
|||||||
"next_page": self.confirm_and_next_page,
|
"next_page": self.confirm_and_next_page,
|
||||||
"discard_page": self.discard_page,
|
"discard_page": self.discard_page,
|
||||||
"send_end": self.send_page_end,
|
"send_end": self.send_page_end,
|
||||||
|
"reverse_pages": self.reverse_pages,
|
||||||
"restart_file": self.restart_current_file,
|
"restart_file": self.restart_current_file,
|
||||||
"arranger": self.start_arranger,
|
"arranger": self.start_arranger,
|
||||||
"prev_file": self.go_to_previous_file,
|
"prev_file": self.go_to_previous_file,
|
||||||
@@ -271,7 +276,7 @@ class PDFPreviewer:
|
|||||||
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
||||||
|
|
||||||
# --- Render Page ---
|
# --- 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)
|
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
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)
|
# Re-open the file from disk to reset changes (like moved pages)
|
||||||
try:
|
try:
|
||||||
self.doc = fitz.open(self.pdf_path)
|
self.doc = pymupdf.open(self.pdf_path)
|
||||||
except (OSError, RuntimeError, ValueError) as e:
|
except (OSError, RuntimeError, ValueError) as e:
|
||||||
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
|
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
|
||||||
self.master.destroy()
|
self.master.destroy()
|
||||||
@@ -319,6 +324,16 @@ class PDFPreviewer:
|
|||||||
self.load_page()
|
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):
|
def move_line_left(self, event=None):
|
||||||
"""Moves the split line to the left."""
|
"""Moves the split line to the left."""
|
||||||
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
|
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
|
self.file_rotation + self.global_rotation) % 360
|
||||||
|
|
||||||
if keep == "as_is":
|
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 = 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.show_pdf_page(page_full.rect, self.doc, i)
|
||||||
page_full.set_rotation(rotation)
|
page_full.set_rotation(rotation)
|
||||||
@@ -477,13 +492,13 @@ class PDFPreviewer:
|
|||||||
|
|
||||||
# --- Create Left Part ---
|
# --- Create Left Part ---
|
||||||
if rotation == 0:
|
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:
|
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:
|
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 = 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.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
|
||||||
page_left.set_rotation(rotation)
|
page_left.set_rotation(rotation)
|
||||||
@@ -494,11 +509,11 @@ class PDFPreviewer:
|
|||||||
|
|
||||||
# --- Create Right Part ---
|
# --- Create Right Part ---
|
||||||
if rotation == 0:
|
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:
|
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:
|
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 = 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.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
|
||||||
page_right.set_rotation(rotation)
|
page_right.set_rotation(rotation)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from collections import defaultdict
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import fitz
|
import pymupdf
|
||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
from copienator import utils
|
from copienator import utils
|
||||||
@@ -72,7 +72,7 @@ def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
|
|||||||
|
|
||||||
|
|
||||||
def _save_cropped_page(
|
def _save_cropped_page(
|
||||||
document: fitz.Document,
|
document: pymupdf.Document,
|
||||||
page_number: int,
|
page_number: int,
|
||||||
x0: float,
|
x0: float,
|
||||||
y0: float,
|
y0: float,
|
||||||
@@ -82,14 +82,14 @@ def _save_cropped_page(
|
|||||||
) -> None:
|
) -> None:
|
||||||
page = document[page_number]
|
page = document[page_number]
|
||||||
rotated_rectangle = page.rect * page.transformation_matrix
|
rotated_rectangle = page.rect * page.transformation_matrix
|
||||||
visual_crop = fitz.Rect(
|
visual_crop = pymupdf.Rect(
|
||||||
rotated_rectangle.x0 + x0,
|
rotated_rectangle.x0 + x0,
|
||||||
y0,
|
y0,
|
||||||
rotated_rectangle.x0 + x1,
|
rotated_rectangle.x0 + x1,
|
||||||
y1,
|
y1,
|
||||||
)
|
)
|
||||||
unrotated_clip = visual_crop * page.derotation_matrix
|
unrotated_clip = visual_crop * page.derotation_matrix
|
||||||
cropped = fitz.open()
|
cropped = pymupdf.open()
|
||||||
try:
|
try:
|
||||||
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
|
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
|
||||||
target_page.show_pdf_page(
|
target_page.show_pdf_page(
|
||||||
@@ -110,7 +110,7 @@ def _render_split_outputs(
|
|||||||
staging: Path,
|
staging: Path,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""Render every current answer into an otherwise empty staging directory."""
|
"""Render every current answer into an otherwise empty staging directory."""
|
||||||
document = fitz.open(input_pdf)
|
document = pymupdf.open(input_pdf)
|
||||||
try:
|
try:
|
||||||
parsed = _parse_coordinates(coords_list)
|
parsed = _parse_coordinates(coords_list)
|
||||||
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
|
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
|
||||||
|
|||||||
+120
-2
@@ -279,6 +279,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid(
|
ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid(
|
||||||
row=0, column=0, sticky="ew"
|
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 = ttk.Frame(self.detail)
|
||||||
buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0))
|
buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0))
|
||||||
@@ -304,7 +306,22 @@ class CopienatorApp(tk.Tk):
|
|||||||
foreground="#efefef",
|
foreground="#efefef",
|
||||||
insertbackground="white",
|
insertbackground="white",
|
||||||
state="disabled",
|
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)
|
console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview)
|
||||||
self.console.configure(yscrollcommand=console_scroll.set)
|
self.console.configure(yscrollcommand=console_scroll.set)
|
||||||
self.console.grid(row=0, column=0, sticky="nsew")
|
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 = ttk.Frame(console_frame)
|
||||||
console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
|
console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
|
||||||
console_actions.columnconfigure(1, weight=1)
|
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))
|
ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
|
||||||
self.stdin_var = tk.StringVar()
|
self.stdin_var = tk.StringVar()
|
||||||
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
|
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
|
||||||
@@ -467,7 +488,13 @@ class CopienatorApp(tk.Tk):
|
|||||||
if not evaluation:
|
if not evaluation:
|
||||||
return list(step.requires)
|
return list(step.requires)
|
||||||
missing = []
|
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 "*?["):
|
if any(char in pattern for char in "*?["):
|
||||||
exists = next(evaluation.glob(pattern), None) is not None
|
exists = next(evaluation.glob(pattern), None) is not None
|
||||||
else:
|
else:
|
||||||
@@ -475,8 +502,19 @@ class CopienatorApp(tk.Tk):
|
|||||||
exists = path.exists()
|
exists = path.exists()
|
||||||
if not exists:
|
if not exists:
|
||||||
missing.append(pattern)
|
missing.append(pattern)
|
||||||
|
if step.id == "inputs" and not ((evaluation / "names").is_file() or (self.repository / "names").is_file()):
|
||||||
|
missing.append("names")
|
||||||
return missing
|
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:
|
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
|
||||||
selection = self.tree.selection()
|
selection = self.tree.selection()
|
||||||
if not selection or selection[0].startswith("section:"):
|
if not selection or selection[0].startswith("section:"):
|
||||||
@@ -561,6 +599,10 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.title_label.configure(text=step.title)
|
self.title_label.configure(text=step.title)
|
||||||
missing = self._missing_requirements(step)
|
missing = self._missing_requirements(step)
|
||||||
description = step.description
|
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:
|
if missing:
|
||||||
description += "\nPrérequis non détectés : " + ", ".join(missing)
|
description += "\nPrérequis non détectés : " + ", ".join(missing)
|
||||||
self.description_label.configure(text=description)
|
self.description_label.configure(text=description)
|
||||||
@@ -633,6 +675,19 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._update_controls()
|
self._update_controls()
|
||||||
|
|
||||||
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
|
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:
|
if step.section == REFAIRE_SECTION:
|
||||||
evaluation = self.evaluation
|
evaluation = self.evaluation
|
||||||
if step.id in {"refaire_selection", "refaire_correct"}:
|
if step.id in {"refaire_selection", "refaire_correct"}:
|
||||||
@@ -675,7 +730,8 @@ class CopienatorApp(tk.Tk):
|
|||||||
ttk.Label(copies, text=summary, wraplength=570, justify="left").grid(
|
ttk.Label(copies, text=summary, wraplength=570, justify="left").grid(
|
||||||
row=0, column=0, columnspan=2, sticky="ew"
|
row=0, column=0, columnspan=2, sticky="ew"
|
||||||
)
|
)
|
||||||
self.copy_var.set(names[0] if names else "")
|
if self.copy_var.get() not in names:
|
||||||
|
self.copy_var.set(names[0] if names else "")
|
||||||
selector = ttk.Combobox(
|
selector = ttk.Combobox(
|
||||||
copies,
|
copies,
|
||||||
textvariable=self.copy_var,
|
textvariable=self.copy_var,
|
||||||
@@ -689,6 +745,14 @@ class CopienatorApp(tk.Tk):
|
|||||||
command=self._open_selected_copy,
|
command=self._open_selected_copy,
|
||||||
state="normal" if names else "disabled",
|
state="normal" if names else "disabled",
|
||||||
).grid(row=1, column=1, padx=(6, 0), pady=(7, 0))
|
).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
|
row += 1
|
||||||
|
|
||||||
if step.id == "plotting":
|
if step.id == "plotting":
|
||||||
@@ -702,6 +766,14 @@ class CopienatorApp(tk.Tk):
|
|||||||
row += 1
|
row += 1
|
||||||
return row
|
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:
|
def _refaire_restart_controls(self, row: int) -> int:
|
||||||
controls = ttk.Frame(self.form)
|
controls = ttk.Frame(self.form)
|
||||||
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
|
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:
|
def _open_selected_copy(self) -> None:
|
||||||
self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie")
|
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:
|
def _open_desktop_path(self, path: Path | None, label: str) -> None:
|
||||||
if path is None or not path.exists():
|
if path is None or not path.exists():
|
||||||
messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.")
|
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:
|
def _update_command_preview(self) -> None:
|
||||||
if self._rendering or not self.current_step:
|
if self._rendering or not self.current_step:
|
||||||
return
|
return
|
||||||
|
self.copy_command_button.configure(state="disabled")
|
||||||
if self.current_step.is_manual:
|
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.")
|
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
|
return
|
||||||
@@ -916,9 +999,44 @@ class CopienatorApp(tk.Tk):
|
|||||||
if len(commands) > 2:
|
if len(commands) > 2:
|
||||||
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
|
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
|
||||||
self.command_var.set(preview)
|
self.command_var.set(preview)
|
||||||
|
self.copy_command_button.configure(state="normal")
|
||||||
except (OSError, ValueError, TypeError) as exc:
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
self.command_var.set(f"Arguments invalides : {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:
|
def _browse_target_file(self, variable: tk.Variable) -> None:
|
||||||
selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository)
|
selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository)
|
||||||
if selected:
|
if selected:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def collect_diagnostics(
|
|||||||
"pdf2image": "pdf2image",
|
"pdf2image": "pdf2image",
|
||||||
"reportlab": "reportlab",
|
"reportlab": "reportlab",
|
||||||
"img2pdf": "img2pdf",
|
"img2pdf": "img2pdf",
|
||||||
"PyMuPDF": "fitz",
|
"PyMuPDF": "pymupdf",
|
||||||
"ftfy": "ftfy",
|
"ftfy": "ftfy",
|
||||||
"ezodf": "ezodf",
|
"ezodf": "ezodf",
|
||||||
"Google GenAI": "google.genai",
|
"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.",
|
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
|
||||||
(
|
(
|
||||||
python("gemini", "Analyse avec Gemini", "statement"),
|
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=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("Dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
@@ -107,6 +107,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
|
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
|
||||||
artifacts=("labels", "Text", "Sol", "Persp"),
|
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(
|
StepDefinition(
|
||||||
"review_persp",
|
"review_persp",
|
||||||
"Prétraitement de l’énoncé",
|
"Prétraitement de l’énoncé",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ PAGE_SPLITTER_KB = {
|
|||||||
"next_page": "s",
|
"next_page": "s",
|
||||||
"discard_page": "z",
|
"discard_page": "z",
|
||||||
"send_end": "a", # Send this page to the end
|
"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",
|
"restart_file": "T",
|
||||||
"arranger": "A", # Call `pdf arranger` software, if available
|
"arranger": "A", # Call `pdf arranger` software, if available
|
||||||
"prev_file": "P",
|
"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"
|
evaluation = Path(directory) / "Exam"
|
||||||
copy_pdf = evaluation / "Copies" / "Copie01.pdf"
|
copy_pdf = evaluation / "Copies" / "Copie01.pdf"
|
||||||
copy_pdf.parent.mkdir(parents=True)
|
copy_pdf.parent.mkdir(parents=True)
|
||||||
document = module.fitz.open()
|
document = module.pymupdf.open()
|
||||||
page = document.new_page(width=600, height=800)
|
page = document.new_page(width=600, height=800)
|
||||||
page.insert_text((100, 200), "Student answer")
|
page.insert_text((100, 200), "Student answer")
|
||||||
document.save(copy_pdf)
|
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