enonce_info.py

This commit is contained in:
2026-07-09 15:54:36 +02:00
parent 121bd0714a
commit fd35675e4c
6 changed files with 619 additions and 180 deletions
+21 -9
View File
@@ -1,7 +1,7 @@
#+title: Script #+title: Script
#+author: Sébastien Miquel #+author: Sébastien Miquel
#+date: 14-03-2026 #+date: 14-03-2026
# Time-stamp: <29-06-26 13:05> # Time-stamp: <09-07-26 15:54>
#+OPTIONS: #+OPTIONS:
* Méta * Méta
@@ -72,17 +72,19 @@ export GEMINI_API_KEY=…
1. Créer un fichier =names= dans le dossier courant, avec les 1. Créer un fichier =names= dans le dossier courant, avec les
noms/prénoms des élèves, un par ligne noms/prénoms des élèves, un par ligne
2. Créer un dossier correspondant à l'évaluation, comme =Interro= 2. Créer un dossier correspondant à l'évaluation, comme =Interro=
3. Mettre les fichiers pdfs scannés dans =Interro=. 3. Mettre l'énoncé, au format pdf, et l'énoncé et le corrigé au
4. Dans le dossier =Interro= créer un fichier =labels= avec les labels format .tex dans le dossier.
4. Mettre les fichiers pdfs scannés dans =Interro=.
5. Dans le dossier =Interro= créer un fichier =labels= avec les labels
des questions, comme =Ex 1 : 1)a)=, un par ligne. des questions, comme =Ex 1 : 1)a)=, un par ligne.
5. Il faudra créer des dossiers =Text=, =Sol= et =Persp=, et dans ces 6. Il faudra créer des dossiers =Text=, =Sol= et =Persp=, et dans ces
dossiers créer, pour chaque label (ou groupe de labels : par dossiers créer, pour chaque label (ou groupe de labels : par
exemple un seul fichier =Ex 1= peut être utilisé pour toutes les exemple un seul fichier =Ex 1= peut être utilisé pour toutes les
questions de l'exercice 1) un fichier texte qui contient questions de l'exercice 1) un fichier texte qui contient
respectivement l'énoncé, un corrigé, et des indications de comment respectivement l'énoncé, un corrigé, et des indications de comment
corriger (Gemini met une note sur 4, on peut demander 2 points corriger (Gemini met une note sur 4, on peut demander 2 points
pour tel truc, etc) pour tel truc, etc)
6. Suivre les étapes plus bas. 7. Suivre les étapes plus bas.
* Étapes et Script * Étapes et Script
** Prétraitement de l'énoncé ** Prétraitement de l'énoncé
@@ -92,8 +94,16 @@ export GEMINI_API_KEY=…
+ `enonce.tex` + `enonce.tex`
+ `correction.tex`. + `correction.tex`.
- `python gemini_for_enonce.py Interro` - `python gemini_for_enonce.py Interro`
Se charge de créer des dossiers `Text` et `Sol` avec Se charge de créer
+ Le fichier `Text` contient + un fichier `labels` avec les labels des questions
+ Un dossier `Text` avec le contenu textuel des questions,
regroupées.
+ Un dossier `Sol` avec le contenu textuel du corrigé, question par
question
+ Un dossier `Text2`, qui compile un fichier `.tex` pour chaque
question
+ Un dossier `Sol2`, qui compile un fichier `.tex` pour chaque
correction de chaque question.
** Prétraitement des copies ** Prétraitement des copies
@@ -123,8 +133,10 @@ export GEMINI_API_KEY=…
1. =python enonce_info.py Interro= (gestion perso) 1. =python enonce_info.py Interro= (gestion perso)
OU OU
2. =python gemini_for_enonce.py Interro= 1. =python gemini_for_enonce.py Interro=
+ Nécessite =enonce.tex/org= et `correction.tex/org` + Nécessite `enonce.pdf`, =enonce.tex/org= et `correction.tex/org`
+ Génère : `Text` and `Sol` folders. Todo : `Text2`.
`python gemini_for_enonce.py Interro --reread` will regenerate the
** Labelisation et regroupement ** Labelisation et regroupement
+7 -55
View File
@@ -8,57 +8,7 @@ import subprocess
import tempfile import tempfile
import shutil import shutil
def compile_to_pdf(text, output_pdf_path): # 21 cm + 3.8 (dimension de la marge de gauche) from utils import compile_to_pdf
"""Wraps text in a standalone template and compiles it to PDF."""
latex_template = f"""\\documentclass[varwidth=24.8cm,margin=0.4cm]{{standalone}}
\\usepackage[utf8]{{inputenc}}
\\usepackage[T1]{{fontenc}}
\\usepackage{{lmodern}}
\\usepackage{{amsmath, amssymb}}
\\usepackage{{commands}}
\\usepackage{{minted}}
\\usepackage{{graphicx}}
\\usepackage{{enumitem}}
\\begin{{document}}
\\begin{{minipage}}{{24.8cm}}
{text}
\\end{{minipage}}
\\end{{document}}
"""
with tempfile.TemporaryDirectory() as temp_dir:
tex_filename = 'text.tex'
pdf_filename = 'text.pdf'
tex_path = os.path.join(temp_dir, tex_filename)
with open(tex_path, 'w', encoding='utf-8') as f:
f.write(latex_template)
# Set TEXINPUTS so pdflatex can find commands.sty if it's in the current dir
# env = os.environ.copy()
# current_dir = os.getcwd()
# env['TEXINPUTS'] = f".:{current_dir}:"
try:
subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False
)
if "minted" in text:
subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False)
generated_pdf = os.path.join(temp_dir, pdf_filename)
if os.path.exists(generated_pdf):
shutil.move(generated_pdf, output_pdf_path)
except Exception as e:
print(f"Compilation error for {output_pdf_path}: {e}")
def fetch_and_save_sub_text(ex_id, indices, label, text_path): def fetch_and_save_sub_text(ex_id, indices, label, text_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex""" """Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
@@ -194,7 +144,9 @@ def process_directory(directory):
# Prepare output directories # Prepare output directories
paths = { paths = {
'Text': os.path.join(directory, "Text"), 'Text': os.path.join(directory, "Text"),
'Text2': os.path.join(directory, "Text2"),
'Sol': os.path.join(directory, "Sol"), 'Sol': os.path.join(directory, "Sol"),
'Sol2': os.path.join(directory, "Sol2"),
'Persp': os.path.join(directory, "Persp") 'Persp': os.path.join(directory, "Persp")
} }
for p in paths.values(): for p in paths.values():
@@ -240,15 +192,15 @@ def process_directory(directory):
if not indexes: if not indexes:
label = f"Ex {current_ex_num}" label = f"Ex {current_ex_num}"
f_labels.write(f"{label}\n") f_labels.write(f"{label}\n")
fetch_and_save_sub_text(ids, [], label, paths['Text']) fetch_and_save_sub_text(ids, [], label, paths['Text2'])
fetch_and_save_sub_sol(ids, [], label, paths['Sol']) fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
else: else:
for item in indexes: for item in indexes:
suffix = format_indices(item['indices'], problem) suffix = format_indices(item['indices'], problem)
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "") label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
f_labels.write(f"{label}\n") f_labels.write(f"{label}\n")
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text']) fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol']) fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
# Construct URL (append pb=true if \Roman matched) # Construct URL (append pb=true if \Roman matched)
+468 -93
View File
@@ -1,31 +1,70 @@
import shlex import shlex
import re
import os import os
import subprocess import subprocess
import sys import sys
import argparse import argparse
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List from typing import List, Union
from google import genai from google import genai
from google.genai import types from google.genai import types
from concurrent.futures import ThreadPoolExecutor
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
return s1[:i]
# Bug : l'output est limité à 8k token…
# MODEL_ID = "gemini-3-flash-preview" # MODEL_ID = "gemini-3-flash-preview"
MODEL_ID = "gemini-3.1-flash-lite" MODEL_ID = "gemini-3.1-flash-lite"
api_key = os.environ.get("GEMINI_API_KEY") api_key = os.environ.get("GEMINI_API_KEY")
class QuestionItem(BaseModel): # --- 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')") 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.") 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.") solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
class ExamExtraction(BaseModel): class ExamSolutions(BaseModel):
questions: List[QuestionItem] solutions: List[SolutionOnlyItem]
PROMPT = """I am providing: # --- 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.")
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èle fusionné (pour le reste du script) ---
class QuestionItem(BaseModel):
label: str
question_content: str
solution_content: str
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
class GroupedExamExtraction(BaseModel):
groups: List[List[Union[QuestionItem, ContextItem]]]
PROMPT_1 = """I am providing:
1. A PDF of an exam (`enonce.pdf`) 1. A PDF of an exam (`enonce.pdf`)
2. The source code of the exam questions (`enonce` file) 2. The source code of the exam questions (`enonce` file)
3. The source code of the exam solutions (`correction` file)
Your task: Your task:
1. Identify all distinct question labels using the PDF document. 1. Identify all distinct question labels using the PDF document.
@@ -34,9 +73,26 @@ Your task:
from the `enonce` source file. Do not include the label itself from the `enonce` source file. Do not include the label itself
in this extracted text (nor LaTeX like `item` nor org-mode list in this extracted text (nor LaTeX like `item` nor org-mode list
labelling like `2.`). labelling like `2.`).
3. For each label, extract its exact corresponding solution textual Return the result as a JSON list in the exact reading order of the document.
content from the `correction` source file. 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.
For each extracted piece of information, identify the label of the FIRST question that comes immediately AFTER this information in the exam.
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:
@@ -70,126 +126,445 @@ def process_exam(folder_path: str):
client = genai.Client(api_key=api_key) client = genai.Client(api_key=api_key)
contents = [ # ==========================================
# REQUÊTE 1 : Extraction des Énoncés
# ==========================================
contents_1 = [
types.Content( types.Content(
role="user", role="user",
parts=[ parts=[
types.Part.from_text(text=PROMPT), types.Part.from_text(text=PROMPT_1),
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"), 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}"), 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(),
)
cache_q_file = folder / "gemini_questions.json"
if cache_q_file.is_file():
print("Loading cached questions from 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...")
cache_q_file.write_text(response_q_text, encoding="utf-8")
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}"), types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
], ],
) )
] ]
config = types.GenerateContentConfig( config_2 = types.GenerateContentConfig(
temperature=0.1, temperature=0.1,
response_mime_type="application/json", response_mime_type="application/json",
response_json_schema=ExamExtraction.model_json_schema(), response_json_schema=ExamSolutions.model_json_schema(),
) )
cache_file = folder / "gemini_response.json" cache_s_file = folder / "gemini_solutions.json"
if cache_file.is_file(): if cache_s_file.is_file():
print("Loading cached response from gemini_response.json...") print("Loading cached solutions from gemini_solutions.json...")
response_text = cache_file.read_text(encoding="utf-8") response_s_text = cache_s_file.read_text(encoding="utf-8")
else: else:
print("Sending request to Gemini...") print("Sending request 2 (Solutions) to Gemini...")
response = client.models.generate_content( response_s = client.models.generate_content(
model=MODEL_ID, model=MODEL_ID,
contents=contents, contents=contents_2,
config=config config=config_2
) )
response_text = response.text response_s_text = response_s.text
print("Saving solutions to cache...")
cache_s_file.write_text(response_s_text, encoding="utf-8")
print("Saving response to cache...") solutions_data = ExamSolutions.model_validate_json(response_s_text)
cache_file.write_text(response_text, encoding="utf-8")
# Validate from the text variable (cached or fresh) # ==========================================
extracted_data = ExamExtraction.model_validate_json(response_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(),
)
cache_c_file = folder / "gemini_context.json"
if cache_c_file.is_file():
print("Loading cached context from 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...")
cache_c_file.write_text(response_c_text, encoding="utf-8")
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}
# 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] += "\n\n" + c.context_content
else:
ctx_map[c.target_question_label] = c.context_content
merged_items = []
for q in questions_data.questions:
# 1. S'il y a un contexte pour cette question, on l'insère d'abord dans la liste
if q.label in ctx_map:
merged_items.append(ContextItem(content=ctx_map[q.label]))
# 2. Puis on ajoute la question
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 = folder / "exam_items.txt"
full_items_file = folder / "exam_items_full.txt"
trunc_map = {}
# --- INITIAL GROUPING COMPUTATION ---
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
q_group_indices = []
if questions_only:
current_g = [0]
for i in range(1, len(questions_only)):
p = get_lcp(questions_only[current_g[0]].label, questions_only[i].label)
proposed = current_g + [i]
valid = True
for k in range(len(proposed) - 1):
if get_lcp(questions_only[proposed[k]].label, questions_only[proposed[k+1]].label) != p:
valid = False
break
if valid:
current_g.append(i)
else:
q_group_indices.append(current_g)
current_g = [i]
q_group_indices.append(current_g)
group_starter_labels = {questions_only[g[0]].label for g in q_group_indices[1:]} if q_group_indices else set()
initial_groups = []
current_group = []
for i, item in enumerate(extracted_data.items):
is_new_group = False
if isinstance(item, QuestionItem):
if item.label in group_starter_labels:
if not (len(current_group) > 0 and isinstance(current_group[-1], ContextItem)):
is_new_group = True
elif isinstance(item, ContextItem):
if i + 1 < len(extracted_data.items):
next_item = extracted_data.items[i+1]
if isinstance(next_item, QuestionItem) and next_item.label in group_starter_labels:
is_new_group = True
if is_new_group and current_group:
initial_groups.append(current_group)
current_group = []
current_group.append(item)
if current_group:
initial_groups.append(current_group)
# ---- Transform labels, and check uniqueness
seen_labels = set()
for group in initial_groups:
for item in group:
if isinstance(item, QuestionItem):
# 1. Transform label
item.label = item.label.replace("Exercice", "Ex")
item.label = item.label.replace(".", ")")
# 2. Ensure uniqueness (prefix with XX)
while item.label in seen_labels:
item.label = f"XX{item.label}"
seen_labels.add(item.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...")
editor = os.environ.get("EDITOR")
try:
if editor:
subprocess.run(shlex.split(editor) + [str(items_file)])
else:
if sys.platform.startswith("linux"):
subprocess.run(["xdg-open", str(items_file)])
elif sys.platform == "darwin":
subprocess.run(["open", str(items_file)])
else:
os.startfile(str(items_file))
input("Press ENTER here once you have saved and closed the text file...")
except Exception as e:
print(f"Error running editor: {e}")
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
# 2. Actual Parsing
grouped_items = []
current_group = []
labels_list = []
orig_idx = 0
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 new_label != "CONTEXT":
labels_list.append(new_label)
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 orig_idx < len(extracted_data.items):
orig_item = extracted_data.items[orig_idx]
if isinstance(orig_item, QuestionItem):
current_group.append(QuestionItem(
label=new_label,
question_content=edited_content,
solution_content=orig_item.solution_content
))
elif isinstance(orig_item, ContextItem):
current_group.append(ContextItem(content=edited_content))
orig_idx += 1
if current_group:
grouped_items.append(current_group)
# If we reached here without 'continue', the data is valid
break
# 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 # 2. Setup output directories
text_dir = folder / "Text" text_dir = folder / "Text"
sol_dir = folder / "Sol" sol_dir = folder / "Sol"
text_dir.mkdir(exist_ok=True) text2_dir = folder / "Text2"
sol_dir.mkdir(exist_ok=True) sol2_dir = folder / "Sol2"
dirs = [text_dir, sol_dir, text2_dir, sol2_dir]
labels_file = folder / "labels" 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()
# Step 1: Write initial labels if answer not in ("y", "yes"):
print("Writing initial labels file...") print("Aborted.")
with open(labels_file, "w", encoding="utf-8") as flabels: sys.exit(1)
for q in extracted_data.questions: # Empty each directory
flabels.write(f"{q.label}\n") 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)
# Step 2: Open labels file for user editing text_dir.mkdir(exist_ok=True)
print("Opening labels file for editing...") sol_dir.mkdir(exist_ok=True)
editor = os.environ.get("EDITOR")
try:
if editor:
subprocess.run(shlex.split(editor) + [str(labels_file)])
else:
# Fallbacks if $EDITOR is not set
if sys.platform.startswith("linux"):
subprocess.Popen(["xdg-open", str(labels_file)])
elif sys.platform == "darwin":
subprocess.Popen(["open", str(labels_file)])
else:
os.startfile(str(labels_file))
# xdg-open/open usually do not block, so we wait for user confirmation
input("Press ENTER here once you have saved and closed the labels file...")
except Exception:
print("Error running editor, using labels as given.")
# Step 3 & 4: Read the edited file back and create a mapping print("Writing grouped question and solution files...")
with open(labels_file, "r", encoding="utf-8") as flabels:
edited_lines = [line.strip() for line in flabels if line.strip()]
mapping = [] for group in grouped_extraction.groups:
final_labels = [] q_items = [item for item in group if isinstance(item, QuestionItem)]
orig_idx = 0 labels = [q.label for q in q_items]
for line in edited_lines: if not labels:
if line.startswith("+"): continue # Skip if a group has no questions (only contexts)
new_label = line[1:].lstrip()
final_labels.append(new_label)
# New label, no source content
mapping.append((new_label, None))
else:
new_label = line
final_labels.append(new_label)
# Map to initial order, advancing index only for non-'+' items
q_item = extracted_data.questions[orig_idx] if orig_idx < len(extracted_data.questions) else None
mapping.append((new_label, q_item))
orig_idx += 1
# Rewrite the labels file cleanly (removing '+' prefixes) # 1. Compute the common prefix for the group
with open(labels_file, "w", encoding="utf-8") as flabels: prefix = labels[0]
for lbl in final_labels: for lbl in labels[1:]:
flabels.write(f"{lbl}\n") prefix = get_lcp(prefix, lbl)
# Step 5: Write the final question and solution files # 2. Format the Text filename: prefix [label1, label2]
print("Writing question and solution files...") labels_str = ",".join([label[len(prefix):] for label in labels])
for new_label, q_item in mapping: group_filename = f"{prefix}[{labels_str}]"
safe_label = new_label.replace("/", "_") safe_group_filename = group_filename.replace("/", "_")
if q_item: text_content_lines = []
q_content = q_item.question_content.replace("\\n", "\n")
s_content = q_item.solution_content.replace("\\n", "\n")
else:
q_content = ""
s_content = ""
# Write Text/label # 3. Process each item in the group
with open(text_dir / safe_label, "w", encoding="utf-8") as f: for item in group:
f.write(f"{new_label}\n{q_content}") 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)
# Write Sol/label # 2. Build Text entry
with open(sol_dir / safe_label, "w", encoding="utf-8") as f: text_content_lines.append(f"{item.label} :")
f.write(f"{new_label}\n{s_content}") 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}")
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(tabulated_ctx)
# 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):
"""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}")
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)
print(f"Success! Processed {len(mapping)} labels.")
if __name__ == "__main__": if __name__ == "__main__":
if not api_key: if not api_key:
+7 -18
View File
@@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
import io import io
import utils
main_prompt = """I'm giving you an image of several written answers to an exam. main_prompt = """I'm giving you an image of several written answers to an exam.
@@ -85,26 +86,14 @@ Here is a possible correct answer :
You are asked to score the question or exercice labeled `<<label>>`, You are asked to score the question or exercice labeled `<<label>>`,
do not score or give feedback to any other question.""" do not score or give feedback to any other question."""
from utils import get_label_text_content, get_label_sol_content, get_label_persp_content
def make_prompt(input_dir,full_label): def make_prompt(input_dir,full_label):
def read_longest_prefix_file(subdir): text = get_label_text_content(input_dir, full_label)
dir_path = input_dir / subdir corr = get_label_sol_content(input_dir, full_label)
if not dir_path.exists(): persp = get_label_persp_content(input_dir, full_label)
if subdir != "Persp":
print("Warning !! Directory doesn't exist : ", dir_path)
return ""
matches = [f for f in dir_path.iterdir()
if f.is_file()
and full_label.startswith(f.name)
and f.suffix not in [".pdf", ".tex"]]
if not matches:
return ""
return max(matches, key=lambda f: len(f.name)).read_text(encoding="utf-8", errors="replace")
text = read_longest_prefix_file("Text") if persp and persp != "":
corr = read_longest_prefix_file("Sol")
persp = read_longest_prefix_file("Persp")
if persp != "":
persp = "\n\nHere are additional scoring instructions : \n\n```\n" + persp +"\n```\n" persp = "\n\nHere are additional scoring instructions : \n\n```\n" + persp +"\n```\n"
return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label) return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
+6 -5
View File
@@ -9,18 +9,19 @@ import threading
import annotating import annotating
from utils import natural_key from utils import natural_key, pdf_image_of_enonce, pdf_image_of_solution
from reading_annotations import detect_checks_and_notes, has_significant_notes from reading_annotations import detect_checks_and_notes, has_significant_notes
def get_extra_pdfs_as_images(root_dir, label, annotating_module): def get_extra_pdfs_as_images(root_dir, label, annotating_module):
"""Fetches Text and Sol pdfs for a given label and converts them to images.""" """Fetches Text and Sol pdfs for a given label and converts them to images."""
extra_images = [] extra_images = []
for folder in ["Text", "Sol"]: a, b = pdf_image_of_enonce(root_dir, label), pdf_image_of_solution(root_dir, label)
pdf_path = os.path.join(root_dir, folder, f"{label}.pdf") for c in [a, b]:
if os.path.exists(pdf_path): if c:
img, _, _ = annotating_module.make_base_image(pdf_path) img, _, _ = annotating_module.make_base_image(c)
if img: if img:
extra_images.append(img) extra_images.append(img)
return extra_images return extra_images
def save_paginated_pdf(image_groups, output_path): def save_paginated_pdf(image_groups, output_path):
+110
View File
@@ -21,3 +21,113 @@ def enonce_total(base_dir):
output.append(f"{filepath.name}\n{content}\n\n\n") output.append(f"{filepath.name}\n{content}\n\n\n")
return "".join(output) return "".join(output)
import os
def pdf_image_of_enonce(root_dir, label):
pdf_path = os.path.join(root_dir, "Text2", f"{label}.pdf")
if os.path.exists(pdf_path):
return pdf_path
def pdf_image_of_solution(root_dir, label):
pdf_path = os.path.join(root_dir, "Sol2", f"{label}.pdf")
if os.path.exists(pdf_path):
return pdf_path
def get_exam_file_content(folder_path, mode, label):
"""
Retrieves content from the Text or Sol directory for a specific label.
Checks for exact filename matches or grouped 'prefix[a,b,c]' filenames.
"""
target_dir = Path(folder_path) / mode
if not target_dir.is_dir():
return None
# Sanitize label (consistent with the script's saving logic)
safe_label = label.replace("/", "_")
# 1. Try exact filename match
direct_file = target_dir / safe_label
if direct_file.is_file():
return direct_file.read_text(encoding="utf-8")
# 2. Search for grouped files: prefix[suffix1,suffix2,...]
for file_path in target_dir.glob("*[*]"):
name = file_path.name
if "[" in name and name.endswith("]"):
# Split 'prefix[suffixes]' -> 'prefix', 'suffixes'
prefix, rest = name.split("[", 1)
suffixes = rest[:-1].split(",") # Remove trailing ']' and split
# Check if any reconstructed label matches
for s in suffixes:
if (prefix + s) == safe_label:
return file_path.read_text(encoding="utf-8")
return None
def get_label_text_content(folder, label):
return get_exam_file_content(folder, "Text", label)
def get_label_sol_content(folder, label):
return get_exam_file_content(folder, "Sol", label)
def get_label_persp_content(folder, label):
return get_exam_file_content(folder, "Persp", label)
import tempfile
import shutil
import subprocess
def compile_to_pdf(text, output_pdf_path): # 21 cm + 3.8 (dimension de la marge de gauche)
"""Wraps text in a standalone template and compiles it to PDF."""
latex_template = f"""\\documentclass[varwidth=24.8cm,margin=0.4cm]{{standalone}}
\\usepackage[utf8]{{inputenc}}
\\usepackage[T1]{{fontenc}}
\\usepackage{{lmodern}}
\\usepackage{{amsmath, amssymb}}
\\usepackage{{commands}}
\\usepackage{{minted}}
\\usepackage{{graphicx}}
\\usepackage{{enumitem}}
\\begin{{document}}
\\begin{{minipage}}{{24.8cm}}
{text}
\\end{{minipage}}
\\end{{document}}
"""
with tempfile.TemporaryDirectory() as temp_dir:
tex_filename = 'text.tex'
pdf_filename = 'text.pdf'
tex_path = os.path.join(temp_dir, tex_filename)
with open(tex_path, 'w', encoding='utf-8') as f:
f.write(latex_template)
# Set TEXINPUTS so pdflatex can find commands.sty if it's in the current dir
# env = os.environ.copy()
# current_dir = os.getcwd()
# env['TEXINPUTS'] = f".:{current_dir}:"
try:
subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False
)
if "minted" in text:
subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False)
generated_pdf = os.path.join(temp_dir, pdf_filename)
if os.path.exists(generated_pdf):
shutil.move(generated_pdf, output_pdf_path)
except Exception as e:
print(f"Compilation error for {output_pdf_path}: {e}")