Guide d'utilisateur GUI ; Recoverable statement/Persp Gemini generation
This commit is contained in:
1 parent
2f8422f6d5
commit
94ca8b8256
9 files changed
+1333
-170
No files matched your search
@@ -1,29 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
utils,
|
||||
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.latex_json import repair_latex_json, normalize_literal_newlines
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.rubrics import present_rubric_labels
|
||||
from copienator.statement_checkpoints import extract_stage, report_error
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
# --- Modèle fusionné (pour le reste du script) ---
|
||||
@@ -224,8 +239,7 @@ def find_file(folder: Path, base_name: str) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
|
||||
"""Use the same rubric request for full and selective statement generation."""
|
||||
def _request_rubrics(client, group_context_text: str):
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
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(),
|
||||
),
|
||||
)
|
||||
rubrics = GroupRubrics.model_validate_json(repair_latex_json(response.text)).rubrics
|
||||
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}
|
||||
return response
|
||||
|
||||
|
||||
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:
|
||||
@@ -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")
|
||||
|
||||
|
||||
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
|
||||
"""Regroup or replace rubrics without regenerating statements or solutions."""
|
||||
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None,
|
||||
delete_persp: bool = False) -> ExitCode:
|
||||
"""Regroup questions or generate missing rubrics, retaining completed groups."""
|
||||
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)
|
||||
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 = {}
|
||||
for label in labels:
|
||||
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()
|
||||
if line.strip()]
|
||||
validate_groups(groups, labels)
|
||||
with staged_directory(workspace.root / "Persp") as staging:
|
||||
for group in groups:
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(group)}...")
|
||||
group_content = (
|
||||
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n"
|
||||
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
|
||||
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group)
|
||||
)
|
||||
rubrics = generate_rubrics(api_client, group_content)
|
||||
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()):
|
||||
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved")
|
||||
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:
|
||||
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 = (
|
||||
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n"
|
||||
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
|
||||
+ ", ".join(missing) + "\n\n" + "\n\n".join(questions[label] for label in group)
|
||||
)
|
||||
rubrics = generate_rubrics(
|
||||
api_client, group_content, workspace=workspace, expected_labels=missing,
|
||||
)
|
||||
with staged_files(workspace.root / "Persp") as staging:
|
||||
for label, rubric in rubrics.items():
|
||||
(staging / label.replace("/", "_")).write_text(
|
||||
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:
|
||||
raise ValueError(f"Unknown statement refinement: {mode}")
|
||||
return ExitCode.SUCCESS
|
||||
@@ -336,9 +431,15 @@ def process_exam(
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
|
||||
cache_q_file = cache_dir / "gemini_questions.json"
|
||||
cache_s_file = cache_dir / "gemini_solutions.json"
|
||||
cache_c_file = cache_dir / "gemini_context.json"
|
||||
if restart:
|
||||
archive = cache_dir / 'statement_history' / uuid4().hex
|
||||
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
|
||||
pdf_path = workspace.statement_pdf_file
|
||||
@@ -392,38 +493,16 @@ def process_exam(
|
||||
response_json_schema=ExamQuestions.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_q_file.is_file() and not restart:
|
||||
print("Loading cached questions from Cache/gemini_questions.json...")
|
||||
response_q_text = cache_q_file.read_text(encoding="utf-8")
|
||||
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))
|
||||
questions_data = ExamQuestions(questions=extract_stage(
|
||||
folder, 'questions', QuestionOnlyItem, 'question_content',
|
||||
lambda _pending: client.models.generate_content(model=MODEL_ID, contents=contents_1, config=config_1),
|
||||
))
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 2 : Extraction des Corrections
|
||||
# ==========================================
|
||||
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(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=0.1,
|
||||
@@ -431,21 +510,19 @@ def process_exam(
|
||||
response_json_schema=ExamSolutions.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_s_file.is_file() and not restart:
|
||||
print("Loading cached solutions from Cache/gemini_solutions.json...")
|
||||
response_s_text = cache_s_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 2 (Solutions) to Gemini...")
|
||||
response_s = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_2,
|
||||
config=config_2
|
||||
)
|
||||
response_s_text = response_s.text
|
||||
print("Saving solutions to cache...")
|
||||
atomic_write_text(cache_s_file, response_s_text)
|
||||
|
||||
solutions_data = ExamSolutions.model_validate_json(repair_latex_json(response_s_text))
|
||||
question_labels = [q.label for q in questions_data.questions]
|
||||
def request_solutions(pending):
|
||||
requested = pending if pending is not None else question_labels
|
||||
selected = ExamQuestions(questions=[q for q in questions_data.questions if q.label in requested])
|
||||
contents = [types.Content(role='user', parts=[
|
||||
types.Part.from_text(text=PROMPT_2),
|
||||
types.Part.from_text(text='--- QUESTIONS EXTRAITES ---\n' + selected.model_dump_json(indent=2)),
|
||||
types.Part.from_text(text=f'--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}'),
|
||||
])]
|
||||
return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_2)
|
||||
solutions_data = ExamSolutions(solutions=extract_stage(
|
||||
folder, 'solutions', SolutionOnlyItem, 'solution_content', request_solutions, labels=question_labels,
|
||||
))
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
||||
@@ -468,21 +545,22 @@ def process_exam(
|
||||
response_json_schema=ExamContext.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_c_file.is_file() and not restart:
|
||||
print("Loading cached context from Cache/gemini_context.json...")
|
||||
response_c_text = cache_c_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 3 (Context) to Gemini...")
|
||||
response_c = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_3,
|
||||
config=config_3
|
||||
)
|
||||
response_c_text = response_c.text
|
||||
print("Saving context to cache...")
|
||||
atomic_write_text(cache_c_file, response_c_text)
|
||||
def request_contexts(pending):
|
||||
contents = contents_3
|
||||
if pending:
|
||||
contents = [types.Content(role='user', parts=[*contents_3[0].parts,
|
||||
types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : '
|
||||
+ ', '.join(pending))])]
|
||||
return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_3)
|
||||
context_data = ExamContext(contexts=extract_stage(
|
||||
folder, 'contexts', ExtractedContext, 'context_content', request_contexts, labels=question_labels,
|
||||
))
|
||||
|
||||
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
|
||||
@@ -634,8 +712,7 @@ def process_exam(
|
||||
# --- WRITE TEXT FILES ---
|
||||
print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
|
||||
|
||||
with open(items_file, "w", encoding="utf-8") as f, \
|
||||
open(full_items_file, "w", encoding="utf-8") as f_full:
|
||||
with io.StringIO() as f, io.StringIO() as f_full:
|
||||
|
||||
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
|
||||
f.write(header)
|
||||
@@ -664,8 +741,15 @@ def process_exam(
|
||||
f.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 ---
|
||||
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...")
|
||||
utils.edit_file_and_enter(items_file)
|
||||
|
||||
@@ -704,6 +788,9 @@ def process_exam(
|
||||
all_new_q_labels.append(lbl)
|
||||
|
||||
# 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)))}
|
||||
|
||||
orig_q_idx = 0
|
||||
@@ -802,37 +889,40 @@ def process_exam(
|
||||
# Save labels and proceed
|
||||
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
|
||||
|
||||
# 2. Setup output directories
|
||||
text_dir = folder / "Text"
|
||||
sol_dir = folder / "Sol"
|
||||
text2_dir = folder / "Text2"
|
||||
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)
|
||||
if not labels_list or len(labels_list) != len(set(labels_list)):
|
||||
raise CliError('Approved groups must contain unique question labels')
|
||||
atomic_write_json(grouping_checkpoint, grouped_extraction.model_dump())
|
||||
return write_exam_outputs(workspace, grouped_extraction, client, restart=restart)
|
||||
|
||||
|
||||
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...")
|
||||
processing_errors = []
|
||||
|
||||
@@ -855,9 +945,15 @@ def process_exam(
|
||||
|
||||
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:
|
||||
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
|
||||
print(f"Error generating rubric for group {labels[0]}: {e}")
|
||||
processing_errors.append(str(e))
|
||||
@@ -887,21 +983,15 @@ def process_exam(
|
||||
text_content_lines.append(f"{item.label} :")
|
||||
text_content_lines.append(tabulated)
|
||||
|
||||
# Write individual Sol file (remains unchanged)
|
||||
safe_label = item.label.replace("/", "_")
|
||||
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol:
|
||||
f_sol.write(f"{item.label}\n{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}")
|
||||
safe_label = item.label.replace('/', '_')
|
||||
save_missing(sol_dir / safe_label, f'{item.label}\n{item.solution_content}')
|
||||
save_missing(text2_dir / f'{safe_label}.tex', f'\\textbf{{{item.label}}} {item.question_content}')
|
||||
save_missing(sol2_dir / f'{safe_label}.tex', f'\\textbf{{{item.label}}} {item.solution_content}')
|
||||
|
||||
# --- Écriture du Barème (Persp) ---
|
||||
rubric_text = rubrics_map.get(item.label, "")
|
||||
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp:
|
||||
f_persp.write(f"{item.label}\n{rubric_text}")
|
||||
if item.label in rubrics_map:
|
||||
atomic_write_text(persp_dir / safe_label,
|
||||
f"{item.label}\n{rubrics_map[item.label]}")
|
||||
|
||||
elif isinstance(item, ContextItem):
|
||||
raw_ctx = item.content.strip()
|
||||
@@ -915,15 +1005,14 @@ def process_exam(
|
||||
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
|
||||
ctxt_path = text2_dir / ctxt_filename
|
||||
|
||||
# If file exists, prepend some spacing before appending
|
||||
prefix = "\n\n" if ctxt_path.exists() else ""
|
||||
context_sources.setdefault(ctxt_path, [])
|
||||
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:
|
||||
f_c2.write(prefix + item.content)
|
||||
save_missing(text_dir / safe_group_filename, '\n'.join(text_content_lines))
|
||||
|
||||
# 4. Write the grouped Text file
|
||||
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
|
||||
f_text.write("\n".join(text_content_lines))
|
||||
for path, contents in context_sources.items():
|
||||
save_missing(path, '\n\n'.join(contents))
|
||||
|
||||
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:
|
||||
"""Helper to read content and call the utility function."""
|
||||
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)
|
||||
atomic_write_json(status_path, {'complete': True})
|
||||
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
||||
return f"Error compiling {tex_file.name}: {e}"
|
||||
return None
|
||||
@@ -947,19 +1043,8 @@ def process_exam(
|
||||
error for error in executor.map(compile_worker, all_tex_files) if error
|
||||
]
|
||||
for error in compile_errors:
|
||||
print(error)
|
||||
report_error(folder, 'PDF compilation', error)
|
||||
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
|
||||
|
||||
|
||||
@@ -969,26 +1054,31 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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")
|
||||
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(
|
||||
"--restart",
|
||||
action="store_true",
|
||||
help="Ignore cached Gemini extraction responses",
|
||||
help="Start over; archive previous extraction checkpoints, edits and generated outputs",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
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(
|
||||
parser,
|
||||
argv,
|
||||
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,
|
||||
),
|
||||
run,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user