Standardisation 9

This commit is contained in:
2026-08-20 15:23:45 +02:00
parent b19d3b0db6
commit 3a8d0fe3ff
4 changed files with 183 additions and 71 deletions
+91 -51
View File
@@ -1,18 +1,30 @@
import re
import os
import utils
import subprocess
import sys
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from pydantic import BaseModel, Field
from typing import List, Union
from google import genai
from google.genai import types
from concurrent.futures import ThreadPoolExecutor
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]:
@@ -25,8 +37,6 @@ def get_lcp(s1: str, s2: str) -> str:
return lcp
import config
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
@@ -36,7 +46,7 @@ class QuestionOnlyItem(BaseModel):
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]
questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 ---
class SolutionOnlyItem(BaseModel):
@@ -44,7 +54,7 @@ class SolutionOnlyItem(BaseModel):
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
class ExamSolutions(BaseModel):
solutions: List[SolutionOnlyItem]
solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 ---
class ExtractedContext(BaseModel):
@@ -53,7 +63,7 @@ class ExtractedContext(BaseModel):
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
class ExamContext(BaseModel):
contexts: List[ExtractedContext]
contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) ---
class RubricItem(BaseModel):
@@ -61,7 +71,7 @@ class RubricItem(BaseModel):
rubric_content: str = Field(description="Le barème détaillé en français.")
class GroupRubrics(BaseModel):
rubrics: List[RubricItem]
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 :
@@ -87,10 +97,10 @@ class ContextItem(BaseModel):
content: str # Juste une string encapsulée pour le différencier facilement
class ExamExtraction(BaseModel):
items: List[Union[QuestionItem, ContextItem]] # Liste mixte
items: list[QuestionItem | ContextItem] # Liste mixte
class GroupedExamExtraction(BaseModel):
groups: List[List[Union[QuestionItem, ContextItem]]]
groups: list[list[QuestionItem | ContextItem]]
PROMPT_1 = """I am providing:
1. A PDF of an exam (`enonce.pdf`)
@@ -138,15 +148,20 @@ the `Let N, M be two commutating matrices` part is not a question itself, and is
Return the result as a JSON list.
"""
def find_file(folder: Path, base_name: str) -> Path:
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(folder_path: str, restart: bool = False):
folder = Path(folder_path)
def process_exam(
workspace: EvaluationWorkspace,
restart: bool = False,
*,
api_client=None,
) -> ExitCode:
folder = workspace.root
cache_dir = folder / "Cache"
tmp_dir = folder / "Tmp"
@@ -168,15 +183,21 @@ def process_exam(folder_path: str, restart: bool = False):
if not correction_path: missing.append("correction.org or correction.tex")
if missing:
print(f"Error: Missing files in {folder}: {', '.join(missing)}")
sys.exit(1)
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")
client = genai.Client(api_key=api_key)
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
@@ -210,7 +231,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_q_text = response_q.text
print("Saving questions to cache...")
cache_q_file.write_text(response_q_text, encoding="utf-8")
atomic_write_text(cache_q_file, response_q_text)
questions_data = ExamQuestions.model_validate_json(response_q_text)
@@ -248,7 +269,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_s_text = response_s.text
print("Saving solutions to cache...")
cache_s_file.write_text(response_s_text, encoding="utf-8")
atomic_write_text(cache_s_file, response_s_text)
solutions_data = ExamSolutions.model_validate_json(response_s_text)
@@ -284,7 +305,7 @@ def process_exam(folder_path: str, restart: bool = False):
)
response_c_text = response_c.text
print("Saving context to cache...")
cache_c_file.write_text(response_c_text, encoding="utf-8")
atomic_write_text(cache_c_file, response_c_text)
context_data = ExamContext.model_validate_json(response_c_text)
@@ -388,7 +409,6 @@ def process_exam(folder_path: str, restart: bool = False):
for g_indices in q_group_indices:
group_items = []
first_q_idx = g_indices[0]
last_q_idx = g_indices[-1]
for q_idx in g_indices:
q_item = questions_only[q_idx]
@@ -497,14 +517,13 @@ def process_exam(folder_path: str, restart: bool = False):
# 2. Actual Parsing
grouped_items = []
current_raw_group = [] # Stores (is_context, label_or_flag, content)
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, _content_raw = line.split(" ### ", 1)
lbl = lbl.strip()
if lbl != "CONTEXT":
all_new_q_labels.append(lbl)
@@ -606,10 +625,6 @@ def process_exam(folder_path: str, restart: bool = False):
validate_windows_labels(labels_list)
# Save labels and proceed
with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
for label in labels_list:
f_labels.write(f"{label}\n")
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
# 2. Setup output directories
@@ -628,8 +643,7 @@ def process_exam(folder_path: str, restart: bool = False):
).strip().lower()
if answer not in ("y", "yes"):
print("Aborted.")
sys.exit(1)
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
# Empty each directory
for d in dirs:
if d.exists():
@@ -645,6 +659,7 @@ def process_exam(folder_path: str, restart: bool = False):
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)]
@@ -690,8 +705,9 @@ def process_exam(folder_path: str, restart: bool = False):
)
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:
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
@@ -737,7 +753,7 @@ def process_exam(folder_path: str, restart: bool = False):
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(f"CONTEXT :")
text_content_lines.append("CONTEXT :")
text_content_lines.append(tabulated_ctx)
# --- Save context to Text2 (Concatenating if exists) ---
@@ -762,29 +778,53 @@ def process_exam(folder_path: str, restart: bool = False):
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
def compile_worker(tex_file: Path):
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:
print(f"Error compiling {tex_file.name}: {e}")
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:
executor.map(compile_worker, all_tex_files)
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__":
if not api_key:
print("Error: GEMINI_API_KEY environment variable is not set.")
sys.exit(1)
parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.")
parser.add_argument("folder", help="Directory containing the exam files")
parser.add_argument("--restart", action="store_true", help="Ignore cache files and re-run extraction requests.")
args = parser.parse_args()
process_exam(args.folder, restart=args.restart)
raise SystemExit(main())