831 lines
32 KiB
Python
831 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from collections.abc import Sequence
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
|
|
from google import genai
|
|
from google.genai import types
|
|
from pydantic import BaseModel, Field
|
|
|
|
import config
|
|
import utils
|
|
from copienator import (
|
|
CliError,
|
|
EvaluationWorkspace,
|
|
ExitCode,
|
|
atomic_write_text,
|
|
evaluation_parser,
|
|
execute,
|
|
workspace_from_args,
|
|
)
|
|
from platform_utils import validate_windows_labels
|
|
from utils import compile_to_pdf
|
|
|
|
|
|
def get_lcp(s1: str, s2: str) -> str:
|
|
i = 0
|
|
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
|
|
i += 1
|
|
lcp = s1[:i]
|
|
if ')' in s1 or ')' in s2:
|
|
last_paren = lcp.rfind(')')
|
|
if last_paren != -1:
|
|
return lcp[:last_paren + 1]
|
|
return lcp
|
|
|
|
|
|
MODEL_ID = config.MODEL_LITE_ID
|
|
api_key = config.API_KEY
|
|
|
|
# --- Modèles pour la Requête 1 ---
|
|
class QuestionOnlyItem(BaseModel):
|
|
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
|
|
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
|
|
|
|
class ExamQuestions(BaseModel):
|
|
questions: list[QuestionOnlyItem]
|
|
|
|
# --- Modèles pour la Requête 2 ---
|
|
class SolutionOnlyItem(BaseModel):
|
|
label: str = Field(description="The exact unique label of the question provided in the input.")
|
|
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
|
|
|
|
class ExamSolutions(BaseModel):
|
|
solutions: list[SolutionOnlyItem]
|
|
|
|
# --- Modèles pour la Requête 3 ---
|
|
class ExtractedContext(BaseModel):
|
|
target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
|
|
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
|
|
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
|
|
|
|
class ExamContext(BaseModel):
|
|
contexts: list[ExtractedContext]
|
|
|
|
# --- Modèles pour la Requête 4 (Barèmes) ---
|
|
class RubricItem(BaseModel):
|
|
label: str = Field(description="The exact label of the question.")
|
|
rubric_content: str = Field(description="Le barème détaillé en français.")
|
|
|
|
class GroupRubrics(BaseModel):
|
|
rubrics: list[RubricItem]
|
|
|
|
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
|
|
Ta tâche :
|
|
Établir un barème de correction détaillé en français pour CHAQUE question.
|
|
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
|
|
Par exemple :
|
|
- Au moins 2 points si le résultat est correct.
|
|
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
|
|
- Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
|
|
|
Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
|
|
"""
|
|
|
|
# --- Modèle fusionné (pour le reste du script) ---
|
|
class QuestionItem(BaseModel):
|
|
label: str
|
|
question_content: str
|
|
solution_content: str
|
|
|
|
class ContextItem(BaseModel):
|
|
target_question_label: str
|
|
last_question_label: str
|
|
content: str # Juste une string encapsulée pour le différencier facilement
|
|
|
|
class ExamExtraction(BaseModel):
|
|
items: list[QuestionItem | ContextItem] # Liste mixte
|
|
|
|
class GroupedExamExtraction(BaseModel):
|
|
groups: list[list[QuestionItem | ContextItem]]
|
|
|
|
PROMPT_1 = """I am providing:
|
|
1. A PDF of an exam (`enonce.pdf`)
|
|
2. The source code of the exam questions (`enonce` file)
|
|
|
|
Your task:
|
|
1. Identify all distinct question labels using the PDF document.
|
|
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
|
|
2. For each label, extract its exact corresponding question text
|
|
from the `enonce` source file. Do not include the label itself
|
|
in this extracted text (nor LaTeX like `item` nor org-mode list
|
|
labelling like `2.`).
|
|
Return the result as a JSON list in the exact reading order of the document.
|
|
"""
|
|
|
|
PROMPT_2 = """I am providing:
|
|
1. A JSON list of question labels and their texts extracted from an exam.
|
|
2. The source code of the exam solutions (`correction` file).
|
|
|
|
Your task:
|
|
For each question label provided in the JSON, extract its exact corresponding solution textual
|
|
content from the `correction` source file. Return the result as a JSON list in the exact same order.
|
|
"""
|
|
|
|
PROMPT_3 = """I am providing:
|
|
1. A JSON list of question labels and their texts extracted from an exam.
|
|
2. The source code of the exam questions (`enonce` file).
|
|
|
|
Your task:
|
|
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
|
|
|
|
For example, given LaTeX code like
|
|
|
|
\\item Let N, M be two commutating matrices
|
|
\\begin{itemize}
|
|
\\item Prove that N, M have a common eigenvector
|
|
\\item Prove that N, M are co-trigonalizable.
|
|
\\end{itemize}
|
|
|
|
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
|
|
|
|
For each extracted piece of information, identify:
|
|
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
|
|
2. The label of the LAST question that uses or relies on this information.
|
|
Return the result as a JSON list.
|
|
"""
|
|
|
|
def find_file(folder: Path, base_name: str) -> Path | None:
|
|
for ext in [".org", ".tex"]:
|
|
path = folder / f"{base_name}{ext}"
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
def process_exam(
|
|
workspace: EvaluationWorkspace,
|
|
restart: bool = False,
|
|
*,
|
|
api_client=None,
|
|
) -> ExitCode:
|
|
folder = workspace.root
|
|
|
|
cache_dir = folder / "Cache"
|
|
tmp_dir = folder / "Tmp"
|
|
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"
|
|
|
|
# 1. Resolve files
|
|
pdf_path = folder / "enonce.pdf"
|
|
enonce_path = find_file(folder, "enonce")
|
|
correction_path = find_file(folder, "correction")
|
|
|
|
missing = []
|
|
if not pdf_path.is_file(): missing.append("enonce.pdf")
|
|
if not enonce_path: missing.append("enonce.org or enonce.tex")
|
|
if not correction_path: missing.append("correction.org or correction.tex")
|
|
|
|
if missing:
|
|
raise CliError(
|
|
f"Missing files in {folder}: {', '.join(missing)}",
|
|
ExitCode.INVALID_WORKSPACE,
|
|
)
|
|
|
|
print("Reading files...")
|
|
pdf_bytes = pdf_path.read_bytes()
|
|
enonce_text = enonce_path.read_text(encoding="utf-8")
|
|
correction_text = correction_path.read_text(encoding="utf-8")
|
|
|
|
if api_client is None:
|
|
if not api_key:
|
|
raise CliError("GEMINI_API_KEY is not configured")
|
|
api_client = genai.Client(api_key=api_key)
|
|
client = api_client
|
|
|
|
# ==========================================
|
|
# REQUÊTE 1 : Extraction des Énoncés
|
|
# ==========================================
|
|
contents_1 = [
|
|
types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part.from_text(text=PROMPT_1),
|
|
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
|
|
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
|
],
|
|
)
|
|
]
|
|
|
|
config_1 = types.GenerateContentConfig(
|
|
temperature=0.1,
|
|
response_mime_type="application/json",
|
|
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(response_q_text)
|
|
|
|
# ==========================================
|
|
# 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"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
|
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
|
|
],
|
|
)
|
|
]
|
|
|
|
config_2 = types.GenerateContentConfig(
|
|
temperature=0.1,
|
|
response_mime_type="application/json",
|
|
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(response_s_text)
|
|
|
|
# ==========================================
|
|
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
|
# ==========================================
|
|
contents_3 = [
|
|
types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part.from_text(text=PROMPT_3),
|
|
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
|
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
|
],
|
|
)
|
|
]
|
|
|
|
config_3 = types.GenerateContentConfig(
|
|
temperature=0.1,
|
|
response_mime_type="application/json",
|
|
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)
|
|
|
|
context_data = ExamContext.model_validate_json(response_c_text)
|
|
|
|
# ==========================================
|
|
# FUSION des trois résultats
|
|
# ==========================================
|
|
sol_map = {s.label: s.solution_content for s in solutions_data.solutions}
|
|
|
|
|
|
# Map labels to their index to validate ordering
|
|
label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)}
|
|
|
|
for c in context_data.contexts:
|
|
first_idx = label_to_idx.get(c.target_question_label, -1)
|
|
last_idx = label_to_idx.get(c.last_question_label, -1)
|
|
|
|
# Enforce LAST is after (or equal to) FIRST
|
|
if first_idx != -1 and last_idx != -1 and last_idx < first_idx:
|
|
print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.")
|
|
c.last_question_label = c.target_question_label
|
|
elif last_idx == -1: # Fallback if invalid
|
|
c.last_question_label = c.target_question_label
|
|
|
|
# Grouper les contextes par label cible
|
|
ctx_map = {}
|
|
for c in context_data.contexts:
|
|
if c.target_question_label in ctx_map:
|
|
ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content
|
|
# Keep the furthest LAST question label
|
|
curr_last = ctx_map[c.target_question_label]['last']
|
|
if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1):
|
|
ctx_map[c.target_question_label]['last'] = c.last_question_label
|
|
else:
|
|
ctx_map[c.target_question_label] = {
|
|
'content': c.context_content,
|
|
'last': c.last_question_label
|
|
}
|
|
|
|
merged_items = []
|
|
for q in questions_data.questions:
|
|
if q.label in ctx_map:
|
|
merged_items.append(ContextItem(
|
|
target_question_label=q.label,
|
|
last_question_label=ctx_map[q.label]['last'],
|
|
content=ctx_map[q.label]['content']
|
|
))
|
|
|
|
sol_content = sol_map.get(q.label, "")
|
|
merged_items.append(QuestionItem(
|
|
label=q.label,
|
|
question_content=q.question_content,
|
|
solution_content=sol_content
|
|
))
|
|
|
|
extracted_data = ExamExtraction(items=merged_items)
|
|
|
|
# ==========================================
|
|
# INITIAL GROUPING COMPUTATION
|
|
# ==========================================
|
|
items_file = tmp_dir / "exam_items.txt"
|
|
full_items_file = tmp_dir / "exam_items_full.txt"
|
|
trunc_map = {}
|
|
|
|
# --- INITIAL GROUPING COMPUTATION ---
|
|
# 1. Normalize labels first
|
|
for item in extracted_data.items:
|
|
if isinstance(item, QuestionItem):
|
|
item.label = item.label.replace("Exercice", "Ex").replace(".", ")")
|
|
|
|
# 2. Extract questions and compute grouping indices
|
|
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
|
# questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
|
|
|
q_group_indices = []
|
|
if questions_only:
|
|
n = len(questions_only)
|
|
if n == 1:
|
|
q_group_indices = [[0]]
|
|
else:
|
|
adj_lcp = [get_lcp(questions_only[i].label, questions_only[i+1].label) for i in range(n - 1)]
|
|
|
|
current_g = [0]
|
|
for i in range(n - 1):
|
|
p = adj_lcp[i]
|
|
prev_p = adj_lcp[i - 1] if i > 0 else ""
|
|
next_p = adj_lcp[i + 1] if i < n - 2 else ""
|
|
|
|
# Group i and i+1 together if p is non-empty and at least as specific as adjacent LCPs
|
|
if p and len(p) >= len(prev_p) and len(p) >= len(next_p):
|
|
current_g.append(i + 1)
|
|
else:
|
|
q_group_indices.append(current_g)
|
|
current_g = [i + 1]
|
|
q_group_indices.append(current_g)
|
|
|
|
|
|
# Build list of unique ContextItems from extracted data
|
|
all_contexts = [item for item in extracted_data.items if isinstance(item, ContextItem)]
|
|
|
|
initial_groups = []
|
|
for g_indices in q_group_indices:
|
|
group_items = []
|
|
first_q_idx = g_indices[0]
|
|
|
|
for q_idx in g_indices:
|
|
q_item = questions_only[q_idx]
|
|
|
|
# 1. Collect contexts targeting this specific question
|
|
# 2. Or contexts carried over from an earlier group (only added at the start of the group)
|
|
for ctx in all_contexts:
|
|
target_idx = label_to_idx.get(ctx.target_question_label, -1)
|
|
last_idx = label_to_idx.get(ctx.last_question_label, -1)
|
|
|
|
if target_idx != -1 and last_idx != -1:
|
|
is_exact_target = (target_idx == q_idx)
|
|
is_carried_over = (q_idx == first_q_idx and target_idx < first_q_idx and last_idx >= first_q_idx)
|
|
|
|
if is_exact_target or is_carried_over:
|
|
group_items.append(ContextItem(
|
|
target_question_label=ctx.target_question_label,
|
|
last_question_label=ctx.last_question_label,
|
|
content=ctx.content
|
|
))
|
|
|
|
group_items.append(q_item)
|
|
|
|
initial_groups.append(group_items)
|
|
|
|
# ---- Transform labels, and check uniqueness
|
|
|
|
seen_labels = set()
|
|
label_updates = {}
|
|
for group in initial_groups:
|
|
for item in group:
|
|
if isinstance(item, QuestionItem):
|
|
orig_label = item.label
|
|
|
|
# 2. Ensure uniqueness (prefix with XX)
|
|
while item.label in seen_labels:
|
|
item.label = f"XX{item.label}"
|
|
seen_labels.add(item.label)
|
|
label_updates[orig_label] = item.label
|
|
|
|
# Sync the modified labels to ContextItem
|
|
for group in initial_groups:
|
|
for item in group:
|
|
if isinstance(item, ContextItem):
|
|
item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label)
|
|
item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label)
|
|
|
|
# --- 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:
|
|
|
|
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
|
|
f.write(header)
|
|
f_full.write(header)
|
|
|
|
for g_idx, group in enumerate(initial_groups):
|
|
if g_idx > 0:
|
|
f.write("\n---\n\n")
|
|
f_full.write("\n---\n\n")
|
|
|
|
for item in group:
|
|
if isinstance(item, QuestionItem):
|
|
safe_content = item.question_content.replace('\n', ' \\n ')
|
|
f_full.write(f"{item.label} ### {safe_content}\n")
|
|
|
|
if len(safe_content) > 65:
|
|
trunc_content = safe_content[:64] + "…"
|
|
trunc_map[trunc_content] = safe_content
|
|
else:
|
|
trunc_content = safe_content
|
|
|
|
f.write(f"{item.label} ### {trunc_content}\n")
|
|
|
|
elif isinstance(item, ContextItem):
|
|
safe_content = item.content.replace('\n', ' \\n ')
|
|
f.write(f"CONTEXT ### {safe_content}\n")
|
|
f_full.write(f"CONTEXT ### {safe_content}\n")
|
|
|
|
# --- OPEN EDITOR AND PARSE ---
|
|
while True:
|
|
print("Opening items file for editing...")
|
|
utils.edit_file_and_enter(items_file)
|
|
|
|
print(f"Parsing edited items from {items_file.name}...")
|
|
with open(items_file, "r", encoding="utf-8") as f:
|
|
edited_lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
|
|
|
# 1. Validation for XX labels
|
|
has_xx = False
|
|
for line in edited_lines:
|
|
if " ### " in line:
|
|
lbl = line.split(" ### ", 1)[0].strip()
|
|
if lbl.startswith("XX"):
|
|
has_xx = True
|
|
break
|
|
|
|
if has_xx:
|
|
print("\n!!! ERROR: Some labels still start with 'XX'. Please remove the 'XX' prefixes to ensure unique, valid labels.")
|
|
input("Press ENTER to return to the editor...")
|
|
continue
|
|
|
|
# Map original contexts by normalized content
|
|
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
|
|
|
|
# 2. Actual Parsing
|
|
grouped_items = []
|
|
all_new_q_labels = []
|
|
|
|
# Pass 1: Read all edited lines and collect question labels in sequence
|
|
for line in edited_lines:
|
|
if line == "---" or " ### " not in line:
|
|
continue
|
|
lbl, _content_raw = line.split(" ### ", 1)
|
|
lbl = lbl.strip()
|
|
if lbl != "CONTEXT":
|
|
all_new_q_labels.append(lbl)
|
|
|
|
# Mapping from original question index to new label
|
|
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
|
|
current_group = []
|
|
|
|
for line in edited_lines:
|
|
if line == "---":
|
|
if current_group:
|
|
grouped_items.append(current_group)
|
|
current_group = []
|
|
continue
|
|
|
|
if " ### " not in line:
|
|
continue
|
|
|
|
new_label, edited_content_raw = line.split(" ### ", 1)
|
|
new_label = new_label.strip()
|
|
|
|
if "…" in edited_content_raw and edited_content_raw in trunc_map:
|
|
edited_content_raw = trunc_map[edited_content_raw]
|
|
|
|
edited_content = edited_content_raw.replace(' \\n ', '\n')
|
|
|
|
if new_label == "CONTEXT":
|
|
current_group.append(('CONTEXT', edited_content))
|
|
else:
|
|
sol_content = questions_only[orig_q_idx].solution_content if orig_q_idx < len(questions_only) else ""
|
|
current_group.append(QuestionItem(
|
|
label=new_label,
|
|
question_content=edited_content,
|
|
solution_content=sol_content
|
|
))
|
|
orig_q_idx += 1
|
|
|
|
if current_group:
|
|
grouped_items.append(current_group)
|
|
|
|
# Pass 2: Resolve ContextItem target/last labels per group
|
|
final_grouped_items = []
|
|
for group in grouped_items:
|
|
final_group = []
|
|
q_in_group = [item for item in group if isinstance(item, QuestionItem)]
|
|
g_first_label = q_in_group[0].label if q_in_group else ""
|
|
g_last_label = q_in_group[-1].label if q_in_group else ""
|
|
|
|
for i, item in enumerate(group):
|
|
if isinstance(item, tuple) and item[0] == 'CONTEXT':
|
|
c_text = item[1]
|
|
norm_text = c_text.strip()
|
|
|
|
# Find next question label in group following this context
|
|
next_q_label = g_first_label
|
|
for successor in group[i+1:]:
|
|
if isinstance(successor, QuestionItem):
|
|
next_q_label = successor.label
|
|
break
|
|
|
|
if norm_text in orig_contexts:
|
|
orig_c = orig_contexts[norm_text]
|
|
orig_target_idx = label_to_idx.get(orig_c.target_question_label, -1)
|
|
orig_last_idx = label_to_idx.get(orig_c.last_question_label, -1)
|
|
|
|
mapped_target = idx_to_new_label.get(orig_target_idx, next_q_label)
|
|
mapped_last = idx_to_new_label.get(orig_last_idx, g_last_label)
|
|
|
|
# Check if context's last question is BEFORE the first question of this group
|
|
first_q_idx_in_exam = all_new_q_labels.index(g_first_label) if g_first_label in all_new_q_labels else -1
|
|
last_q_idx_in_exam = all_new_q_labels.index(mapped_last) if mapped_last in all_new_q_labels else -1
|
|
|
|
if last_q_idx_in_exam != -1 and first_q_idx_in_exam != -1 and last_q_idx_in_exam < first_q_idx_in_exam:
|
|
mapped_last = g_last_label
|
|
|
|
final_group.append(ContextItem(
|
|
target_question_label=mapped_target,
|
|
last_question_label=mapped_last,
|
|
content=c_text
|
|
))
|
|
else:
|
|
# New context created by user
|
|
final_group.append(ContextItem(
|
|
target_question_label=next_q_label,
|
|
last_question_label=g_last_label,
|
|
content=c_text
|
|
))
|
|
else:
|
|
final_group.append(item)
|
|
|
|
final_grouped_items.append(final_group)
|
|
|
|
grouped_items = final_grouped_items
|
|
break
|
|
|
|
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
|
|
validate_windows_labels(labels_list)
|
|
|
|
# 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)
|
|
|
|
|
|
print("Writing grouped question and solution files...")
|
|
processing_errors = []
|
|
|
|
for group in grouped_extraction.groups:
|
|
q_items = [item for item in group if isinstance(item, QuestionItem)]
|
|
labels = [q.label for q in q_items]
|
|
|
|
if not labels:
|
|
continue # Skip if a group has no questions (only contexts)
|
|
|
|
# ==========================================
|
|
# REQUÊTE 4 : Génération du Barème pour le groupe
|
|
# ==========================================
|
|
group_text_parts = []
|
|
for item in group:
|
|
if isinstance(item, QuestionItem):
|
|
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
|
|
elif isinstance(item, ContextItem):
|
|
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
|
|
|
|
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
|
|
|
contents_4 = [
|
|
types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part.from_text(text=PROMPT_4),
|
|
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
|
],
|
|
)
|
|
]
|
|
|
|
config_4 = types.GenerateContentConfig(
|
|
temperature=0.2,
|
|
response_mime_type="application/json",
|
|
response_json_schema=GroupRubrics.model_json_schema(),
|
|
)
|
|
|
|
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
|
try:
|
|
response_r = client.models.generate_content(
|
|
model=MODEL_ID,
|
|
contents=contents_4,
|
|
config=config_4
|
|
)
|
|
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
|
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
|
except Exception as e: # noqa: BLE001 - remote API boundary
|
|
print(f"Error generating rubric for group {labels[0]}: {e}")
|
|
processing_errors.append(str(e))
|
|
rubrics_map = {}
|
|
|
|
# 1. Compute the common prefix for the group
|
|
prefix = labels[0]
|
|
for lbl in labels[1:]:
|
|
prefix = get_lcp(prefix, lbl)
|
|
|
|
# 2. Format the Text filename: prefix [label1, label2]
|
|
labels_str = ",".join([label[len(prefix):] for label in labels])
|
|
group_filename = f"{prefix}[{labels_str}]"
|
|
safe_group_filename = group_filename.replace("/", "_")
|
|
|
|
text_content_lines = []
|
|
|
|
# 3. Process each item in the group
|
|
for item in group:
|
|
if isinstance(item, QuestionItem):
|
|
# 1. Prepare tabulated content:
|
|
# Start with a tab, then replace every newline+whitespace with newline+tab
|
|
raw_content = item.question_content.strip()
|
|
tabulated = "\t" + re.sub(r'\n\s*', '\n\t', raw_content)
|
|
|
|
# 2. Build Text entry
|
|
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}")
|
|
|
|
# --- É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}")
|
|
|
|
elif isinstance(item, ContextItem):
|
|
raw_ctx = item.content.strip()
|
|
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
|
|
text_content_lines.append("CONTEXT :")
|
|
text_content_lines.append(tabulated_ctx)
|
|
|
|
# --- Save context to Text2 (Concatenating if exists) ---
|
|
safe_first = item.target_question_label.replace("/", "_")
|
|
safe_last = item.last_question_label.replace("/", "_")
|
|
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 ""
|
|
|
|
with open(ctxt_path, "a", encoding="utf-8") as f_c2:
|
|
f_c2.write(prefix + item.content)
|
|
|
|
# 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))
|
|
|
|
print(f"Success! Processed {len(grouped_extraction.groups)} groups.")
|
|
# ==========================================
|
|
# PDF COMPILATION (4 Threads)
|
|
# ==========================================
|
|
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
|
|
|
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")
|
|
compile_to_pdf(content, pdf_path)
|
|
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
|
return f"Error compiling {tex_file.name}: {e}"
|
|
return None
|
|
|
|
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
|
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
compile_errors = [
|
|
error for error in executor.map(compile_worker, all_tex_files) if error
|
|
]
|
|
for error in compile_errors:
|
|
print(error)
|
|
processing_errors.extend(compile_errors)
|
|
atomic_write_text(
|
|
workspace.labels_file,
|
|
"".join(f"{label}\n" for label in labels_list),
|
|
)
|
|
|
|
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = evaluation_parser("Extract exam and solution code via Gemini")
|
|
parser.add_argument(
|
|
"--restart",
|
|
action="store_true",
|
|
help="Ignore cached Gemini extraction responses",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
return execute(
|
|
parser,
|
|
argv,
|
|
lambda args: process_exam(
|
|
workspace_from_args(args),
|
|
restart=args.restart,
|
|
),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|