Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
137a9a7868 | ||
|
|
9d7a4f37b6 | ||
|
|
09befe1d55 | ||
|
|
fd35675e4c | ||
|
|
121bd0714a | ||
|
|
27c0dae20e | ||
|
|
a80187ba80 | ||
|
|
80d06e4693 | ||
|
|
9e8b9ac191 | ||
|
|
c2e915226e |
+89
-27
@@ -1,7 +1,7 @@
|
||||
#+title: Script
|
||||
#+author: Sébastien Miquel
|
||||
#+date: 14-03-2026
|
||||
# Time-stamp: <17-05-26 10:51>
|
||||
# Time-stamp: <08-08-26 12:28>
|
||||
#+OPTIONS:
|
||||
|
||||
* Méta
|
||||
@@ -67,29 +67,71 @@ Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
|
||||
export GEMINI_API_KEY=…
|
||||
#+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
|
||||
|
||||
1. Créer un fichier =names= dans le dossier courant, avec les
|
||||
noms/prénoms des élèves, un par ligne
|
||||
2. Créer un dossier correspondant à l'évaluation, comme =Interro=
|
||||
3. Mettre les fichiers pdfs scannés dans =Interro=.
|
||||
4. Dans le dossier =Interro= créer un fichier =labels= avec les labels
|
||||
des questions, comme =Ex 1 : 1)a)=, un par ligne.
|
||||
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.
|
||||
2. Créer un dossier correspondant à l'évaluation (=Interro= dans la
|
||||
suite)
|
||||
3. Mettre l'énoncé, au format pdf, et l'énoncé et le corrigé au
|
||||
format .tex dans le dossier.
|
||||
|
||||
* Étapes et Script
|
||||
** Prétraitement
|
||||
** Prétraitement de l'énoncé
|
||||
|
||||
1. =./rotate_all.sh Interro=
|
||||
(facultatif)
|
||||
Retourne toutes les pdf de 180°
|
||||
Dans le dossier de l'évaluation, mettre : `enonce.pdf`, `enonce.tex`,
|
||||
`correction.tex`.
|
||||
|
||||
- `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=
|
||||
change le nom des copies en =Copie{id}.pdf=
|
||||
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=
|
||||
|
||||
** 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
|
||||
|
||||
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
|
||||
=correction.json=.
|
||||
- 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
|
||||
|
||||
@@ -245,7 +293,10 @@ OU
|
||||
|
||||
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)
|
||||
+ =gestion_classe ne= pour créer l'interro puis
|
||||
+ =gestion_classe we= (set barème here)
|
||||
@@ -269,7 +320,7 @@ groupée into refaire !!
|
||||
+ =python plotting.py InterroTest/Copie01.pdf=
|
||||
+ =python splitting_int.py InterroTest/Copie20.pdf=
|
||||
2. Créer =refaire.json=, avec un contenu comme
|
||||
: [["Copie01", []],
|
||||
: [["Copie02", []],
|
||||
: ["Copie01", ["Ex 1 : 1)"]]]
|
||||
3. Appeler =correction= avec --refaire. Il doit créer des groupes
|
||||
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.
|
||||
4. ?? Si je fais refaire, avant d'avoir créer les annotating with
|
||||
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 from_tablette.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
@@ -34,6 +34,9 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
|
||||
student_id = item['id']
|
||||
result_obj = item['result']
|
||||
|
||||
if result_obj.get("suffix") == "_old":
|
||||
continue
|
||||
|
||||
# Find coordinates
|
||||
coordinates = None
|
||||
height,width= None, None
|
||||
@@ -58,9 +61,11 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
|
||||
if coordinates:
|
||||
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"
|
||||
|
||||
# Initialize dictionary structure for this ID if missing
|
||||
if student_id not in result_data:
|
||||
result_data[student_id] = {}
|
||||
@@ -68,6 +73,8 @@ def make_dictionary(root_dir, refaire=False, refaire_list=[]):
|
||||
fb = result_obj.get("feedback", [])
|
||||
for i in range(len(fb)):
|
||||
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"]:
|
||||
el["box_2d"][0] = (el["box_2d"][0] * 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:
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
||||
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")
|
||||
continue
|
||||
result_data[sid][lbl] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": {
|
||||
"score": 0.0,
|
||||
"confidence": 1.0,
|
||||
"feedback": [],
|
||||
"error": "non traité"
|
||||
},
|
||||
"coordinates": (0,0)
|
||||
}
|
||||
# result_data[sid][lbl] = {
|
||||
# "pdf_path": pdf_path,
|
||||
# "result": {
|
||||
# "score": 0.0,
|
||||
# "feedback": [],
|
||||
# "error": "non traité"
|
||||
# },
|
||||
# "coordinates": (0,0)
|
||||
# }
|
||||
else: # Ce student id n'a jamais été corrigé
|
||||
result_data[sid] = {}
|
||||
for lbl in labels_to_redo:
|
||||
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
|
||||
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")
|
||||
continue
|
||||
result_data[sid][lbl] = {
|
||||
"pdf_path": pdf_path,
|
||||
"result": {
|
||||
"score": 0.0,
|
||||
"confidence": 1.0,
|
||||
"feedback": [],
|
||||
"error": "non traité"
|
||||
},
|
||||
@@ -267,24 +280,19 @@ import subprocess
|
||||
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):
|
||||
dpi = 100
|
||||
width_in = width_px / dpi
|
||||
line_spacing = int(fontsize * 1.2)
|
||||
|
||||
# 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}}
|
||||
\\usepackage[utf8]{{inputenc}}
|
||||
\\usepackage[T1]{{fontenc}}
|
||||
\\usepackage{{lmodern}} % Enables arbitrary font scaling
|
||||
\\usepackage{{amsmath, amssymb}}
|
||||
\\usepackage{{commands}}
|
||||
%\\usepackage{{anyfontsize}} % replaced by lmodern
|
||||
\\begin{{document}}
|
||||
\\fontsize{{{fontsize}}}{{{line_spacing}}}\\selectfont
|
||||
{text}
|
||||
\\end{{document}}
|
||||
"""
|
||||
header = LATEX_ANOT_BEFORE.format(
|
||||
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
|
||||
)
|
||||
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
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:
|
||||
# 1. Find PDF path
|
||||
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}")
|
||||
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):
|
||||
|
||||
# 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
|
||||
# 1. Il faut protéger les appels à matplotlib
|
||||
# 2. tu vas perdre les erreurs
|
||||
|
||||
@@ -143,6 +143,13 @@ def main():
|
||||
continue
|
||||
|
||||
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:
|
||||
base_prefix = "Group"
|
||||
|
||||
@@ -156,7 +163,7 @@ def main():
|
||||
if counter == 2 and previous_prefix and previous_prefix in unique_prefix:
|
||||
unique_prefix = f"{previous_prefix}-{counter}"
|
||||
elif counter == 2:
|
||||
previous_prefx = unique_prefix
|
||||
previous_prefix = unique_prefix
|
||||
|
||||
used_prefixes.add(unique_prefix)
|
||||
|
||||
|
||||
+268
-342
@@ -3,6 +3,14 @@ import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
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:
|
||||
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")
|
||||
parser.add_argument("--deal-with-batched", action="store_true",
|
||||
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()
|
||||
|
||||
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":
|
||||
# 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
|
||||
INPUT_DIR = arg_path.parent.parent.parent
|
||||
COPIES_DIR = INPUT_DIR / "Copies"
|
||||
@@ -58,124 +68,6 @@ for path_str in args.paths:
|
||||
for img in sub.glob("*.jpg"):
|
||||
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
|
||||
|
||||
@@ -186,12 +78,11 @@ if PROXY_URL:
|
||||
os.environ["http_proxy"] = PROXY_URL
|
||||
os.environ["https_proxy"] = PROXY_URL
|
||||
|
||||
MODEL_ID_pro = "gemini-3.1-pro-preview"
|
||||
MODEL_ID_flash = "gemini-3-flash-preview"
|
||||
api_key = os.environ["GEMINI_API_KEY"]
|
||||
import config
|
||||
|
||||
import signal
|
||||
import sys
|
||||
MODEL_ID_pro = config.MODEL_PRO_ID
|
||||
MODEL_ID_flash = config.MODEL_FLASH_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
# --- Thread-safe Logging ---
|
||||
log_lock = threading.Lock()
|
||||
@@ -227,97 +118,44 @@ def handle_interrupt(sig, frame):
|
||||
flush_thread_log(tid)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, 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)
|
||||
output_path = INPUT_DIR / "correction.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()
|
||||
overwrite = args.overwrite
|
||||
limit = args.limit
|
||||
@@ -371,6 +209,17 @@ def call_gemini_with_retries(model_id, contents, config,
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
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
|
||||
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.")
|
||||
raise
|
||||
|
||||
import io
|
||||
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,
|
||||
def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
|
||||
yming, ymaxg, width_r, total_height):
|
||||
"""Requests corrected bounding boxes from Gemini Flash on the single image."""
|
||||
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()
|
||||
)
|
||||
# pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
|
||||
|
||||
contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks)
|
||||
response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config)
|
||||
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
|
||||
for f in corrected_feedbacks:
|
||||
b = f.get("box_2d")
|
||||
@@ -499,27 +289,7 @@ def handle_label_errors(pid, label, res, pdf_path):
|
||||
|
||||
if error_type == "wrong-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.
|
||||
|
||||
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)
|
||||
contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt)
|
||||
new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'')
|
||||
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")
|
||||
@@ -528,53 +298,41 @@ Here is a list of all possible labels. You need to answer with one of these :
|
||||
if new_label == label:
|
||||
res["error"] = ""
|
||||
return []
|
||||
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
|
||||
if new_pdf_path.exists():
|
||||
tprint(f"\t\tCopie{pid} tried to move wrong {label} to {new_label}, but it already exists.")
|
||||
res["error"] = f"wrg-lbl:{new_label}?exists"
|
||||
|
||||
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
|
||||
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}_new.pdf"
|
||||
|
||||
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:
|
||||
res["error"] = f"wrg-lbl-moved-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
|
||||
# into account in the future, I think
|
||||
|
||||
# Copie vers _new, puis renommage de l'original vers _old
|
||||
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)
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)],
|
||||
GROUPS_DIR)
|
||||
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
tprint(f"\t\tMaking {new_label} group {idx+1}")
|
||||
new_tasks.append((str(GROUPS_DIR / new_label / f"Group_{idx+1}.jpg"),
|
||||
new_label, False))
|
||||
|
||||
elif error_type == "additional-answer":
|
||||
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, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt)
|
||||
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:
|
||||
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config))
|
||||
except Exception:
|
||||
add_labels = []
|
||||
|
||||
tprint(f"\tHandling additional-answer for {pid} {label}")
|
||||
keep_error = False
|
||||
error = "al:"
|
||||
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}??"
|
||||
keep_error = True
|
||||
continue
|
||||
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
|
||||
if not new_pdf_path.exists():
|
||||
shutil.copy(str(pdf_path), str(new_pdf_path))
|
||||
|
||||
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
|
||||
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}")
|
||||
idx = get_next_group_idx(add_label)
|
||||
tprint(f"\t\tMaking {add_label} group {idx+1}")
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(add_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
height = grouping.get_pdf_height(str(add_pdf_path))
|
||||
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"),
|
||||
add_label, False))
|
||||
error += f"(->){add_label}"
|
||||
keep_error = True
|
||||
else:
|
||||
keep_error = True
|
||||
error += f"(xx){add_label}"
|
||||
tprint(f"\t\tAlready present (not copied) Copie{pid} : {label} -> {add_label}")
|
||||
|
||||
error += f"(->){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:
|
||||
res["error"] = ""
|
||||
else:
|
||||
@@ -644,7 +405,7 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
flash_count += 1
|
||||
|
||||
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
|
||||
|
||||
if precomputed_response:
|
||||
@@ -663,6 +424,26 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
yming, ymaxg, width_r = d_data[pid]
|
||||
|
||||
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":
|
||||
tprint("\tSwallowing an additional-answer from a subsequent task.")
|
||||
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"]:
|
||||
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 = []
|
||||
for (i,f) in enumerate(res["feedback"]):
|
||||
@@ -699,8 +487,9 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
if needs_correction:
|
||||
tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...")
|
||||
try:
|
||||
# Pensez à passer pdf_path à la fonction modifiée !
|
||||
res["feedback"] = correct_boxes_with_gemini(
|
||||
pid, label, res["feedback"],
|
||||
pid, label, pdf_path, res["feedback"],
|
||||
yming, ymaxg, width_r, total_height)
|
||||
except Exception as e:
|
||||
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:
|
||||
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:
|
||||
error_msg = f"Exception processing {file_path}: {e}"
|
||||
print(error_msg, file=sys.stderr)
|
||||
@@ -734,6 +525,84 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
finally:
|
||||
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 args.refaire:
|
||||
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 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:
|
||||
# 1. Extract and backup old corrections
|
||||
@@ -781,12 +658,21 @@ if __name__ == "__main__":
|
||||
|
||||
# 2. Make new group and add to tasks
|
||||
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():
|
||||
idx = get_next_group_idx(label)
|
||||
height = grouping.get_pdf_height(str(pdf_path))
|
||||
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
|
||||
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:
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
@@ -798,11 +684,14 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
if args.batch or args.batch_from:
|
||||
from utils import read_all_labels
|
||||
all_labels = read_all_labels(INPUT_DIR)
|
||||
|
||||
batch_tasks = []
|
||||
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:
|
||||
sys.exit(f"Error: Label '{args.batch_from}' not found. Available labels: {all_labels}")
|
||||
|
||||
@@ -852,7 +741,7 @@ if __name__ == "__main__":
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"inlineData": {"mimeType": "image/jpeg", "data": b64_img}},
|
||||
{"text": make_prompt(label)}
|
||||
{"text": prompting.make_prompt(INPUT_DIR,label)}
|
||||
]
|
||||
}],
|
||||
"generation_config": {
|
||||
@@ -860,7 +749,7 @@ if __name__ == "__main__":
|
||||
"topP": 0.95,
|
||||
"maxOutputTokens": 65535,
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": UNROLLED_SCHEMA
|
||||
"responseSchema": prompting.UNROLLED_SCHEMA
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -904,6 +793,9 @@ if __name__ == "__main__":
|
||||
else:
|
||||
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...")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
|
||||
futures = {}
|
||||
@@ -912,17 +804,51 @@ if __name__ == "__main__":
|
||||
precomp = batched_responses.get(file_path)
|
||||
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):
|
||||
try:
|
||||
new_generated_tasks = future.result()
|
||||
if 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
|
||||
except Exception as e:
|
||||
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()
|
||||
print("Time elapsed : ", end_time - start_time)
|
||||
print("Requests to pro / flash : ", pro_count, flash_count)
|
||||
|
||||
@@ -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
@@ -8,49 +8,7 @@ import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
def compile_to_pdf(text, output_pdf_path): # 21 cm + 3.8 (dimension de la marge de gauche)
|
||||
"""Wraps text in a standalone template and compiles it to PDF."""
|
||||
latex_template = f"""\\documentclass[varwidth=24.8cm,margin=0.4cm]{{standalone}}
|
||||
\\usepackage[utf8]{{inputenc}}
|
||||
\\usepackage[T1]{{fontenc}}
|
||||
\\usepackage{{lmodern}}
|
||||
\\usepackage{{amsmath, amssymb}}
|
||||
\\usepackage{{commands}}
|
||||
\\usepackage{{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}")
|
||||
from utils import compile_to_pdf
|
||||
|
||||
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"""
|
||||
@@ -186,7 +144,9 @@ def process_directory(directory):
|
||||
# Prepare output directories
|
||||
paths = {
|
||||
'Text': os.path.join(directory, "Text"),
|
||||
'Text2': os.path.join(directory, "Text2"),
|
||||
'Sol': os.path.join(directory, "Sol"),
|
||||
'Sol2': os.path.join(directory, "Sol2"),
|
||||
'Persp': os.path.join(directory, "Persp")
|
||||
}
|
||||
for p in paths.values():
|
||||
@@ -232,15 +192,15 @@ def process_directory(directory):
|
||||
if not indexes:
|
||||
label = f"Ex {current_ex_num}"
|
||||
f_labels.write(f"{label}\n")
|
||||
fetch_and_save_sub_text(ids, [], label, paths['Text'])
|
||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol'])
|
||||
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
||||
else:
|
||||
for item in indexes:
|
||||
suffix = format_indices(item['indices'], problem)
|
||||
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
||||
f_labels.write(f"{label}\n")
|
||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text'])
|
||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol'])
|
||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
||||
|
||||
|
||||
# Construct URL (append pb=true if \Roman matched)
|
||||
|
||||
+702
-39
@@ -1,27 +1,99 @@
|
||||
import shlex
|
||||
import re
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from utils import compile_to_pdf
|
||||
|
||||
MODEL_ID = "gemini-3-flash-preview"
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
def get_lcp(s1: str, s2: str) -> str:
|
||||
i = 0
|
||||
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
|
||||
i += 1
|
||||
lcp = s1[:i]
|
||||
if ')' in s1 or ')' in s2:
|
||||
last_paren = lcp.rfind(')')
|
||||
if last_paren != -1:
|
||||
return lcp[:last_paren + 1]
|
||||
return lcp
|
||||
|
||||
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')")
|
||||
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
|
||||
|
||||
class ExamQuestions(BaseModel):
|
||||
questions: List[QuestionOnlyItem]
|
||||
|
||||
# --- Modèles pour la Requête 2 ---
|
||||
class SolutionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The exact unique label of the question provided in the input.")
|
||||
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
|
||||
|
||||
class ExamExtraction(BaseModel):
|
||||
questions: List[QuestionItem]
|
||||
class ExamSolutions(BaseModel):
|
||||
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`)
|
||||
2. The source code of the exam questions (`enonce` file)
|
||||
3. The source code of the exam solutions (`correction` file)
|
||||
|
||||
Your task:
|
||||
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
|
||||
in this extracted text (nor LaTeX like `item` nor org-mode list
|
||||
labelling like `2.`).
|
||||
3. For each label, extract its exact corresponding solution textual
|
||||
content from the `correction` source file. Return the result as
|
||||
a JSON list in the exact reading order of the document.
|
||||
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:
|
||||
@@ -42,9 +144,16 @@ def find_file(folder: Path, base_name: str) -> Path:
|
||||
return path
|
||||
return None
|
||||
|
||||
def process_exam(folder_path: str):
|
||||
def process_exam(folder_path: str, restart: bool = False):
|
||||
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
|
||||
pdf_path = folder / "enonce.pdf"
|
||||
enonce_path = find_file(folder, "enonce")
|
||||
@@ -66,63 +175,615 @@ def process_exam(folder_path: str):
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
contents = [
|
||||
# ==========================================
|
||||
# REQUÊTE 1 : Extraction des Énoncés
|
||||
# ==========================================
|
||||
contents_1 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
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_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}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config = types.GenerateContentConfig(
|
||||
config_2 = types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamExtraction.model_json_schema(),
|
||||
response_json_schema=ExamSolutions.model_json_schema(),
|
||||
)
|
||||
|
||||
print("Sending request to Gemini...")
|
||||
response = client.models.generate_content(
|
||||
if cache_s_file.is_file() and not restart:
|
||||
print("Loading cached solutions from Cache/gemini_solutions.json...")
|
||||
response_s_text = cache_s_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 2 (Solutions) to Gemini...")
|
||||
response_s = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents,
|
||||
config=config
|
||||
contents=contents_2,
|
||||
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
|
||||
text_dir = folder / "Text"
|
||||
sol_dir = folder / "Sol"
|
||||
text2_dir = folder / "Text2"
|
||||
sol2_dir = folder / "Sol2"
|
||||
persp_dir = folder / "Persp"
|
||||
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
|
||||
|
||||
import shutil
|
||||
# Ask only if any directory already exists
|
||||
if any(d.exists() for d in dirs):
|
||||
answer = input(
|
||||
"Output directories already exist. Delete their contents? [y/N] "
|
||||
).strip().lower()
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
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)
|
||||
sol_dir.mkdir(exist_ok=True)
|
||||
|
||||
labels_file = folder / "labels"
|
||||
|
||||
print("Writing 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("/", "_")
|
||||
print("Writing grouped question and solution files...")
|
||||
|
||||
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
|
||||
q_content = q.question_content.replace("\\n", "\n")
|
||||
s_content = q.solution_content.replace("\\n", "\n")
|
||||
if not labels:
|
||||
continue # Skip if a group has no questions (only contexts)
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 4 : Génération du Barème pour le groupe
|
||||
# ==========================================
|
||||
group_text_parts = []
|
||||
for item in group:
|
||||
if isinstance(item, QuestionItem):
|
||||
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
|
||||
elif isinstance(item, ContextItem):
|
||||
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
|
||||
|
||||
# Write Text/label
|
||||
with open(text_dir / safe_label, "w", encoding="utf-8") as f:
|
||||
f.write(f"{q.label}\n{q.question_content}")
|
||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||
|
||||
# Write Sol/label
|
||||
with open(sol_dir / safe_label, "w", encoding="utf-8") as f:
|
||||
f.write(f"{q.label}\n{q.solution_content}")
|
||||
contents_4 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_4 = types.GenerateContentConfig(
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
)
|
||||
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||
try:
|
||||
response_r = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_4,
|
||||
config=config_4
|
||||
)
|
||||
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
||||
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
||||
except Exception as e:
|
||||
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 not api_key:
|
||||
@@ -131,6 +792,8 @@ if __name__ == "__main__":
|
||||
|
||||
parser = argparse.ArgumentParser(description="Extract exam and solution code via Gemini.")
|
||||
parser.add_argument("folder", help="Directory containing the exam files")
|
||||
parser.add_argument("--restart", action="store_true", help="Ignore cache files and re-run extraction requests.")
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
process_exam(args.folder)
|
||||
process_exam(args.folder, restart=args.restart)
|
||||
|
||||
+23
-7
@@ -12,9 +12,10 @@ import argparse
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import config
|
||||
|
||||
MODEL_ID = "gemini-3-flash-preview"
|
||||
api_key = os.environ["GEMINI_API_KEY"]
|
||||
MODEL_ID = config.MODEL_FLASH_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
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.
|
||||
@@ -61,6 +62,8 @@ be missing.
|
||||
|
||||
##labels##
|
||||
|
||||
##wrong_labels##
|
||||
|
||||
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
|
||||
|
||||
@@ -116,6 +119,8 @@ be missing.
|
||||
|
||||
##labels##
|
||||
|
||||
##wrong_labels##
|
||||
|
||||
Since this copy isn't the first part of a sequence, simply set the
|
||||
name to `\"Continued\"`."""
|
||||
|
||||
@@ -128,7 +133,7 @@ class AnnotationData(BaseModel):
|
||||
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."""
|
||||
|
||||
image_path = Path(file)
|
||||
@@ -142,6 +147,11 @@ def generate_request(file, labels, names, context_labels):
|
||||
else:
|
||||
text = my_prompt2.replace("##labels##", labels)\
|
||||
.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 = [
|
||||
types.Content(
|
||||
@@ -183,9 +193,12 @@ for path_str in args.input_paths:
|
||||
|
||||
# 1. Determine which files to process
|
||||
if input_arg.is_file():
|
||||
INPUT_DIR = input_arg.parent.parent
|
||||
target_files = [input_arg]
|
||||
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:
|
||||
print(f"Warning: No Copie*.pdf files found in {input_arg}")
|
||||
else:
|
||||
@@ -194,7 +207,7 @@ for path_str in args.input_paths:
|
||||
|
||||
# 2. Run the logic for all collected files
|
||||
for target_file in target_files:
|
||||
INPUT_DIR = target_file.parent
|
||||
# INPUT_DIR = target_file.parent
|
||||
CUTLEFT_DIR = INPUT_DIR / 'Cutleft'
|
||||
|
||||
# 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...")
|
||||
|
||||
attempt = -1
|
||||
wrong_labels = []
|
||||
while True:
|
||||
attempt += 1
|
||||
if attempt > 0:
|
||||
time.sleep(10 * attempt)
|
||||
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(
|
||||
model=MODEL_ID,
|
||||
@@ -286,6 +301,7 @@ def process_copy_group(group_key, files):
|
||||
name = annota.name
|
||||
if unknown:
|
||||
print(f"Error: {image_file.name} contained unknown labels: {unknown}")
|
||||
wrong_labels.extend(unknown)
|
||||
print("Retrying request...")
|
||||
continue # Retry immediately
|
||||
|
||||
@@ -306,7 +322,7 @@ def process_copy_group(group_key, files):
|
||||
accumulated_labels.append(box.label)
|
||||
break # exit retry loop
|
||||
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
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
+5
-2
@@ -2,6 +2,8 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
def main():
|
||||
@@ -10,6 +12,7 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
work_dir = os.path.abspath(sys.argv[1])
|
||||
copies_dir = Path(work_dir) / "Copies"
|
||||
bnot_dir = sys.argv[2]
|
||||
target_subdir = os.path.join(work_dir, "A Rendre")
|
||||
os.makedirs(target_subdir, exist_ok=True)
|
||||
@@ -31,11 +34,11 @@ def main():
|
||||
copies_map = defaultdict(list)
|
||||
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)
|
||||
if match:
|
||||
copie_id = match.group(1)
|
||||
json_path = os.path.join(work_dir, filename)
|
||||
json_path = os.path.join(copies_dir, filename)
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
+107
-33
@@ -10,12 +10,16 @@ import shutil
|
||||
import subprocess
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from config import PAGE_SPLITTER_KB
|
||||
|
||||
|
||||
# --- Constants ---
|
||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||
CM_TO_POINTS = (1 / 2.54) * 72
|
||||
|
||||
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:
|
||||
|
||||
@@ -84,7 +88,10 @@ class PDFPreviewer:
|
||||
self.num = 0
|
||||
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
|
||||
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
|
||||
|
||||
@@ -94,18 +101,21 @@ class PDFPreviewer:
|
||||
# Set a reasonable initial size for the window
|
||||
self.master.geometry("800x1000")
|
||||
|
||||
def fmt(action):
|
||||
k = PAGE_SPLITTER_KB.get(action, "")
|
||||
return k
|
||||
|
||||
# Dynamic instructions text
|
||||
instructions = (
|
||||
"← / → : Move line 1cm left/right\n"
|
||||
"'c': Rotate page 180°, 'C' : rotate all pages, ',' : rotate all files\n"
|
||||
"t s r n m: 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('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n"
|
||||
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\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"
|
||||
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.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.pack(pady=5, side=tk.TOP)
|
||||
|
||||
@@ -114,21 +124,43 @@ class PDFPreviewer:
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
|
||||
# --- Bindings ---
|
||||
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)
|
||||
action_map = {
|
||||
"move_left": self.move_line_left,
|
||||
"move_right": self.move_line_right,
|
||||
"confirm_next": self.confirm_and_next_page,
|
||||
"rotate_page": self.rotate_page,
|
||||
"rotate_all_pages": self.rotate_all_pages,
|
||||
"rotate_all_files": self.rotate_all_files,
|
||||
"keep_left": self.keep_left,
|
||||
"keep_right": self.keep_right,
|
||||
"keep_as_is": self.keep_as_is,
|
||||
"next_page": self.confirm_and_next_page,
|
||||
"discard_page": self.discard_page,
|
||||
"send_end": self.send_page_end,
|
||||
"restart_file": self.restart_current_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
|
||||
@@ -462,30 +494,72 @@ class PDFPreviewer:
|
||||
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"):
|
||||
psk = ps[i]['keep']
|
||||
|
||||
# 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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
ri += 1
|
||||
|
||||
i += 2
|
||||
else:
|
||||
psk = ps[i]['keep']
|
||||
if psk == "left" or psk == "both" or psk == "as_is":
|
||||
# Si c'est une page simple (ou as_is)
|
||||
if psk in ["left", "both", "as_is"]:
|
||||
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
if psk == "right" or psk == "both":
|
||||
if psk in ["right", "both"]:
|
||||
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||
ri += 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):
|
||||
writer = PdfWriter()
|
||||
|
||||
+47
-10
@@ -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 ---
|
||||
padding = 60
|
||||
|
||||
valid_labels_set = None
|
||||
|
||||
# Queue payload: (pil_image, json_path, metadata)
|
||||
# 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}...")
|
||||
(pil_image, 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 = {
|
||||
"copie": copie,
|
||||
"part": copie_part,
|
||||
"schema": json_schema,
|
||||
"name": json_name
|
||||
"name": json_name,
|
||||
"error": error_msg
|
||||
}
|
||||
|
||||
image_queue.put((pil_image, json_path, metadata))
|
||||
except Exception as e:
|
||||
print(f"Error processing {img_path.name}: {e}")
|
||||
|
||||
# Sentinel to indicate finished
|
||||
image_queue.put((None, None, None))
|
||||
@@ -155,6 +161,16 @@ def worker_thread(base_dir, files_to_process, all_labels):
|
||||
class ImageViewer:
|
||||
def __init__(self, root, base_dir):
|
||||
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.root.title("Bounding Box Viewer")
|
||||
self.label = tk.Label(root, text="Waiting for images...")
|
||||
@@ -177,14 +193,15 @@ class ImageViewer:
|
||||
self.forward_stack = []
|
||||
self.current_pil_image = None
|
||||
|
||||
from config import PLOTTING_KB
|
||||
|
||||
# Bindings
|
||||
self.root.bind('<Return>', self.on_enter)
|
||||
self.root.bind('p', self.on_previous)
|
||||
self.root.bind('e', self.on_edit)
|
||||
self.root.bind('o', self.on_open_pdf)
|
||||
self.root.bind('i', self.on_open_interro)
|
||||
self.root.bind('O', self.on_open_ori_pdf)
|
||||
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
|
||||
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
|
||||
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
|
||||
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
|
||||
self.root.bind(PLOTTING_KB["open originial pdf"], 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.label.bind('<Button-1>', self.on_click)
|
||||
|
||||
@@ -261,6 +278,10 @@ class ImageViewer:
|
||||
self.is_viewing = True
|
||||
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):
|
||||
if self.is_viewing:
|
||||
print(f"Committing data for {self.current_json_path.name}...")
|
||||
@@ -277,6 +298,17 @@ class ImageViewer:
|
||||
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)
|
||||
|
||||
# Add to accumulator
|
||||
@@ -362,6 +394,7 @@ if __name__ == "__main__":
|
||||
input_path = Path(sys.argv[1])
|
||||
files_to_process = []
|
||||
|
||||
|
||||
if input_path.is_file():
|
||||
# Correctly identify base_dir if we are in 'Copies' or 'Cutleft'
|
||||
if input_path.parent.name in ["Copies", "Cutleft"]:
|
||||
@@ -386,6 +419,10 @@ if __name__ == "__main__":
|
||||
sys.exit(1)
|
||||
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:
|
||||
all_labels = read_all_labels(base_dir)
|
||||
except FileNotFoundError:
|
||||
|
||||
+7
-1
@@ -131,7 +131,13 @@ def clean_obj(obj):
|
||||
return [clean_obj(x) for x in obj]
|
||||
|
||||
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:
|
||||
return obj
|
||||
|
||||
+299
@@ -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
@@ -161,7 +161,8 @@ def has_significant_notes(note_img, threshold=20):
|
||||
# print(f"Debug : visible pixels is {visible_pixels}")
|
||||
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,
|
||||
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}")
|
||||
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) ---
|
||||
concat_list = []
|
||||
concat_list_F = []
|
||||
@@ -255,7 +273,8 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
|
||||
|
||||
# B. Regenerate Label Image
|
||||
# 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
|
||||
|
||||
(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
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python reading_annotations.py <Dir>")
|
||||
sys.exit(1)
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Read annotations and compile PDFs")
|
||||
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:
|
||||
all_labels = read_all_labels(Path(root_dir))
|
||||
@@ -351,7 +372,9 @@ if __name__ == "__main__":
|
||||
if os.path.exists(bnot_dir):
|
||||
print(f"Processing annotations for: {student_id}")
|
||||
actions, notes = detect_checks_and_notes(bnot_dir)
|
||||
if actions or notes:
|
||||
apply_actions_and_regenerate(root_dir, original_data, student_id, actions, notes, all_labels)
|
||||
if actions or notes or args.update_score:
|
||||
apply_actions_and_regenerate(root_dir, original_data, student_id,
|
||||
actions, notes, all_labels,
|
||||
update_score=args.update_score)
|
||||
else:
|
||||
print(" No changes detected or missing files.")
|
||||
|
||||
@@ -9,30 +9,37 @@ import threading
|
||||
|
||||
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
|
||||
|
||||
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."""
|
||||
extra_images = []
|
||||
for folder in ["Text", "Sol"]:
|
||||
pdf_path = os.path.join(root_dir, folder, f"{label}.pdf")
|
||||
if os.path.exists(pdf_path):
|
||||
img, _, _ = annotating_module.make_base_image(pdf_path)
|
||||
a, b = pdf_image_of_enonce(root_dir, label), pdf_image_of_solution(root_dir, label)
|
||||
e = pdf_images_of_contexts(root_dir, label, all_labels)
|
||||
for c in e + [a, b]:
|
||||
if c:
|
||||
img, _, _ = annotating_module.make_base_image(c)
|
||||
if img:
|
||||
extra_images.append(img)
|
||||
|
||||
return extra_images
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
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)
|
||||
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 = []
|
||||
current_page_imgs = []
|
||||
@@ -46,11 +53,10 @@ def save_paginated_pdf(image_groups, output_path):
|
||||
processed_group = []
|
||||
for i, img in enumerate(group):
|
||||
if i in (0, 1):
|
||||
img = img.copy() # Do not modify the original image object in memory
|
||||
img = img.copy()
|
||||
draw = ImageDraw.Draw(img)
|
||||
color = "black" if i == 0 else "blue"
|
||||
|
||||
# Draw the border inside the image edges
|
||||
draw.rectangle(
|
||||
[0, 0, img.width - 1, img.height - 1],
|
||||
outline=color,
|
||||
@@ -60,11 +66,12 @@ def save_paginated_pdf(image_groups, output_path):
|
||||
|
||||
group_h = sum(img.height for img in processed_group)
|
||||
|
||||
if current_page_imgs and (current_h + group_h > max_page_h):
|
||||
page = Image.new("RGB", (max_w, current_h), "white")
|
||||
y = 0
|
||||
if current_page_imgs and (current_h + group_h > max_content_h):
|
||||
# Create page with margins included in dimensions
|
||||
page = Image.new("RGB", (max_w + left_margin, current_h + 2 * tb_margin), "white")
|
||||
y = tb_margin
|
||||
for c_img in current_page_imgs:
|
||||
page.paste(c_img, (0, y))
|
||||
page.paste(c_img, (left_margin, y))
|
||||
y += c_img.height
|
||||
pages.append(page)
|
||||
|
||||
@@ -75,10 +82,10 @@ def save_paginated_pdf(image_groups, output_path):
|
||||
current_h += group_h
|
||||
|
||||
if current_page_imgs:
|
||||
page = Image.new("RGB", (max_w, current_h), "white")
|
||||
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:
|
||||
page.paste(c_img, (0, y))
|
||||
page.paste(c_img, (left_margin, y))
|
||||
y += c_img.height
|
||||
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:])
|
||||
|
||||
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,
|
||||
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}")
|
||||
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) ---
|
||||
concat_list = []
|
||||
concat_list_F = []
|
||||
@@ -162,7 +187,8 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id,
|
||||
result = content['result']
|
||||
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
|
||||
|
||||
(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
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
from utils import read_all_labels
|
||||
@@ -264,6 +278,7 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Read grouped annotations and compile PDFs")
|
||||
parser.add_argument("input_path", help="Directory path")
|
||||
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()
|
||||
|
||||
root_dir = sys.argv[1]
|
||||
@@ -402,7 +417,8 @@ if __name__ == "__main__":
|
||||
sid,
|
||||
actions_by_student[sid],
|
||||
notes_by_student[sid],
|
||||
all_labels
|
||||
all_labels,
|
||||
update_score=args.update_score
|
||||
)
|
||||
|
||||
# --- 2. Process each student concurrently using 4 threads ---
|
||||
|
||||
@@ -14,6 +14,11 @@ for file in *.pdf; do
|
||||
# Handle case where no pdfs exist
|
||||
[ -e "$file" ] || continue
|
||||
|
||||
if [ "$file" = "enonce.pdf" ]; then
|
||||
echo "Skipping: $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Rename with 0-padding (e.g., Copie01.pdf)
|
||||
mv -- "$file" "$(printf "Copie%02d.pdf" "$count")"
|
||||
((count++))
|
||||
|
||||
@@ -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.")
|
||||
@@ -13,6 +13,11 @@ cd "$1" || { echo "Error: Cannot access directory '$1'"; exit 1; }
|
||||
shopt -s nullglob
|
||||
|
||||
for file in *.pdf; do
|
||||
if [ "$file" = "enonce.pdf" ]; then
|
||||
echo "Skipping: $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Rotate to a temporary file
|
||||
if qpdf --rotate=+180 "$file" "temp_rotated.pdf"; then
|
||||
mv "temp_rotated.pdf" "$file"
|
||||
|
||||
+2
-2
@@ -112,8 +112,8 @@ def split_an_interro(base_dir, input_pdf, coords_list):
|
||||
if is_stop:
|
||||
end_page = n_pn
|
||||
# end_y_target_raw = n_y_start
|
||||
# On avait retiré un carreau précédemment, on le rajoute…
|
||||
end_y_target_raw = min(n_y_start + int(1.25 * carreau), 1000)
|
||||
# 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.5 * carreau), 1000)
|
||||
break
|
||||
|
||||
# RULES 3 & 4: Calculate horizontal boundaries (0.0 to 1.0 fraction of local page width)
|
||||
|
||||
@@ -21,3 +21,140 @@ def enonce_total(base_dir):
|
||||
output.append(f"{filepath.name}\n{content}\n\n\n")
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user