Guide d'utilisateur GUI ; Recoverable statement/Persp Gemini generation

This commit is contained in:
sebastien committed 2026-10-05 09:04:01 +02:00
1 parent 2f8422f6d5
commit 94ca8b8256
9 files changed
+1328 -165

No files matched your search

+243 -153
View File
@@ -1,29 +1,36 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import io
import json
import re import re
from collections.abc import Sequence from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from uuid import uuid4
from google import genai from google import genai
from google.genai import types from google.genai import types
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from copienator import configuration as config
from copienator import utils
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
atomic_write_json,
atomic_write_text, atomic_write_text,
evaluation_parser, evaluation_parser,
execute, execute,
utils,
workspace_from_args, workspace_from_args,
) )
from copienator import configuration as config
from copienator.filesystem import staged_files
from copienator.latex_json import normalize_literal_newlines, repair_latex_json
from copienator.platform import validate_windows_labels from copienator.platform import validate_windows_labels
from copienator.latex_json import repair_latex_json, normalize_literal_newlines from copienator.rubrics import present_rubric_labels
from copienator.filesystem import staged_directory from copienator.statement_checkpoints import extract_stage, report_error
from copienator.utils import compile_to_pdf from copienator.utils import compile_to_pdf
@@ -130,7 +137,15 @@ def validate_rubric_content(content: str) -> str:
an intentional newline. Do not guess or rewrite those after decoding. an intentional newline. Do not guess or rewrite those after decoding.
""" """
if any((ord(char) < 32 and char != "\n") or ord(char) == 127 for char in content): if any((ord(char) < 32 and char != "\n") or ord(char) == 127 for char in content):
raise ValueError("Rubric contains control characters, likely from unescaped LaTeX") positions = ", ".join(
f"U+{ord(char):04X} at offset {index}"
for index, char in enumerate(content)
if (ord(char) < 32 and char != "\n") or ord(char) == 127
)
raise ValueError(
"Rubric contains control characters, likely from unescaped LaTeX: "
f"{positions}; decoded content: {content!r}"
)
return content return content
# --- Modèle fusionné (pour le reste du script) --- # --- Modèle fusionné (pour le reste du script) ---
@@ -224,8 +239,7 @@ def find_file(folder: Path, base_name: str) -> Path | None:
return None return None
def generate_rubrics(client, group_context_text: str) -> dict[str, str]: def _request_rubrics(client, group_context_text: str):
"""Use the same rubric request for full and selective statement generation."""
response = client.models.generate_content( response = client.models.generate_content(
model=MODEL_ID, model=MODEL_ID,
contents=[types.Content(role="user", parts=[ contents=[types.Content(role="user", parts=[
@@ -240,10 +254,71 @@ def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
response_json_schema=GroupRubrics.model_json_schema(), response_json_schema=GroupRubrics.model_json_schema(),
), ),
) )
rubrics = GroupRubrics.model_validate_json(repair_latex_json(response.text)).rubrics return response
if len({item.label for item in rubrics}) != len(rubrics):
raise ValueError("Gemini returned duplicate rubric labels")
return {item.label: validate_rubric_content(item.rubric_content) for item in rubrics} def generate_rubrics(
client, group_context_text: str, *, workspace: EvaluationWorkspace | None = None,
expected_labels: list[str] | None = None,
) -> dict[str, str]:
"""Save the raw response before decoding; report failures without hiding text."""
raw = None
response_path = None
try:
response = _request_rubrics(client, group_context_text)
raw = response.text
if workspace is not None:
stamp = datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')
cache_path = workspace.root / 'Cache' / 'rubric_responses' / f'{stamp}-{uuid4().hex}.json'
atomic_write_text(cache_path, raw or '')
response_path = cache_path
payload = json.loads(repair_latex_json(raw))
if not isinstance(payload, dict) or not isinstance(payload.get('rubrics'), list):
raise TypeError('Gemini response must contain a rubrics list')
result = {}
item_errors = []
for entry in payload['rubrics']:
try:
item = RubricItem.model_validate(entry)
if item.label in result:
raise ValueError('Gemini returned duplicate rubric labels')
if expected_labels is not None and item.label not in expected_labels:
raise ValueError('Unexpected rubric label')
result[item.label] = validate_rubric_content(item.rubric_content)
except ValueError as error:
label = entry.get('label', 'unknown') if isinstance(entry, dict) else 'unknown'
item_errors.append(f"Question {label}: {error}; item (escaped): {entry!r}")
if workspace is not None and expected_labels is not None:
# Retain valid siblings even if another rubric in this response fails.
for label, content in result.items():
if label in expected_labels and content.strip():
atomic_write_text(workspace.root / 'Persp' / label.replace('/', '_'),
f'{label}\n{content}')
if item_errors:
raise ValueError('\n'.join(item_errors))
if expected_labels is not None and (
set(result) != set(expected_labels) or any(not value.strip() for value in result.values())
):
raise CliError("Incomplete or unexpected Gemini rubrics; failed group preserved")
return result
except Exception as error:
diagnostic = (
f"Rubric generation failed for {expected_labels or 'unspecified group'}: {error}\n"
f"Raw response (escaped): {raw!r}\n"
)
if response_path is not None:
diagnostic += f"Raw response saved to: {response_path}\n"
print(diagnostic, end='', flush=True)
if workspace is not None:
log_path = workspace.root / 'Cache' / 'rubric_errors.log'
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open('a', encoding='utf-8') as log:
log.write(f"[{datetime.now(UTC).isoformat()}]\n{diagnostic}\n")
print(f"Error log: {log_path}", flush=True)
except OSError as log_error:
print(f"Could not save rubric error log: {log_error}", flush=True)
raise
def validate_groups(groups: list[list[str]], labels: list[str]) -> None: def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
@@ -253,12 +328,19 @@ def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
raise CliError("Groups must contain every existing label exactly once") raise CliError("Groups must contain every existing label exactly once")
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode: def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None,
"""Regroup or replace rubrics without regenerating statements or solutions.""" delete_persp: bool = False) -> ExitCode:
"""Regroup questions or generate missing rubrics, retaining completed groups."""
labels = workspace.read_labels() labels = workspace.read_labels()
if not labels or len(labels) != len(set(labels)): if not labels or len(labels) != len(set(labels)):
raise CliError("Generate unique question labels before refining the statement") raise CliError("Generate unique question labels before refining the statement")
validate_windows_labels(labels) validate_windows_labels(labels)
present = set(present_rubric_labels(workspace.root, labels)) if mode == 'persp' else set()
if mode == 'persp':
print(f"Persp: {len(present)}/{len(labels)} present, {len(labels) - len(present)} missing.")
if len(present) == len(labels) and not delete_persp:
print('All Persp rubrics are already present; nothing to generate.')
return ExitCode.SUCCESS
questions = {} questions = {}
for label in labels: for label in labels:
safe_label = label.replace("/", "_") safe_label = label.replace("/", "_")
@@ -304,21 +386,34 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines() for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines()
if line.strip()] if line.strip()]
validate_groups(groups, labels) validate_groups(groups, labels)
with staged_directory(workspace.root / "Persp") as staging: if delete_persp:
persp_dir = workspace.root / 'Persp'
remove = [path.name for path in persp_dir.iterdir() if path.is_file()] if persp_dir.exists() else []
with staged_files(persp_dir, remove=remove):
pass
present.clear()
print('Deleted current Persp; generating all rubrics.')
generated = 0
for group in groups: for group in groups:
print(f"Generating rubric (Persp) for group: {', '.join(group)}...") missing = [label for label in group if label not in present]
if not missing:
print(f"Skipping existing Persp for group: {', '.join(group)}.")
continue
print(f"Generating missing rubric (Persp): {', '.join(missing)}...")
group_content = ( group_content = (
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n" "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 : " + context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group) + ", ".join(missing) + "\n\n" + "\n\n".join(questions[label] for label in group)
) )
rubrics = generate_rubrics(api_client, group_content) rubrics = generate_rubrics(
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()): api_client, group_content, workspace=workspace, expected_labels=missing,
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved") )
with staged_files(workspace.root / "Persp") as staging:
for label, rubric in rubrics.items(): for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text( (staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8") f"{label}\n{rubric}", encoding="utf-8")
print("Replaced Persp with Gemini rubrics.") generated += len(rubrics)
print(f"Generated {generated} missing Persp rubrics; all {len(labels)} are now present.")
else: else:
raise ValueError(f"Unknown statement refinement: {mode}") raise ValueError(f"Unknown statement refinement: {mode}")
return ExitCode.SUCCESS return ExitCode.SUCCESS
@@ -336,9 +431,15 @@ def process_exam(
cache_dir.mkdir(exist_ok=True) cache_dir.mkdir(exist_ok=True)
tmp_dir.mkdir(exist_ok=True) tmp_dir.mkdir(exist_ok=True)
cache_q_file = cache_dir / "gemini_questions.json" if restart:
cache_s_file = cache_dir / "gemini_solutions.json" archive = cache_dir / 'statement_history' / uuid4().hex
cache_c_file = cache_dir / "gemini_context.json" for path in [*cache_dir.glob('statement_*.json'), *cache_dir.glob('gemini_*.json'),
tmp_dir / 'exam_items.txt', tmp_dir / 'exam_items_full.txt']:
if path.is_file():
archive.mkdir(parents=True, exist_ok=True)
path.replace(archive / path.name)
print('Starting a new extraction; previous checkpoints and edits archived.')
atomic_write_json(cache_dir / 'statement_reset_outputs.json', {'pending': True})
# 1. Resolve files # 1. Resolve files
pdf_path = workspace.statement_pdf_file pdf_path = workspace.statement_pdf_file
@@ -392,38 +493,16 @@ def process_exam(
response_json_schema=ExamQuestions.model_json_schema(), response_json_schema=ExamQuestions.model_json_schema(),
) )
if cache_q_file.is_file() and not restart: questions_data = ExamQuestions(questions=extract_stage(
print("Loading cached questions from Cache/gemini_questions.json...") folder, 'questions', QuestionOnlyItem, 'question_content',
response_q_text = cache_q_file.read_text(encoding="utf-8") lambda _pending: client.models.generate_content(model=MODEL_ID, contents=contents_1, config=config_1),
else: ))
print("Sending request 1 (Questions) to Gemini...")
response_q = client.models.generate_content(
model=MODEL_ID,
contents=contents_1,
config=config_1
)
response_q_text = response_q.text
print("Saving questions to cache...")
atomic_write_text(cache_q_file, response_q_text)
questions_data = ExamQuestions.model_validate_json(repair_latex_json(response_q_text))
# ========================================== # ==========================================
# REQUÊTE 2 : Extraction des Corrections # REQUÊTE 2 : Extraction des Corrections
# ========================================== # ==========================================
extracted_questions_json = questions_data.model_dump_json(indent=2) extracted_questions_json = questions_data.model_dump_json(indent=2)
contents_2 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_2),
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
],
)
]
config_2 = types.GenerateContentConfig( config_2 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, temperature=0.1,
@@ -431,21 +510,19 @@ def process_exam(
response_json_schema=ExamSolutions.model_json_schema(), response_json_schema=ExamSolutions.model_json_schema(),
) )
if cache_s_file.is_file() and not restart: question_labels = [q.label for q in questions_data.questions]
print("Loading cached solutions from Cache/gemini_solutions.json...") def request_solutions(pending):
response_s_text = cache_s_file.read_text(encoding="utf-8") requested = pending if pending is not None else question_labels
else: selected = ExamQuestions(questions=[q for q in questions_data.questions if q.label in requested])
print("Sending request 2 (Solutions) to Gemini...") contents = [types.Content(role='user', parts=[
response_s = client.models.generate_content( types.Part.from_text(text=PROMPT_2),
model=MODEL_ID, types.Part.from_text(text='--- QUESTIONS EXTRAITES ---\n' + selected.model_dump_json(indent=2)),
contents=contents_2, types.Part.from_text(text=f'--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}'),
config=config_2 ])]
) return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_2)
response_s_text = response_s.text solutions_data = ExamSolutions(solutions=extract_stage(
print("Saving solutions to cache...") folder, 'solutions', SolutionOnlyItem, 'solution_content', request_solutions, labels=question_labels,
atomic_write_text(cache_s_file, response_s_text) ))
solutions_data = ExamSolutions.model_validate_json(repair_latex_json(response_s_text))
# ========================================== # ==========================================
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.) # REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
@@ -468,21 +545,22 @@ def process_exam(
response_json_schema=ExamContext.model_json_schema(), response_json_schema=ExamContext.model_json_schema(),
) )
if cache_c_file.is_file() and not restart: def request_contexts(pending):
print("Loading cached context from Cache/gemini_context.json...") contents = contents_3
response_c_text = cache_c_file.read_text(encoding="utf-8") if pending:
else: contents = [types.Content(role='user', parts=[*contents_3[0].parts,
print("Sending request 3 (Context) to Gemini...") types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : '
response_c = client.models.generate_content( + ', '.join(pending))])]
model=MODEL_ID, return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_3)
contents=contents_3, context_data = ExamContext(contexts=extract_stage(
config=config_3 folder, 'contexts', ExtractedContext, 'context_content', request_contexts, labels=question_labels,
) ))
response_c_text = response_c.text
print("Saving context to cache...")
atomic_write_text(cache_c_file, response_c_text)
context_data = ExamContext.model_validate_json(repair_latex_json(response_c_text)) grouping_checkpoint = cache_dir / 'statement_grouping.json'
if grouping_checkpoint.is_file():
print('Resuming approved question groups from checkpoint.')
grouped_extraction = GroupedExamExtraction.model_validate_json(grouping_checkpoint.read_text())
return write_exam_outputs(workspace, grouped_extraction, client)
# ========================================== # ==========================================
# FUSION des trois résultats # FUSION des trois résultats
@@ -634,8 +712,7 @@ def process_exam(
# --- WRITE TEXT FILES --- # --- WRITE TEXT FILES ---
print(f"Writing items files to {items_file.name} and {full_items_file.name}...") print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
with open(items_file, "w", encoding="utf-8") as f, \ with io.StringIO() as f, io.StringIO() as f_full:
open(full_items_file, "w", encoding="utf-8") as f_full:
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n" header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
f.write(header) f.write(header)
@@ -664,8 +741,15 @@ def process_exam(
f.write(f"CONTEXT ### {safe_content}\n") f.write(f"CONTEXT ### {safe_content}\n")
f_full.write(f"CONTEXT ### {safe_content}\n") f_full.write(f"CONTEXT ### {safe_content}\n")
if not items_file.exists():
atomic_write_text(items_file, f.getvalue())
if not full_items_file.exists():
atomic_write_text(full_items_file, f_full.getvalue())
# --- OPEN EDITOR AND PARSE --- # --- OPEN EDITOR AND PARSE ---
while True: while True:
print('Check the question list against enonce.pdf before approving: '
'verify completeness, labels, order and context coverage.')
print("Opening items file for editing...") print("Opening items file for editing...")
utils.edit_file_and_enter(items_file) utils.edit_file_and_enter(items_file)
@@ -704,6 +788,9 @@ def process_exam(
all_new_q_labels.append(lbl) all_new_q_labels.append(lbl)
# Mapping from original question index to new label # Mapping from original question index to new label
if len(all_new_q_labels) != len(questions_only):
raise CliError('The edited groups must retain every extracted question. '
'Grouping edits were preserved; correct the list and rerun.')
idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))} idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))}
orig_q_idx = 0 orig_q_idx = 0
@@ -802,37 +889,40 @@ def process_exam(
# Save labels and proceed # Save labels and proceed
grouped_extraction = GroupedExamExtraction(groups=grouped_items) grouped_extraction = GroupedExamExtraction(groups=grouped_items)
# 2. Setup output directories if not labels_list or len(labels_list) != len(set(labels_list)):
text_dir = folder / "Text" raise CliError('Approved groups must contain unique question labels')
sol_dir = folder / "Sol" atomic_write_json(grouping_checkpoint, grouped_extraction.model_dump())
text2_dir = folder / "Text2" return write_exam_outputs(workspace, grouped_extraction, client, restart=restart)
sol2_dir = folder / "Sol2"
persp_dir = folder / "Persp"
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
import shutil
# Ask only if any directory already exists
if any(d.exists() for d in dirs):
answer = input(
"Output directories already exist. Delete their contents? [y/N] "
).strip().lower()
if answer not in ("y", "yes"):
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
# Empty each directory
for d in dirs:
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
else:
# Create them if they don't exist
for d in dirs:
d.mkdir(parents=True)
text_dir.mkdir(exist_ok=True)
sol_dir.mkdir(exist_ok=True)
def write_exam_outputs(workspace: EvaluationWorkspace, grouped_extraction: GroupedExamExtraction,
client, *, restart: bool = False) -> ExitCode:
folder = workspace.root
labels_list = [item.label for group in grouped_extraction.groups
for item in group if isinstance(item, QuestionItem)]
text_dir, sol_dir, text2_dir, sol2_dir, persp_dir = [
folder / name for name in ('Text', 'Sol', 'Text2', 'Sol2', 'Persp')]
reset_marker = folder / 'Cache' / 'statement_reset_outputs.json'
if restart or reset_marker.exists():
archive = folder / 'Cache' / 'statement_history' / uuid4().hex
for directory in (text_dir, sol_dir, text2_dir, sol2_dir, persp_dir):
if directory.exists():
archive.mkdir(parents=True, exist_ok=True)
directory.replace(archive / directory.name)
reset_marker.unlink(missing_ok=True)
for directory in (text_dir, sol_dir, text2_dir, sol2_dir, persp_dir):
directory.mkdir(parents=True, exist_ok=True)
atomic_write_text(workspace.labels_file, ''.join(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)))
def save_missing(path: Path, text: str):
if not path.is_file() or not path.read_text(encoding='utf-8').strip():
atomic_write_text(path, text)
present = set(present_rubric_labels(folder, labels_list))
context_sources = {}
print("Writing grouped question and solution files...") print("Writing grouped question and solution files...")
processing_errors = [] processing_errors = []
@@ -855,9 +945,15 @@ 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)
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...") missing_rubrics = [label for label in labels if label not in present]
try: try:
rubrics_map = generate_rubrics(client, group_context_text) rubrics_map = {}
if missing_rubrics:
print(f"Generating missing rubric (Persp): {', '.join(missing_rubrics)}...")
rubrics_map = generate_rubrics(
client, group_context_text + '\nProduis des barèmes UNIQUEMENT pour : '
+ ', '.join(missing_rubrics), workspace=workspace, expected_labels=missing_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))
@@ -887,21 +983,15 @@ def process_exam(
text_content_lines.append(f"{item.label} :") text_content_lines.append(f"{item.label} :")
text_content_lines.append(tabulated) text_content_lines.append(tabulated)
# Write individual Sol file (remains unchanged) safe_label = item.label.replace('/', '_')
safe_label = item.label.replace("/", "_") save_missing(sol_dir / safe_label, f'{item.label}\n{item.solution_content}')
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol: save_missing(text2_dir / f'{safe_label}.tex', f'\\textbf{{{item.label}}} {item.question_content}')
f_sol.write(f"{item.label}\n{item.solution_content}") save_missing(sol2_dir / f'{safe_label}.tex', f'\\textbf{{{item.label}}} {item.solution_content}')
with open(text2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_t2:
f_t2.write(f"\\textbf{{{item.label}}} {item.question_content}")
with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2:
f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}")
# --- Écriture du Barème (Persp) --- # --- Écriture du Barème (Persp) ---
rubric_text = rubrics_map.get(item.label, "") if item.label in rubrics_map:
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp: atomic_write_text(persp_dir / safe_label,
f_persp.write(f"{item.label}\n{rubric_text}") f"{item.label}\n{rubrics_map[item.label]}")
elif isinstance(item, ContextItem): elif isinstance(item, ContextItem):
raw_ctx = item.content.strip() raw_ctx = item.content.strip()
@@ -915,15 +1005,14 @@ def process_exam(
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex" ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
ctxt_path = text2_dir / ctxt_filename ctxt_path = text2_dir / ctxt_filename
# If file exists, prepend some spacing before appending context_sources.setdefault(ctxt_path, [])
prefix = "\n\n" if ctxt_path.exists() else "" if item.content not in context_sources[ctxt_path]:
context_sources[ctxt_path].append(item.content)
with open(ctxt_path, "a", encoding="utf-8") as f_c2: save_missing(text_dir / safe_group_filename, '\n'.join(text_content_lines))
f_c2.write(prefix + item.content)
# 4. Write the grouped Text file for path, contents in context_sources.items():
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text: save_missing(path, '\n\n'.join(contents))
f_text.write("\n".join(text_content_lines))
print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...") print(f"Wrote {len(grouped_extraction.groups)} groups; checking PDF compilation...")
# ========================================== # ==========================================
@@ -934,9 +1023,16 @@ def process_exam(
def compile_worker(tex_file: Path) -> str | None: def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function.""" """Helper to read content and call the utility function."""
try: try:
content = tex_file.read_text(encoding="utf-8") pdf_path = tex_file.with_suffix('.pdf')
pdf_path = tex_file.with_suffix(".pdf") status_path = folder / 'Cache' / 'statement_pdf_status' / tex_file.parent.name / (tex_file.stem + '.json')
failed_before = status_path.exists() and not json.loads(status_path.read_text()).get('complete')
if (pdf_path.is_file() and pdf_path.stat().st_size > 0
and pdf_path.stat().st_mtime_ns >= tex_file.stat().st_mtime_ns and not failed_before):
return None
atomic_write_json(status_path, {'complete': False})
content = tex_file.read_text(encoding='utf-8')
compile_to_pdf(content, pdf_path, strict=True) compile_to_pdf(content, pdf_path, strict=True)
atomic_write_json(status_path, {'complete': True})
except Exception as e: # noqa: BLE001 - compiler worker boundary except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}" return f"Error compiling {tex_file.name}: {e}"
return None return None
@@ -947,19 +1043,8 @@ def process_exam(
error for error in executor.map(compile_worker, all_tex_files) if error error for error in executor.map(compile_worker, all_tex_files) if error
] ]
for error in compile_errors: for error in compile_errors:
print(error) report_error(folder, 'PDF compilation', error)
processing_errors.extend(compile_errors) processing_errors.extend(compile_errors)
atomic_write_text(
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 return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
@@ -969,26 +1054,31 @@ def build_parser() -> argparse.ArgumentParser:
actions.add_argument("--groups-only", action="store_true", actions.add_argument("--groups-only", action="store_true",
help="Regroup existing questions with Gemini; update only label_groups") help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true", actions.add_argument("--persp-only", action="store_true",
help="Replace only Persp with Gemini rubrics for existing groups") help="Generate only missing Persp rubrics for existing groups")
parser.add_argument('--delete-persp', action='store_true',
help='Delete current Persp before generation (requires --persp-only)')
parser.add_argument( parser.add_argument(
"--restart", "--restart",
action="store_true", action="store_true",
help="Ignore cached Gemini extraction responses", help="Start over; archive previous extraction checkpoints, edits and generated outputs",
) )
return parser return parser
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser() parser = build_parser()
def run(args):
if args.delete_persp and not args.persp_only:
raise CliError('--delete-persp requires --persp-only', ExitCode.INVALID_ARGUMENTS)
if args.groups_only or args.persp_only:
kwargs = {'delete_persp': True} if args.delete_persp else {}
return refine_existing(workspace_from_args(args),
'groups' if args.groups_only else 'persp', **kwargs)
return process_exam(workspace_from_args(args), restart=args.restart)
return execute( return execute(
parser, parser,
argv, argv,
lambda args: refine_existing(workspace_from_args(args), run,
"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,
),
) )
+21
View File
@@ -0,0 +1,21 @@
"""Shared inventory of usable, individual Persp files."""
from pathlib import Path
def present_rubric_labels(root: Path, labels: list[str]) -> list[str]:
present = []
for label in labels:
path = root / 'Persp' / label.replace('/', '_')
if not path.is_file():
continue
try:
content = path.read_text(encoding='utf-8').strip()
except UnicodeError:
continue
lines = content.split('\n', 1)
body = lines[1].strip() if lines[0].strip() == label and len(lines) > 1 else content
if body and body != label and not any(
(ord(char) < 32 and char != '\n') or ord(char) == 127 for char in content
):
present.append(label)
return present
+154
View File
@@ -0,0 +1,154 @@
"""Extraction checkpoints assume unchanged exam sources until an explicit restart."""
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from copienator import CliError, atomic_write_json, atomic_write_text
from copienator.latex_json import repair_latex_json
def archive_response(root: Path, stage: str, raw: str | None) -> Path:
stamp = datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')
path = root / 'Cache' / 'statement_responses' / f'{stamp}-{stage}-{uuid4().hex}.json'
atomic_write_text(path, raw or '')
return path
def report_error(root: Path, stage: str, error, raw=None, response_path=None) -> None:
diagnostic = f'{stage}: {error}\n'
if raw is not None:
diagnostic += f'Raw response (escaped): {raw!r}\n'
if response_path:
diagnostic += f'Raw response saved to: {response_path}\n'
print(diagnostic, end='', flush=True)
try:
path = root / 'Cache' / 'statement_errors.log'
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as log:
log.write(f'[{datetime.now(UTC).isoformat()}]\n{diagnostic}\n')
except OSError as log_error:
print(f'Could not save extraction error log: {log_error}', flush=True)
def validate_text(text: str) -> None:
if not text.strip():
raise ValueError('Empty extracted content')
bad = [(i, ord(c)) for i, c in enumerate(text)
if (ord(c) < 32 and c not in '\n\t') or ord(c) == 127]
# Tabs can be legitimate indentation in code, but in math they often
# indicate an unknown misescaped TeX command beginning with t.
for match in re.finditer(r'(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])', text, re.DOTALL):
bad.extend((match.start() + i, ord(c)) for i, c in enumerate(match.group()) if c == '\t')
if bad:
positions = ', '.join(f'U+{code:04X} at offset {i}' for i, code in bad)
raise ValueError(f'Control characters: {positions}; decoded content: {text!r}')
def extract_stage(root: Path, stage: str, item_model, content_field: str, request,
*, labels: list[str] | None = None) -> list:
"""Keep valid items; request only pending known labels on the next run.
Questions are re-requested as a complete ordered inventory because there
is no independent list of question labels before extraction. Contexts may
legitimately be empty; an empty questions/solutions response may not.
"""
checkpoint = root / 'Cache' / f'statement_{stage}.json'
state = json.loads(checkpoint.read_text()) if checkpoint.exists() else {}
accepted = state.get(stage, [])
inventory = state.get('inventory', [item['label'] for item in accepted] if stage == 'questions' else [])
if state.get('complete'):
return [item_model.model_validate(item) for item in accepted]
pending = state.get('pending')
if stage == 'solutions':
known = {item['label'] for item in accepted}
pending = [label for label in labels if label not in known]
legacy = root / 'Cache' / f'gemini_{"context" if stage == "contexts" else stage}.json'
raw = None
response_path = None
try:
if not checkpoint.exists() and legacy.exists():
raw = legacy.read_text(encoding='utf-8')
print(f'Importing legacy {stage} response from {legacy}')
else:
print(f'Extracting {stage}' + (f' for {pending}' if pending else '') + '...')
raw = request(pending).text
response_path = archive_response(root, stage, raw)
payload = json.loads(repair_latex_json(raw))
if not isinstance(payload, dict) or not isinstance(payload.get(stage), list):
raise TypeError(f'Response must contain a {stage} list')
except Exception as error:
# Mark legacy data as attempted too, so an invalid cache is never
# replayed indefinitely. Previously accepted items remain untouched.
atomic_write_json(checkpoint, {stage: accepted, 'complete': False, 'pending': pending,
'inventory': inventory})
report_error(root, stage, error, raw, response_path)
raise CliError(f'{stage} extraction failed; saved progress can be resumed') from error
errors = []
failed_targets = []
items = []
seen = set()
for index, entry in enumerate(payload[stage]):
if stage == 'questions' and isinstance(entry, dict):
label = entry.get('label')
if isinstance(label, str) and label.strip() and label not in inventory:
inventory.append(label)
try:
item = item_model.model_validate(entry)
validate_text(getattr(item, content_field))
if stage == 'contexts':
first, last = item.target_question_label, item.last_question_label
if first not in labels or last not in labels or labels.index(first) > labels.index(last):
raise ValueError('Invalid context target or range')
if pending and first not in pending:
raise ValueError('Unexpected context target')
key = (first, last, item.context_content)
else:
if not item.label.strip():
raise ValueError('Empty question label')
if labels is not None and item.label not in (pending or labels):
raise ValueError('Unexpected solution label')
key = item.label
if key in seen:
raise ValueError('Duplicate extracted item')
seen.add(key)
items.append(item.model_dump())
except (ValueError, TypeError) as error:
errors.append(f'Item {index}: {error}; item (escaped): {entry!r}')
if isinstance(entry, dict) and entry.get('target_question_label') in (labels or []):
failed_targets.append(entry['target_question_label'])
if stage == 'contexts':
# Preserve good contexts even when another context for the same target fails.
combined = accepted + [item for item in items if item not in accepted]
next_pending = list(dict.fromkeys(failed_targets)) if len(failed_targets) == len(errors) else None
absent = [label for label in (pending or []) if not any(
item['target_question_label'] == label for item in items)]
if absent:
errors.append(f'Missing context targets: {absent}')
next_pending = list(dict.fromkeys((next_pending or []) + absent))
else:
previous = {item['label']: item for item in accepted}
previous.update({item['label']: item for item in items if item['label'] not in previous})
order = [item['label'] for item in items] if stage == 'questions' else labels
combined = [previous[label] for label in order if label in previous]
combined += [item for label, item in previous.items() if label not in order]
missing = [label for label in (labels or []) if label not in previous]
if stage == 'questions':
missing = [label for label in inventory if label not in seen]
if not items:
errors.append('No valid questions returned')
if missing:
errors.append(f'Missing labels: {missing}')
next_pending = [label for label in (labels or []) if label not in previous] or None
complete = not errors
atomic_write_json(checkpoint, {stage: combined, 'complete': complete, 'pending': next_pending,
'inventory': inventory})
if errors:
report_error(root, stage, '\n'.join(errors), raw, response_path)
raise CliError(f'Incomplete {stage}; {len(combined)} valid items saved. Rerun to resume.')
return [item_model.model_validate(item) for item in combined]
+41
View File
@@ -21,6 +21,7 @@ from copienator.platform import (
validate_windows_labels, validate_windows_labels,
) )
from copienator.utils import LATEX_WARNING_MARKER from copienator.utils import LATEX_WARNING_MARKER
from copienator.rubrics import present_rubric_labels
from .diagnostics import collect_diagnostics from .diagnostics import collect_diagnostics
from .batch_monitor import BatchMonitor from .batch_monitor import BatchMonitor
@@ -821,6 +822,7 @@ class CopienatorApp(tk.Tk):
"plotting", "plotting",
"manual_resolution", "manual_resolution",
"giving_names", "giving_names",
"statement_persp",
} }
self.console.configure(height=8 if expanded_form else 14) self.console.configure(height=8 if expanded_form else 14)
self.rowconfigure(1, weight=4 if expanded_form else 3) self.rowconfigure(1, weight=4 if expanded_form else 3)
@@ -877,6 +879,10 @@ class CopienatorApp(tk.Tk):
if spec.variants and variant.id not in spec.variants: if spec.variants and variant.id not in spec.variants:
continue continue
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
if step.id == 'statement_persp' and spec.name == 'delete_persp':
value = False # Deletion is a one-run choice, never a saved default.
if step.id == 'statement' and spec.name == 'restart':
value = False
choices = spec.choices choices = spec.choices
if spec.name == "annotation_dir" and step.id in {"export", "import"}: if spec.name == "annotation_dir" and step.id in {"export", "import"}:
choices, value = self._annotation_directory_choices(step.id) choices, value = self._annotation_directory_choices(step.id)
@@ -910,6 +916,16 @@ class CopienatorApp(tk.Tk):
attach_tooltip(widget, spec.help) attach_tooltip(widget, spec.help)
row += 1 row += 1
if step.id == 'statement_persp':
self.persp_inventory_label = ttk.Label(self.form, wraplength=680, justify='left')
self.persp_inventory_label.grid(row=row, column=0, columnspan=2, sticky='ew', pady=8)
row += 1
ttk.Button(self.form, text='Actualiser les barèmes présents',
command=self._refresh_persp_inventory).grid(row=row, column=1, sticky='w')
row += 1
self.arg_vars['target'].trace_add('write', lambda *_args: self._refresh_persp_inventory())
self._refresh_persp_inventory()
if step.id == "manual_resolution": if step.id == "manual_resolution":
def manual_evaluation(): def manual_evaluation():
target = self.arg_vars.get("target") target = self.arg_vars.get("target")
@@ -947,6 +963,23 @@ class CopienatorApp(tk.Tk):
self._update_command_preview() self._update_command_preview()
self._update_controls() self._update_controls()
def _refresh_persp_inventory(self) -> None:
if not self.current_step or self.current_step.id != 'statement_persp':
return
target = self.arg_vars.get('target')
value = str(target.get()).strip() if target else ''
root = (self.repository / value).resolve() if value else self.evaluation
try:
labels = EvaluationWorkspace(root).read_labels() if root else []
present = present_rubric_labels(root, labels) if root else []
missing = [label for label in labels if label not in set(present)]
text = (f'Barèmes présents : {len(present)}/{len(labels)} — Manquants : {len(missing)}\n'
+ 'Présents : ' + (', '.join(present) or 'aucun') + '\n'
+ 'Manquants : ' + (', '.join(missing) or 'aucun'))
except (OSError, ValueError) as error:
text = f'Inventaire Persp indisponible : {error}'
self.persp_inventory_label.configure(text=text)
def _render_context_controls(self, step: StepDefinition, row: int) -> int: def _render_context_controls(self, step: StepDefinition, row: int) -> int:
if step.id == "annotation" and self.variant_var.get() == "grouped": if step.id == "annotation" and self.variant_var.get() == "grouped":
controls = ttk.Frame(self.form) controls = ttk.Frame(self.form)
@@ -1589,6 +1622,12 @@ class CopienatorApp(tk.Tk):
self._append_console(f"\n$ {command_display(command)}\n") self._append_console(f"\n$ {command_display(command)}\n")
try: try:
self.runner.start(command, self.repository, environment, log_path) self.runner.start(command, self.repository, environment, log_path)
if step.id == 'statement_persp':
self.arg_vars['delete_persp'].set(False)
self._save_current_form()
if step.id == 'statement' and 'restart' in self.arg_vars:
self.arg_vars['restart'].set(False)
self._save_current_form()
if step.id == "correction" and variant.id in {"batch", "hybrid"}: if step.id == "correction" and variant.id in {"batch", "hybrid"}:
self.state_store.update_step("correction", batch_started=True) self.state_store.update_step("correction", batch_started=True)
except (OSError, RuntimeError) as exc: except (OSError, RuntimeError) as exc:
@@ -1922,6 +1961,8 @@ class CopienatorApp(tk.Tk):
self.active_step_id = None self.active_step_id = None
self._populate_tree() self._populate_tree()
self._update_controls() self._update_controls()
if step_id == 'statement_persp':
self._refresh_persp_inventory()
if step_id == "statement" and self._latex_warning_paths: if step_id == "statement" and self._latex_warning_paths:
self.after_idle(self._show_current_latex_warnings) self.after_idle(self._show_current_latex_warnings)
if step_id == "personal_read": if step_id == "personal_read":
+11 -5
View File
@@ -152,10 +152,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
arg_target("le dossier de l’évaluation"), arg_target("le dossier de l’évaluation"),
ArgumentSpec( ArgumentSpec(
"restart", "restart",
"Ignorer le cache (--restart)", "Recommencer l’analyse (--restart)",
kind="bool", kind="bool",
flag="--restart", flag="--restart",
help="Ignore les résultats Gemini mis en cache et recommence entièrement l’analyse de l’énoncé.", help="Recommence entièrement l’analyse. Archive les checkpoints, les modifications des groupes et les sorties précédentes. Sans cette option, reprend le travail incomplet en conservant les résultats déjà enregistrés.",
variants=("gemini",), variants=("gemini",),
), ),
), ),
@@ -172,10 +172,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
optional=True, personal=True, requires=("labels", "Text2", "Sol2"), optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
), ),
StepDefinition( StepDefinition(
"statement_persp", "Prétraitement de l’énoncé", "Générer les barèmes par Gemini", "statement_persp", "Prétraitement de l’énoncé", "Générer les barèmes manquants avec Gemini",
"Facultatif. Inutile après l'analyse de l'énoncé par Gemini, puisque les barèmes ont déjà été générés. ", "Génère uniquement les barèmes manquants dans Persp et conserve ceux déjà présents. "
"Les fichiers vides ou contenant seulement le label sont considérés comme manquants. "
"Après un échec, relancez cette étape pour compléter les barèmes. "
"Cochez Delete current Persp pour supprimer les barèmes actuels au lancement et tous les régénérer.",
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),), (python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
arguments=(arg_target("le dossier de l’évaluation"),), arguments=(arg_target("le dossier de l’évaluation"),
ArgumentSpec('delete_persp', 'Delete current Persp', kind='bool',
flag='--delete-persp', default=False,
help='Supprime tous les fichiers actuels de Persp au lancement, y compris les barèmes modifiés manuellement. Puis régénère tous les barèmes.')),
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"), optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
), ),
StepDefinition( StepDefinition(
+421
View File
@@ -0,0 +1,421 @@
# Copienator — guide de l’interface graphique
Ce guide suit les intitulés et l’ordre des étapes de l’interface. Un **label** est l’identifiant d’une question ; une **copie** correspond au travail d’un élève.
## Prendre en main l’interface
- Choisissez le dossier **Évaluation** avec **Parcourir…**, puis **Charger**.
- Suivez la liste de gauche. Avant **Exécuter**, vérifiez le **Mode**, la **Cible** et les prérequis signalés. La cible est normalement toute l’évaluation ; choisir un fichier permet, lorsque proposé, de ne traiter qu’une copie. Les aides **ⓘ** précisent les options.
- N’activez une option d’écrasement que pour remplacer volontairement les résultats précédents.
## Préparation et énoncé
### Vérifier les fichiers d’entrée
**Effet :** vérifie la présence des documents nécessaires à l’évaluation.
Placez les scans des copies, `enonce.pdf`, `enonce.tex`, `correction.tex` et la liste d’élèves `names` dans le dossier d’évaluation (`names` peut aussi être à la racine du projet).
Pour un dossier nommé `A`, les noms `A.pdf`, `A.tex`, `Ac.tex` sont également acceptés. Vérifiez que qu’aucun PDF étranger ne sera pris pour une copie.
Après un ajout, cliquez sur **Recharger — vérifier à nouveau**.
### Analyser l’énoncé
**Effet :** extrait les labels des questions, les contenus des questions, contextes et solutions de vos fichiers, puis prépare des regroupement initiaux de questions, ainsi que des barèmes.
Choisissez **Analyse avec Gemini**.
Éventuellement traitez les alertes **Problèmes LaTeX…** avant de poursuivre.
**Ignorer le cache** recommence l’analyse Gemini : à réserver à une
analyse devenue incorrecte ou obsolète.
### Relire les barèmes — facultatif
**Effet :** permet de vérifier et d’ajuster les instructions qui guideront la correction.
Ouvrez les barèmes dans `Persp`. Vérifiez les réponses acceptables, les raisonnements alternatifs et les exigences de justification ; corrigez les consignes trop sévères, ambiguës ou erronées. Enregistrez vos modifications avant de marquer l’étape terminée.
## Prétraitement des copies
### Retourner toutes les copies — facultatif
**Effet :** applique une rotation de 180° à tous les scans de copies du dossier.
Exécutez seulement si toutes les copies sont à l’envers.
### Renommer les PDF en CopieXX.pdf
**Effet :** attribue un numéro aux copies selon l’ordre de leurs noms de fichiers.
Vérifiez qu’un PDF correspond à une copie et que le dossier ne contient pas de documents parasites. Exécutez puis contrôlez le nombre de copies.
### Séparer et réordonner les pages
**Effet :** Découpe les scans A3 en pages A4 correctement ordonnées.
Examinez chaque page :
- déplacer la coupure si jamais l'élève a dépassé d'une moitié de sa
copie A3 sur l'autre.
- Utiliser les raccourcis donnés pour retirer les pages vides.
Suivez les raccourcis affichés dans la fenêtre, qui peuvent varier
selon votre profil. Par défaut :
| Action | Touche |
|---------------------------------------------------------------------|-----------------|
| Déplacer la séparation | ← / → |
| Garder les deux moitiés et avancer | `s` |
| Garder seulement la gauche / la droite | `q` / `d` |
| Garder la page entière, pour une page déjà A4 | `f` |
| Écarter une page réellement vide | `z` |
| Retourner la page / toutes les pages du fichier / tous les fichiers | `r` / `R` / `,` |
| Envoyer la page en fin / inverser l’ordre des pages | `a` / `i` |
| Recommencer le fichier / revenir au fichier précédent | `T` / `P` |
| Ouvrir l’outil de réorganisation, s’il est disponible | `A` |
**Copies signalées uniquement** limite le traitement aux copies
précédemment signalées (par une étape suivante).
### Rogner les zones vides — facultatif
**Effet :** retire les marges vides en haut et en bas des pages, avec
sauvegarde des versions précédentes.
Ce n'est pas particulièrement utile, et il pourrait y avoir des faux positifs.
### Découper une partie à gauche pour détection des labels
**Effet :** Prépare des images qui seront utilisées pour repérés les numéros de questions.
Éventuellement, cocher `Toujours utiliser la page entière`. Pour rendre
cette étape non interactive, cocher aussi `et ne pas vérifier visuellement`.
Ce mode est plus cher : sans avoir vérifié, Astra estime que le coût
passerait de 0,50€ à 2€.
Sans quoi, pour chaque copie, il faut cadrer la bande pour qu'elle
contienne bien tous les labels :
**←/→** la déplacent, **↑/↓** l’élargissent ou la réduisent ; **Entrée** valide.
Si les labels sont dispersés, utilisez **1** pour utiliser les pages entières pour cette copie.
Cette vérification visuelle permet toutefois de repérer des copies à
l'envers, ou dont les pages sont dans le mauvais ordre. Utiliser **!**
pour signaler une copie problématique.
Vous pourrez alors relancer l'étape de réordonnement des copies avec
les copies signalées.
## Labels et regroupement
### Détecter les labels avec Gemini
**Effet :** reconnaît les identifiants de questions et leur position dans les copies.
### Vérifier visuellement les labels
**Effet :** valide ou corrige les repères qui serviront à découper les réponses.
Vérifiez visuellement les labels.
- Quand un label est manquant, le suivant est affiché en jaune.
- Quand un label n'est pas dans le bon ordre, il est affiché en rouge.
Pour faire des modifications : **e** ouvre le fichier .json à modifier à la main
- Vous pouvez retirer un label, ou le modifier.
- Pour ajouter un label manqué :
- cliquer sur l'image là où est le label, ce qui va copie les coordonnées correspondantes
- puis les coller dans le fichier .json, et renseignez le label.
**Entrée** pour valider, **i** pour ouvrir l'énoncer, **o** pour
ouvrir la copie d'origine, **p** pour revenir en arrière.
### Découper les réponses par question
**Effet :** crée un document de réponse pour chaque question repérée
dans chaque copie.
### Rogner le bas des réponses — facultatif
**Effet :** enlève les grands espaces vides sous les réponses découpées, avec sauvegarde des originaux.
### Regrouper les réponses
**Effet :** regroupe les réponses par label pour préparer la correction.
## Correction
### Lancer ou intégrer la correction
**Effet :** obtient les corrections automatiques ou intègre des résultats préparés en différé.
Il faut choisir un des modes suivants.
Initialement, vous voulez
- soit le mode **Correction immédiate**, qui
va faire les requêtes de corrections en live, ce qui peut prendre de
l'ordre de 30min ou 1h. (Si elle est interrompue, il suffit de la
relancer pour qu'elle reprenne d'où elle en était).
- soit le mode **Préparer toutes les requêtes batch** : au lieu de
faire des requêtes en live, les requêtes sont batchées et toutes
envoyée à Gemini, qui les traitera en théorie au pire sous 24h, en
pratique dans l'heure. L'avantage est que le coût est divisé par
deux.
Le mode **Batch à partir d'un label** est un mode hybride inutile, où
certaines requêtes sont faites en live, et d'autres sont batch.
| Mode | À faire et à vérifier |
|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| **Correction immédiate** | Lancez et surveillez le nombre de groupes traités. |
| **Préparer toutes les requêtes batch** | Préparez les demandes, puis passez à **Envoyer les batchs** : la préparation seule ne les envoie pas. |
| **Batch à partir d’un label** | Indiquez le premier label différé ; les précédents sont corrigés immédiatement. Suivez ensuite le circuit batch. |
| **Recorrection depuis refaire.json** | Relancez les questions sélectionnées, notamment après résolution des conflits. Vérifiez la sélection avant de lancer. |
| **Intégrer les résultats batch** | Utilisez après leur récupération ; attendez la fin de l’intégration avant de générer les annotations. |
| **Réinitialiser les corrections** | Réservez ce mode à un recommencement volontaire ; lisez la confirmation et sauvegardez les corrections utiles auparavant. |
Laissez **Écraser les corrections existantes** décoché pour reprendre
sans tout recalculer. Une **Limite d’appels Pro** peut limiter le
travail de cette exécution : vérifiez ce qui reste à traiter.
**Interrompre** bloque les nouveaux appels, attend ceux en cours et
sauvegarde leurs résultats ; relancez pour terminer les validations
restantes. Examinez les erreurs et les conflits proposés, puis relisez
les résultats sur les copies annotées.
### Envoyer les batchs — si correction différée
**Effet :** transmet à Gemini les demandes de correction préparées.
Exécutez après la préparation batch et vérifiez que les envois
aboutissent. En cas d’envoi partiel, reprenez cette étape et consultez
l’état des jobs avant de poursuivre.
### Consulter l’état des batchs — si correction différée
**Effet :** vérifie si tous les traitements différés ont réussi et sont récupérables.
Cliquez sur **Exécuter**, ou sur **Vérifier toutes les 5 minutes** en
gardant Copienator ouvert. Attendez que tous les résultats soient
prêts ; une notification l’indique. Un job en attente ou en échec ne
vaut pas une correction terminée : lisez son état au lieu de forcer le
passage à la suite.
### Récupérer les résultats batch — si correction différée
**Effet :** télécharge et rassemble les réponses des traitements terminés.
Exécutez lorsque tous les résultats sont disponibles. Vérifiez
l’absence de récupération manquante ; l’interface revient ensuite à
**Lancer ou intégrer la correction**, en mode **Intégrer les résultats
batch**. Exécutez cette intégration.
### Résoudre les conflits manuels — si des conflits sont signalés
**Effet :** corrige les attributions ambiguës de réponses avant leur recorrection.
Lors de la correction, certains labels seront inévitablement érronés
(l'étudiant a mis le mauvais label, ou n'a pas mis de label, ou à
traiter une question à droite d'une autre).
L'étape de correction détecte ces problèmes (enfin, certains…), et
essaie de les corriger automatiquement. Les cas non automatiques
(«conflits») sont ceux où elle détecte un contenu correspondant à un
label `B`, alors qu'il y a déjà une image pour le label `B`
Cette étape permet de résoudre manuellement les conflits : on vous
donne une image initiale, avec un label d'origine `A` dont le contenu
est bizarre et correspond, éventuellement partiellement, à un label
`B` cible, qui a déjà une image.
On vous demande de choisir ce qu'il doit advenir de l'image d'origine.
Ouvrez les PDF source et cible proposés et comparez-les à l’énoncé.
Cliquez sur **Ouvrir dans un éditeur de texte** pour ajuster les
instructions. Leur forme est `Copie01 Label source OP Label cible|`.
Choisissez l’action d’après le contenu réel :
| Opérateur | Résultat |
|-------------|---------------------------------------------------------------------------------|
| `->` / `x>` | Ajouter la source à la cible ; conserver / archiver la source pour le label `A` |
| `-x` / `xx` | Remplacer la cible par la source ; conserver / archiver la source. |
| `ss` | Garder les deux réponses séparées (= ne rien faire) |
| `sx` / `xs` | Garder la source / la cible et archiver l’autre. |
Pour une fusion, `Cible|` met la cible avant la source, `|Cible` fait l’inverse.
Une autre opération est **Cut** : dans les cas où vous voulez découper
l'image d'origine en deux. Utiliser le bouton **Cut** pour choisir la
coupure, et quelle moitié doit être gardée. L'interface graphique vous
fournira alors une instruction texte à copier et à mettre dans le
fichier texte.
Enregistrez le fichier texte, éventuellement le **Recharger**, puis
**Exécuter**.
Retirer une instruction signifie accepter les PDF sans changement ;
exécutez même si vous avez retiré toutes les lignes.
Après succès, l’interface revient à l'étape de correction (avec le
mode **recorrection à partir de refaire.json** ) : lancez-la.
### Nettoyer la correction
**Effet :** Essaye de réparer des problèmes d'encodages courant dans les réponses de Gemini.
## Annotations et tablette
### Générer les copies annotées
**Effet :** Génère des versions des copies, annotées avec la correction Gemini.
Choisir le mode :
- **Annotations simples (Anot)** Annote chaque copie en des documents qu
- **Annotations avec cases (Bnot)** Annote chaque copie, avec des
cases qui vous permettrons, en les cochant, de supprimer certains
commentaires, ou de modifier la note
- **Annotations groupées (BGnot)** Génère des documents pdf, où les
réponses annotées sont regroupées par label (ou plutôt par groupes
de label).
Les groupes de label sont ceux qui ont été générés dans l'analyse
de l'énoncé. Il est possible de les modifier à la main ici.
**Écraser les sorties** remplace les documents existants. **Mode
refaire** ne concerne qu’une reprise sélectionnée ; utilisez de
préférence le parcours dédié décrit plus bas.
### Exporter — facultatif
**Effet :** place les documents générés dans le dossier de transfert configuré.
Choisissez le même **Dossier d’annotations** qu’à la génération.
### Annoter les PDF sur la tablette
**Effet :** recueille votre validation pédagogique, vos changements de scores et vos annotations manuscrites.
Relisez chaque réponse avec sa correction. Dans les modes avec cases, cochez une seule note par question pour la remplacer ; laissez les notes correctes inchangées.
Une case près d’un commentaire le supprime, celle sur un rectangle
supprime ce rectangle ; **clr** supprime tous les commentaires et
rectangles de la question. Ajoutez vos propres remarques sans écrire
accidentellement dans les cases. Gardez les dimensions et l’ordre des
pages pour permettre la lecture du retour.
Avec le circuit d’export/import, renvoyez les fichiers sous **leur nom
exporté** (groupe ou `CopieXX.pdf`), dans le dossier d’import prévu.
### Importer les annotations manuscrites
**Effet :** rattache les fichiers modifiés manuellement aux documents de l’évaluation.
Vérifiez l’évaluation, le dossier d’import et le **Dossier
d’annotations**, puis exécutez. Écartez les anciens retours et ceux
d’autres évaluations pour éviter une mauvaise association.
## Finalisation
### Lire les annotations manuscrites
**Effet :** applique les cases cochées et les ajouts manuscrits pour reconstruire les copies et leurs scores.
Choisissez **Lecture Bnot** ou **Lecture BGnot** conformément à la
génération. Pour le parcours `Anot`, passez directement à
l’attribution des noms. Après lecture, ouvrez les résultats et
vérifiez notes modifiées, suppressions de commentaires et remarques
manuscrites.
Si les options `answers` sont présentes, choisissez les compléments
souhaités dans les réponses individuelles : contexte, énoncé,
correction.
En mode groupé, le PDF final peut omettre une réponse parfaite sans
commentaire conservé ni annotation manuscrite significative ; son
score reste enregistré. Le PDF peut même être absent si aucune réponse
ne reste à y inclure. Contrôlez donc aussi les scores et l’image
complète, sans assimiler automatiquement une omission à une perte de
réponse.
Le mode **Régénérer avec les nouveaux énoncés/corrigés et les
score.json** sert à mettre à jour les scores sur les images finales à
partir d'une modification manuelle des fichiers `score.json`.
### Attribuer les noms et préparer A Rendre
**Effet :** rassemble les résultats de chaque élève dans le dossier `A Rendre`.
Choisissez le dossier d’annotations utilisé et exécutez. Dans
**Vérification des noms**, traitez tous les noms `Unknown` et les
doublons : **Ouvrir l’originale**, saisir le nom, puis **Renommer**.
**Mettre à jour uniquement les images de answers** ne rafraîchit que
les réponses individuelles des élèves existants.
## Refaire une partie de l’évaluation
Utilisez ce parcours après avoir terminé le passage principal. Conservez ses annotations : elles servent à préserver les questions non sélectionnées. Ne démarrez **Nouvelle reprise** qu’après avoir importé et fusionné la reprise courante ; **Refaire la même sélection** permet de repartir avec les mêmes copies et questions.
### Choisir les copies et les questions
**Effet :** enregistre les réponses à reprendre et leur mode de présentation.
Choisissez une copie et une question, puis **+ Ajouter** ; répétez selon les besoins. **Toute la copie** sélectionne toutes ses questions ; **Toutes les copies** applique le choix à la classe. Vérifiez le tableau, retirez les erreurs, choisissez le **Passage principal** correct et la présentation **PDF à vérifier**, puis **Enregistrer la sélection**.
### Reprendre le découpage — facultatif
**Effet :** permet de corriger les repères des copies sélectionnées.
Reprenez les contrôles de la vérification visuelle des labels. Validez les images modifiées ; chaque copie sélectionnée s’ouvre à son tour, et fermer sa fenêtre passe à la suivante. Ignorez si les découpages sont déjà corrects.
### Redécouper les réponses — facultatif
**Effet :** reconstruit les réponses des copies sélectionnées à partir des repères corrigés.
Exécutez après toute modification du découpage. Ouvrez les réponses concernées et vérifiez leurs limites ; en présence de résolutions manuelles, contrôlez particulièrement les versions `_new` et `_old`. Ignorez si le découpage n’a pas changé.
### Refaire la correction — facultatif
**Effet :** relance la correction automatique des seules questions sélectionnées.
Vérifiez la sélection puis exécutez. Contrôlez les erreurs et les résultats proposés. Ignorez si vous souhaitez uniquement modifier les notes ou commentaires à la main lors de la relecture.
### Préparer les copies à vérifier
**Effet :** génère dans `BRnot` les documents avec cases du passage de reprise.
Exécutez puis vérifiez que seules les réponses voulues sont présentes, avec la présentation choisie. Cette génération remplace les documents de reprise concernés : ne la relancez pas sur une relecture manuscrite à conserver.
### Exporter vers la tablette — facultatif
**Effet :** transfère les documents du passage de reprise vers le dossier d’export.
Écartez les anciens exports, exécutez et contrôlez les documents transférés. Conservez leur nom complet, y compris le préfixe de reprise s’il est présent : il permet d’associer les retours au bon passage. Ignorez si vous relisez directement les fichiers locaux.
### Vérifier sur la tablette
**Effet :** recueille la validation et les modifications des réponses reprises.
Appliquez les mêmes règles de cases et d’écriture que pour le premier passage. Retournez tous les documents, même sans changement, sous leur nom exporté dans le dossier d’import ; retirez les anciens retours. En travail local, placez les `Concat_annotated.pdf` dans les sous-dossiers de cette reprise.
### Importer les copies vérifiées — facultatif
**Effet :** récupère les annotations du passage de reprise courant.
Exécutez et vérifiez que tous les retours attendus sont acceptés. Les fichiers portant le préfixe d’une autre reprise sont ignorés. Ignorez cette étape seulement si les `Concat_annotated.pdf` sont déjà au bon endroit.
### Mettre à jour les copies finales
**Effet :** fusionne les réponses refaites avec les autres réponses conservées du passage principal.
Vérifiez le **Passage principal**, puis exécutez. Contrôlez une question refaite et une question conservée pour chaque copie concernée ; résolvez tout retour manquant avant de poursuivre. Relancez ensuite **Attribuer les noms et préparer A Rendre** et vérifiez les livrables mis à jour avant de les remettre aux élèves.
## Archivage
### Nettoyer les fichiers intermédiaires — facultatif
**Effet :** supprime définitivement les fichiers de travail et la progression de l’interface en conservant les livrables prévus.
Attendez la fin des relectures et reprises. Vérifiez les résultats de tous les élèves dans `A Rendre` et sauvegardez l’évaluation si vous voulez pouvoir reprendre le travail. Choisissez d’abord **Prévisualiser sans supprimer**, puis examinez la liste. Ne lancez **Nettoyage définitif** qu’une fois certain de ne plus avoir besoin des intermédiaires : les PDF traités, textes de l’énoncé, corrections, journaux et livrables de `A Rendre` sont conservés, mais une reprise exigera de régénérer les données supprimées.
+55
View File
@@ -0,0 +1,55 @@
# Reprendre la génération Gemini de l’énoncé
Relancez la même commande après une erreur ou une interruption :
```sh
python -m copienator statement Interro05
```
Sans `--restart`, les sources sont supposées inchangées. Les questions,
solutions et contextes validés sont conservés dans `Cache/statement_*.json`.
Une réponse lisible contenant un élément invalide conserve ses autres éléments
valides. Les solutions et contextes incomplets sont redemandés pour leurs labels
manquants. Une réponse JSON illisible reste archivée, mais ses éléments ne sont
pas récupérés automatiquement.
Les questions sont redemandées sous forme d’une liste complète et ordonnée :
avant leur extraction, aucun inventaire indépendant de labels n’est disponible.
Les labels déjà observés doivent rester présents. Vérifiez la liste contre
`enonce.pdf` dans l’éditeur de groupes avant de l’approuver, notamment pour
repérer une question que Gemini aurait entièrement omise.
Les modifications de `Tmp/exam_items.txt` survivent à une interruption de
l’éditeur. Après approbation, les groupes sont enregistrés dans
`Cache/statement_grouping.json` ; une reprise utilise ce checkpoint sans rouvrir
l’éditeur. Les fichiers générés déjà présents sont conservés, les fichiers
manquants sont complétés, et seuls les PDF manquants, échoués ou plus anciens
que leur source générée sont recompilés. Les contextes ne sont pas ajoutés une
seconde fois lors d’une reprise.
Les barèmes `Persp` valides sont conservés, y compris ceux des autres questions
d’une réponse dont un barème est invalide. Les labels et groupes sont écrits
avant la génération des barèmes et la compilation, ce qui permet aussi une
reprise par `statement Interro05 --persp-only`.
Toutes les réponses d’extraction sont archivées avant décodage dans
`Cache/statement_responses/`. Les erreurs, avec le texte échappé et le chemin
de la réponse, sont affichées et ajoutées à `Cache/statement_errors.log`.
Les barèmes utilisent `Cache/rubric_responses/` et `Cache/rubric_errors.log`.
Les anciens caches `gemini_questions.json`, `gemini_solutions.json` et
`gemini_context.json` sont importés une fois : une réponse invalide n’est pas
rechargée indéfiniment.
Pour recommencer explicitement depuis les sources, notamment après les avoir
modifiées :
```sh
python -m copienator statement Interro05 --restart
```
Les checkpoints et modifications des groupes précédents sont déplacés dans
`Cache/statement_history/`. Les anciennes sorties y sont également déplacées
quand la nouvelle extraction et les groupes sont prêts, avant leur remplacement.
Une erreur d’extraction conserve donc les anciennes sorties. Les réponses brutes
restent disponibles. Si ce nouveau départ échoue, relancez sans `--restart`
pour reprendre sa progression.
+123 -7
View File
@@ -74,7 +74,8 @@ class SelectiveGeminiTests(unittest.TestCase):
(self.root / folder).mkdir() (self.root / folder).mkdir()
for label in self.labels: for label in self.labels:
suffix = ".tex" if folder.endswith("2") else "" suffix = ".tex" if folder.endswith("2") else ""
(self.root / folder / (label + suffix)).write_text(f"Personal {folder}: {label}") (self.root / folder / (label + suffix)).write_text(
label + '\n' if folder == 'Persp' else f"Personal {folder}: {label}")
(self.root / "Persp" / "Ex 1").write_text("Old aggregate rubric") (self.root / "Persp" / "Ex 1").write_text("Old aggregate rubric")
self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock())) self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock()))
@@ -113,8 +114,11 @@ class SelectiveGeminiTests(unittest.TestCase):
self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS) self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS)
after = self.snapshot() after = self.snapshot()
self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")}, 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/")}) {key: value for key, value in after.items()
self.assertFalse((self.root / "Persp" / "Ex 1").exists()) if not key.startswith(("Persp/", "Cache/"))})
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 2)
self.assertEqual((self.root / "Persp" / "Ex 1").read_bytes(), before['Persp/Ex 1'])
for label in self.labels: for label in self.labels:
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text()) self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
for call in self.client.models.generate_content.call_args_list: for call in self.client.models.generate_content.call_args_list:
@@ -126,13 +130,22 @@ class SelectiveGeminiTests(unittest.TestCase):
self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4) self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4)
self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4) self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4)
def test_incomplete_or_failed_rubrics_preserve_entire_persp(self): def test_incomplete_or_failed_rubrics_keep_completed_groups(self):
before = self.snapshot() before = self.snapshot()
for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")): for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")):
for label in self.labels[:2]:
(self.root / 'Persp' / label).write_bytes(before[f'Persp/{label}'])
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last] self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last]
with self.assertRaises((CliError, RuntimeError)): with self.assertRaises((CliError, RuntimeError)):
gemini.refine_existing(self.workspace, "persp", api_client=self.client) gemini.refine_existing(self.workspace, "persp", api_client=self.client)
self.assertEqual(self.snapshot(), before) after = self.snapshot()
for label in self.labels[:2]:
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
self.assertEqual(after['Persp/Ex 1'], before['Persp/Ex 1'])
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/', 'Cache/'))})
def test_rubric_latex_and_line_breaks_survive_json_and_file_writes(self): def test_rubric_latex_and_line_breaks_survive_json_and_file_writes(self):
expected = ( expected = (
@@ -161,15 +174,96 @@ class SelectiveGeminiTests(unittest.TestCase):
{'Ex 2': '$' + command + '$'}) {'Ex 2': '$' + command + '$'})
self.assertEqual(self.client.models.generate_content.call_count, 1) self.assertEqual(self.client.models.generate_content.call_count, 1)
def test_unrepairable_control_character_preserves_all_existing_rubrics(self): def test_control_character_keeps_progress_and_saves_diagnostics(self):
before = self.snapshot() before = self.snapshot()
broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]}) broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]})
self.client.models.generate_content.side_effect = [ self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), broken] self.rubric_response(self.labels[:2]), broken]
with self.assertRaisesRegex(ValueError, 'control characters'): output = io.StringIO()
with patch('sys.stdout', output), self.assertRaisesRegex(ValueError, 'control characters'):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client) gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
after = self.snapshot()
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
for label in self.labels[:2]:
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
self.assertIn('Question Ex 2', output.getvalue())
self.assertIn('U+0001 at offset 8', output.getvalue())
self.assertIn(r'invalid \x01 control', output.getvalue())
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 2)
self.assertIn(broken.text, [path.read_text() for path in responses])
log = (self.root / 'Cache' / 'rubric_errors.log').read_text()
self.assertIn('Question Ex 2', log)
self.assertIn(r'invalid \x01 control', log)
def test_invalid_json_is_saved_exactly_and_reported(self):
raw = '{"rubrics": [this is malformed JSON'
self.client.models.generate_content.return_value = SimpleNamespace(text=raw)
output = io.StringIO()
with patch('sys.stdout', output), self.assertRaises(ValueError):
gemini.generate_rubrics(self.client, 'context', workspace=self.workspace,
expected_labels=['Ex 2'])
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0].read_text(), raw)
self.assertIn(raw, output.getvalue())
self.assertIn(str(responses[0]), output.getvalue())
def test_missing_question_only_preserves_existing_and_keeps_group_context(self):
kept = self.root / 'Persp' / self.labels[0]
kept.write_text(self.labels[0] + '\nManually reviewed rubric')
before = kept.read_bytes()
(self.root / 'Persp' / self.labels[1]).write_text('')
(self.root / 'Persp' / self.labels[2]).unlink()
self.client.models.generate_content.side_effect = [
self.rubric_response([self.labels[1]]), self.rubric_response([self.labels[2]])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(kept.read_bytes(), before)
call = self.client.models.generate_content.call_args_list[0]
text = call.kwargs['contents'][0].parts[1].text
self.assertIn('UNIQUEMENT pour les labels suivants : ' + self.labels[1] + '\n', text)
self.assertIn('Question [' + self.labels[0] + ']', text)
self.assertEqual(self.client.models.generate_content.call_count, 2)
def test_retry_requests_only_failed_group(self):
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), RuntimeError('API unavailable')]
with self.assertRaises(RuntimeError):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
before = {label: (self.root / 'Persp' / label).read_bytes() for label in self.labels[:2]}
self.client.models.generate_content.reset_mock()
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[2:])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(self.client.models.generate_content.call_count, 1)
for label, content in before.items():
self.assertEqual((self.root / 'Persp' / label).read_bytes(), content)
def test_complete_persp_needs_no_api_key_or_requests(self):
for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
before = self.snapshot()
with patch.object(gemini, 'api_key', ''), patch.object(gemini.genai, 'Client') as client:
self.assertEqual(gemini.refine_existing(self.workspace, 'persp'), ExitCode.SUCCESS)
client.assert_not_called()
self.assertEqual(self.snapshot(), before) self.assertEqual(self.snapshot(), before)
def test_delete_persp_forces_regeneration_and_removes_old_files(self):
for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), self.rubric_response(self.labels[2:])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client, delete_persp=True)
self.assertEqual(self.client.models.generate_content.call_count, 2)
self.assertEqual({path.name for path in (self.root / 'Persp').iterdir()}, set(self.labels))
def test_cli_delete_persp_dispatch(self):
with patch.object(gemini, 'refine_existing', return_value=ExitCode.SUCCESS) as refine:
self.assertEqual(gemini.main([str(self.root), '--persp-only', '--delete-persp']), ExitCode.SUCCESS)
self.assertEqual(refine.call_args.kwargs, {'delete_persp': True})
refine.reset_mock()
self.assertEqual(gemini.main([str(self.root), '--delete-persp']), ExitCode.INVALID_ARGUMENTS)
refine.assert_not_called()
def test_cli_dispatches_selective_modes_without_full_extraction(self): def test_cli_dispatches_selective_modes_without_full_extraction(self):
with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object( with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object(
gemini, "process_exam" gemini, "process_exam"
@@ -251,6 +345,28 @@ class PersonalStatementGuiTests(unittest.TestCase):
self.assertIn(flag, app.command_var.get()) self.assertIn(flag, app.command_var.get())
self.assertTrue(app.current_step.optional) self.assertTrue(app.current_step.optional)
self.assertFalse(app.runner.running) self.assertFalse(app.runner.running)
(evaluation / 'labels').write_text('Ex 1\nEx 2\n')
(evaluation / 'Persp').mkdir()
(evaluation / 'Persp' / 'Ex 1').write_text('Ex 1\nReviewed rubric')
(evaluation / 'Persp' / 'Ex 2').write_text('Ex 2\n')
app._refresh_persp_inventory()
self.assertIn('Barèmes présents : 1/2', app.persp_inventory_label.cget('text'))
self.assertIn('Manquants : Ex 2', app.persp_inventory_label.cget('text'))
self.assertFalse(app.arg_vars['delete_persp'].get())
app.arg_vars['delete_persp'].set(True)
self.assertIn('--delete-persp', app.command_var.get())
app._save_current_form()
app._render_step()
self.assertFalse(app.arg_vars['delete_persp'].get())
self.assertNotIn('--delete-persp', app.command_var.get())
app.tree.selection_set('statement')
app.update()
app._select_variant(1)
app.arg_vars['restart'].set(True)
app._save_current_form()
app._render_step()
self.assertFalse(app.arg_vars['restart'].get())
self.assertNotIn('--restart', app.command_var.get())
finally: finally:
for callback in app.tk.splitlist(app.tk.call("after", "info")): for callback in app.tk.splitlist(app.tk.call("after", "info")):
app.after_cancel(callback) app.after_cancel(callback)
+259
View File
@@ -0,0 +1,259 @@
import io
import json
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 gemini_for_enonce as gemini
from copienator.statement_checkpoints import extract_stage
def response(**data):
return SimpleNamespace(text=json.dumps(data))
class ExtractionCheckpointTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
def solutions(self, request):
return extract_stage(self.root, 'solutions', gemini.SolutionOnlyItem,
'solution_content', request, labels=['1)', '2)'])
def test_valid_sibling_survives_and_retry_only_requests_missing(self):
request = Mock(return_value=response(solutions=[
{'label': '1)', 'solution_content': 'good'},
{'label': '2)', 'solution_content': 'bad\x01'}]))
output = io.StringIO()
with patch('sys.stdout', output), self.assertRaises(CliError):
self.solutions(request)
saved = json.loads((self.root / 'Cache/statement_solutions.json').read_text())
self.assertEqual(saved['solutions'], [{'label': '1)', 'solution_content': 'good'}])
self.assertIn(r'bad\x01', output.getvalue())
self.assertIn('U+0001', (self.root / 'Cache/statement_errors.log').read_text())
request = Mock(return_value=response(solutions=[{'label': '2)', 'solution_content': 'fixed'}]))
items = self.solutions(request)
request.assert_called_once_with(['2)'])
self.assertEqual([item.solution_content for item in items], ['good', 'fixed'])
request.reset_mock()
self.solutions(request)
request.assert_not_called()
def test_malformed_json_and_api_failure_preserve_accepted_items(self):
request = Mock(return_value=response(solutions=[{'label': '1)', 'solution_content': 'good'}]))
with self.assertRaises(CliError):
self.solutions(request)
before = (self.root / 'Cache/statement_solutions.json').read_bytes()
raw = '{"solutions": [broken'
request.return_value = SimpleNamespace(text=raw)
with self.assertRaises(CliError):
self.solutions(request)
self.assertEqual((self.root / 'Cache/statement_solutions.json').read_bytes(), before)
self.assertIn(raw, [p.read_text() for p in (self.root / 'Cache/statement_responses').iterdir()])
request.side_effect = RuntimeError('offline')
with self.assertRaises(CliError):
self.solutions(request)
self.assertEqual((self.root / 'Cache/statement_solutions.json').read_bytes(), before)
def test_bad_legacy_cache_is_archived_then_not_replayed(self):
(self.root / 'Cache').mkdir()
legacy = self.root / 'Cache/gemini_solutions.json'
legacy.write_text('broken JSON')
request = Mock(return_value=response(solutions=[
{'label': label, 'solution_content': 'fixed'} for label in ['1)', '2)']]))
with self.assertRaises(CliError):
self.solutions(request)
request.assert_not_called()
self.solutions(request)
request.assert_called_once_with(['1)', '2)'])
self.assertEqual(legacy.read_text(), 'broken JSON')
def test_questions_retry_full_inventory_keep_valid_text_and_order(self):
request = Mock(return_value=response(questions=[
{'label': '1)', 'question_content': 'good'},
{'label': '2)', 'question_content': 'bad\x01'}]))
def run():
return extract_stage(self.root, 'questions', gemini.QuestionOnlyItem, 'question_content', request)
with self.assertRaises(CliError):
run()
request.return_value = response(questions=[{'label': '2)', 'question_content': 'fixed'}])
with self.assertRaises(CliError):
run() # Cannot silently lose the known first question.
request.return_value = response(questions=[{'label': '1)', 'question_content': 'good'}])
with self.assertRaises(CliError):
run() # Cannot silently omit the previously rejected question either.
request.return_value = response(questions=[
{'label': '1)', 'question_content': 'rewritten'},
{'label': '2)', 'question_content': 'fixed'}])
items = run()
self.assertEqual([item.question_content for item in items], ['good', 'fixed'])
self.assertEqual(request.call_args.args, (None,))
def test_contexts_retry_bad_target_and_allow_no_context(self):
request = Mock(return_value=response(contexts=[
{'target_question_label': '1)', 'last_question_label': '2)', 'context_content': 'good'},
{'target_question_label': '2)', 'last_question_label': '2)', 'context_content': 'bad\x01'}]))
def run():
return extract_stage(self.root, 'contexts', gemini.ExtractedContext, 'context_content',
request, labels=['1)', '2)'])
with self.assertRaises(CliError):
run()
request.return_value = response(contexts=[])
with self.assertRaises(CliError):
run()
request.return_value = response(contexts=[
{'target_question_label': '2)', 'last_question_label': '2)', 'context_content': 'fixed'}])
self.assertEqual(len(run()), 2)
self.assertEqual(request.call_args.args, (['2)'],))
with tempfile.TemporaryDirectory() as root:
self.assertEqual(extract_stage(Path(root), 'contexts', gemini.ExtractedContext,
'context_content', Mock(return_value=response(contexts=[])),
labels=['1)']), [])
def test_duplicate_and_unknown_solutions_are_not_silently_accepted(self):
request = Mock(return_value=response(solutions=[
{'label': '1)', 'solution_content': 'good'},
{'label': '1)', 'solution_content': 'duplicate'},
{'label': 'unknown', 'solution_content': 'bad'}]))
with self.assertRaises(CliError):
self.solutions(request)
saved = json.loads((self.root / 'Cache/statement_solutions.json').read_text())
self.assertFalse(saved['complete'])
self.assertEqual(saved['solutions'], [{'label': '1)', 'solution_content': 'good'}])
class FullStatementResumeTests(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.root / 'enonce.pdf').write_bytes(b'pdf')
(self.root / 'enonce.tex').write_text('source')
(self.root / 'correction.tex').write_text('correction')
self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock()))
self.editor = Mock()
self.compiler = Mock(side_effect=lambda content, path, **kwargs: Path(path).write_bytes(b'PDF'))
def run_exam(self, **kwargs):
with patch.object(gemini.utils, 'edit_file_and_enter', self.editor), patch.object(
gemini, 'compile_to_pdf', self.compiler):
return gemini.process_exam(self.workspace, api_client=self.client, **kwargs)
def responses(self):
return [response(questions=[{'label': 'Ex 1', 'question_content': 'question'}]),
response(solutions=[{'label': 'Ex 1', 'solution_content': 'answer'}]),
response(contexts=[]),
response(rubrics=[{'label': 'Ex 1', 'rubric_content': '4 points : correct'}])]
def test_rubric_failure_retry_keeps_grouping_edits_and_sources(self):
self.client.models.generate_content.side_effect = [*self.responses()[:3], RuntimeError('offline')]
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
self.assertTrue((self.root / 'Cache/statement_grouping.json').is_file())
self.assertEqual((self.root / 'labels').read_text(), 'Ex 1\n')
tex = self.root / 'Text2/Ex 1.tex'
tex.write_text('manually edited source')
edited_items = (self.root / 'Tmp/exam_items.txt').read_bytes()
self.client.models.generate_content.reset_mock()
self.client.models.generate_content.side_effect = [self.responses()[-1]]
self.editor.reset_mock()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.editor.assert_not_called()
self.assertEqual(self.client.models.generate_content.call_count, 1)
self.assertEqual(tex.read_text(), 'manually edited source')
self.assertEqual((self.root / 'Tmp/exam_items.txt').read_bytes(), edited_items)
def test_failed_pdf_only_is_compiled_on_retry(self):
self.client.models.generate_content.side_effect = self.responses()
def compile_pdf(content, path, **kwargs):
if Path(path).parent.name == 'Sol2':
Path(path).write_bytes(b'partial PDF from failed compilation')
raise RuntimeError('compiler failure')
Path(path).write_bytes(b'PDF')
self.compiler.side_effect = compile_pdf
self.assertEqual(self.run_exam(), ExitCode.PARTIAL)
good_pdf = (self.root / 'Text2/Ex 1.pdf').read_bytes()
self.compiler.reset_mock()
self.compiler.side_effect = lambda content, path, **kwargs: Path(path).write_bytes(b'PDF')
self.client.models.generate_content.reset_mock()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.client.models.generate_content.assert_not_called()
self.assertEqual(self.compiler.call_count, 1)
self.assertEqual(self.compiler.call_args.args[1], self.root / 'Sol2/Ex 1.pdf')
self.assertEqual((self.root / 'Text2/Ex 1.pdf').read_bytes(), good_pdf)
def test_interrupted_editor_keeps_edits_until_approval(self):
self.client.models.generate_content.side_effect = self.responses()[:3]
def interrupt(path):
Path(path).write_text('Custom label ### manually edited question\n')
raise KeyboardInterrupt()
self.editor.side_effect = interrupt
with self.assertRaises(KeyboardInterrupt):
self.run_exam()
self.editor.side_effect = None
self.client.models.generate_content.side_effect = [response(rubrics=[
{'label': 'Custom label', 'rubric_content': '4 points : correct'}])]
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.assertIn('manually edited question', (self.root / 'Text2/Custom label.tex').read_text())
def test_restart_archives_prior_work_and_reextracts(self):
self.client.models.generate_content.side_effect = self.responses()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
old = (self.root / 'Persp/Ex 1').read_bytes()
self.client.models.generate_content.side_effect = self.responses()
self.editor.reset_mock()
self.assertEqual(self.run_exam(restart=True), ExitCode.SUCCESS)
self.editor.assert_called_once()
archived = list((self.root / 'Cache/statement_history').glob('*/Persp/Ex 1'))
self.assertEqual(len(archived), 1)
self.assertEqual(archived[0].read_bytes(), old)
def test_failed_restart_then_resume_still_replaces_old_outputs(self):
self.client.models.generate_content.side_effect = self.responses()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.client.models.generate_content.side_effect = [SimpleNamespace(text='broken JSON')]
with self.assertRaises(CliError):
self.run_exam(restart=True)
self.assertTrue((self.root / 'Persp/Ex 1').exists())
self.client.models.generate_content.side_effect = self.responses()
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.assertTrue(list((self.root / 'Cache/statement_history').glob('*/Persp/Ex 1')))
def test_rubric_valid_sibling_saved_even_on_invalid_content(self):
self.client.models.generate_content.return_value = response(rubrics=[
{'label': 'Ex 1', 'rubric_content': 'good'},
{'label': 'Ex 2', 'rubric_content': 'bad\x01'}])
with self.assertRaises(ValueError):
gemini.generate_rubrics(self.client, 'context', workspace=self.workspace,
expected_labels=['Ex 1', 'Ex 2'])
self.assertEqual((self.root / 'Persp/Ex 1').read_text(), 'Ex 1\ngood')
self.assertFalse((self.root / 'Persp/Ex 2').exists())
def test_rubric_schema_error_also_preserves_valid_sibling(self):
self.client.models.generate_content.return_value = response(rubrics=[
{'label': 'Ex 1', 'rubric_content': 'good'}, {'label': 'Ex 2'}])
with self.assertRaises(ValueError):
gemini.generate_rubrics(self.client, 'context', workspace=self.workspace,
expected_labels=['Ex 1', 'Ex 2'])
self.assertEqual((self.root / 'Persp/Ex 1').read_text(), 'Ex 1\ngood')
def test_extraction_failure_leaves_existing_outputs_untouched(self):
(self.root / 'Text2').mkdir()
existing = self.root / 'Text2/reviewed.tex'
existing.write_text('manually reviewed question')
self.client.models.generate_content.side_effect = [self.responses()[0], response(solutions=[
{'label': 'Ex 1', 'solution_content': 'bad\x01'}])]
with self.assertRaises(CliError):
self.run_exam()
self.assertEqual(existing.read_text(), 'manually reviewed question')
self.assertTrue(json.loads((self.root / 'Cache/statement_questions.json').read_text())['complete'])
self.client.models.generate_content.reset_mock()
self.client.models.generate_content.side_effect = self.responses()[1:]
self.assertEqual(self.run_exam(), ExitCode.SUCCESS)
self.assertEqual(self.client.models.generate_content.call_count, 3)
self.assertEqual(existing.read_text(), 'manually reviewed question')