start-gui.sh et améliorations diverses

This commit is contained in:
2026-09-08 12:04:18 +02:00
parent 06d7bad04e
commit db4ed2ef31
14 changed files with 697 additions and 50 deletions
+8 -3
View File
@@ -13,6 +13,7 @@ from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
@@ -142,10 +143,11 @@ def save_split_content(text, path, base_fname, problem):
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
directory = str(workspace.root)
# Find the first .tex file in the directory
tex_files = glob.glob(os.path.join(directory, "*.tex"))
enonce = workspace.root / "enonce.tex"
tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex")))
if not tex_files:
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
int_name = directory.removesuffix("/")
int_name = workspace.root.name
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
if os.path.exists(tex_path):
tex_file = tex_path
@@ -172,6 +174,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
current_ex_num = 1
had_errors = False
exercise_groups = []
# Read entirely to allow chunking
with open(tex_file, 'r', encoding='utf-8') as f_in:
@@ -267,6 +270,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
for label in block_labels:
f_labels.write(f"{label}\n")
exercise_groups.append(block_labels)
current_ex_num += 1
except WindowsLabelError:
@@ -280,6 +284,8 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
had_errors = True
labels_staging.replace(labels_file)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in exercise_groups))
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
@@ -293,4 +299,3 @@ def main(argv: Sequence[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
+117 -25
View File
@@ -22,6 +22,7 @@ from copienator import (
workspace_from_args,
)
from copienator.platform import validate_windows_labels
from copienator.filesystem import staged_directory
from copienator.utils import compile_to_pdf
@@ -73,6 +74,9 @@ class RubricItem(BaseModel):
class GroupRubrics(BaseModel):
rubrics: list[RubricItem]
class LabelGroups(BaseModel):
groups: list[list[str]]
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
Ta tâche :
Établir un barème de correction détaillé pour CHAQUE question.
@@ -176,6 +180,104 @@ def find_file(folder: Path, base_name: str) -> Path | None:
return path
return None
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
"""Use the same rubric request for full and selective statement generation."""
response = client.models.generate_content(
model=MODEL_ID,
contents=[types.Content(role="user", parts=[
types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
])],
config=types.GenerateContentConfig(
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
),
)
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
if len({item.label for item in rubrics}) != len(rubrics):
raise ValueError("Gemini returned duplicate rubric labels")
return {item.label: item.rubric_content for item in rubrics}
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
flattened = [label for group in groups for label in group]
if (not groups or any(not group for group in groups)
or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)):
raise CliError("Groups must contain every existing label exactly once")
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
"""Regroup or replace rubrics without regenerating statements or solutions."""
labels = workspace.read_labels()
if not labels or len(labels) != len(set(labels)):
raise CliError("Generate unique question labels before refining the statement")
validate_windows_labels(labels)
questions = {}
for label in labels:
safe_label = label.replace("/", "_")
parts = []
for directory, title in (("Text2", "Question"), ("Sol2", "Correction")):
path = workspace.root / directory / f"{safe_label}.tex"
if not path.is_file():
raise CliError(f"Missing {path}; generate statements and solutions first")
parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}")
questions[label] = "\n".join(parts)
context = utils.enonce_total(workspace.root)
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
if mode == "groups":
response = api_client.models.generate_content(
model=MODEL_ID,
contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
"Regroupe ces questions dexamen en groupes cohérents pour la correction "
"et lannotation, selon leurs dépendances et leur contexte commun. "
"Ne mélange pas des exercices différents. Conserve lordre des questions. "
"Chaque label doit apparaître exactement une fois, sans modification. "
"Renvoie uniquement un objet JSON groups contenant des listes de labels.\n\n"
+ context + "\n\n" + "\n\n".join(questions.values())
))])],
config=types.GenerateContentConfig(
temperature=0.1, response_mime_type="application/json",
response_json_schema=LabelGroups.model_json_schema(),
),
)
groups = LabelGroups.model_validate_json(response.text).groups
validate_groups(groups, labels)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in groups))
print(f"Updated label_groups: {len(groups)} Gemini groups.")
elif mode == "persp":
if not workspace.label_groups_file.is_file():
raise CliError("Generate label_groups before generating rubrics")
groups = [[label.strip() for label in line.split(",") if label.strip()]
for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines()
if line.strip()]
validate_groups(groups, labels)
with staged_directory(workspace.root / "Persp") as staging:
for group in groups:
print(f"Generating rubric (Persp) for group: {', '.join(group)}...")
group_content = (
"Contexte général de lexamen, fourni uniquement pour comprendre les questions :\n"
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group)
)
rubrics = generate_rubrics(api_client, group_content)
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()):
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved")
for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8")
print("Replaced Persp with Gemini rubrics.")
else:
raise ValueError(f"Unknown statement refinement: {mode}")
return ExitCode.SUCCESS
def process_exam(
workspace: EvaluationWorkspace,
restart: bool = False,
@@ -701,32 +803,9 @@ def process_exam(
group_context_text = "\n\n---\n\n".join(group_text_parts)
contents_4 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
],
)
]
config_4 = types.GenerateContentConfig(
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
)
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
try:
response_r = client.models.generate_content(
model=MODEL_ID,
contents=contents_4,
config=config_4
)
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
rubrics_map = generate_rubrics(client, group_context_text)
except Exception as e: # noqa: BLE001 - remote API boundary
print(f"Error generating rubric for group {labels[0]}: {e}")
processing_errors.append(str(e))
@@ -822,12 +901,23 @@ def process_exam(
workspace.labels_file,
"".join(f"{label}\n" for label in labels_list),
)
atomic_write_text(
workspace.label_groups_file,
"".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n"
for group in grouped_extraction.groups
if any(isinstance(item, QuestionItem) for item in group)),
)
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Extract exam and solution code via Gemini")
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--groups-only", action="store_true",
help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true",
help="Replace only Persp with Gemini rubrics for existing groups")
parser.add_argument(
"--restart",
action="store_true",
@@ -841,7 +931,9 @@ def main(argv: Sequence[str] | None = None) -> int:
return execute(
parser,
argv,
lambda args: process_exam(
lambda args: refine_existing(workspace_from_args(args),
"groups" if args.groups_only else "persp")
if args.groups_only or args.persp_only else process_exam(
workspace_from_args(args),
restart=args.restart,
),
+26 -11
View File
@@ -12,7 +12,7 @@ from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
import fitz # PyMuPDF
import pymupdf # PyMuPDF
from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter
@@ -27,6 +27,9 @@ from copienator import (
)
from copienator.platform import launch_pdf_arranger
# Keep the new shortcut available with older personal configuration files.
PAGE_SPLITTER_KB = {"reverse_pages": "i", **PAGE_SPLITTER_KB}
# --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72
@@ -112,7 +115,7 @@ class PDFPreviewer:
self.page_settings = []
self.processing = False # Flag to prevent multiple finish calls
try:
self.doc = fitz.open(self.pdf_path)
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
@@ -166,6 +169,7 @@ class PDFPreviewer:
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
@@ -192,6 +196,7 @@ class PDFPreviewer:
"next_page": self.confirm_and_next_page,
"discard_page": self.discard_page,
"send_end": self.send_page_end,
"reverse_pages": self.reverse_pages,
"restart_file": self.restart_current_file,
"arranger": self.start_arranger,
"prev_file": self.go_to_previous_file,
@@ -271,7 +276,7 @@ class PDFPreviewer:
self.current_zoom = min(zoom_x, zoom_y) * 0.98
# --- Render Page ---
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
mat = pymupdf.Matrix(self.current_zoom, self.current_zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
@@ -302,7 +307,7 @@ class PDFPreviewer:
# Re-open the file from disk to reset changes (like moved pages)
try:
self.doc = fitz.open(self.pdf_path)
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
self.master.destroy()
@@ -319,6 +324,16 @@ class PDFPreviewer:
self.load_page()
def reverse_pages(self, event=None):
"""Reverse the current document and discard earlier page decisions."""
if self.processing or not len(self.doc):
return
self.doc.select(list(reversed(range(len(self.doc)))))
self.current_page_index = 0
self.page_settings = []
self._initialize_current_page_settings()
self.load_page()
def move_line_left(self, event=None):
"""Moves the split line to the left."""
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
@@ -465,7 +480,7 @@ class PDFPreviewer:
self.file_rotation + self.global_rotation) % 360
if keep == "as_is":
doc_full = fitz.open()
doc_full = pymupdf.open()
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
page_full.show_pdf_page(page_full.rect, self.doc, i)
page_full.set_rotation(rotation)
@@ -477,13 +492,13 @@ class PDFPreviewer:
# --- Create Left Part ---
if rotation == 0:
rect_left = fitz.Rect(0, 0, line_x, page.rect.height)
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
else:
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
if (keep == "both" or keep == "left") and line_x > 0:
doc_left = fitz.open()
doc_left = pymupdf.open()
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
page_left.set_rotation(rotation)
@@ -494,11 +509,11 @@ class PDFPreviewer:
# --- Create Right Part ---
if rotation == 0:
rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
else:
rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height)
rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height)
if (keep == "both" or keep == "right") and line_x < page.rect.width:
doc_right = fitz.open()
doc_right = pymupdf.open()
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
page_right.set_rotation(rotation)
+5 -5
View File
@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
import fitz
import pymupdf
from pypdf import PdfReader, PdfWriter
from copienator import utils
@@ -72,7 +72,7 @@ def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
def _save_cropped_page(
document: fitz.Document,
document: pymupdf.Document,
page_number: int,
x0: float,
y0: float,
@@ -82,14 +82,14 @@ def _save_cropped_page(
) -> None:
page = document[page_number]
rotated_rectangle = page.rect * page.transformation_matrix
visual_crop = fitz.Rect(
visual_crop = pymupdf.Rect(
rotated_rectangle.x0 + x0,
y0,
rotated_rectangle.x0 + x1,
y1,
)
unrotated_clip = visual_crop * page.derotation_matrix
cropped = fitz.open()
cropped = pymupdf.open()
try:
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
target_page.show_pdf_page(
@@ -110,7 +110,7 @@ def _render_split_outputs(
staging: Path,
) -> set[str]:
"""Render every current answer into an otherwise empty staging directory."""
document = fitz.open(input_pdf)
document = pymupdf.open(input_pdf)
try:
parsed = _parse_coordinates(coords_list)
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)