Compare commits

...
10 Commits
Author SHA1 Message Date
sebastien 137a9a7868 Gemini_for_enonce : améliorations 2026-08-08 12:30:14 +02:00
sebastien 9d7a4f37b6 New file : config.py (for models) 2026-08-07 20:55:49 +02:00
sebastien 09befe1d55 Initial Persp generation 2026-07-11 20:30:27 +02:00
sebastien fd35675e4c enonce_info.py 2026-07-09 15:54:36 +02:00
sebastien 121bd0714a DS09 : Conflict resolution, and other 2026-06-29 13:29:28 +02:00
sebastien 27c0dae20e Miscs 2026-06-06 22:09:00 +02:00
sebastien a80187ba80 support for `--update-score' after manual update of score.json 2026-06-06 10:10:53 +02:00
sebastien 80d06e4693 Miscs for Interro31 2026-06-06 09:27:48 +02:00
sebastien 9e8b9ac191 Initial support for _old and _new 2026-06-01 09:51:21 +02:00
sebastien c2e915226e Prompting.py ; Miscs for Interro29 2026-05-19 11:02:49 +02:00
20 changed files with 2145 additions and 631 deletions
+89 -27
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: <17-05-26 10:51> # Time-stamp: <08-08-26 12:28>
#+OPTIONS: #+OPTIONS:
* Méta * Méta
@@ -67,29 +67,71 @@ Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
export GEMINI_API_KEY= export GEMINI_API_KEY=
#+END_SRC #+END_SRC
ou éventuellement, la renseigner directement dans le fichier
`config.py`.
** Configuration
Copier `default_config.py` en `config.py`. Éventuellement le modifier.
** Correction d'un paquet de copies ** Correction d'un paquet de copies
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 (=Interro= dans la
3. Mettre les fichiers pdfs scannés dans =Interro=. suite)
4. Dans le dossier =Interro= créer un fichier =labels= avec les labels 3. Mettre l'énoncé, au format pdf, et l'énoncé et le corrigé au
des questions, comme =Ex 1 : 1)a)=, un par ligne. format .tex dans le dossier.
5. Il faudra créer des dossiers =Text=, =Sol= et =Persp=, et dans ces
dossiers créer, pour chaque label (ou groupe de labels : par
exemple un seul fichier =Ex 1= peut être utilisé pour toutes les
questions de l'exercice 1) un fichier texte qui contient
respectivement l'énoncé, un corrigé, et des indications de comment
corriger (Gemini met une note sur 4, on peut demander 2 points
pour tel truc, etc)
6. Suivre les étapes plus bas.
* Étapes et Script * Étapes et Script
** Prétraitement ** Prétraitement de l'énoncé
1. =./rotate_all.sh Interro= Dans le dossier de l'évaluation, mettre : `enonce.pdf`, `enonce.tex`,
(facultatif) `correction.tex`.
Retourne toutes les pdf de 180°
- `python gemini_for_enonce.py Interro` or
`python gemini_for_enonce.py Interro --restart`
À partir des trois fichiers précédents, se charge de détecter les
labels des questions et leur contenu.
Les questions vont également être regroupées. Par la suite, quand
des requêtes de corrections seront effectuées sur une question,
seulement les énoncés des questions du groupe seront envoyés (et le
corrigé de la question). Il faut donc que chaque groupe contienne
si possible le contexte nécessaire pour comprendre la question.
Une fenêtre s'ouvre pour permettre d'éditer le résultat. Ne pas
hésiter à faire des groupes plus gros que les groupes par défaut.
Après relecture le script génère :
+ 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 (utilisé pour compiler un rendu pdf du corrigé pour
chaque question)
+ Un dossier `Sol2`, qui compile un fichier `.tex` pour chaque
correction de chaque question.
+ Un dossier `Persp` avec des instruction de barème pour chaque
question.
Éventuellement : vérifier et modifier les barèmes dans `Persp`.
- Alternative personnelle : `python enonce_info.py Interro`
** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=.
1. =./rotate_all.sh Interro= (facultatif)
Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers.
2. =./rename_to_copie.sh Interro= 2. =./rename_to_copie.sh Interro=
change le nom des copies en =Copie{id}.pdf= change le nom des copies en =Copie{id}.pdf=
3. =python page_splitter.py Interro= 3. =python page_splitter.py Interro=
@@ -109,13 +151,6 @@ export GEMINI_API_KEY=…
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf= Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
** Génération d'information sur l'énoncé
1. =python enonce_info.py Interro= (gestion perso)
OU
2. =python gemini_for_enonce.py Interro=
+ Nécessite =enonce.tex/org= et `correction.tex/org`
** Labelisation et regroupement ** Labelisation et regroupement
Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~ Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
@@ -185,6 +220,19 @@ Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
- Essaye de corriger des erreurs d'encodage/d'accents dans - Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=. =correction.json=.
- aussi échappe les `_` en dehors du mode math, pour LaTeX. - aussi échappe les `_` en dehors du mode math, pour LaTeX.
4. Résolution manuel de conflits
Edit `manual_resolutions.txt`. Use :
+ `->` or `x>` : Here set a pipe `|` before or after the new_label name
+ `-x` : replace the goal
+ `ss` : do nothing
+ `sx` : stay, and remove goal.
+ `xx` : move to goal.
+ `xs` : remove old, keep goal.
Then call `python resolve_manual.py Interro`
5. Call `python correction.py Interro --refaire`.
** Génération des copies annotées ** Génération des copies annotées
@@ -245,7 +293,10 @@ OU
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans. Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
On peut faire des changements manuels aux =score.json= ici. 4. On peut faire des changements manuels aux =score.json= ici, puis
- `python reading_annotations.py --update-score Interro`
- `python reading_grouped_annotations.py --update-score Interro`
pour mettre à jour les scores dans les images.
4. (gestion perso) 4. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis + =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here) + =gestion_classe we= (set barème here)
@@ -269,7 +320,7 @@ groupée into refaire !!
+ =python plotting.py InterroTest/Copie01.pdf= + =python plotting.py InterroTest/Copie01.pdf=
+ =python splitting_int.py InterroTest/Copie20.pdf= + =python splitting_int.py InterroTest/Copie20.pdf=
2. Créer =refaire.json=, avec un contenu comme 2. Créer =refaire.json=, avec un contenu comme
: [["Copie01", []], : [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]] : ["Copie01", ["Ex 1 : 1)"]]]
3. Appeler =correction= avec --refaire. Il doit créer des groupes 3. Appeler =correction= avec --refaire. Il doit créer des groupes
individuels, faire des requêtes, et remplacer les corrections individuels, faire des requêtes, et remplacer les corrections
@@ -278,7 +329,18 @@ groupée into refaire !!
Ou non, si tu veux le faire à la main. Ou non, si tu veux le faire à la main.
4. ?? Si je fais refaire, avant d'avoir créer les annotating with 4. ?? Si je fais refaire, avant d'avoir créer les annotating with
checks, que se passe-t-il ??? checks, que se passe-t-il ???
5. Appeler =annotating_with_checks.py --refaire --overwrite= avec --refaire. 5. Appeler =annotating_with_checks.py --refaire --overwrite=
6. =python to_tablette.py --refaire Interro24= 6. =python to_tablette.py --refaire Interro24=
6. =python from_tablette.py --refaire Interro24= 6. =python from_tablette.py --refaire Interro24=
7. =python reading_grouped_annotations.py --refaire Interro24= 7. =python reading_grouped_annotations.py --refaire Interro24=
** Exemple de replotting, refaire d'une copie
1. replot it.
2. `python splitting_int.py DS09VA/Copies/Copie25.pdf`
this will get rid of old/new.
!! Attention, et si ça dégage un new : bad bad bad.
3. Make `refaire.json`, avec la copie, et les labels à refaire.
4. `python correction.py DS09VA --refaire`
5. `python annotating_with_checks.py DS09VA --refaire`
6. `python from_tablette.py Interro24 --refaire`
+35 -39
View File
@@ -34,6 +34,9 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
student_id = item['id'] student_id = item['id']
result_obj = item['result'] result_obj = item['result']
if result_obj.get("suffix") == "_old":
continue
# Find coordinates # Find coordinates
coordinates = None coordinates = None
height,width= None, None height,width= None, None
@@ -58,9 +61,11 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
if coordinates: if coordinates:
break break
# Construct PDF path: Dir/Copie{id}/{label}.pdf suffix = result_obj.get("suffix", "")
if suffix == "_new":
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}_new.pdf"
else:
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf" pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf"
# Initialize dictionary structure for this ID if missing # Initialize dictionary structure for this ID if missing
if student_id not in result_data: if student_id not in result_data:
result_data[student_id] = {} result_data[student_id] = {}
@@ -68,6 +73,8 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
fb = result_obj.get("feedback", []) fb = result_obj.get("feedback", [])
for i in range(len(fb)): for i in range(len(fb)):
el = fb[i] el = fb[i]
if height == None or width == None:
print("?? height or width is None, for ", student_id, label)
if "box_2d" in el and el["box_2d"]: if "box_2d" in el and el["box_2d"]:
el["box_2d"][0] = (el["box_2d"][0] * height)//1000 el["box_2d"][0] = (el["box_2d"][0] * height)//1000
el["box_2d"][2] = (el["box_2d"][2] * height)//1000 el["box_2d"][2] = (el["box_2d"][2] * height)//1000
@@ -91,30 +98,36 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
for lbl in labels_to_redo: for lbl in labels_to_redo:
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf" pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
if not Path(pdf_path).exists(): if not Path(pdf_path).exists():
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
if pdf_path_new.exists():
pdf_path = pdf_path_new
else:
print("Debug : asked to refaire", sid, lbl, "but pdf absent") print("Debug : asked to refaire", sid, lbl, "but pdf absent")
continue continue
result_data[sid][lbl] = { # result_data[sid][lbl] = {
"pdf_path": pdf_path, # "pdf_path": pdf_path,
"result": { # "result": {
"score": 0.0, # "score": 0.0,
"confidence": 1.0, # "feedback": [],
"feedback": [], # "error": "non traité"
"error": "non traité" # },
}, # "coordinates": (0,0)
"coordinates": (0,0) # }
}
else: # Ce student id n'a jamais été corrigé else: # Ce student id n'a jamais été corrigé
result_data[sid] = {} result_data[sid] = {}
for lbl in labels_to_redo: for lbl in labels_to_redo:
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf" pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
if not pdf_path.exists(): if not pdf_path.exists():
pdf_path_new = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}_new.pdf"
if pdf_path_new.exists():
pdf_path = pdf_path_new
else:
print("Debug : asked to refaire", sid, lbl, "but pdf absent") print("Debug : asked to refaire", sid, lbl, "but pdf absent")
continue continue
result_data[sid][lbl] = { result_data[sid][lbl] = {
"pdf_path": pdf_path, "pdf_path": pdf_path,
"result": { "result": {
"score": 0.0, "score": 0.0,
"confidence": 1.0,
"feedback": [], "feedback": [],
"error": "non traité" "error": "non traité"
}, },
@@ -267,24 +280,19 @@ import subprocess
import PIL.ImageOps import PIL.ImageOps
from config import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19): def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
dpi = 100 dpi = 100
width_in = width_px / dpi width_in = width_px / dpi
line_spacing = int(fontsize * 1.2) line_spacing = int(fontsize * 1.2)
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width # Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
latex_template = f"""\\documentclass[varwidth={width_in}in,margin=0.2cm]{{standalone}} header = LATEX_ANOT_BEFORE.format(
\\usepackage[utf8]{{inputenc}} width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
\\usepackage[T1]{{fontenc}} )
\\usepackage{{lmodern}} % Enables arbitrary font scaling latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
\\usepackage{{amsmath, amssymb}}
\\usepackage{{commands}}
%\\usepackage{{anyfontsize}} % replaced by lmodern
\\begin{{document}}
\\fontsize{{{fontsize}}}{{{line_spacing}}}\\selectfont
{text}
\\end{{document}}
"""
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
tex_path = os.path.join(temp_dir, 'text.tex') tex_path = os.path.join(temp_dir, 'text.tex')
@@ -571,9 +579,9 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
for label, content in sorted_labels: for label, content in sorted_labels:
# 1. Find PDF path # 1. Find PDF path
copie_folder = f"Copie{student_id}" copie_folder = f"Copie{student_id}"
pdf_full_path = Path(root_dir) / "Copies" / copie_folder / f"{label}.pdf" pdf_full_path = content.get('pdf_path')
if not os.path.exists(pdf_full_path): if not pdf_full_path or not os.path.exists(pdf_full_path):
print(f"File not found: {pdf_full_path}") print(f"File not found: {pdf_full_path}")
continue continue
@@ -615,18 +623,6 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
def process_correction(root_dir, data, all_labels, overwrite=False): def process_correction(root_dir, data, all_labels, overwrite=False):
# with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
# # Create a list of futures
# futures = []
# for student_id, labels in sorted(data.items()):
# futures.append(
# executor.submit(process_student, student_id, labels, root_dir, all_labels, overwrite)
# )
# # Wait for all threads to complete
# concurrent.futures.wait(futures)
# Ne pas thread cette application # Ne pas thread cette application
# 1. Il faut protéger les appels à matplotlib # 1. Il faut protéger les appels à matplotlib
# 2. tu vas perdre les erreurs # 2. tu vas perdre les erreurs
+8 -1
View File
@@ -143,6 +143,13 @@ def main():
continue continue
base_prefix = os.path.commonprefix(safe_labels).strip() base_prefix = os.path.commonprefix(safe_labels).strip()
if base_prefix and previous_prefix is not None:
if natural_key(base_prefix) < natural_key(previous_prefix):
base_prefix_maybe = f"{safe_labels[0]}+"
if natural_key(base_prefix_maybe) > natural_key(previous_prefix):
base_prefix = base_prefix_maybe
if not base_prefix: if not base_prefix:
base_prefix = "Group" base_prefix = "Group"
@@ -156,7 +163,7 @@ def main():
if counter == 2 and previous_prefix and previous_prefix in unique_prefix: if counter == 2 and previous_prefix and previous_prefix in unique_prefix:
unique_prefix = f"{previous_prefix}-{counter}" unique_prefix = f"{previous_prefix}-{counter}"
elif counter == 2: elif counter == 2:
previous_prefx = unique_prefix previous_prefix = unique_prefix
used_prefixes.add(unique_prefix) used_prefixes.add(unique_prefix)
+268 -342
View File
@@ -3,6 +3,14 @@ import os
import time import time
from pathlib import Path from pathlib import Path
import argparse import argparse
import prompting
import signal
from google import genai
import base64
import shlex
import json
import threading
import concurrent.futures
if len(sys.argv) < 2: if len(sys.argv) < 2:
sys.exit("Usage: python script.py 'InterroTest/Ex 2/Group_1.jpg' OR <InputDir> OR 'file1' 'file2'") sys.exit("Usage: python script.py 'InterroTest/Ex 2/Group_1.jpg' OR <InputDir> OR 'file1' 'file2'")
@@ -21,6 +29,8 @@ parser.add_argument("--batch-from", type=str, metavar="LABEL",
help="Do live requests before LABEL, and batch requests from LABEL onwards") help="Do live requests before LABEL, and batch requests from LABEL onwards")
parser.add_argument("--deal-with-batched", action="store_true", parser.add_argument("--deal-with-batched", action="store_true",
help="Process a JSONL file containing completed batch results") help="Process a JSONL file containing completed batch results")
parser.add_argument("--reset", action="store_true",
help="Remove correction.json, revert _old.pdf, delete _new.pdf, then exit")
args, _ = parser.parse_known_args() args, _ = parser.parse_known_args()
tasks = [] # List of tuples: (filepath_str, label_str) tasks = [] # List of tuples: (filepath_str, label_str)
@@ -36,7 +46,7 @@ for path_str in args.paths:
if arg_path.is_file() and arg_path.suffix.lower() == ".jpg": if arg_path.is_file() and arg_path.suffix.lower() == ".jpg":
# Handle individual file # Handle individual file
# Note: assumes structure InterroTest/Ex 2/Group_1.jpg to get parents[1] # Note: assumes structure InterroTest/Ex 2/Group_1.jpg
label = arg_path.parent.name label = arg_path.parent.name
INPUT_DIR = arg_path.parent.parent.parent INPUT_DIR = arg_path.parent.parent.parent
COPIES_DIR = INPUT_DIR / "Copies" COPIES_DIR = INPUT_DIR / "Copies"
@@ -58,124 +68,6 @@ for path_str in args.paths:
for img in sub.glob("*.jpg"): for img in sub.glob("*.jpg"):
tasks.append((str(img), label)) tasks.append((str(img), label))
my_prompt = """I'm giving you an image of several written answers to an exam.
Each answer is separated by a black horizontal line, and underneath,
to the left, is indicated the ID of the answer, from `01` to `50`.
I want you to score each answer, from 0 to 4, you may score half
points, such as 2.5. Even if a result is wrong, if the reasoning is
correct and could lead to a right answer, you should give at least
half the points.
You also need to give feedback to the student, in french :
- which part of his answer is wrong,
- why is it wrong
- possibly, what he should have done instead.
Your feedback may contain LaTeX fragments written like `$a^2 + b^2 = c^2$`.
If your score is not 4, you should always provide some feedback
explaining what's missing.
For each piece of feedback, if it is related to a specific part of the
answer that is wrong, you may provide a `box_2d`, to locate this
specific part of the answer. This `box_2d` should be in the form
[ymin, xmin, ymax, xmax] normalized to 0-1000. If you do not provide
one, set `box_2d` to `null`.
If the answer is correct, there is no need to provide feedback. You do
not have to give positive feedback, but if you do, do not provide a
`box_2d` for it.
For example, if the student says a function is continuous when it
isn't, provide the coordinates where the word «continuous» is. If a
calculation went wrong, gives the coordinates of the step where it
goes wrong, and as feedback, what went wrong.
Avoid giving feedback about confusing letters `n` with `m`, `x` with
`n` or `h` with `k`. If it looks wrong, assume you read it wrong,
unless the distinction is very important.
You should also give me a measure of confidence, from 0 to 1 that you
were able to correctly understand the answer. A score below 0.5 means
that you think it is likely that you couldn't understand an important
part.
In some case, you may find that either
- The student didn't answer the right question. Set the score to 0.
Since it could be a labeling error, indicate is by setting `error`
to \"wrong-label\".
- You can find an answer to another question of the exercice (taking
more than a couple of lines). Score the question you are supposed
to score, but set `error` to \"additional-answer\".
- The answer to the question is empty, or the student has only
rewritten the statement of the question. In this case, set `error`
to \"empty-answer\" and do not provide any kind of feedback.
If there's no error, set `error` to `\"\"`.
You will answer using json describing a list of dictionary with a key
\"id\", and a key \"result\" that contains the \"score\", the \"confidence\", a
list \"feedback\", and possibly an \"error\". Like this example :
[{ \"id\": \"01\",
\"result\": {\"score\" : 2.5,
\"confidence\" : 0.8,
\"feedback\": [{text: \"Un retour générique. Il faut apprendre le cours.\", box_2d: null},
{text: \"Non, la fonction n'est pas forcément continue\", pos: [145, 280, 340, 500]}],
\"error\": \"\"}
},
{ \"id\": \"04\",
\"result\": {\"score\" : 4.,
\"confidence\" : 0.9,
\"feedback\" : []
\"error\": \"\" }
}
]
Here is the text of the exercice (or the relevant part of the problem)
of the exam :
```
<<text>>
```
Here is a possible correct answer :
```
<<corr>>
```
<<persp>>
You are asked to score the question or exercice labeled `<<label>>`,
do not score or give feedback to any other question."""
def make_prompt(full_label):
def read_longest_prefix_file(subdir):
dir_path = INPUT_DIR / subdir
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")
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"
return my_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
from google import genai
from google.genai import types
import base64
import shlex
import json
import os
import threading
import concurrent.futures
NB_THREADS = 12 NB_THREADS = 12
@@ -186,12 +78,11 @@ if PROXY_URL:
os.environ["http_proxy"] = PROXY_URL os.environ["http_proxy"] = PROXY_URL
os.environ["https_proxy"] = PROXY_URL os.environ["https_proxy"] = PROXY_URL
MODEL_ID_pro = "gemini-3.1-pro-preview" import config
MODEL_ID_flash = "gemini-3-flash-preview"
api_key = os.environ["GEMINI_API_KEY"]
import signal MODEL_ID_pro = config.MODEL_PRO_ID
import sys MODEL_ID_flash = config.MODEL_FLASH_ID
api_key = config.API_KEY
# --- Thread-safe Logging --- # --- Thread-safe Logging ---
log_lock = threading.Lock() log_lock = threading.Lock()
@@ -227,97 +118,44 @@ def handle_interrupt(sig, frame):
flush_thread_log(tid) flush_thread_log(tid)
sys.exit(1) sys.exit(1)
signal.signal(signal.SIGINT, handle_interrupt) signal.signal(signal.SIGINT, handle_interrupt)
signal.signal(signal.SIGTERM, handle_interrupt) signal.signal(signal.SIGTERM, handle_interrupt)
# --------------------------- # ---------------------------
from pydantic import BaseModel, Field, TypeAdapter
from typing import List, Optional, Tuple
class FeedbackItem(BaseModel):
text: str = Field(description="Feedback content")
box_2d: Optional[List[int]] = Field(None, description="box coordinates or null")
class ResultData(BaseModel):
score: float = Field(description="The numeric score")
confidence: float = Field(description="Confidence level")
feedback: List[FeedbackItem] = Field(description="List of feedback items")
error: str = Field(description="Indicates if an error occurred")
class EvaluationEntry(BaseModel):
id: str = Field(description="Entry identifier")
result: ResultData = Field(description="Result details")
# These nested definitions do not work with the batch api, unroll them
UNROLLED_SCHEMA = {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"id": {"type": "STRING", "description": "Entry identifier"},
"result": {
"type": "OBJECT",
"properties": {
"score": {"type": "NUMBER", "description": "The numeric score"},
"confidence": {"type": "NUMBER", "description": "Confidence level"},
"error": {"type": "STRING", "description": "Indicates if an error occurred"},
"feedback": {
"type": "ARRAY",
"description": "List of feedback items",
"items": {
"type": "OBJECT",
"properties": {
"text": {"type": "STRING", "description": "Feedback content"},
"box_2d": {
"type": "ARRAY",
"items": {"type": "INTEGER"},
"nullable": True,
"description": "box coordinates or null"
}
},
"required": ["text"]
}
}
},
"required": ["score", "confidence", "feedback", "error"]
}
},
"required": ["id", "result"]
}
}
# The root model for parsing is be: List[EvaluationEntry]
def generate_request(file, full_label):
"""Generates request for Gemini."""
prompt = make_prompt(full_label)
image_path = Path(file)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=prompt),
],
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= TypeAdapter(List[EvaluationEntry]).json_schema()
)
return (contents, generate_content_config)
client = genai.Client(api_key=api_key) client = genai.Client(api_key=api_key)
output_path = INPUT_DIR / "correction.json" output_path = INPUT_DIR / "correction.json"
progress_path = INPUT_DIR / "correction_progress.json" progress_path = INPUT_DIR / "correction_progress.json"
if args.reset:
print("--- Running Reset ---")
if output_path.exists():
output_path.unlink()
print(f"Deleted: {output_path}")
if progress_path.exists():
progress_path.unlink()
print(f"Deleted: {progress_path}")
if COPIES_DIR.exists():
for copie_dir in COPIES_DIR.iterdir():
if not copie_dir.is_dir():
continue
# Revert _old.pdf files
for old_pdf in copie_dir.glob("*_old.pdf"):
orig_pdf = copie_dir / old_pdf.name.replace("_old.pdf", ".pdf")
if orig_pdf.exists():
orig_pdf.unlink() # Prevent FileExistsError on Windows
old_pdf.rename(orig_pdf)
print(f"Moved: {copie_dir.name}/{old_pdf.name} -> {orig_pdf.name}")
# Delete _new.pdf files
for new_pdf in copie_dir.glob("*_new.pdf"):
new_pdf.unlink()
print(f"Deleted: {copie_dir.name}/{new_pdf.name}")
sys.exit("Reset almost complete. For each deleted `_new`, you should manually delete the group in `Par label`")
start_time = time.time() start_time = time.time()
overwrite = args.overwrite overwrite = args.overwrite
limit = args.limit limit = args.limit
@@ -371,6 +209,17 @@ def call_gemini_with_retries(model_id, contents, config,
except Exception as e: except Exception as e:
error_msg = str(e).lower() error_msg = str(e).lower()
is_quota_error = "429" in error_msg or "quota" in error_msg or "exhausted" in error_msg is_quota_error = "429" in error_msg or "quota" in error_msg or "exhausted" in error_msg
is_minute_limit = "minute" in error_msg or "rpm" in error_msg or "tpm" in error_msg
if is_minute_limit:
import re
# Extract wait time if present, else use default delay
retry_match = re.search(r"retry in ([\d.]+)s", error_msg)
wait_time = float(retry_match.group(1)) + 1.0 if retry_match else delays[attempt]
tprint(f"\tGemini Pro minute limit hit. Waiting {wait_time:.1f}s...")
time.sleep(wait_time)
continue # Retry same model
# Immediately fallback to Flash without waiting if it's a Pro quota error # Immediately fallback to Flash without waiting if it's a Pro quota error
if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id: if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id:
@@ -386,76 +235,17 @@ def call_gemini_with_retries(model_id, contents, config,
tprint(f"\tGemini API failure: {e}. Maximum retries reached.") tprint(f"\tGemini API failure: {e}. Maximum retries reached.")
raise raise
import io def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
from pdf2image import convert_from_path
from PIL import Image
def get_single_image_bytes(pdf_path):
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
if not imgs:
raise ValueError(f"No pages in {pdf_path}")
if len(imgs) == 1:
combined = imgs[0]
else:
max_width = max(img.width for img in imgs)
total_height = sum(img.height for img in imgs)
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in imgs:
combined.paste(img, (0, y_offset))
y_offset += img.height
img_byte_arr = io.BytesIO()
combined.save(img_byte_arr, format='JPEG', quality=85)
return img_byte_arr.getvalue()
def correct_boxes_with_gemini(pid, label, original_feedbacks,
yming, ymaxg, width_r, total_height): yming, ymaxg, width_r, total_height):
"""Requests corrected bounding boxes from Gemini Flash on the single image.""" """Requests corrected bounding boxes from Gemini Flash on the single image."""
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf" # pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
img_bytes = get_single_image_bytes(pdf_path)
localized_feedbacks = [f for f in original_feedbacks if f["box_2d"]]
global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]]
prompt = f"""
Here is a single student's submission to a question in a written exam. The following JSON contains feedback items with bounding boxes (box_2d) that are incorrect. Each piece of feedback is supposed to be related to a piece of the answer that is wrong.
For example, if the student says a function is continuous when it
isn't, the coordinates should be where the word «continuous» is. If a
calculation went wrong, the coordinates should be where the step where
it goes wrong, and the feedback is what went wrong.
Please analyze the image and return the exact same feedback text, but with ONLY the box_2d coordinates corrected for this specific image.
Coordinates must be [ymin, xmin, ymax, xmax] scaled to 1000. If a box is invalid/not found, return null for it.
Original feedback:
{json.dumps(localized_feedbacks, indent=2)}
"""
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg"),
types.Part.from_text(text=prompt),
],
)
]
config = types.GenerateContentConfig(
temperature=0.0, # Low temperature for accurate correction
response_mime_type="application/json",
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema()
)
contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks)
response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config) response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config)
corrected_feedbacks = json.loads(response_text) corrected_feedbacks = json.loads(response_text)
global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]]
# Map the coordinates back from the single image to the group canvas # Map the coordinates back from the single image to the group canvas
for f in corrected_feedbacks: for f in corrected_feedbacks:
b = f.get("box_2d") b = f.get("box_2d")
@@ -499,27 +289,7 @@ def handle_label_errors(pid, label, res, pdf_path):
if error_type == "wrong-label": if error_type == "wrong-label":
tprint(f"\tHandling wrong-label for {pid} {label}") tprint(f"\tHandling wrong-label for {pid} {label}")
prompt = f"""This image is a part of the answer of a student to a written exam. contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt)
It was initially labeled '{label}' but I suspect this label is wrong. Perhaps the student himself wrote the wrong label.
You need to analyse this image, and find the label of the question it answers. Do not trust the label written by the student but instead check the content of its answer and the notation he uses to identify the correct label of the question the student answered.
Return ONLY the exact label string.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with one of these :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt) ])]
config = types.GenerateContentConfig(temperature=0.0)
new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'') new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'')
if new_label not in all_labels: if new_label not in all_labels:
tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring") tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring")
@@ -528,53 +298,41 @@ Here is a list of all possible labels. You need to answer with one of these :
if new_label == label: if new_label == label:
res["error"] = "" res["error"] = ""
return [] return []
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
if new_pdf_path.exists(): base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
tprint(f"\t\tCopie{pid} tried to move wrong {label} to {new_label}, but it already exists.") new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}_new.pdf"
res["error"] = f"wrg-lbl:{new_label}?exists"
if base_new_pdf_path.exists() or new_pdf_path.exists():
tprint(f"""\t\tCopie{pid} tried to move wrong {label} to {new_label},
but it already exists. Delaying.""")
# res["error"] = f"wrg-lbl:{new_label}?exists"
res["error"] = f"wrg-lbl:{new_label}?"
res.setdefault("delayed", []).append(["wrong-label", new_label])
else: else:
res["error"] = f"wrg-lbl-moved-to:{new_label}" res["error"] = f"wrg-lbl-moved-to:{new_label}"
tprint(f"\t\tCopie{pid} : moving wrong {label} to {new_label}.") tprint(f"\t\tCopie{pid} : moving wrong {label} to {new_label}.")
shutil.move(str(pdf_path), str(new_pdf_path))
# Since we moved the file, this Copie/label should not be taken # Copie vers _new, puis renommage de l'original vers _old
# into account in the future, I think shutil.copy(str(pdf_path), str(new_pdf_path))
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(new_label) idx = get_next_group_idx(new_label)
height = grouping.get_pdf_height(str(new_pdf_path)) height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
GROUPS_DIR)
tprint(f"\t\tMaking {new_label} group {idx+1}") tprint(f"\t\tMaking {new_label} group {idx+1}")
new_tasks.append((str(GROUPS_DIR / new_label / f"Group_{idx+1}.jpg"), new_tasks.append((str(GROUPS_DIR / new_label / f"Group_{idx+1}.jpg"),
new_label, False)) new_label, False))
elif error_type == "additional-answer": elif error_type == "additional-answer":
prompt = f"""This image is a part of the answer of a student to a written exam. contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt)
It was initially labeled '{label}' but I suspect this image also contains answers to another, or several other questions.
You need to analyse this image, and find the list of the labels of the questions it answers. Return ONLY the list of the exact label strings.
If the end of the image only contains the first line of an answer to another question, ignore it.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with a list one of these :
{labels_txt}
"""
tprint(f"\tHandling additional-answer for {pid} {label}") tprint(f"\tHandling additional-answer for {pid} {label}")
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)
])]
config = types.GenerateContentConfig(temperature=0.0, response_mime_type="application/json")
try: try:
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config)) add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config))
except Exception: except Exception:
add_labels = [] add_labels = []
tprint(f"\tHandling additional-answer for {pid} {label}")
keep_error = False keep_error = False
error = "al:" error = "al:"
for add_label in add_labels: for add_label in add_labels:
@@ -585,23 +343,26 @@ Here is a list of all possible labels. You need to answer with a list one of the
error += f"{add_label}??" error += f"{add_label}??"
keep_error = True keep_error = True
continue continue
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
if not new_pdf_path.exists(): base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
shutil.copy(str(pdf_path), str(new_pdf_path)) add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}_new.pdf"
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
shutil.copy(str(pdf_path), str(add_pdf_path))
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}") tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
idx = get_next_group_idx(add_label) idx = get_next_group_idx(add_label)
tprint(f"\t\tMaking {add_label} group {idx+1}") tprint(f"\t\tMaking {add_label} group {idx+1}")
height = grouping.get_pdf_height(str(new_pdf_path)) height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(add_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR) grouping.create_jpg(add_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / add_label / f"Group_{idx+1}.jpg"), new_tasks.append((str(GROUPS_DIR / add_label / f"Group_{idx+1}.jpg"),
add_label, False)) add_label, False))
error += f"(->){add_label}" error += f"(->){add_label}"
keep_error = True keep_error = True
else: else:
keep_error = True keep_error = True
error += f"(xx){add_label}" error += f"(->){add_label}?"
tprint(f"\t\tAlready present (not copied) Copie{pid} : {label} -> {add_label}") res.setdefault("delayed", []).append(["add-label", add_label])
tprint(f"\t\tAlready present (not copied) Copie{pid} : {label} -> {add_label}. Delaying.")
if not keep_error: if not keep_error:
res["error"] = "" res["error"] = ""
else: else:
@@ -644,7 +405,7 @@ def process_single_task(task_tuple, precomputed_response=None):
flash_count += 1 flash_count += 1
try: try:
contents, config = generate_request(file_path, label) contents, config = prompting.generate_request(INPUT_DIR, file_path, label)
model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro
if precomputed_response: if precomputed_response:
@@ -663,6 +424,26 @@ def process_single_task(task_tuple, precomputed_response=None):
yming, ymaxg, width_r = d_data[pid] yming, ymaxg, width_r = d_data[pid]
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf" pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
current_suffix = ""
# Détection du vrai fichier s'il a un suffixe
if not pdf_path.exists():
if pdf_path.with_name(f"{label}_new.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
current_suffix = "_new"
# Quand est-ce que ce chemin est utilisé ? Jamais ?
elif pdf_path.with_name(f"{label}_old.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
current_suffix = "_old"
# 1. Gestion de empty-answer
if res.get("error") == "empty-answer":
old_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path.exists() and pdf_path != old_path:
shutil.move(str(pdf_path), str(old_path))
pdf_path = old_path
current_suffix = "_old"
if (not can_spawn_tasks) and res["error"] == "additional-answer": if (not can_spawn_tasks) and res["error"] == "additional-answer":
tprint("\tSwallowing an additional-answer from a subsequent task.") tprint("\tSwallowing an additional-answer from a subsequent task.")
res["error"]= "" res["error"]= ""
@@ -671,6 +452,13 @@ def process_single_task(task_tuple, precomputed_response=None):
if can_spawn_tasks and res.get("error") in ["wrong-label", "additional-answer"]: if can_spawn_tasks and res.get("error") in ["wrong-label", "additional-answer"]:
new_tasks.extend(handle_label_errors(pid, label, res, pdf_path)) new_tasks.extend(handle_label_errors(pid, label, res, pdf_path))
# Si "wrong-label" a déplacé le fichier courant vers _old
if res.get("error", "").startswith("wrg-lbl-moved-to:"):
current_suffix = "_old"
# 5. Enregistrer l'information dans correction.json
if current_suffix:
res["suffix"] = current_suffix
needs_correction = [] needs_correction = []
for (i,f) in enumerate(res["feedback"]): for (i,f) in enumerate(res["feedback"]):
@@ -699,8 +487,9 @@ def process_single_task(task_tuple, precomputed_response=None):
if needs_correction: if needs_correction:
tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...") tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...")
try: try:
# Pensez à passer pdf_path à la fonction modifiée !
res["feedback"] = correct_boxes_with_gemini( res["feedback"] = correct_boxes_with_gemini(
pid, label, res["feedback"], pid, label, pdf_path, res["feedback"],
yming, ymaxg, width_r, total_height) yming, ymaxg, width_r, total_height)
except Exception as e: except Exception as e:
tprint(f"\tCorrection failed for Copie {pid}, {group_name} : {e}\n\tRemoving the boxes") tprint(f"\tCorrection failed for Copie {pid}, {group_name} : {e}\n\tRemoving the boxes")
@@ -725,6 +514,8 @@ def process_single_task(task_tuple, precomputed_response=None):
except json.JSONDecodeError: except json.JSONDecodeError:
tprint(f"Error decoding JSON for {file_path}", file=sys.stderr) tprint(f"Error decoding JSON for {file_path}", file=sys.stderr)
with io_lock:
errors_summary.append(("Error decoding JSON response", file_path))
except Exception as e: except Exception as e:
error_msg = f"Exception processing {file_path}: {e}" error_msg = f"Exception processing {file_path}: {e}"
print(error_msg, file=sys.stderr) print(error_msg, file=sys.stderr)
@@ -734,6 +525,84 @@ def process_single_task(task_tuple, precomputed_response=None):
finally: finally:
flush_thread_log() flush_thread_log()
def resolve_delayed_moves():
"""Scans the current results to find delayed moves and executes them if space was freed."""
new_tasks = []
with io_lock:
for label, batches in results.items():
for batch in batches:
for p in batch:
res = p.get("result", {})
delayed_list = res.get("delayed", [])
if not delayed_list:
continue
pid = p["id"]
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
if not pdf_path.exists():
if pdf_path.with_name(f"{label}_new.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
elif pdf_path.with_name(f"{label}_old.pdf").exists():
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
remaining_delayed = []
resolved_any = False
for delay_type, target_label in delayed_list:
# 1. Résolution de wrong-label
if delay_type == "wrong-label":
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
if not base_new_pdf_path.exists() and not new_pdf_path.exists():
tprint(f"Resolving delayed move: Copie{pid} {label} -> {target_label}")
res["error"] = f"wrg-lbl-moved-to:{target_label}"
res["suffix"] = "_old" # Fixed typo: was suffixe
resolved_any = True
shutil.copy(str(pdf_path), str(new_pdf_path))
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
if pdf_path != old_pdf_path:
shutil.move(str(pdf_path), str(old_pdf_path))
idx = get_next_group_idx(target_label)
height = grouping.get_pdf_height(str(new_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
else:
remaining_delayed.append([delay_type, target_label])
# 2. Résolution de additional-answer
elif delay_type == "add-label":
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
tprint(f"Resolving delayed additional-answer: Copie{pid} {label} -> {target_label}")
res["error"] = res["error"].replace(f"(xx){target_label}", f"(->){target_label}")
resolved_any = True
shutil.copy(str(pdf_path), str(add_pdf_path))
idx = get_next_group_idx(target_label)
height = grouping.get_pdf_height(str(add_pdf_path))
grouping.create_jpg(target_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
else:
remaining_delayed.append([delay_type, target_label])
if resolved_any:
if remaining_delayed:
res["delayed"] = remaining_delayed
else:
del res["delayed"]
if new_tasks:
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
return new_tasks
if __name__ == "__main__": if __name__ == "__main__":
if args.refaire: if args.refaire:
refaire_path = INPUT_DIR / "refaire.json" refaire_path = INPUT_DIR / "refaire.json"
@@ -756,7 +625,15 @@ if __name__ == "__main__":
# If list is empty, redo all labels available for this Copie # If list is empty, redo all labels available for this Copie
if not labels: if not labels:
labels = [p.stem for p in copie_dir.glob("*.pdf")] labels_set = set()
for p in copie_dir.glob("*.pdf"):
if p.name.endswith("_old.pdf"):
continue # Strictly ignore old files
elif p.name.endswith("_new.pdf"):
labels_set.add(p.stem[:-4]) # Strip '_new' to get base label
else:
labels_set.add(p.stem)
labels = list(labels_set)
for label in labels: for label in labels:
# 1. Extract and backup old corrections # 1. Extract and backup old corrections
@@ -781,12 +658,21 @@ if __name__ == "__main__":
# 2. Make new group and add to tasks # 2. Make new group and add to tasks
pdf_path = copie_dir / f"{label}.pdf" pdf_path = copie_dir / f"{label}.pdf"
is_new = False
if not pdf_path.exists():
if (copie_dir / f"{label}_new.pdf").exists():
pdf_path = copie_dir / f"{label}_new.pdf"
is_new = True
# elif (copie_dir / f"{label}_old.pdf").exists():
# pdf_path = copie_dir / f"{label}_old.pdf"
if pdf_path.exists(): if pdf_path.exists():
idx = get_next_group_idx(label) idx = get_next_group_idx(label)
height = grouping.get_pdf_height(str(pdf_path)) height = grouping.get_pdf_height(str(pdf_path))
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR) grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg") new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg")
tasks_to_process.append((new_group_path, label)) tasks_to_process.append((new_group_path, label, not is_new))
if dirty_results: if dirty_results:
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
@@ -798,11 +684,14 @@ if __name__ == "__main__":
if args.batch or args.batch_from: if args.batch or args.batch_from:
from utils import read_all_labels
all_labels = read_all_labels(INPUT_DIR) all_labels = read_all_labels(INPUT_DIR)
batch_tasks = [] batch_tasks = []
if args.batch_from: if args.batch_from:
for label in all_labels:
if label.startswith(args.batch_from):
args.batch_from = label
input(f"About to batch from: {args.batch_from}. Press Enter to confirm...")
break
if args.batch_from not in all_labels: if args.batch_from not in all_labels:
sys.exit(f"Error: Label '{args.batch_from}' not found. Available labels: {all_labels}") sys.exit(f"Error: Label '{args.batch_from}' not found. Available labels: {all_labels}")
@@ -852,7 +741,7 @@ if __name__ == "__main__":
"role": "user", "role": "user",
"parts": [ "parts": [
{"inlineData": {"mimeType": "image/jpeg", "data": b64_img}}, {"inlineData": {"mimeType": "image/jpeg", "data": b64_img}},
{"text": make_prompt(label)} {"text": prompting.make_prompt(INPUT_DIR,label)}
] ]
}], }],
"generation_config": { "generation_config": {
@@ -860,7 +749,7 @@ if __name__ == "__main__":
"topP": 0.95, "topP": 0.95,
"maxOutputTokens": 65535, "maxOutputTokens": 65535,
"responseMimeType": "application/json", "responseMimeType": "application/json",
"responseSchema": UNROLLED_SCHEMA "responseSchema": prompting.UNROLLED_SCHEMA
} }
} }
} }
@@ -904,6 +793,9 @@ if __name__ == "__main__":
else: else:
print(f"Warning: Batch results file {batch_results_path} not found.", file=sys.stderr) print(f"Warning: Batch results file {batch_results_path} not found.", file=sys.stderr)
made_progress = True
while tasks_to_process or made_progress:
if tasks_to_process:
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...") print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
futures = {} futures = {}
@@ -912,17 +804,51 @@ if __name__ == "__main__":
precomp = batched_responses.get(file_path) precomp = batched_responses.get(file_path)
futures[executor.submit(process_single_task, task, precomp)] = task futures[executor.submit(process_single_task, task, precomp)] = task
# Process tasks as they complete, allowing dynamic task addition
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
try: try:
new_generated_tasks = future.result() new_generated_tasks = future.result()
if new_generated_tasks: if new_generated_tasks:
for new_task in new_generated_tasks: for new_task in new_generated_tasks:
# New tasks from wrong-label/additional-answer will fallback to live API
futures[executor.submit(process_single_task, new_task)] = new_task futures[executor.submit(process_single_task, new_task)] = new_task
except Exception as e: except Exception as e:
print(f"Exception during task execution: {e}", file=sys.stderr) print(f"Exception during task execution: {e}", file=sys.stderr)
tasks_to_process = [] # Vider la liste une fois traitée
# Après avoir traité toutes les tâches actuelles (live ou batched),
# on tente de débloquer les mouvements qui étaient en attente
delayed_tasks = resolve_delayed_moves()
if delayed_tasks:
print(f"Resolved {len(delayed_tasks)} delayed moves! Running executor for new tasks...")
tasks_to_process.extend(delayed_tasks)
made_progress = True
else:
made_progress = False
# Check for remaining unresolved delayed tasks
unresolved_delayed = []
with io_lock:
for label, batches in results.items():
for batch in batches:
for p in batch:
res = p.get("result", {})
delayed = res.get("delayed", [])
pid = p["id"]
for delay_type, target_label in delayed:
if delay_type == "wrong-label":
unresolved_delayed.append(f"Copie{pid} {label} x> {target_label}|")
elif delay_type == "add-label":
unresolved_delayed.append(f"Copie{pid} {label} -> {target_label}|")
if unresolved_delayed:
manual_path = INPUT_DIR / "manual_resolutions.txt"
with open(manual_path, "w", encoding="utf-8") as f:
f.write("### Use -> x>, -x, ss, sx, xx, xs\n")
f.write("\n".join(unresolved_delayed) + "\n")
print(f"\n[!] Unresolved delayed tasks found! Wrote to {manual_path}.")
print(" Please edit it manually, then run `python resolve_manual.py <InputDir>`")
end_time = time.time() end_time = time.time()
print("Time elapsed : ", end_time - start_time) print("Time elapsed : ", end_time - start_time)
print("Requests to pro / flash : ", pro_count, flash_count) print("Requests to pro / flash : ", pro_count, flash_count)
+83
View File
@@ -0,0 +1,83 @@
import os
API_KEY = os.environ.get("GEMINI_API_KEY")
# Modèle pour des choses très légères
MODEL_LITE_ID = "gemini-3.5-flash-lite"
# Modèle pour des choses normales
MODEL_FLASH_ID = "gemini-3.6-flash"
# Modèle pour des choses dures
MODEL_PRO_ID = "gemini-3.6-flash"
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
PAGE_SPLITTER_KB = {
"move_left": "<Left>",
"move_right": "<Right>",
"confirm_next": "<Return>",
"rotate_page": "r",
"rotate_all_pages": "R",
"rotate_all_files": ",",
"keep_left": "q",
"keep_right": "d",
"keep_as_is": "f",
"next_page": "s",
"discard_page": "z",
"send_end": "a", # Send this page to the end
"restart_file": "T",
"arranger": "A", # Call `pdf arranger` software, if available
"prev_file": "P",
}
PLOTTING_KB = {
"OK": "<Return>",
"previous": "p",
"edit": "e",
"open pdf": "o",
"open original pdf": "O",
"open eval": "i",
}
### Latex templates
### Pour la génération d'énoncés
LATEX_BEFORE = r"""\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}
"""
LATEX_AFTER = r"""
\end{minipage}
\end{document}
"""
### Pour les annotations sur copies
LATEX_ANOT_BEFORE = r"""\documentclass[varwidth={width_in}in,margin=0.2cm]{standalone}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage{lmodern} % Enables arbitrary font scaling
\usepackage{amsmath, amssymb}
\usepackage{mathabx} % larger inline operators.
\usepackage{commands}
%\usepackage{anyfontsize} % replaced by lmodern
\begin{document}
\fontsize{{{fontsize}}}{{{line_spacing}}}\selectfont
"""
LATEX_ANOT_AFTER = r"""
\end{document}
"""
+7 -47
View File
@@ -8,49 +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{{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
)
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"""
@@ -186,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():
@@ -232,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)
+702 -39
View File
@@ -1,27 +1,99 @@
import shlex
import re
import os import os
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
MODEL_ID = "gemini-3-flash-preview" def get_lcp(s1: str, s2: str) -> str:
api_key = os.environ.get("GEMINI_API_KEY") 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
class QuestionItem(BaseModel):
import config
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')") 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.")
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[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.
@@ -30,9 +102,39 @@ 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. 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: def find_file(folder: Path, base_name: str) -> Path:
@@ -42,9 +144,16 @@ def find_file(folder: Path, base_name: str) -> Path:
return path return path
return None return None
def process_exam(folder_path: str): def process_exam(folder_path: str, restart: bool = False):
folder = Path(folder_path) folder = Path(folder_path)
cache_dir = folder / "Cache"
cache_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 # 1. Resolve files
pdf_path = folder / "enonce.pdf" pdf_path = folder / "enonce.pdf"
enonce_path = find_file(folder, "enonce") enonce_path = find_file(folder, "enonce")
@@ -66,63 +175,615 @@ 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(),
)
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...")
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(),
) )
print("Sending request to Gemini...") if cache_s_file.is_file() and not restart:
response = client.models.generate_content( 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, model=MODEL_ID,
contents=contents, contents=contents_2,
config=config config=config_2
)
response_s_text = response_s.text
print("Saving solutions to cache...")
cache_s_file.write_text(response_s_text, encoding="utf-8")
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(),
) )
extracted_data = ExamExtraction.model_validate_json(response.text) 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...")
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}
# 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 = folder / "exam_items.txt"
full_items_file = folder / "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]
last_q_idx = g_indices[-1]
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...")
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
# Map original contexts by normalized content
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
# 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 = 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)]
# 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"
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"):
print("Aborted.")
sys.exit(1)
# 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) text_dir.mkdir(exist_ok=True)
sol_dir.mkdir(exist_ok=True) sol_dir.mkdir(exist_ok=True)
labels_file = folder / "labels"
print("Writing files...") print("Writing grouped question and solution files...")
with open(labels_file, "w", encoding="utf-8") as flabels:
for q in extracted_data.questions:
# Sanitize label for filesystem (prevent directory traversal if label contains '/')
safe_label = q.label.replace("/", "_")
flabels.write(f"{safe_label}\n") 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]
# Fix double-escaped newlines if not labels:
q_content = q.question_content.replace("\\n", "\n") continue # Skip if a group has no questions (only contexts)
s_content = q.solution_content.replace("\\n", "\n")
# ==========================================
# 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}")
# Write Text/label group_context_text = "\n\n---\n\n".join(group_text_parts)
with open(text_dir / safe_label, "w", encoding="utf-8") as f:
f.write(f"{q.label}\n{q.question_content}")
# Write Sol/label contents_4 = [
with open(sol_dir / safe_label, "w", encoding="utf-8") as f: types.Content(
f.write(f"{q.label}\n{q.solution_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:
print(f"Error generating rubric for group {labels[0]}: {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(f"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):
"""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(extracted_data.questions)} questions.")
if __name__ == "__main__": if __name__ == "__main__":
if not api_key: if not api_key:
@@ -131,6 +792,8 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.") parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.")
parser.add_argument("folder", help="Directory containing the exam files") 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() args = parser.parse_args()
process_exam(args.folder) process_exam(args.folder, restart=args.restart)
+23 -7
View File
@@ -12,9 +12,10 @@ import argparse
import re import re
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import config
MODEL_ID = "gemini-3-flash-preview" MODEL_ID = config.MODEL_FLASH_ID
api_key = os.environ["GEMINI_API_KEY"] api_key = config.API_KEY
my_prompt = """I'm giving you an image of the left columns of a written exam. my_prompt = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions. Students answer several exercises, which can have several questions.
@@ -61,6 +62,8 @@ be missing.
##labels## ##labels##
##wrong_labels##
Here's a list of the names of the students, pick the one that matches Here's a list of the names of the students, pick the one that matches
the best or `\"Unknown\"` if you cannot read the name the best or `\"Unknown\"` if you cannot read the name
@@ -116,6 +119,8 @@ be missing.
##labels## ##labels##
##wrong_labels##
Since this copy isn't the first part of a sequence, simply set the Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`.""" name to `\"Continued\"`."""
@@ -128,7 +133,7 @@ class AnnotationData(BaseModel):
list: List[BoxItem] = Field(description="List of bounding box items") list: List[BoxItem] = Field(description="List of bounding box items")
def generate_request(file, labels, names, context_labels): def generate_request(file, labels, names, context_labels, wrong_labels):
"""Generates request for Gemini with context.""" """Generates request for Gemini with context."""
image_path = Path(file) image_path = Path(file)
@@ -142,6 +147,11 @@ def generate_request(file, labels, names, context_labels):
else: else:
text = my_prompt2.replace("##labels##", labels)\ text = my_prompt2.replace("##labels##", labels)\
.replace("##prev_context##", context_str) .replace("##prev_context##", context_str)
if wrong_labels:
text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.")
else:
text = text.replace("##wrong_labels##\n\n", "")
contents = [ contents = [
types.Content( types.Content(
@@ -183,9 +193,12 @@ for path_str in args.input_paths:
# 1. Determine which files to process # 1. Determine which files to process
if input_arg.is_file(): if input_arg.is_file():
INPUT_DIR = input_arg.parent.parent
target_files = [input_arg] target_files = [input_arg]
elif input_arg.is_dir(): elif input_arg.is_dir():
target_files = list(input_arg.glob("Copie*.pdf")) INPUT_DIR = input_arg
COPIES_DIR = INPUT_DIR / "Copies"
target_files = list(COPIES_DIR.glob("Copie*.pdf"))
if not target_files: if not target_files:
print(f"Warning: No Copie*.pdf files found in {input_arg}") print(f"Warning: No Copie*.pdf files found in {input_arg}")
else: else:
@@ -194,7 +207,7 @@ for path_str in args.input_paths:
# 2. Run the logic for all collected files # 2. Run the logic for all collected files
for target_file in target_files: for target_file in target_files:
INPUT_DIR = target_file.parent # INPUT_DIR = target_file.parent
CUTLEFT_DIR = INPUT_DIR / 'Cutleft' CUTLEFT_DIR = INPUT_DIR / 'Cutleft'
# Matches stem_01.jpg, stem_02.jpg, etc. # Matches stem_01.jpg, stem_02.jpg, etc.
@@ -268,12 +281,14 @@ def process_copy_group(group_key, files):
print(f"[{group_key}] Processing {image_file.name} with {len(accumulated_labels)} accumulated labels...") print(f"[{group_key}] Processing {image_file.name} with {len(accumulated_labels)} accumulated labels...")
attempt = -1 attempt = -1
wrong_labels = []
while True: while True:
attempt += 1 attempt += 1
if attempt > 0: if attempt > 0:
time.sleep(10 * attempt) time.sleep(10 * attempt)
try: try:
contents, config = generate_request(image_file, labels_txt, names_txt, accumulated_labels) contents, config = generate_request(image_file, labels_txt, names_txt, accumulated_labels,
wrong_labels)
response = client.models.generate_content( response = client.models.generate_content(
model=MODEL_ID, model=MODEL_ID,
@@ -286,6 +301,7 @@ def process_copy_group(group_key, files):
name = annota.name name = annota.name
if unknown: if unknown:
print(f"Error: {image_file.name} contained unknown labels: {unknown}") print(f"Error: {image_file.name} contained unknown labels: {unknown}")
wrong_labels.extend(unknown)
print("Retrying request...") print("Retrying request...")
continue # Retry immediately continue # Retry immediately
@@ -306,7 +322,7 @@ def process_copy_group(group_key, files):
accumulated_labels.append(box.label) accumulated_labels.append(box.label)
break # exit retry loop break # exit retry loop
except Exception as e: except Exception as e:
print(f"Error processing {image_file.name}: {e}") print(f"Error processing {image_file.name}: {e}\n\tIt will be retried.")
# Rate Limiting # Rate Limiting
elapsed = time.time() - start_time elapsed = time.time() - start_time
+5 -2
View File
@@ -2,6 +2,8 @@ import os
import sys import sys
import json import json
import re import re
from pathlib import Path
from collections import defaultdict from collections import defaultdict
def main(): def main():
@@ -10,6 +12,7 @@ def main():
sys.exit(1) sys.exit(1)
work_dir = os.path.abspath(sys.argv[1]) work_dir = os.path.abspath(sys.argv[1])
copies_dir = Path(work_dir) / "Copies"
bnot_dir = sys.argv[2] bnot_dir = sys.argv[2]
target_subdir = os.path.join(work_dir, "A Rendre") target_subdir = os.path.join(work_dir, "A Rendre")
os.makedirs(target_subdir, exist_ok=True) os.makedirs(target_subdir, exist_ok=True)
@@ -31,11 +34,11 @@ def main():
copies_map = defaultdict(list) copies_map = defaultdict(list)
assigned_names = set() # To track which names were successfully linked assigned_names = set() # To track which names were successfully linked
for filename in os.listdir(work_dir): for filename in os.listdir(copies_dir):
match = pattern.match(filename) match = pattern.match(filename)
if match: if match:
copie_id = match.group(1) copie_id = match.group(1)
json_path = os.path.join(work_dir, filename) json_path = os.path.join(copies_dir, filename)
try: try:
with open(json_path, 'r', encoding='utf-8') as f: with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
+107 -33
View File
@@ -10,12 +10,16 @@ import shutil
import subprocess import subprocess
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from config import PAGE_SPLITTER_KB
# --- Constants --- # --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72 CM_TO_POINTS = (1 / 2.54) * 72
def list_pdf_files(directory): def list_pdf_files(directory):
return list(reversed(sorted(glob.glob(os.path.join(directory, "*.pdf"))))) l = list(reversed(sorted(glob.glob(os.path.join(directory, "*.pdf")))))
return [u for u in l if "enonce" not in u]
class PDFPreviewer: class PDFPreviewer:
@@ -84,7 +88,10 @@ class PDFPreviewer:
self.num = 0 self.num = 0
self.global_rotation = 0 # Rotation appliquée à tous les fichiers self.global_rotation = 0 # Rotation appliquée à tous les fichiers
self.history = [] self.history = []
self.setup_next_file() if not self.setup_next_file():
print(f"Aucun fichier PDF valide trouvé dans : {path}")
master.destroy()
return
self._resize_job = None # For debouncing resize events self._resize_job = None # For debouncing resize events
@@ -94,18 +101,21 @@ class PDFPreviewer:
# Set a reasonable initial size for the window # Set a reasonable initial size for the window
self.master.geometry("800x1000") self.master.geometry("800x1000")
def fmt(action):
k = PAGE_SPLITTER_KB.get(action, "")
return k
# Dynamic instructions text
instructions = ( instructions = (
"← / → : Move line 1cm left/right\n" f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n"
"'c': Rotate page 180°, 'C' : rotate all pages, ',' : rotate all files\n" f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
"t s r n m: keep left, next page, keep none, keep right, keep as is\n" f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
"z: send this page to the end, 'A':pdf arranger 'R':restart file, 'P':back to previous file\n" f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
) )
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT) self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
self.info_label.pack(pady=5, side=tk.TOP) self.info_label.pack(pady=5, side=tk.TOP)
# self.restart_btn = tk.Button(master, text="Restart File (R)", command=self.restart_current_file)
# self.restart_btn.pack(pady=2, side=tk.TOP)
self.page_label = tk.Label(master, text="", font=("Helvetica", 12)) self.page_label = tk.Label(master, text="", font=("Helvetica", 12))
self.page_label.pack(pady=5, side=tk.TOP) self.page_label.pack(pady=5, side=tk.TOP)
@@ -114,21 +124,43 @@ class PDFPreviewer:
self.canvas.pack(fill="both", expand=True) self.canvas.pack(fill="both", expand=True)
# --- Bindings --- # --- Bindings ---
self.master.bind("<Left>", self.move_line_left) action_map = {
self.master.bind("<Right>", self.move_line_right) "move_left": self.move_line_left,
self.master.bind("<Return>", self.confirm_and_next_page) "move_right": self.move_line_right,
self.master.bind("c", self.rotate_page) "confirm_next": self.confirm_and_next_page,
self.master.bind("C", self.rotate_all_pages) "rotate_page": self.rotate_page,
self.master.bind(",", self.rotate_all_files) "rotate_all_pages": self.rotate_all_pages,
self.master.bind("t", self.keep_left) "rotate_all_files": self.rotate_all_files,
self.master.bind("n", self.keep_right) "keep_left": self.keep_left,
self.master.bind("m", self.keep_as_is) "keep_right": self.keep_right,
self.master.bind("s", self.confirm_and_next_page) "keep_as_is": self.keep_as_is,
self.master.bind("r", self.discard_page) "next_page": self.confirm_and_next_page,
self.master.bind("z", self.send_page_end) "discard_page": self.discard_page,
self.master.bind("R", self.restart_current_file) "send_end": self.send_page_end,
self.master.bind("A", self.start_arranger) "restart_file": self.restart_current_file,
self.master.bind("P", self.go_to_previous_file) "arranger": self.start_arranger,
"prev_file": self.go_to_previous_file,
}
for action, key in PAGE_SPLITTER_KB.items():
if action in action_map:
self.master.bind(key, action_map[action])
# self.master.bind("<Left>", self.move_line_left)
# self.master.bind("<Right>", self.move_line_right)
# self.master.bind("<Return>", self.confirm_and_next_page)
# self.master.bind("c", self.rotate_page)
# self.master.bind("C", self.rotate_all_pages)
# self.master.bind(",", self.rotate_all_files)
# self.master.bind("t", self.keep_left)
# self.master.bind("n", self.keep_right)
# self.master.bind("m", self.keep_as_is)
# self.master.bind("s", self.confirm_and_next_page)
# self.master.bind("r", self.discard_page)
# self.master.bind("z", self.send_page_end)
# self.master.bind("R", self.restart_current_file)
# self.master.bind("A", self.start_arranger)
# self.master.bind("P", self.go_to_previous_file)
# Bind the resize event on the canvas # Bind the resize event on the canvas
@@ -462,30 +494,72 @@ class PDFPreviewer:
ri = 0 ri = 0
i = 0 i = 0
while i < len(ps): while i < len(ps):
# Si c'est une copie double psk = ps[i]['keep']
if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
and i < len(ps)-1 and (ps[i+1]['keep'] != "right"): # Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
# 1. Page de garde (Extérieur Droit)
if ps[i]['keep'] in ["both", "right"]:
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
ri += 1 ri += 1
if ps[i+1]['keep'] != "none":
# 2. Intérieur Gauche
if ps[i+1]['keep'] in ["both", "left"]:
shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri)) shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
ri += 1 ri += 1
if ps[i+1]['keep'] != "left":
# 3. Intérieur Droit
if ps[i+1]['keep'] in ["both", "right"]:
shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri)) shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
ri += 1 ri += 1
if ps[i]['keep'] == "both":
# 4. Dos de la copie (Extérieur Gauche)
if ps[i]['keep'] in ["both", "left"]:
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
ri += 1 ri += 1
i += 2 i += 2
else: else:
psk = ps[i]['keep'] # Si c'est une page simple (ou as_is)
if psk == "left" or psk == "both" or psk == "as_is": if psk in ["left", "both", "as_is"]:
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri)) shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
ri += 1 ri += 1
if psk == "right" or psk == "both": if psk in ["right", "both"]:
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri)) shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
ri += 1 ri += 1
i += 1 i += 1
# def reorder_pdfs(self):
# """Reordonne les pages, si ce sont des copies doubles."""
# self.clean_up_dir(self.reorder_dir)
# ps = self.page_settings
# ri = 0
# i = 0
# while i < len(ps):
# # Si c'est une copie double
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
# and i < len(ps)-1 and (ps[i+1]['keep'] != "right"):
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
# ri += 1
# if ps[i+1]['keep'] != "none":
# shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
# ri += 1
# if ps[i+1]['keep'] != "left":
# shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
# ri += 1
# if ps[i]['keep'] == "both":
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
# ri += 1
# i += 2
# else:
# psk = ps[i]['keep']
# if psk == "left" or psk == "both" or psk == "as_is":
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
# ri += 1
# if psk == "right" or psk == "both":
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
# ri += 1
# i += 1
def concate_files(self): def concate_files(self):
writer = PdfWriter() writer = PdfWriter()
+47 -10
View File
@@ -13,7 +13,7 @@ print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interr
# --- Configuration & Globals --- # --- Configuration & Globals ---
padding = 60 padding = 60
valid_labels_set = None
# Queue payload: (pil_image, json_path, metadata) # Queue payload: (pil_image, json_path, metadata)
# metadata is a dict: {'copie': str, 'part': int, 'schema': dict} # metadata is a dict: {'copie': str, 'part': int, 'schema': dict}
@@ -135,17 +135,23 @@ def worker_thread(base_dir, files_to_process, all_labels):
print(f"Buffering {img_path.name}...") print(f"Buffering {img_path.name}...")
(pil_image, last_label_index) = \ (pil_image, last_label_index) = \
prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index) prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index)
error_msg = None
image_queue.put((pil_image, json_path, metadata))
except Exception as e:
print(f"Error processing {img_path.name}: {e}")
pil_image = Image.open(str(img_path))
error_msg = str(e)
metadata = { metadata = {
"copie": copie, "copie": copie,
"part": copie_part, "part": copie_part,
"schema": json_schema, "schema": json_schema,
"name": json_name "name": json_name,
"error": error_msg
} }
image_queue.put((pil_image, json_path, metadata)) image_queue.put((pil_image, json_path, metadata))
except Exception as e:
print(f"Error processing {img_path.name}: {e}")
# Sentinel to indicate finished # Sentinel to indicate finished
image_queue.put((None, None, None)) image_queue.put((None, None, None))
@@ -155,6 +161,16 @@ def worker_thread(base_dir, files_to_process, all_labels):
class ImageViewer: class ImageViewer:
def __init__(self, root, base_dir): def __init__(self, root, base_dir):
self.root = root self.root = root
self.root.resizable(False, False) # If you resize, coordinates will be wrong
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
x = int(screen_w * 0.1)
y = int(screen_h * 0.05)
root.geometry(f"+{x}+{y}")
self.base_dir = base_dir self.base_dir = base_dir
self.root.title("Bounding Box Viewer") self.root.title("Bounding Box Viewer")
self.label = tk.Label(root, text="Waiting for images...") self.label = tk.Label(root, text="Waiting for images...")
@@ -177,14 +193,15 @@ class ImageViewer:
self.forward_stack = [] self.forward_stack = []
self.current_pil_image = None self.current_pil_image = None
from config import PLOTTING_KB
# Bindings # Bindings
self.root.bind('<Return>', self.on_enter) self.root.bind(PLOTTING_KB["OK"], self.on_enter)
self.root.bind('p', self.on_previous) self.root.bind(PLOTTING_KB["previous"], self.on_previous)
self.root.bind('e', self.on_edit) self.root.bind(PLOTTING_KB["edit"], self.on_edit)
self.root.bind('o', self.on_open_pdf) self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
self.root.bind('i', self.on_open_interro) self.root.bind(PLOTTING_KB["open originial pdf"], self.on_open_ori_pdf)
self.root.bind('O', self.on_open_ori_pdf) self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
self.root.bind('<Escape>', lambda e: self.root.quit()) self.root.bind('<Escape>', lambda e: self.root.quit())
self.label.bind('<Button-1>', self.on_click) self.label.bind('<Button-1>', self.on_click)
@@ -261,6 +278,10 @@ class ImageViewer:
self.is_viewing = True self.is_viewing = True
self.root.lift() self.root.lift()
if metadata.get("error"):
msg = f"Error generating boxes for {json_path.name}:\n\n{metadata['error']}\n\nPlease press 'e' to fix the JSON file, then press Enter to retry."
messagebox.showerror("Processing Error", msg)
def on_enter(self, event): def on_enter(self, event):
if self.is_viewing: if self.is_viewing:
print(f"Committing data for {self.current_json_path.name}...") print(f"Committing data for {self.current_json_path.name}...")
@@ -277,6 +298,17 @@ class ImageViewer:
self.current_meta["schema"] self.current_meta["schema"]
) )
labels = [v["label"] for v in current_data["list"]]
labels = [label for label in labels if label != "_"]
labels = [label[1:] for label in labels if label[0] == "|"]
labels = [label[:-1] for label in labels if label[-1] == "|"]
false_labels = [label for label in labels if label not in valid_labels_set]
if false_labels:
msg = f"Wrong label in {self.current_json_path.name}: {false_labels}\n\n\tPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("Label Error", msg)
return
num_added = len(converted_items) num_added = len(converted_items)
# Add to accumulator # Add to accumulator
@@ -362,6 +394,7 @@ if __name__ == "__main__":
input_path = Path(sys.argv[1]) input_path = Path(sys.argv[1])
files_to_process = [] files_to_process = []
if input_path.is_file(): if input_path.is_file():
# Correctly identify base_dir if we are in 'Copies' or 'Cutleft' # Correctly identify base_dir if we are in 'Copies' or 'Cutleft'
if input_path.parent.name in ["Copies", "Cutleft"]: if input_path.parent.name in ["Copies", "Cutleft"]:
@@ -386,6 +419,10 @@ if __name__ == "__main__":
sys.exit(1) sys.exit(1)
files_to_process = sorted(cutleft_dir.glob("*.jpg")) files_to_process = sorted(cutleft_dir.glob("*.jpg"))
labels_txt = (base_dir / "labels").read_text()
valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip())
try: try:
all_labels = read_all_labels(base_dir) all_labels = read_all_labels(base_dir)
except FileNotFoundError: except FileNotFoundError:
+7 -1
View File
@@ -131,7 +131,13 @@ def clean_obj(obj):
return [clean_obj(x) for x in obj] return [clean_obj(x) for x in obj]
elif isinstance(obj, dict): elif isinstance(obj, dict):
return {k: clean_obj(v) for k, v in obj.items()} r = {}
for k, v in obj.items():
if k != "suffix":
r[k] = clean_obj(v)
else:
r[k] = v
return r
else: else:
return obj return obj
+299
View File
@@ -0,0 +1,299 @@
from pathlib import Path
import io
import utils
main_prompt = """I'm giving you an image of several written answers to an exam.
Each answer is separated by a black horizontal line, and underneath,
to the left, is indicated the ID of the answer, from `01` to `50`.
I want you to score each answer, from 0 to 4, you may score half
points, such as 2.5. Even if a result is wrong, if the reasoning is
correct and could lead to a right answer, you should give at least
half the points.
You also need to give feedback to the student, in french :
- which part of his answer is wrong,
- why is it wrong
- possibly, what he should have done instead.
Your feedback may contain LaTeX fragments written like `$a^2 + b^2 = c^2$`.
If your score is not 4, you should always provide some feedback
explaining what's missing.
For each piece of feedback, if it is related to a specific part of the
answer that is wrong, you may provide a `box_2d`, to locate this
specific part of the answer. This `box_2d` should be in the form
[ymin, xmin, ymax, xmax] normalized to 0-1000. If you do not provide
one, set `box_2d` to `null`.
If the answer is correct, there is no need to provide feedback. You do
not have to give positive feedback, but if you do, do not provide a
`box_2d` for it.
For example, if the student says a function is continuous when it
isn't, provide the coordinates where the word «continuous» is. If a
calculation went wrong, gives the coordinates of the step where it
goes wrong, and as feedback, what went wrong.
Avoid giving feedback about confusing letters `n` with `m`, `x` with
`n` or `h` with `k`. If it looks wrong, assume you read it wrong,
unless the distinction is very important.
In some case, you may find that either
- The student didn't answer the right question. Set the score to 0.
Since it could be a labeling error, indicate it by setting `error`
to \"wrong-label\".
- You can find an answer to another question of the exercice (taking
more than a couple of lines). Score the question you are supposed
to score, but set `error` to \"additional-answer\".
- The answer to the question is empty, or the student has only
rewritten the statement of the question. In this case, set `error`
to \"empty-answer\" and do not provide any kind of feedback.
If there's no error, set `error` to `\"\"`.
You will answer using json describing a list of dictionary with a key
\"id\", and a key \"result\" that contains the \"score\", a list
\"feedback\", and possibly an \"error\". Like this example :
[{ \"id\": \"01\",
\"result\": {\"score\" : 2.5,
\"feedback\": [{text: \"Un retour générique. Il faut apprendre le cours.\", box_2d: null},
{text: \"Non, la fonction n'est pas forcément continue\", pos: [145, 280, 340, 500]}],
\"error\": \"\"}
},
{ \"id\": \"04\",
\"result\": {\"score\" : 4.,
\"feedback\" : []
\"error\": \"\" }
}
]
Here is the text of the exercice (or the relevant part of the problem)
of the exam :
```
<<text>>
```
Here is a possible correct answer :
```
<<corr>>
```
<<persp>>
You are asked to score the question or exercice labeled `<<label>>`,
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):
text = get_label_text_content(input_dir, full_label)
corr = get_label_sol_content(input_dir, full_label)
persp = get_label_persp_content(input_dir, full_label)
if persp and persp != "":
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)
from pydantic import BaseModel, Field, TypeAdapter
from typing import List, Optional, Tuple
class FeedbackItem(BaseModel):
text: str = Field(description="Feedback content")
box_2d: Optional[List[int]] = Field(None, description="box coordinates or null")
class ResultData(BaseModel):
score: float = Field(description="The numeric score")
feedback: List[FeedbackItem] = Field(description="List of feedback items")
error: str = Field(description="Indicates if an error occurred")
class EvaluationEntry(BaseModel):
id: str = Field(description="Entry identifier")
result: ResultData = Field(description="Result details")
# These nested definitions do not work with the batch api, unroll them
UNROLLED_SCHEMA = {
"type": "ARRAY",
"items": {
"type": "OBJECT",
"properties": {
"id": {"type": "STRING", "description": "Entry identifier"},
"result": {
"type": "OBJECT",
"properties": {
"score": {"type": "NUMBER", "description": "The numeric score"},
"error": {"type": "STRING", "description": "Indicates if an error occurred"},
"feedback": {
"type": "ARRAY",
"description": "List of feedback items",
"items": {
"type": "OBJECT",
"properties": {
"text": {"type": "STRING", "description": "Feedback content"},
"box_2d": {
"type": "ARRAY",
"items": {"type": "INTEGER"},
"nullable": True,
"description": "box coordinates or null"
}
},
"required": ["text"]
}
}
},
"required": ["score", "feedback", "error"]
}
},
"required": ["id", "result"]
}
}
from google.genai import types
# The root model for parsing is be: List[EvaluationEntry]
def generate_request(input_dir, file, full_label):
"""Generates request for Gemini."""
prompt = make_prompt(input_dir, full_label)
image_path = Path(file)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=prompt),
],
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1.0,
top_p=0.95,
seed=0,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= TypeAdapter(List[EvaluationEntry]).json_schema()
)
return (contents, generate_content_config)
from pdf2image import convert_from_path
from PIL import Image
import json
def get_single_image_bytes(pdf_path):
"""Converts a multi-page PDF into a single stitched JPEG in memory."""
imgs = convert_from_path(pdf_path, dpi=200) # Same DPI as grouping.py
if not imgs:
raise ValueError(f"No pages in {pdf_path}")
if len(imgs) == 1:
combined = imgs[0]
else:
max_width = max(img.width for img in imgs)
total_height = sum(img.height for img in imgs)
combined = Image.new('RGB', (max_width, total_height), 'white')
y_offset = 0
for img in imgs:
combined.paste(img, (0, y_offset))
y_offset += img.height
img_byte_arr = io.BytesIO()
combined.save(img_byte_arr, format='JPEG', quality=85)
return img_byte_arr.getvalue()
def request_for_box_correction(pdf_path, original_feedbacks):
img_bytes = get_single_image_bytes(pdf_path)
localized_feedbacks = [f for f in original_feedbacks if f["box_2d"]]
prompt = f"""
Here is a single student's submission to a question in a written exam. The following JSON contains feedback items with bounding boxes (box_2d) that are incorrect. Each piece of feedback is supposed to be related to a piece of the answer that is wrong.
For example, if the student says a function is continuous when it
isn't, the coordinates should be where the word «continuous» is. If a
calculation went wrong, the coordinates should be where the step where
it goes wrong, and the feedback is what went wrong.
Please analyze the image and return the same feedback json content, but with ONLY the box_2d coordinates corrected for this specific image.
Coordinates must be [ymin, xmin, ymax, xmax] scaled to 1000. If a box is invalid/not found, return null for it.
Original feedback:
{json.dumps(localized_feedbacks, indent=2)}
"""
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg"),
types.Part.from_text(text=prompt),
],
)
]
config = types.GenerateContentConfig(
temperature=1.0,
response_mime_type="application/json",
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema()
)
return contents,config
def request_for_wrong_label(pdf_path, label, enonce, labels_txt):
prompt = f"""This image is a part of the answer of a student to a written exam.
It was initially labeled '{label}' but I suspect this label is wrong. Perhaps the student himself wrote the wrong label.
You need to analyse this image, and find the label of the question it answers. Do not trust the label written by the student but instead check the content of its answer and the notation he uses to identify the correct label of the question the student answered.
Return ONLY the exact label string.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with one of these :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)])]
config = types.GenerateContentConfig(temperature=1.0)
return contents, config
def request_for_additional_answer(pdf_path, label, enonce, labels_txt):
prompt = f"""This image is a part of the answer of a student to a written exam.
It was initially labeled '{label}' but I suspect this image also contains answers to another, or several other questions.
You need to analyse this image, and find the list of the labels of the questions it answers. Return ONLY the list of the exact label strings.
If the end of the image only contains the first line of an answer to another question, ignore it.
Here is the full content of the exam :
{enonce}
Here is a list of all possible labels. You need to answer with a list one of these :
{labels_txt}
"""
contents = [types.Content(role="user", parts=[
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
types.Part.from_text(text=prompt)
])]
config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json")
return contents, config
+31 -8
View File
@@ -161,7 +161,8 @@ def has_significant_notes(note_img, threshold=20):
# print(f"Debug : visible pixels is {visible_pixels}") # print(f"Debug : visible pixels is {visible_pixels}")
return visible_pixels > threshold return visible_pixels > threshold
def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_layer, all_labels): def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_layer,
all_labels, update_score=False):
""" """
Modifies data based on actions, reads bnote.json, cuts notes, Modifies data based on actions, reads bnote.json, cuts notes,
regenerates all label images for consistency, saves dirty ones, regenerates all label images for consistency, saves dirty ones,
@@ -230,6 +231,23 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
print(f" > Deleted rect in {label}") print(f" > Deleted rect in {label}")
dirty_labels.add(label) dirty_labels.add(label)
# --- 1.5 Override with existing score.json if requested ---
if update_score and os.path.exists(score_path):
try:
with open(score_path, "r") as f:
existing_scores = json.load(f)
for label, existing_score in existing_scores.items():
if label in labels_data:
current_score = str(labels_data[label]['result'].get('score', 0))
# If manually modified, override the result and mark dirty
if current_score != str(existing_score):
labels_data[label]['result']['score'] = existing_score
dirty_labels.add(label)
print(f" > Overrode score for {label} to {existing_score} from existing score.json")
except json.JSONDecodeError:
print(f" > Warning: Could not read existing {score_path}")
# --- 2. Process Images (Cut notes, Regenerate, Concatenate) --- # --- 2. Process Images (Cut notes, Regenerate, Concatenate) ---
concat_list = [] concat_list = []
concat_list_F = [] concat_list_F = []
@@ -255,7 +273,8 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
# B. Regenerate Label Image # B. Regenerate Label Image
# We always regenerate to ensure Concat.jpg is consistent with any modifications # We always regenerate to ensure Concat.jpg is consistent with any modifications
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf" # pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf"
pdf_path = content.get('pdf_path') # Contient le suffixe _new si nécessaire
if not os.path.exists(pdf_path): continue if not os.path.exists(pdf_path): continue
(base_img, _, _) = annotating.make_base_image(pdf_path) (base_img, _, _) = annotating.make_base_image(pdf_path)
@@ -331,11 +350,13 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
from utils import read_all_labels from utils import read_all_labels
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 2: import argparse
print("Usage: python reading_annotations.py <Dir>") parser = argparse.ArgumentParser(description="Read annotations and compile PDFs")
sys.exit(1) parser.add_argument("input_path", help="Directory path")
parser.add_argument("--update-score", action="store_true", help="Override scores with values from existing score.json")
args = parser.parse_args()
root_dir = sys.argv[1] root_dir = args.input_path
try: try:
all_labels = read_all_labels(Path(root_dir)) all_labels = read_all_labels(Path(root_dir))
@@ -351,7 +372,9 @@ if __name__ == "__main__":
if os.path.exists(bnot_dir): if os.path.exists(bnot_dir):
print(f"Processing annotations for: {student_id}") print(f"Processing annotations for: {student_id}")
actions, notes = detect_checks_and_notes(bnot_dir) actions, notes = detect_checks_and_notes(bnot_dir)
if actions or notes: if actions or notes or args.update_score:
apply_actions_and_regenerate(root_dir, original_data, student_id, actions, notes, all_labels) apply_actions_and_regenerate(root_dir, original_data, student_id,
actions, notes, all_labels,
update_score=args.update_score)
else: else:
print(" No changes detected or missing files.") print(" No changes detected or missing files.")
+50 -34
View File
@@ -9,30 +9,37 @@ import threading
import annotating import annotating
from utils import natural_key from utils import natural_key, pdf_image_of_enonce, pdf_image_of_solution, pdf_images_of_contexts
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, all_labels):
"""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") e = pdf_images_of_contexts(root_dir, label, all_labels)
if os.path.exists(pdf_path): for c in e + [a, b]:
img, _, _ = annotating_module.make_base_image(pdf_path) if c:
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):
"""Concatenates groups of images vertically, adding specific inner borders.""" """Concatenates groups of images vertically, adding inner borders and margins."""
if not image_groups: if not image_groups:
return return
max_w = max(img.width for group in image_groups for img in group) max_w = max(img.width for group in image_groups for img in group)
max_page_h = int(max_w * 1.414 * 1.3) max_page_h = int(max_w * 1.414 * 1.25)
# Calculate 0.2 cm in pixels at 100 DPI (0.2 / 2.54 inches * 100) # Calculate sizes in pixels at 100 DPI
border_px = int((0.2 / 2.54) * 100) border_px = int((0.2 / 2.54) * 100)
left_margin = int((0.3 / 2.54) * 100)
tb_margin = int((0.2 / 2.54) * 100)
# Available height for images once top/bottom margins are added
max_content_h = max_page_h - (2 * tb_margin)
pages = [] pages = []
current_page_imgs = [] current_page_imgs = []
@@ -46,11 +53,10 @@ def save_paginated_pdf(image_groups, output_path):
processed_group = [] processed_group = []
for i, img in enumerate(group): for i, img in enumerate(group):
if i in (0, 1): if i in (0, 1):
img = img.copy() # Do not modify the original image object in memory img = img.copy()
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
color = "black" if i == 0 else "blue" color = "black" if i == 0 else "blue"
# Draw the border inside the image edges
draw.rectangle( draw.rectangle(
[0, 0, img.width - 1, img.height - 1], [0, 0, img.width - 1, img.height - 1],
outline=color, outline=color,
@@ -60,11 +66,12 @@ def save_paginated_pdf(image_groups, output_path):
group_h = sum(img.height for img in processed_group) group_h = sum(img.height for img in processed_group)
if current_page_imgs and (current_h + group_h > max_page_h): if current_page_imgs and (current_h + group_h > max_content_h):
page = Image.new("RGB", (max_w, current_h), "white") # Create page with margins included in dimensions
y = 0 page = Image.new("RGB", (max_w + left_margin, current_h + 2 * tb_margin), "white")
y = tb_margin
for c_img in current_page_imgs: for c_img in current_page_imgs:
page.paste(c_img, (0, y)) page.paste(c_img, (left_margin, y))
y += c_img.height y += c_img.height
pages.append(page) pages.append(page)
@@ -75,10 +82,10 @@ def save_paginated_pdf(image_groups, output_path):
current_h += group_h current_h += group_h
if current_page_imgs: if current_page_imgs:
page = Image.new("RGB", (max_w, current_h), "white") page = Image.new("RGB", (max_w + left_margin, current_h + 2 * tb_margin), "white")
y = 0 y = tb_margin
for c_img in current_page_imgs: for c_img in current_page_imgs:
page.paste(c_img, (0, y)) page.paste(c_img, (left_margin, y))
y += c_img.height y += c_img.height
pages.append(page) pages.append(page)
@@ -86,7 +93,8 @@ def save_paginated_pdf(image_groups, output_path):
pages[0].save(output_path, "PDF", resolution=100.0, save_all=True, append_images=pages[1:]) pages[0].save(output_path, "PDF", resolution=100.0, save_all=True, append_images=pages[1:])
def apply_actions_and_regenerate_grouped(root_dir, data, student_id, def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
actions, label_notes, all_labels): actions, label_notes, all_labels,
update_score=False):
""" """
Modifies data based on actions, pastes label-specific note crops, Modifies data based on actions, pastes label-specific note crops,
regenerates label images for consistency, saves dirty ones, regenerates label images for consistency, saves dirty ones,
@@ -150,6 +158,23 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
logs.append(f" > Deleted rect in {label}") logs.append(f" > Deleted rect in {label}")
dirty_labels.add(label) dirty_labels.add(label)
# --- 1.5 Override with existing score.json if requested ---
if update_score and os.path.exists(score_path):
try:
with open(score_path, "r") as f:
existing_scores = json.load(f)
for label, existing_score in existing_scores.items():
if label in labels_data:
current_score = str(labels_data[label]['result'].get('score', 0))
# If manually modified, override the result and mark dirty
if current_score != str(existing_score):
labels_data[label]['result']['score'] = existing_score
dirty_labels.add(label)
logs.append(f" > Overrode score for {label} to {existing_score} from existing score.json")
except json.JSONDecodeError:
logs.append(f" > Warning: Could not read existing {score_path}")
# --- 2. Process Images (Regenerate & Concatenate) --- # --- 2. Process Images (Regenerate & Concatenate) ---
concat_list = [] concat_list = []
concat_list_F = [] concat_list_F = []
@@ -162,7 +187,8 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
result = content['result'] result = content['result']
d_notes[label] = str(result.get('score', 0)) d_notes[label] = str(result.get('score', 0))
pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf" # pdf_path = Path(root_dir) / "Copies" / f"Copie{student_id}" / f"{label}.pdf"
pdf_path = content.get('pdf_path')
if not os.path.exists(pdf_path): continue if not os.path.exists(pdf_path): continue
(base_img, _, _) = annotating.make_base_image(pdf_path) (base_img, _, _) = annotating.make_base_image(pdf_path)
@@ -214,7 +240,7 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
perfect_no_comment = False perfect_no_comment = False
if not perfect_no_comment or has_notes: if not perfect_no_comment or has_notes:
extras = get_extra_pdfs_as_images(root_dir, label, annotating) extras = get_extra_pdfs_as_images(root_dir, label, annotating, all_labels)
extras.append(final_img) extras.append(final_img)
concat_list_F.append(extras) concat_list_F.append(extras)
@@ -241,18 +267,6 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
save_paginated_pdf(concat_list_F, pdf_out_path) save_paginated_pdf(concat_list_F, pdf_out_path)
logs.append(f" Saved regenerated Concat_F.pdf") logs.append(f" Saved regenerated Concat_F.pdf")
# max_w = max(i.width for i in concat_list_F)
# total_h = sum(i.height for i in concat_list_F)
# full_img = Image.new("RGB", (max_w, total_h), "white")
# y = 0
# for img in concat_list_F:
# full_img.paste(img, (0, y))
# y += img.height
# full_img.save(os.path.join(output_dir, "Concat_F.jpg"))
# logs.append(f" Saved regenerated Concat_F.jpg")
return "\n".join(logs) return "\n".join(logs)
from utils import read_all_labels from utils import read_all_labels
@@ -264,6 +278,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Read grouped annotations and compile PDFs") parser = argparse.ArgumentParser(description="Read grouped annotations and compile PDFs")
parser.add_argument("input_path", help="Directory path") parser.add_argument("input_path", help="Directory path")
parser.add_argument("--refaire", action="store_true", help="Merge refaire annotations from Bnot") parser.add_argument("--refaire", action="store_true", help="Merge refaire annotations from Bnot")
parser.add_argument("--update-score", action="store_true", help="Override scores with values from existing score.json")
args = parser.parse_args() args = parser.parse_args()
root_dir = sys.argv[1] root_dir = sys.argv[1]
@@ -402,7 +417,8 @@ if __name__ == "__main__":
sid, sid,
actions_by_student[sid], actions_by_student[sid],
notes_by_student[sid], notes_by_student[sid],
all_labels all_labels,
update_score=args.update_score
) )
# --- 2. Process each student concurrently using 4 threads --- # --- 2. Process each student concurrently using 4 threads ---
+5
View File
@@ -14,6 +14,11 @@ for file in *.pdf; do
# Handle case where no pdfs exist # Handle case where no pdfs exist
[ -e "$file" ] || continue [ -e "$file" ] || continue
if [ "$file" = "enonce.pdf" ]; then
echo "Skipping: $file"
continue
fi
# Rename with 0-padding (e.g., Copie01.pdf) # Rename with 0-padding (e.g., Copie01.pdf)
mv -- "$file" "$(printf "Copie%02d.pdf" "$count")" mv -- "$file" "$(printf "Copie%02d.pdf" "$count")"
((count++)) ((count++))
+196
View File
@@ -0,0 +1,196 @@
import sys
import os
import json
import re
import shutil
from pathlib import Path
from pypdf import PdfWriter
if len(sys.argv) < 2:
sys.exit("Usage: python resolve_manual.py <InputDir>")
input_dir = Path(sys.argv[1])
manual_file = input_dir / "manual_resolutions.txt"
correction_file = input_dir / "correction.json"
refaire_file = input_dir / "refaire.json"
copies_dir = input_dir / "Copies"
if not manual_file.exists():
sys.exit(f"No {manual_file.name} found. Nothing to resolve.")
with open(correction_file, "r", encoding="utf-8") as f:
results = json.load(f)
def set_suffix_and_clean_error(pid, label, suffix, new_lbl_target=None):
"""Updates correction.json to set suffixes and clear resolved delayed tags."""
if label in results:
for batch in results[label]:
for item in batch:
if item["id"] == pid:
if suffix:
item["result"]["suffix"] = suffix
err = item["result"].get("error", "")
if new_lbl_target:
if f"wrg-lbl:{new_lbl_target}?delayed" in err:
item["result"]["error"] = f"wrg-lbl-moved-to:{new_lbl_target}"
if f"(delayed){new_lbl_target}" in err:
item["result"]["error"] = err.replace(f"(delayed){new_lbl_target}", f"(->){new_lbl_target}")
def get_actual_pdf(pid, label):
"""Finds the effective PDF considering possible suffixes."""
base = copies_dir / f"Copie{pid}" / f"{label}.pdf"
if base.exists(): return base
if base.with_name(f"{label}_new.pdf").exists(): return base.with_name(f"{label}_new.pdf")
if base.with_name(f"{label}_old.pdf").exists(): return base.with_name(f"{label}_old.pdf")
return base
def safe_strip_suffix(stem):
if stem.endswith("_new"): return stem[:-4]
if stem.endswith("_old"): return stem[:-4]
return stem
instructions = []
with open(manual_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("###"): continue
# Regex to split on the operator properly handles spaces in labels
match = re.search(r'\s+(-x|->|x>)\s+', line)
if not match:
print(f"Skipping malformed line: {line}")
continue
op = match.group(1)
left = line[:match.start()].strip()
right = line[match.end():].strip()
m_left = re.match(r'Copie(\d+)\s+(.+)', left)
if not m_left:
continue
pid = m_left.group(1)
old_label = m_left.group(2).strip()
new_part = right
pipe_first = new_part.startswith("|")
# pipe_last = new_part.endswith("|")
new_label = new_part.strip("|").strip()
instructions.append((pid, old_label, op, new_label, pipe_first))
# State trackers
initial_paths = {} # Tracks the source files at script start (enables safe Swapping)
current_paths = {} # Tracks the "latest active file" (which may be a temp file for chained merges)
files_to_old = set()
temp_files = []
# Pre-load existing paths
for pid, old_label, _, new_label, _ in instructions:
for lbl in (old_label, new_label):
if (pid, lbl) not in initial_paths:
p = get_actual_pdf(pid, lbl)
initial_paths[(pid, lbl)] = p
current_paths[(pid, lbl)] = p
# Evaluate instructions
for pid, old_label, op, new_label, pipe_first in instructions:
should_merge = op[1] == ">"
src_pdf = initial_paths[(pid, old_label)]
dest_pdf = current_paths[(pid, new_label)]
temp_out = copies_dir / f"Copie{pid}" / f"temp_{len(temp_files)}.pdf"
if op[0] == "x":
files_to_old.add(initial_paths[(pid, old_label)])
if op[1] == "x":
files_to_old.add(initial_paths[(pid, new_label)])
if should_merge:
if not dest_pdf.exists() or not src_pdf.exists():
print("Debug : should_merge but, {} or {} doesn't exist"
.format(src_pdf, dest_pdf))
input("You should Ctrl-C and fix.")
# MERGE
merger = PdfWriter()
if pipe_first:
merger.append(src_pdf)
merger.append(dest_pdf)
else:
merger.append(dest_pdf)
merger.append(src_pdf)
merger.write(temp_out)
merger.close()
current_paths[(pid, new_label)] = temp_out
temp_files.append(temp_out)
# Original destination is now embedded in the merge, back it up
files_to_old.add(initial_paths[(pid, new_label)])
else:
if op[1] != "s" and op[0] != "s": # xx or -x
shutil.copy(src_pdf, temp_out)
current_paths[(pid, new_label)] = temp_out
temp_files.append(temp_out)
# Commit Filesystem Changes
# 1. Archive deprecated files to _old.pdf
for pdf in files_to_old:
if pdf.exists():
pid_str = pdf.parent.name.replace("Copie", "")
label = safe_strip_suffix(pdf.stem)
old_name = pdf.with_name(f"{label}_old.pdf")
if pdf != old_name:
if old_name.exists(): old_name.unlink()
shutil.move(str(pdf), str(old_name))
set_suffix_and_clean_error(pid_str, label, "_old")
# 2. Clear all delayed errors for the instructions
for pid, old_label, op, new_label, pf in instructions:
set_suffix_and_clean_error(pid, old_label, None, new_label)
refaire_tasks = []
# 3. Rename active temp files to _new.pdf and queue for --refaire
for (pid, label), current_path in current_paths.items():
if "temp_" in current_path.name:
final_name = copies_dir / f"Copie{pid}" / f"{label}_new.pdf"
if final_name.exists(): final_name.unlink()
shutil.move(str(current_path), str(final_name))
set_suffix_and_clean_error(pid, label, "_new")
# Push to refaire_tasks uniquely
added = False
for t in refaire_tasks:
if t[0] == f"Copie{pid}":
if label not in t[1]:
t[1].append(label)
added = True
break
if not added:
refaire_tasks.append([f"Copie{pid}", [label]])
# 4. Clean up any unused temp files (overwritten by chained replacements)
used_temps = set(current_paths.values())
for temp in temp_files:
if temp not in used_temps and temp.exists():
temp.unlink()
# Finalize JSONs
with open(correction_file, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
if refaire_tasks:
with open(refaire_file, "w", encoding="utf-8") as f:
json.dump(refaire_tasks, f, indent=2)
manual_file.unlink(missing_ok=True)
print("Manual resolutions successfully applied.")
if refaire_tasks:
print(f"File {refaire_file.name} generated. Run `python correction.py \"{input_dir}\" --refaire` to process updates.")
else:
print("No new corrections required.")
+5
View File
@@ -13,6 +13,11 @@ cd "$1" || { echo "Error: Cannot access directory '$1'"; exit 1; }
shopt -s nullglob shopt -s nullglob
for file in *.pdf; do for file in *.pdf; do
if [ "$file" = "enonce.pdf" ]; then
echo "Skipping: $file"
continue
fi
# Rotate to a temporary file # Rotate to a temporary file
if qpdf --rotate=+180 "$file" "temp_rotated.pdf"; then if qpdf --rotate=+180 "$file" "temp_rotated.pdf"; then
mv "temp_rotated.pdf" "$file" mv "temp_rotated.pdf" "$file"
+2 -2
View File
@@ -112,8 +112,8 @@ def split_an_interro(base_dir, input_pdf, coords_list):
if is_stop: if is_stop:
end_page = n_pn end_page = n_pn
# end_y_target_raw = n_y_start # end_y_target_raw = n_y_start
# On avait retiré un carreau précédemment, on le rajoute # On avait retiré un carreau précédemment inutilement, on le rajoute, plus un demi carreau
end_y_target_raw = min(n_y_start + int(1.25 * carreau), 1000) end_y_target_raw = min(n_y_start + int(1.5 * carreau), 1000)
break break
# RULES 3 & 4: Calculate horizontal boundaries (0.0 to 1.0 fraction of local page width) # RULES 3 & 4: Calculate horizontal boundaries (0.0 to 1.0 fraction of local page width)
+137
View File
@@ -21,3 +21,140 @@ 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 pdf_images_of_contexts(root_dir, label, all_labels):
text2_dir = os.path.join(root_dir, "Text2")
if not os.path.isdir(text2_dir):
return []
# Map safe labels (with '/' replaced by '_') to their chronological index
safe_to_idx = {l.replace("/", "_"): i for i, l in enumerate(all_labels)}
safe_target = label.replace("/", "_")
target_idx = safe_to_idx.get(safe_target, -1)
if target_idx == -1:
return []
pertinent_contexts = []
for filename in os.listdir(text2_dir):
if filename.startswith("CTXT ") and filename.endswith(".pdf"):
# Extract "first_label -> last_label" from "CTXT first_label -> last_label.pdf"
core = filename[5:-4]
parts = core.split(" -> ")
if len(parts) == 2:
first_safe, last_safe = parts
first_idx = safe_to_idx.get(first_safe, -1)
last_idx = safe_to_idx.get(last_safe, -1)
# Check if the current label falls within the context's validity range
if first_idx != -1 and last_idx != -1:
if first_idx <= target_idx <= last_idx:
pdf_path = os.path.join(text2_dir, filename)
pertinent_contexts.append((first_idx, pdf_path))
# Sort by first_idx to ensure contexts are returned in logical reading order
pertinent_contexts.sort(key=lambda x: x[0])
return [path for _, path in pertinent_contexts]
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
from config import LATEX_AFTER, LATEX_BEFORE
def compile_to_pdf(text, output_pdf_path):
"""Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{LATEX_BEFORE}{text}{LATEX_AFTER}"
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}")