Compare commits

..
56 Commits
Author SHA1 Message Date
sebastien 0a453403cf améliorations diverses 2026-09-17 22:19:05 +02:00
sebastien 5b8215e7f5 modest improvement to cropping (safer) 2026-09-15 15:13:41 +02:00
sebastien 9b22a8a137 Miscs improvements (Interro02) 2026-09-15 14:18:22 +02:00
sebastien 0a86403ca6 miscs (Interro02) : horizontal cutting resolution 2026-09-14 22:20:46 +02:00
sebastien 5080274e8f small changes to prompts 2026-09-14 09:49:01 +02:00
sebastien d60d5479d6 Miscs personal GUI improvement 2026-09-14 09:42:13 +02:00
sebastien e8b8a11c5b Configuration de l'output final 2026-09-12 23:56:07 +02:00
sebastien 060859ddef cropping summaries 2026-09-10 20:03:23 +02:00
sebastien dfce79f240 GUI documentation 2026-09-10 07:23:39 +02:00
sebastien f7b3689f23 Remove `Arguments supplémentaires' boxes 2026-09-09 18:11:00 +02:00
sebastien 0a167072ed Cropping of individual exos 2026-09-09 15:24:39 +02:00
sebastien 3969580e01 Initial cropping support 2026-09-09 14:53:04 +02:00
sebastien 359c62004c Disable flash-lite for labels 2026-09-08 20:04:17 +02:00
sebastien 13a08f6cbd fix sorting 2026-09-08 17:00:18 +02:00
sebastien bf05272797 Miscs 2026-09-08 16:41:04 +02:00
sebastien db4ed2ef31 start-gui.sh et améliorations diverses 2026-09-08 12:04:18 +02:00
sebastien 06d7bad04e Tradution en français des prompts 2026-09-06 19:28:54 +02:00
sebastien 2313d51a62 Refaire session. 2026-09-06 19:20:51 +02:00
sebastien 2ff1a9b7b9 Refaire fixes and GUI support 2026-09-06 19:06:42 +02:00
sebastien a9897606ac mac support 2026-08-26 13:45:15 +02:00
sebastien 15d1319374 Clean command 2026-08-22 17:17:30 +02:00
sebastien 576e3c9452 Fix bug de tri des labels dans le fichier .json 2026-08-22 13:52:24 +02:00
sebastien 2921d5ec8e dependencies et .gitignore 2026-08-22 13:45:04 +02:00
sebastien b9e9d00e7d Restructuration de l'application 2026-08-22 13:39:20 +02:00
sebastien dc25b5fefa Splitting of Readme.org 2026-08-22 13:17:54 +02:00
sebastien 5a7ddd407f Reuse enonce groups if availabel in annotating_by_label 2026-08-22 12:22:46 +02:00
sebastien 92a9f9883e extension de export/import aux autres modes de génération 2026-08-22 12:14:20 +02:00
sebastien 9af05f2c86 Améliorations GUI 2026-08-22 11:55:17 +02:00
sebastien ce9f385a0a Untrack binaries 2026-08-22 11:50:20 +02:00
sebastien 81dc658640 Améliorations GUI 2026-08-22 11:49:13 +02:00
sebastien 3a8d0fe3ff Standardisation 9 2026-08-20 15:23:45 +02:00
sebastien b19d3b0db6 Standardisation 8 2026-08-20 15:18:08 +02:00
sebastien 18d1e5e2bb Standardisation 7 2026-08-20 15:06:14 +02:00
sebastien aa40e58dd1 Standardisation 6 2026-08-20 15:01:17 +02:00
sebastien 644e287586 Standardisation 5 2026-08-20 14:53:06 +02:00
sebastien bcba5facc8 Standardisation 4 2026-08-20 14:45:53 +02:00
sebastien 8b087bb3e4 Standardisation 3 2026-08-20 14:35:04 +02:00
sebastien 63f690b353 Standardisation 2 2026-08-20 14:17:32 +02:00
sebastien 0a95afacdd Remove Example 2026-08-20 13:30:15 +02:00
sebastien 352d36e38c Standardisation des scripts 2026-08-20 13:01:26 +02:00
sebastien 2f1cd00e32 EvaluationWorkspace 2026-08-20 12:44:58 +02:00
sebastien ac4ab782b2 Windows compatibility 2026-08-20 12:01:31 +02:00
sebastien 7c366a9ca4 gui 2026-08-20 11:39:14 +02:00
sebastien f7067d8585 Export and Import 2026-08-20 10:05:44 +02:00
sebastien c535febbc4 small fixes 2026-08-08 16:47:09 +02:00
sebastien f345f8bb4f Working state (mostly) 2026-08-08 14:38:26 +02:00
sebastien 137a9a7868 Gemini_for_enonce : améliorations 2026-08-08 12:30:14 +02:00
sebastien 9d7a4f37b6 New file : config.py (for models) 2026-08-07 20:55:49 +02:00
sebastien 09befe1d55 Initial Persp generation 2026-07-11 20:30:27 +02:00
sebastien fd35675e4c enonce_info.py 2026-07-09 15:54:36 +02:00
sebastien 121bd0714a DS09 : Conflict resolution, and other 2026-06-29 13:29:28 +02:00
sebastien 27c0dae20e Miscs 2026-06-06 22:09:00 +02:00
sebastien a80187ba80 support for `--update-score' after manual update of score.json 2026-06-06 10:10:53 +02:00
sebastien 80d06e4693 Miscs for Interro31 2026-06-06 09:27:48 +02:00
sebastien 9e8b9ac191 Initial support for _old and _new 2026-06-01 09:51:21 +02:00
sebastien c2e915226e Prompting.py ; Miscs for Interro29 2026-05-19 11:02:49 +02:00
179 changed files with 23906 additions and 18758 deletions
+13
View File
@@ -0,0 +1,13 @@
OLD/
/Interro*/
/DS*/
__pycache__/
*.py[cod]
.venv/
build/
dist/
*.egg-info/
config.py
.copienator-gui.json
.copienator/
tmp/
+115
View File
@@ -0,0 +1,115 @@
#+title: Architecture et conventions de développement
#+author: Sébastien Miquel
#+date: 22-08-2026
#+OPTIONS:
Ce document décrit les invariants techniques du projet. Il s'adresse
aux développeurs et aux agents logiciels qui modifient le dépôt.
- [[file:Readme.org][Guide de démarrage]]
- [[file:Script.org][Référence des étapes et des scripts]]
* API commune pour les scripts
Le paquet =copienator= centralise les chemins d'une évaluation et les
écritures JSON sûres. Un script ne devrait donc plus reconstruire les
chemins partagés à la main :
#+BEGIN_SRC python
from copienator import EvaluationWorkspace, atomic_write_json
workspace = EvaluationWorkspace("Interro")
atomic_write_json(workspace.correction_file, corrections)
#+END_SRC
=EvaluationWorkspace.discover(path)= retrouve également la racine d'une
évaluation à partir d'un fichier ou d'un sous-dossier. Sa construction
ne crée aucun fichier. La création explicite de
=.copienator/logs/= et =.copienator/runs/= se fait avec
=workspace.ensure_control_directories()=. Le chemin
=.copienator/state.sqlite3= est réservé à une future couche d'état
transactionnelle.
=atomic_write_json= écrit d'abord dans un fichier temporaire situé dans
le même dossier, synchronise son contenu, puis remplace la destination.
Une interruption ne laisse donc pas un JSON partiellement écrit. Pour
une modification concurrente de type lire-modifier-écrire, utiliser
=atomic_update_json= : cet utilitaire protège l'opération complète avec
un verrou inter-processus Linux, Windows et macOS.
* Convention des scripts standardisés
Les scripts standardisés exposent =build_parser()=, =run(...)= et
=main(argv=None)=. Leur import ne lance aucun traitement. Ils acceptent
le dossier d'évaluation comme premier argument positionnel, utilisent
=EvaluationWorkspace= pour les chemins partagés et peuvent afficher la
trace complète d'une erreur avec =--verbose=.
Les codes de sortie communs sont :
| Code | Signification |
|------+---------------|
| 0 | réussite |
| 1 | erreur de traitement |
| 2 | arguments invalides |
| 3 | évaluation ou prérequis invalides |
| 4 | traitement partiel, avec avertissements |
| 130 | interruption par l'utilisateur |
Le GUI distingue notamment un traitement partiel d'un échec. Toutes les
commandes du paquet suivent désormais cette convention. Elles sont
regroupées dans =copienator.commands= et exposées par le répartiteur
=python -m copienator=.
* Organisation du code
- Le paquet =copienator= contient les abstractions partagées par les
scripts, notamment =EvaluationWorkspace= et les écritures atomiques.
- Le paquet =copienator_gui= contient la définition du workflow, l'état
persistant, le lanceur de processus et l'application Tk.
- Le sous-paquet =copienator.commands= contient les points d'entrée.
Leur import ne doit pas déclencher de traitement.
- =copienator.dispatcher= expose le CLI unifié et =copienator_gui=
contient l'application Tk.
* État et exécution du GUI
L'état d'une évaluation est conservé dans =.copienator-gui.json=. Les
valeurs des arguments, le dernier mode exécuté, les statuts et un
historique borné y sont enregistrés atomiquement. Les sorties des
processus sont conservées dans =.copienator/logs/=. Le GUI lance les
scripts dans des processus séparés afin de pouvoir afficher leur sortie,
leur transmettre une saisie et les interrompre.
Les retours en arrière ne suppriment pas les artefacts. Ils marquent les
étapes suivantes comme étant à revalider. Les automatismes de première
visite sont également persistés et ne doivent pas être rejoués lors d'un
retour en arrière.
* Invariants à préserver
- Utiliser =EvaluationWorkspace= pour les chemins partagés d'une
évaluation au lieu de reconstruire ces chemins dans chaque script.
- Publier les JSON et les ensembles de fichiers importants de façon
atomique. Une interruption ou un résultat partiel doit conserver la
dernière sortie complète lorsqu'elle existe.
- Conserver la convention =build_parser()=, =run(...)= et
=main(argv=None)= pour les scripts standardisés.
- Ne lancer aucun traitement lors de l'import d'un module.
- Retourner les codes de sortie communs afin que le GUI distingue une
réussite, un résultat partiel, un échec et une interruption.
- Préserver les modifications manuelles et les fichiers déjà présents,
sauf lorsqu'une option explicite comme =--overwrite= ou =--reset=
autorise leur remplacement.
- Les labels deviennent des noms de fichiers. Ils doivent donc respecter
les contraintes de la plateforme, notamment l'interdiction de =:= sous
Windows.
* Règle de classement de la documentation
- Une information nécessaire pour installer ou démarrer l'application
appartient à [[file:Readme.org][Readme.org]].
- Une commande, un argument ou une étape du workflow appartient à
[[file:Script.org][Script.org]].
- Une convention interne, un invariant ou une décision d'architecture
appartient au présent document.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

@@ -1,23 +0,0 @@
{
"Ex 1": "4.0",
"Ex 2 : 1)": "2.5",
"Ex 2 : 2)": "1.5",
"Ex 3 : 1)": "3.5",
"Ex 3 : 2)": "",
"Ex 4": "2.0",
"Ex 5 : 1)": "2.5",
"Ex 5 : 2)": "1.0",
"Ex 6 : 1)": "1.5",
"Ex 6 : 2)": "1.5",
"Ex 6 : 3)": "2.5",
"Ex 7 : 1)": "0.0",
"Ex 7 : 2)": "0.0",
"Ex 7 : 3)": "0.0",
"Ex 8 : 1)": "3.0",
"Ex 8 : 2)": "",
"Ex 9 : 1)": "2.0",
"Ex 9 : 2)": "",
"Ex 10 : 1)": "0",
"Ex 10 : 2)": "",
"Ex 11": ""
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

-132
View File
@@ -1,132 +0,0 @@
{
"width": 1955,
"height": 22256,
"images": [
{
"id": "01",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 0,
"hmax": 1542
},
{
"id": "01",
"label": "Ex 2 : 2)",
"header_height": 80,
"hmin": 1542,
"hmax": 3191
},
{
"id": "02",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 3191,
"hmax": 3889
},
{
"id": "02",
"label": "Ex 2 : 2)",
"header_height": 119,
"hmin": 3889,
"hmax": 6096
},
{
"id": "03",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 6096,
"hmax": 7435
},
{
"id": "03",
"label": "Ex 2 : 2)",
"header_height": 80,
"hmin": 7435,
"hmax": 9335
},
{
"id": "04",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 9335,
"hmax": 10232
},
{
"id": "04",
"label": "Ex 2 : 2)",
"header_height": 80,
"hmin": 10232,
"hmax": 11406
},
{
"id": "05",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 11406,
"hmax": 12022
},
{
"id": "05",
"label": "Ex 2 : 2)",
"header_height": 150,
"hmin": 12022,
"hmax": 14145
},
{
"id": "06",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 14145,
"hmax": 14616
},
{
"id": "06",
"label": "Ex 2 : 2)",
"header_height": 122,
"hmin": 14616,
"hmax": 15251
},
{
"id": "07",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 15251,
"hmax": 15823
},
{
"id": "07",
"label": "Ex 2 : 2)",
"header_height": 122,
"hmin": 15823,
"hmax": 18154
},
{
"id": "08",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 18154,
"hmax": 18922
},
{
"id": "08",
"label": "Ex 2 : 2)",
"header_height": 80,
"hmin": 18922,
"hmax": 20133
},
{
"id": "09",
"label": "Ex 2 : 1)",
"header_height": 80,
"hmin": 20133,
"hmax": 20887
},
{
"id": "09",
"label": "Ex 2 : 2)",
"header_height": 119,
"hmin": 20887,
"hmax": 22256
}
]
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
-1
View File
@@ -1 +0,0 @@
$$\tr (AB) = \sum_{i=1}^n (AB)_{ii} = \sum_{i=1}^n \sum_{k=1}^n a_{ik} b_{ki} = \sum_{k=1}^n \sum_{i=1}^n b_{ki} a_{ik} = \tr (BA).$$
-7
View File
@@ -1,7 +0,0 @@
1) Puisque $G$ est un groupe, l'application $\varphi_k : A \mapsto A_k A$ est une bijection de $G$ dans lui-même (translation à gauche). On en déduit :
$A_k \sum_{i=1}^p A_i = \sum_{i=1}^p A_k A_i = \sum_{j=1}^p A_j = M$.
En sommant la relation précédente pour $k$ allant de $1$ à $p$, on obtient :
$$M^2 = (\sum_{k=1}^p A_k) M = \sum_{k=1}^p (A_k M) = \sum_{k=1}^p M = pM$$
Soit $u = \frac{1}{p} f$. On a alors $u^2 = \frac{1}{p^2} f^2 = \frac{1}{p^2} (pf) = \frac{1}{p} f = u$. L'endomorphisme $u$ est donc un projecteur et $f = pu$ (soit $\lambda = p$).
2) Dans une base adaptée à la décomposition $\R^n = \op{Im } u \oplus \op{Ker } u$, la matrice de $u$ est une matrice diagonale avec $\op{rg } u$ fois la valeur $1$ et le reste de zéros. Ainsi, $\op{tr } u = \op{rg } u$ et $\sum_{i=1}^p \op{tr}(A_i) = \op{tr}(M) = \op{tr}(p u) = p \op{tr}(u) = p \op{rg}(u)$, qui est bien un multiple de $p$.
-1
View File
@@ -1 +0,0 @@
La condition est $\dim \Ker u \geq 2 + \dim \Ker u \cap \Im u$.
-11
View File
@@ -1,11 +0,0 @@
1) On évalue $f$ sur les vecteurs de la base canonique $(1, X, X^2)$ :
+ $f(1) = 1 + 1 - 2 = 0$
+ $f(X) = (X+2) + X - 2(X+1) = 0$
+ $f(X^2) = (X+2)^2 + X^2 - 2(X+1)^2 = 2X^2+4X+4 - 2X^2-4X-2 = 2$
La matrice de $f$ dans la base canonique est donc :
$$A = \begin{pmatrix} 0 & 0 & 2 \\ 0 & 0 & 0 \\ 0 & 0 & 0 \end{pmatrix}$$
2) On cherche une base $(P_1, P_2, P_3)$ telle que $f(P_1)=0$, $f(P_2)=P_3$ et $f(P_3)=0$.
D'après les calculs précédents, on a $f(X^2) = 2$. On peut donc choisir $P_2 = X^2$, ce qui impose $P_3 = 2$.
On a alors bien $f(P_3) = f(2) = 0$. Il suffit de compléter avec un vecteur $P_1 \in \ker(f)$ indépendant de $P_3$, par exemple $P_1 = X$.
La famille $(X, X^2, 2)$ est une famille de polynômes de degrés échelonnés, c'est donc une base de $\R_2[X]$ dans laquelle la matrice de $f$ a la forme voulue.
-4
View File
@@ -1,4 +0,0 @@
1) $A$ est $B$ sont équivalentes s'il existe $P,Q\in GL_n(\R)$ tels que $A = PBQ$. Par ailleurs, on sait que deux matrices (de même tailles) sont équivalentes si et seulement si elles ont le même rang.
2) En notant $r = \rg A$, on sait que $A$ est équivalente à $J_r$, donc qu'il existe $P,Q\in GL_n(\R)$ telles que $A = PJ_r Q$.
Alors $UAUA = U PJ_r Q U P J_r Q$, et il suffit de prendre $U = Q^{-1} P^{-1}$ (et d'utiliser le fait que $J_rJ_r = J_r$) pour avoir $UAUA = UA$.
-5
View File
@@ -1,5 +0,0 @@
Soit $(e_1,\dots, e_p)$ une base de $V$, que l'on complète en une base $(e_1,\dots, e_n)$ de $E$. Un endomorphisme $u\in\mc L(E)$ vérifie $V\subset \Ker u$ si et seulement si $e_1,\dots, e_p\in \Ker u$, donc si et seulement si la matrice de $u$ dans la base $\mc B$ a ses $p$-premières colonnes nulles.
L'ensemble $\mc M$ des matrices qui vérifient cette condition est de dimension $(n-p) n$.
Comme l'application $\Phi$ qui à $u$ associe $\op{Mat}_{\mc B}(u)$ est un isomorphisme, l'ensemble $\mc V = \Phi^{-1}(\mc M)$ est de dimension $(n-p)n$.
-2
View File
@@ -1,2 +0,0 @@
1) Les colonnes de $X Y^T$ sont les $y_i X$, donc sont toutes colinéaires à $X$, donc $\rg X Y^T\leq 1$ et $\Im (XY^T)\subset \vect X$. Le rang est nul si et seulement si $X = \vec 0$ ou $Y = \vec 0$, auquel cas l'image est $\{\vec 0\}$.
2) Si $M$ est de rang $1$ et $m\colon X\mapsto MX$, $\dim \Ker M = n-1$, donc en complétant n'importe quelle base $(e_2,\dots, e_n)$ de $\Ker M$ en une base de l'espace $\mc B = (e_1,\dots, e_n)$, on aura $\op{Mat}_{\mc B}(m)$ qui sera de la forme voulue. Comme $M = \op{Mat}_{\mc B_{can}}(m)$, $M$ est semblable à la matrice voulue.
-18
View File
@@ -1,18 +0,0 @@
1) Notons $C_1, C_2, C_3, C_4$ les colonnes de $A$. On remarque que $C_2 = -C_1$. De plus, la famille $(C_1, C_3, C_4)$ est libre car :
$$\alpha C_1 + \beta C_3 + \gamma C_4 = 0 \iff \begin{cases} \alpha + 2\beta - 2\gamma = 0 \\ \beta - \gamma = 0 \\ \alpha + \beta = 0 \\ \alpha + \beta = 0 \end{cases} \iff \beta = \gamma, \alpha = -\beta, -\beta + 2\beta - 2\beta = 0 \iff \alpha=\beta=\gamma=0$$
On en déduit :
+ $\op{rg}(f) = 3$ ;
+ $\op{Im } f = \op{Vect}((1, 0, 1, 1), (2, 1, 1, 1), (-2, -1, 0, 0))$ ;
+ Par le théorème du rang, $\dim \ker f = 1$. Comme $C_1+C_2=0$, $\ker f = \op{Vect}((1, 1, 0, 0))$.
2) On cherche $e'_1, e'_2$ dans $\ker(f^2)$ et $e'_3, e'_4$ dans $\ker(f-\op{id})^2$ tels que $f(e'_1)=0, f(e'_2)=e'_1, f(e'_3)=e'_3, f(e'_4)=e'_3+e'_4$.
On peut vérifier que $A e_2 = e_1$, donc on va prendre $e_1' = e_1$ et $e_2' = e_2$.
On peut aussi vérifier que $A e_3 = e_3 + e_4$, donc $e_3' = e_4$ et $e_4' = e_3$ conviennent.
La famille $(e_1', e_2', e_3', e_4')$ est bien une base.
3) La matrice de passage est $P = \begin{pmatrix} 1 & 0 & 0 & 1 \\ 1 & 1 & 0 & 0 \\ 0 & 1 & 1 & 0 \\ 0 & 0 & 1 & 0 \end{pmatrix}$. Par inversion du système $Y=PX$, on trouve $P^{-1} = \begin{pmatrix} 0 & 1 & -1 & 1 \\ 0 & 0 & 1 & -1 \\ 0 & 0 & 0 & 1 \\ 1 & -1 & 1 & -1 \end{pmatrix}$.
Pour $n \ge 2$, $T^n = \begin{pmatrix} 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & n \\ 0 & 0 & 0 & 1 \end{pmatrix}$. On en déduit $A^n = P T^n P^{-1}$.
-5
View File
@@ -1,5 +0,0 @@
1) On a $\Im (AB) \subset \Im A$, d'où $\rg AB \leq \rg A$. De plus, $\Im (AB) = A(\Im B)$, donc $\dim \Im (AB) \leq \dim \Im B$, ce qui donne $\rg AB \leq \rg B$.
2) On a $\Im (A+B) \subset \Im A + \Im B$, et $\dim (\Im A + \Im B) \leq \dim \Im A + \dim \Im B$, d'où $\rg (A+B) \leq \dim (\Im A) + \dim (\Im B) = \rg A + \rg B$.
En écrivant $A = (A+B) - B$, la première inégalité donne $\rg A \leq \rg (A+B) + \rg (-B) = \rg (A+B) + \rg B$, soit $\rg (A+B) \geq \rg A - \rg B$. Par symétrie des rôles de $A$ et $B$, on a également $\rg (A+B) \geq \rg B - \rg A$, d'où l'inégalité en valeur absolue.
3) On a $A(B-I_n) = B$, donc $\rg B\leq \rg A$. De même, $(A-I_n)B = AB - B = A$, ce qui implique par la question 1 que $\rg A \leq \rg B$. Finalement, $\rg A = \rg B$.
-8
View File
@@ -1,8 +0,0 @@
1) On a
$$V_n = \vvvv{v_0}{v_1}{\vdots}{v_n} = \vvvv{u_0}{u_0 + u_1}{\vdots}{\sum_{k=0}^n u_k {n\choose k}} = M U_n = \begin{pmatrix}1 & & & \\ 1 & 1 & & \\ \vdots & \ddots & \ddots & \\ 1 & \dots & \dots & 1 \end{pmatrix} \vvvv{u_0}{u_1}{\vdots}{u_n},$$
où $M\in\M_{n+1}(\R)$ est la matrice $M= \big({i+1\choose j+1}\big)_{i,j\leq n+1}$, triangulaire inférieure.
2) On sait que la matrice $M$ est inversible (triangulaire inférieure, avec une diagonale de $1$).
On sait par ailleurs que la transposée de $M$ est la matrice de l'application $u\in \mc L(\R_n(X))\colon P\mapsto P(X+1)$, donc que $M^{-T}$ est la matrice de $u^{-1}\colon P\mapsto P(X-1)$, c'est-à-dire $M^{-T} = \big({j+1\choose i+1} (-1)^{i+j}\big)_{i,j\leq n+1}$, et $M^{-1} = \big({i+1\choose j+1} (-1)^{i+j}\big)_{i,j\leq n+1}$.
On a alors $U_n = M^{-1} V_n$, c'est-à-dire $u_n = \sum_{k=0}^{n} {n \choose k} (-1)^{n+k} v_k = \sum_{k=0}^{n} {n \choose k} (-1)^{n-k}v_k$.
-24
View File
@@ -1,24 +0,0 @@
1) Avec la représentation du cours d'une file par une liste [3, *,
*, A, B, C] où l[0] est l'indice du premier élément de la file,
les opérations enfile et défile ont des complexités en $O(1)$. La
fonction peek consiste à renvoyer l[l[0]] si l[0] > 0, et None sinon.
2)
#+begin_src python
def hamming(n):
f2, f3, f5 = nouvelle_file(), nouvelle_file(), nouvelle_file()
l = [1]
enfile(f2, 2);enfile(f3, 3);enfile(f5, 5)
while True:
k2, k3, k5 = peek(f2), peek(f3), peek(f5)
m = min(k2, k3, k5)
if k2 == m: defile(f2)
if k3 == m: defile(f3)
if k5 == m: defile(f5)
enfile(f2, 2*m); enfile(f3, 3*m); enfile(f5, 5*m)
if m<n:
l)append(m)
else:
break
return l
#+end_src
-1
View File
@@ -1 +0,0 @@
Soient $A\in\M_{n,p}(\K)$, $B\in\M_{p,n}(\K)$. Montrer que $\tr(AB) = \tr (BA)$.
-6
View File
@@ -1,6 +0,0 @@
Soit $G$ un sous-groupe fini de $(GL_n(\R), \times)$, on note $G = \{A_1, \dots, A_p\}$.
L'objectif est de montrer que $\sum_{i=1}^p \text{tr}(A_i)$ est un multiple entier de $p$.
1) Soit $M = \sum_{i=1}^p A_i$, montrer que $M^2 = pM$.
On note $f$ l'endomorphisme de $\R^n$ canoniquement associé à $M$.
Montrer que l'on peut écrire $f = \lambda u$, où $u$ est un projecteur et $\lambda$ une constante à déterminer.
2) Conclure.
-2
View File
@@ -1,2 +0,0 @@
[X 2022]
Soit $n\geq 3$. caractériser les endomorphismes de $\K^n$ pour lesquels il existe une base dans laquelle $u$ est représenté par une matrice de la forme $\begin{pmatrix}0 & 0 & 0 \\ 0 & M & 0 \\ 0 & 0 & 0 \end{pmatrix}$, où $M\in\M_{n-2}(\K)$.
-3
View File
@@ -1,3 +0,0 @@
Soit $f\in\mc L(\R_2[X])$ définie par $f(P) = P(X+2) + P(X) - 2P(X+1)$.
1) Donner la matrice de $f$ dans la base canonique de $\R_2[X]$.
2) Déterminer une base de $\R_2[X]$ dans laquelle la matrice de $f$ est $\begin{pmatrix}0 & 0 & 0 \\ 0 & 0 & 0 \\ 0 & 1 & 0 \end{pmatrix}$.
-3
View File
@@ -1,3 +0,0 @@
Soient $A,B\in\M_n(\R)$.
1) À quelle condition $A$ et $B$ sont-elles équivalentes ? Rappeler la définition, et une caractérisation simple.
2) Montrer qu'il existe $U\in GL_n(\R)$ tel que $UA$ soit une matrice de projection.
-1
View File
@@ -1 +0,0 @@
Soit $E$ un espace de dimension $n$ et $V$ un sous-espace vectoriel de $E$ de dimension $p$. On note $\mc V = \{u\in \mc L(E)\mid V\subset \Ker u\}$. Déterminer la dimension de $\mc V$.
-2
View File
@@ -1,2 +0,0 @@
1) Pour $X,Y\in\K^n$, dessiner la matrice $M = X Y^T$, préciser son rang et son image.
2) Montrer que toute matrice $M$ de rang $1$ est semblable à une matrice de la forme $\scalemath{0.5}{\begin{pmatrix}* & 0 & \dots & 0 \\ \vdots & \vdots & \dots & \vdots \\ \vdots & \vdots & \dots & \vdots \\ * & 0 & \dots & 0 \end{pmatrix}}$.
-7
View File
@@ -1,7 +0,0 @@
Soit $A = \scalemath{0.6}{\begin{pmatrix} 1 & -1 & 2 & -2 \\ 0 & 0 & 1 & -1 \\ 1 & -1 & 1 & 0 \\ 1 & -1 & 1 & 0 \end{pmatrix}}$ et $f$ l'endomorphisme de $\R^4$ canoniquement associé à la matrice $A$.
1) Déterminer $\op{rg}(f)$, $\ker f$ et $\op{Im } f$.
On admet que les espaces $\ker(f^2)$ et $\ker(f - \op{id})^2$ sont supplémentaires dans $\R^4$ et que $\big(e_1 = (1, 1, 0, 0), e_2 = (0, 1, 1, 0)\big)$ et $\big(e_3 = (1, 0, 0, 0), e_4 = (0, 0, 1, 1)\big)$ forment des bases de ces deux espaces.
2) Montrer qu'il existe une base $\mathcal{B}' = (e'_1, e'_2, e'_3, e'_4)$ de $\R^4$ dans laquelle la matrice de $f$ est égale à $T = \scalemath{0.6}{\begin{pmatrix} 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & 1 \\ 0 & 0 & 0 & 1 \end{pmatrix}}$.
3) Calculer $T^n$ pour tout $n$ de $\N^*$. Expliquer comment en déduire $A^n$.
-4
View File
@@ -1,4 +0,0 @@
Soient $A,B\in\M_n(\K)$.
1) Montrer que $\rg AB\leq \min (\rg A, \rg B)$.
2) Montrer que $\rg (A+B)\leq \rg A + \rg B$, puis en déduire que $\rg (A+B)\geq |\rg A - \rg B|$.
3) Montrer que si $AB = A+B$ alors $\rg A= \rg B$.
-3
View File
@@ -1,3 +0,0 @@
On considère une suite réelle $(u_n)_{n\in\N}$, et la suite $(v_n)$ définie par $\displaystyle\forall n\in\N,\, v_n = \sum_{k=0}^n {n\choose k} u_k$.
1) On note $V_n = (v_0,\dots, v_n)$ et $U_n = (u_0,\dots,u_n)$, interprétés comme des vecteurs colonnes. Expliciter une matrice $M$ tel que $V_n = M U_n$.
2) Déterminer une expression explicite de $u_n$ en fonction des $v_k$, pour $0\leq k\leq n$.
-9
View File
@@ -1,9 +0,0 @@
1) Proposer une représentation naïve d'une file. Pour cette
représentation, donner les complexité des opérations \texttt{enfile} et
\texttt{defile}. Implémenter une fonction \texttt{peek} qui prend en argument la
file et renvoie le premier élément de la file, ou \texttt{None} si elle
est vide, sans le retirer de la file.
2) Les nombres de Hamming sont les entiers de la forme $2^a 3^b 5^c$.
On veut écrire une fonction \texttt{hamming} qui prend un entier $n$ en
argument et renvoie la liste des entiers de Hamming $\leq n$, dans
un ordre croissant, avec une complexité linéaire en la longueur de la liste renvoyée.
-11
View File
@@ -1,11 +0,0 @@
Ex 1
Ex 2 : 1),Ex 2 : 2)
Ex 3 : 1),Ex 3 : 2)
Ex 4
Ex 5 : 1),Ex 5 : 2)
Ex 6 : 1),Ex 6 : 2),Ex 6 : 3)
Ex 7 : 1),Ex 7 : 2),Ex 7 : 3)
Ex 8 : 1),Ex 8 : 2)
Ex 9 : 1),Ex 9 : 2)
Ex 10 : 1),Ex 10 : 2)
Ex 11
-21
View File
@@ -1,21 +0,0 @@
Ex 1
Ex 2 : 1)
Ex 2 : 2)
Ex 3 : 1)
Ex 3 : 2)
Ex 4
Ex 5 : 1)
Ex 5 : 2)
Ex 6 : 1)
Ex 6 : 2)
Ex 6 : 3)
Ex 7 : 1)
Ex 7 : 2)
Ex 7 : 3)
Ex 8 : 1)
Ex 8 : 2)
Ex 9 : 1)
Ex 9 : 2)
Ex 10 : 1)
Ex 10 : 2)
Ex 11
+207 -224
View File
@@ -1,10 +1,10 @@
#+title: Script
#+title: Copienator
#+author: Sébastien Miquel
#+date: 14-03-2026
# Time-stamp: <17-05-26 10:51>
# Time-stamp: <22-08-26 12:22>
#+OPTIONS:
* Méta
* Présentation
** Quézaco
Ce dépôt contient un certain nombre de script Python que j'utilise
@@ -21,264 +21,247 @@ pour faire corriger des copies par Gemini.
4. Ces annotations manuscrites sont lues et recompilées en une
version de la copie pour l'élève.
** Disclaimer
** Limitations
J'utilise régulièrement cet outil et j'en suis satisfait, mais j'ai
fait peu d'efforts pour le rendre universel et simple à l'emploi.
Plusieurs parties de mon workflow sont spécifiques à mes
représentations internes des sujets d'examens et/ou à mon environment.
Pour l'instant, la correction est faite question par question : le LLM
n'a accès qu'à la partie de la copie correspondant à une question
fixée. Ça ne gère donc pas les cas où un argument a été donné dans une
question précédente ou autre.
L'utilisation de ce système nécessite une familiarité avec python et
la ligne de commande unix ; cette familiarité n'est probablement pas
suffisante en l'état, mais en théorie il devrait être possible de le
faire tourner sur le dossier =Example= qui contient une copie
initiale, les fichiers correspondant au sujet de l'interro, et des
examples du rendu final (dans le sous dossier =BGnot=).
*** Noms de labels sous Windows
Cette situation s'améliorera peut-être, mais faciliter l'utilisation
de ce système n'est pas une priorité.
Les labels de questions sont utilisés comme noms de fichiers. Les
labels contenant notamment =:= ne sont pas acceptés par Windows.
** Requirements
** Prérequis et installation
*** Python
*** Python 3.11 ou plus récent
Libraries :
Sous macOS avec Homebrew, installer Python et la version correspondante
de Tkinter avant de créer l'environnement virtuel. Par exemple :
#+BEGIN_SRC bash
pip install numpy pandas matplotlib pillow pydantic pypdf pdf2image reportlab img2pdf pymupdf ftfy ezodf google
brew install python@3.13 python-tk@3.13
#+END_SRC
*** Poppler (for pdf2image)
Utiliser alors =python3.13= à la place de =python= dans les commandes
de création de l'environnement si la commande non versionnée n'est pas
disponible.
+ Linux : install poppler-utils
+ Windows : Download from: https://github.com/oschwartz10612/poppler-windows
and add it to your PATH
Créer et activer un environnement virtuel est recommandé :
#+BEGIN_SRC bash
python -m venv .venv
#+END_SRC
Sous Linux et macOS :
#+BEGIN_SRC bash
source .venv/bin/activate
#+END_SRC
Sous Windows (PowerShell) :
#+BEGIN_SRC powershell
.venv\Scripts\Activate.ps1
#+END_SRC
Installer ensuite Copienator et ses dépendances Python depuis la
racine du dépôt :
#+BEGIN_SRC bash
python -m pip install --upgrade pip
python -m pip install -e .
#+END_SRC
Cette commande installe les modules contrôlés par le diagnostic du GUI
(NumPy, pandas, Matplotlib, Pillow, pydantic, pypdf, pdf2image,
ReportLab, img2pdf, PyMuPDF, ftfy, ezodf et Google Gen AI), ainsi que
les exécutables =copienator= et =copienator-gui=. Le paquet fournissant
=google.genai= est =google-genai=, et non =google=.
Les commandes restent également accessibles avec
=python -m copienator= depuis la racine du dépôt.
*** Programmes externes obligatoires
Le diagnostic vérifie que Poppler et LaTeX sont accessibles depuis
=PATH=.
**** Linux (Debian et Ubuntu)
#+BEGIN_SRC bash
sudo apt install poppler-utils python3-tk texlive-latex-extra texlive-fonts-extra lmodern python3-pygments
#+END_SRC
Cette sélection couvre les modèles LaTeX de la configuration par
défaut, notamment =standalone=, =lmodern=, =mathabx= et =minted=. Selon
les commandes LaTeX utilisées dans vos propres énoncés, d'autres
paquets TeX peuvent être nécessaires. =python3-tk= fournit Tkinter sur
les installations Python qui ne l'incluent pas d'origine.
**** Windows
1. Télécharger [[https://github.com/oschwartz10612/poppler-windows][Poppler pour Windows]] et ajouter son dossier =bin= à
=PATH=.
2. Installer une distribution LaTeX, par exemple MiKTeX ou TeX Live,
et vérifier que =pdflatex= est accessible depuis =PATH=.
Fermer puis rouvrir le terminal et le GUI après une modification de
=PATH=.
**** macOS
Avec Homebrew, installer Poppler et MacTeX :
#+BEGIN_SRC bash
brew install poppler
brew install --cask mactex
#+END_SRC
MacTeX fournit la distribution TeX Live complète utilisée par les
modèles de Copienator. Après son installation, rouvrir le terminal ou
exécuter :
#+BEGIN_SRC bash
eval "$(/usr/libexec/path_helper)"
#+END_SRC
Copienator recherche aussi les exécutables dans =/opt/homebrew/bin=,
=/usr/local/bin= et =/Library/TeX/texbin=, notamment lorsque le GUI ne
récupère pas le =PATH= du terminal. Ces instructions conviennent aux
Mac Intel et Apple Silicon sous macOS 11 ou plus récent.
*** Programme externe facultatif
PDF Arranger permet d'ouvrir et de réorganiser plus facilement les
PDF. Son absence est signalée comme facultative dans le diagnostic. Il
doit fournir la commande =pdf-arranger= ou =pdfarranger= dans =PATH=.
Sous macOS, Copienator utilise automatiquement Aperçu comme solution de
repli.
*** Accès à Gemini
Il faut créer une clef API pour Gemini (pas facile).
NB : Lors de la création, google offre (offrait ?) 300€ d'utilisation,
mais seulement pendant les trois mois à venir.
Puis ajouter =GEMINI_API_KEY= à l'environnement avec :
#+BEGIN_SRC bash
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. Suivre les instructions suivantes
* Étapes et Script
** Prétraitement
** Interface graphique
1. =./rotate_all.sh Interro=
(facultatif)
Retourne toutes les pdf de 180°
2. =./rename_to_copie.sh Interro=
change le nom des copies en =Copie{id}.pdf=
3. =python page_splitter.py Interro=
Lancer l'assistant avec :
Découpe des copies A3 en pages A4, en retirant les pages vides.
+ s pour garder les deux pages
+ t/n pour garder celle de gauche/de droite
+ r pour jeter les deux pages
#+BEGIN_SRC bash
python -m copienator gui
#+END_SRC
Les key bindings ne sont pas adaptés à un clavier azerty… À changer…
Sous Linux ou macOS, le lanceur exécutable =./start-gui.sh= démarre aussi
linterface, depuis nimporte quel répertoire. Il utilise le Python de
=.venv= sil existe, sinon =python3= du PATH. On peut lui passer le dossier
d’évaluation : =./start-gui.sh Interro=. Sans argument, il ouvre le
sous-dossier immédiat non masqué le plus récemment modifié du répertoire
courant, en excluant =copienator=, =copienator_gui=, =tests=, =OLD=,
=__pycache__=, =build=, =dist=, =*.egg-info=, =venv=, =env= et
=node_modules=. Sil ny a aucun dossier admissible, linterface démarre
sans évaluation. Un chemin explicite reste utilisable même sil est exclu
de la sélection automatique.
Fix issues with =python page_splitter.py Interro14/Copies/Copie01.pdf=
4. =python cutleft.py Interro=
=Recharger — vérifier à nouveau= relit les fichiers dentrée du dossier.
Pour reprendre le découpage dune copie, sélectionnez-la dans =Copies
détectées=, cliquez sur =Refaire la copie sélectionnée=, puis sur
=Exécuter=. Le découpage repart de loriginal conservé.
Dans la fenêtre de découpage, =i= inverse lordre de toutes les pages
(dernière vers première) et reprend à la nouvelle première page. Les choix
de découpage déjà saisis sont effacés ; les rotations globales sont conservées.
Ce raccourci est configurable via =PAGE_SPLITTER_KB["reverse_pages"]=.
=Copier la commande= copie les commandes complètes (à lancer depuis la
racine du projet). Dans la console, les boutons de copie et le clic droit
permettent de copier la sélection ou toute la sortie ; =Ctrl+C= et
=Ctrl+A= sont également disponibles (=Cmd= sous macOS).
Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions.
Pendant =Découper une partie à gauche pour détection des labels=, =n= décale
la zone de 50 px vers la droite, =N= de 100 px, =t= de 50 px vers la
gauche et =l= l’élargit de 50 px. =1= utilise les pages entières. =s=
signale la copie en erreur et passe à la suivante ; =Entrée= valide et
enregistre la découpe affichée.
Une copie ignorée conserve ses anciennes découpes. Les signalements sont
conservés dans =.copienator/copy_errors.json=, même après fermeture.
Dans =Séparer et réordonner les pages=, =Traiter les copies signalées=
reprend ces copies à partir des originaux conservés. Le même bouton dans
=Découper une partie à gauche pour détection des labels= reprend leur
découpage ; chaque signalement
est effacé seulement après validation et enregistrement avec =Entrée=.
Fermer la fenêtre, appuyer à nouveau sur =s= ou rencontrer une erreur
conserve le signalement. Les commandes =page-split= et =crop-labels=
acceptent aussi =--marked= pour traiter les copies signalées de l’évaluation.
Rerun on a single file with =python cutleft.py Interro/Copies/Copie01.pdf=
Avec =SHOW_PERSONAL_STEPS = True=, =Analyser l’énoncé= propose le choix
entre Gemini et =Énoncés et solutions personnels (SHEETINFO)=. Ce dernier
lance =python -m copienator statement-personal Interro= : il lit
=enonce.tex=, utilise le service dexercices sur =localhost:8080= pour
générer =Text=, =Sol=, =Text2=, =Sol2= et les barèmes personnels =Persp=,
et écrit un groupe par exercice dans =label_groups=.
** Génération d'information sur l'énoncé
Deux boutons ouvrent ensuite des actions facultatives avec aperçu de
commande et bouton =Exécuter= : =Regrouper avec Gemini…= remplace seulement
=label_groups= (=statement Interro --groups-only=) ; =Remplacer Persp avec
Gemini…= régénère seulement les barèmes des groupes actuels
(=statement Interro --persp-only=), avec le même prompt que le parcours
Gemini complet. Une réponse incomplète ou un échec laisse les anciens
barèmes en place. On peut ignorer ces étapes et conserver les résultats
personnels.
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`
On peut aussi ouvrir directement une évaluation avec =python -m copienator gui
Interro=. L'interface conserve l'état et l'historique des étapes dans
=Interro/.copienator-gui.json=, et les sorties complètes dans
=Interro/.copienator/logs/=. Une relance d'une étape antérieure ne
supprime aucun résultat ; les étapes suivantes sont seulement marquées
comme étant à revalider.
** Labelisation et regroupement
Après la réussite d'un script, l'interface sélectionne automatiquement
l'étape suivante. =Découper les réponses par question= et =Regrouper
les réponses= démarrent automatiquement lors de leur première visite.
Avec le mode =Correction immédiate=, les trois étapes batch sont alors
marquées =Ignorée= à leur première visite. L'étape de résolution
manuelle est traitée de la même façon si =manual_resolutions.txt= est
absent, vide ou ne contient que des commentaires. Ces automatismes ne
se répètent pas lors d'un retour en arrière.
Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
PDF Arranger (ou Aperçu sous macOS) et la configuration Gemini. Sous
Windows, les exécutables externes doivent être accessibles depuis
=PATH=. Sous macOS, les emplacements standards de Homebrew et MacTeX
sont également inspectés. Quand la création de liens symboliques ou
physiques n'est pas autorisée, l'export et la préparation de =A Rendre=
utilisent automatiquement une copie normale.
1. =python gemini_for_labels.py Interro=, avec éventuellement =--overwrite=
Les chemins des étapes personnelles peuvent être adaptés avec
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=.
Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies.
2. =python plotting.py Interro=
* Documentation complémentaire
Permet de vérifier visuellement les labels trouvés.
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
l'éditer a la main.
+ Quand un label est manquant, il est possible de cliquer sur
l'image, ce qui copie les coordonnées dans le presse papier
(sous linux…), puis on peut l'ajouter à la main.
+ Utilisation de `_`, `|…` et `…|` :
+ `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie :
=python plotting.py Interro/Copies/Copie01.pdf=
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
1. En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf
- Rerun =python cutleft.py Interro/Copie{id}=
- Rerun =python gemini_dir_batching.py Interro/Copie{id}= ?? À
vérifier, pas sûr que ça marche.
3. =python splitting_int.py Interro=
Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie.
4. =python grouping.py Interro=
Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables.
** Correction et annotation
Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. Il faut créer des persp, pour indication de comment corriger, et
relancer =enonce_info.py=
2. =python correction.py Interro --limit 240= OU
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python correction.py Interro --overwrite=
=python correction.py Interro --pro-by-label= (needs `labels_for_pro`)
Fais les requêtes de correction à Gemini.
L'argument =limit= limite le nombre de requêtes à Gemini Pro
(chères), pour une version low cost, passer =--limit 0=, toutes
les requêtes seront sur Gemini Flash.
Will it resume ? It seems so. Best to wait a bit.
Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h.
+ =python correction.py Interro --batch=
+ OU =python correction.py Interro --batch-from 'Ex 4'=
+ =python submit_batches.py Interro=
+ =python batch_status.py=
+ =python fetch_batched_results.py Interro=
+ =python correction.py Interro --deal-with-batched=
3. =python post-correction.py Interro=
- Essaye de corriger des erreurs d'encodage/d'accents dans
=correction.json=.
- aussi échappe les `_` en dehors du mode math, pour LaTeX.
** Génération des copies annotées
1. =python annotating.py Interro= (facultatif)
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=.
2. =python annotating_with_checks.py Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=,
=--overwrite=
OU
2. =python annotating_by_label.py Interro= dans =BGnot=
Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question.
Enregistrées dans =BGnot=
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
3. =python to_tablette.py Interro= (gestion perso)
Cela déplace les groupes dans =SyncCopies/À Annoter=.
- Les mettre dans le dossier racine de la tablette, et renommer en =aaa=.
- Vider =Syncthing/Annotées= sur la tablette et localement.
À automatiser, aussi c'est lent…
** Lecture de la correction manuscrite
1. =python from_tablette.py Interro= (gestion perso)
_Before_ : delete =~/SyncCopies/Annotées=, copy or sync from the
tablette to here.
Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=.
(À faire : universaliser =to_tablette.py= et =from_tablette.py=)
2. =python reading_annotations.py Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications.
OU
2. =python reading_grouped_annotations.py Interro=
Idem, mais pour =BGnot=.
3. =python giving_names.py Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers
+ La copie à rendre
+ un fichier =score.json= qui contient les notes par question
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. (gestion perso)
+ =gestion_classe ne= pour créer l'interro puis
+ =gestion_classe we= (set barème here)
+ =python update_ods.py Interro=
ou =python update_ods.py Interro --sum= (en l'absence de barème)
+ =gestion_classe re=
+ =gestion_classe wsent=
+ =python add_final_score.py Interro21=
(this makes files in =Server/copies=)
5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and
+ update the copies from =miqmacs.fr/admin=.
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
** Recorrection d'une seule copie (peu testé)
!! Attention, refaire ne marchera pas si tu fais une annotation non
groupée into refaire !!
1. Redécoupage
+ =python plotting.py InterroTest/Copie01.pdf=
+ =python splitting_int.py InterroTest/Copie20.pdf=
2. Créer =refaire.json=, avec un contenu comme
: [["Copie01", []],
: ["Copie01", ["Ex 1 : 1)"]]]
3. Appeler =correction= avec --refaire. Il doit créer des groupes
individuels, faire des requêtes, et remplacer les corrections
précédentes (à sauver ailleurs).
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.
6. =python to_tablette.py --refaire Interro24=
6. =python from_tablette.py --refaire Interro24=
7. =python reading_grouped_annotations.py --refaire Interro24=
- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et
pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion.
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
prérequis, fichiers produits et parcours alternatifs.
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
état partagé, écritures sûres et règles de maintenance.
+506
View File
@@ -0,0 +1,506 @@
#+title: Référence des étapes et des scripts
#+author: Sébastien Miquel
#+date: 22-08-2026
#+OPTIONS:
Ce document décrit le workflow détaillé, les commandes disponibles et
les parcours alternatifs.
- [[file:Readme.org][Guide de démarrage]]
- [[file:Architecture.org][Architecture et conventions de développement]]
* Étapes
Utiliser `python -m copienator gui` ou `python -m copienator gui Interro` pour lancer un GUI
qui suit automatiquement les étapes décrites ci-dessous.
** Prétraitement de l'énoncé
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation :
`enonce.pdf`, `enonce.tex`, `correction.tex`.
- `python -m copienator statement Interro` or
`python -m copienator statement 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 -m copienator statement-personal Interro`
Ces deux commandes suivent la convention des scripts standardisés.
Leur import ne lance aucun traitement et les erreurs partielles sont
distinguées des échecs. Les réponses d'extraction mises en cache par
=copienator statement= et le fichier =labels= sont publiés
atomiquement.
** Prétraitement des copies
Mettre les copies scannées au format pdf dans =Interro=.
1. =python -m copienator copies rotate Interro= (facultatif)
Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers.
2. =python -m copienator copies rename Interro=
change le nom des copies en =Copie{id}.pdf=
3. =python -m copienator page-split Interro=
Découpe des copies A3 en pages A4, en retirant les pages vides.
Pour chaque page double il est possible
+ de garder les deux pages
+ de ne garder qu'une des deux pages.
+ de jeter les deux pages
+ de déplacer la délimitation à droite/gauche
Fix issues with =python -m copienator page-split Interro14/Copies/Copie01.pdf=
Le PDF transformé est construit dans un dossier temporaire. La
copie produite et la sauvegarde dans =Copies Originales= sont
ensuite installées avec rollback : une erreur conserve les deux
versions précédentes. Une relance ciblée lit directement la
sauvegarde originale sans la déplacer au préalable.
4. =python -m copienator crop-labels Interro=
Découpe la partie gauche des copies, là où il devrait y avoir les
labels des exercices/questions.
=python -m copienator crop-labels Interro --fullpage= to use the full page always.
Rerun on a single file with =python -m copienator crop-labels Interro/Copies/Copie01.pdf=
Les images et le fichier =_schema.json= d'une copie sont remplacés
ensemble. Fermer l'outil juste après la dernière validation ne peut
donc plus interrompre un thread de sauvegarde en arrière-plan.
** Labelisation et regroupement
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. =python -m copienator labels Interro=, avec éventuellement =--overwrite=
Fait des requêtes à Gemini pour identifier les labels des
questions dans images générées à partir des parties gauches des copies.
Une copie PDF ou une image précise de =Cutleft= peut également être
ciblée. Plusieurs cibles de la même évaluation sont acceptées. Les
parties d'une copie restent traitées séquentiellement afin de
conserver les labels précédents comme contexte, tandis que les
copies différentes sont traitées en parallèle. Chaque réponse JSON
validée est écrite atomiquement. Une cible sans image correspondante
produit le code de sortie 4.
2. =python -m copienator review-labels Interro=
Permet de vérifier visuellement les labels trouvés.
+ Sous Linux et macOS, on peut faire =e= pour ouvrir le fichier .json et
l'éditer a la main.
+ Quand un label est manquant, il est possible de cliquer sur
l'image, ce qui copie les coordonnées dans le presse papier
puis on peut l'ajouter à la main.
+ Utilisation de `_`, `|…` et `…|` :
+ `|…` n'est pas arrêté verticalement par son type opposé.
+ `…|` est stoppé horizontalement par le `|…` le plus proche.
Pour modifier une seule copie :
=python -m copienator review-labels Interro/Copies/Copie01.pdf=
Les coordonnées agrégées sont écrites atomiquement dans le JSON de
la copie. Fermer la fenêtre avant la fin d'une copie ne remplace pas
son JSON par un résultat incomplet.
It also generates les =Copie01.json=, à partir des =Copie01_01.json=
En cas de soucis, (par exemple les pages ne sont pas dans le bon ordre)
- Réordonner les pages du fichier pdf
- Rerun =python -m copienator crop-labels Interro/Copie{id}=
- Rerun =python -m copienator labels Interro/Copie{id}=
3. =python -m copienator split-answers Interro=
Découpe les copies suivant les exercices
Peut-être appelé avec une seule copie.
Les réponses d'une copie sont préparées dans un dossier temporaire,
puis remplacent ensemble le dossier précédent. En cas d'erreur,
l'ancienne version est conservée. Les réponses devenues obsolètes
restent archivées dans le sous-dossier =Missing=.
4. =python -m copienator group-answers Interro=
Regroupe les mêmes questions de différentes copies en groupes de
tailles raisonnables.
5. Facultatif : =python -m copienator verify-groups Interro=
Vérifie que chaque réponse PDF apparaît bien dans les métadonnées
des groupes. La commande renvoie un code non nul si une réponse est
absente ou si la vérification est incomplète.
** Correction et annotation
Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
1. Il faut créer des persp, pour indication de comment corriger, et
relancer =copienator statement-personal=
2. =python -m copienator correct Interro --limit 240= OU
=python -m copienator correct Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python -m copienator correct Interro --overwrite=
Fais les requêtes de correction à Gemini.
=copienator correct= peut être relancé sans supprimer son état. Les
fichiers =correction.json= et =correction_progress.json= sont mis à
jour atomiquement. Avec =--overwrite=, leur version précédente reste
en place jusqu'à la première écriture réussie de la nouvelle
exécution. =--reset= est la seule option qui supprime explicitement
cet état et restaure les fichiers =*_old.pdf=.
L'argument =limit= limite le nombre de requêtes à Gemini Pro
(chères), pour une version low cost, passer =--limit 0=, toutes
les requêtes seront sur Gemini Flash.
Pour diminuer le coût, il est possible de batch les requêtes, qui
seront alors traitées sous au plus 24h.
+ =python -m copienator correct Interro --batch=
+ OU =python -m copienator correct Interro --batch-from 'Ex 4'=
+ =python -m copienator batch-submit Interro=
+ =python -m copienator batch-status=
+ =python -m copienator batch-fetch Interro=
+ =python -m copienator correct Interro --deal-with-batched=
Les quatre commandes de ce flux suivent la convention des scripts
standardisés. Les fichiers de requêtes et le résultat JSONL combiné
sont publiés atomiquement : une interruption ne laisse pas de fichier
final partiellement écrit. =copienator batch-submit= conserve aussi les
identifiants distants dans =batch_jobs.json= ; la récupération les
utilise en priorité et garde la recherche par nom pour les anciens
batchs. =python -m copienator batch-status --download JOB --output
resultat.jsonl= permet aussi de télécharger atomiquement le résultat
d'un job particulier.
3. =python -m copienator post-correction Interro=
- 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, s'il y en a.
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 -m copienator resolve-manual Interro`
5. Call `python -m copienator correct Interro --refaire`.
** Génération des copies annotées
1. =python -m copienator annotate-simple Interro= (facultatif)
Ajoute les annotations Gemini aux copies, enregistrées dans le dossier =Anot=.
On peut passer l'argument =--overwrite=.
OU
2. =python -m copienator annotate-checks Interro=
Ajoute les annotations Gemini, et des checkboxes à cocher.
Enregistrées dans le dossier =Bnot=,
=--overwrite=
Une seule copie peut être ciblée avec, par exemple,
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=.
OU
2. =python -m copienator annotate-grouped Interro= dans =BGnot=
Ajoute les annotations Gemini, et des checkboxes, et regroupe les
réponses par question.
Enregistrées dans =BGnot=
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
3. =python -m copienator export Interro BGnot= (gestion perso)
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=).
Le second argument peut être =BGnot=, =Bnot= ou =Anot= et reste
facultatif (=BGnot= par défaut). =Anot= exporte =Concat.jpg= ; les
deux autres modes exportent =Concat.pdf=. Dans le GUI, seuls les
dossiers présents sont proposés et le mode de la dernière génération
d'annotations est présélectionné.
Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
tablette graphique.
** Lecture de la correction manuscrite
_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
1. =python -m copienator import Interro BGnot=
Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=.
Comme pour l'export, le second argument accepte =BGnot=, =Bnot= ou
=Anot=. Le GUI présélectionne le dossier utilisé lors du dernier
export. Pour =Anot=, l'image est importée sous le nom
=Concat_annotated.jpg=.
2. =python -m copienator read-annotations Interro=
Lit les =Concat_annotated= dans =Bnot=, regénère les copies avec
les modifications. Les fichiers générés (=score.json=,
=Concat.jpg=, etc.) sont préparés séparément puis installés
ensemble. Les entrées du dossier =Bnot= restent en place et une
erreur de génération conserve les anciennes sorties.
OU
2. =python -m copienator read-grouped Interro=
Idem, mais pour =BGnot=. Les tâches parallèles remontent leurs
erreurs au processus principal au lieu de les ignorer.
3. =python -m copienator giving-names Interro BGnot=
Crée un dossier =A Rendre= avec des liens symboliques vers
+ =<nom>.jpg= : la correction complète concaténée (=Concat.jpg=)
+ =<nom>.pdf=, si disponible : la correction filtrée, avec contexte,
énoncés et solutions dans le parcours =BGnot= (=Concat_F.pdf=)
+ un fichier =score.json= qui contient les notes par question
+ =info.json= : pour chaque label, =present= (réponse fournie),
=not_empty= (réponse non vide compilée), =touched= (présente dans le
PDF filtré) et =score= (même valeur que dans =score.json=)
+ =answers/*.jpg=, si =RETURN_ANSWERS_ENABLED= est activé : un JPEG
par réponse non vide, contenant toujours la réponse annotée
Le PDF n'est donc pas une conversion du JPEG. Voir la
[[file:docs/final_output.md][documentation des fichiers finaux]] pour les règles de sélection,
la pagination et les différences entre parcours.
=RETURN_JPEG_ENABLED= et =RETURN_PDF_ENABLED= dans =config.py=
permettent de désactiver ces sorties séparément (activées par défaut).
Relancer =giving-names= retire les fichiers nommés désactivés en
conservant leurs sources. =score.json= et =info.json= sont toujours inclus.
=RETURN_ANSWERS_ENABLED= est désactivé par défaut, activé dans la
configuration personnelle. =RETURN_ANSWERS_CONTEXT=,
=RETURN_ANSWERS_QUESTION= et =RETURN_ANSWERS_SOLUTION= choisissent les
documents ajoutés avant chaque réponse (seule la question est activée
par défaut). Recompiler les anciennes annotations une fois avant cet
export pour produire les images finales et leurs métadonnées.
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
4. Éventuellement, faire des modifications manuelles aux =score.json=.
Puis
- `python -m copienator read-annotations --update-score Interro`
- `python -m copienator read-grouped --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)
+ =python -m copienator update-ods Interro=
ou =python -m copienator update-ods Interro --sum= (en l'absence de barème)
+ =gestion_classe re=
+ =gestion_classe wsent=
+ =python -m copienator add-final-score Interro21=
(this makes files in =Server/copies=)
5. (gestion perso)
+ Deploy =miqmacs-copies-assets=, and
+ update the copies from =miqmacs.fr/admin=.
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
** Archivage et nettoyage
Une fois l'évaluation terminée, =python -m copienator clean Interro=
supprime les fichiers intermédiaires et régénérables. La commande ne
conserve que :
+ les PDF =Copies/*.pdf= produits après le découpage des pages ;
+ les sources et sorties textuelles du prétraitement de l'énoncé :
=enonce.tex=, =correction.tex=, =labels=, =label_groups=, =Text=,
=Sol=, =Persp=, les fichiers TeX de =Text2= et =Sol2=, =Cache= et
=Tmp= ;
+ le résultat final =correction.json= ;
+ les journaux de =.copienator/logs= et les journaux placés à la
racine, comme =correction_log= ;
+ les images (y compris =answers=), PDF et fichiers =score.json= et
=info.json= présents dans =A Rendre=.
Les liens symboliques conservés dans =A Rendre= sont remplacés par de
véritables fichiers avant la suppression de leurs cibles. La commande
refuse de démarrer si les copies traitées, =correction.json= ou une
image/un score d'élève sont absents (l'image est facultative si
=RETURN_JPEG_ENABLED= vaut =False=). Elle affiche d'abord un résumé et
demande de saisir le nom de l'évaluation pour confirmer.
Dans le GUI, cette commande apparaît comme dernière étape facultative
dans la section =Archivage=. Un avertissement rappelle que la
progression du GUI et les données binaires permettant de reprendre les
étapes seront définitivement perdues.
Utiliser =python -m copienator clean Interro --dry-run= pour afficher
le plan sans rien supprimer, et =--yes= pour omettre la confirmation
interactive.
* Autres
** Recorrection d'une copie ou de quelques questions
Dans le GUI, ouvrir la section =Refaire des copies (facultatif)=,
repliée par défaut. Ajouter les copies et leurs questions depuis les
listes déroulantes, ou choisir =Toute la copie=, puis enregistrer la
sélection. Le dossier du passage principal est présélectionné d'après
le dernier mode utilisé et les dossiers présents ; vérifier ce choix.
Pour reprendre une même question dans toute la classe, choisir
=Toutes les copies=, sélectionner la question, puis =+ Ajouter=.
Seules les copies ayant un PDF de réponse pour cette question (normal
ou =_new=) sont ajoutées ; le nombre de copies sans réponse est affiché.
Une copie déjà sélectionnée entièrement reste sélectionnée entièrement.
Les boutons =Corrigés (Sol)= et =Consignes de notation (Persp)= ouvrent
les dossiers des textes utilisés par la correction. Modifier et enregistrer
les fichiers des questions concernées avant de relancer =Refaire la correction=.
Ces boutons sont aussi disponibles dans le parcours principal.
Le choix =PDF à vérifier= propose =Automatique=, =Par question (groupé)=
ou =Par copie=. En automatique, une question présente dans plusieurs
copies déclenche le regroupement ; sinon les PDF sont produits par copie.
Les groupes sont limités en hauteur : une question pour toute la classe
peut donc produire quelques PDF plutôt qu'un fichier par élève.
Le GUI écrit =refaire.json= et guide ensuite le parcours ci-dessous.
La vérification du découpage, le redécoupage et la recorrection peuvent
être ignorés. Pour plusieurs copies, les vérifications et découpages
s'exécutent successivement ; un échec ou une interruption arrête la suite.
Ce parcours a sa propre progression : les boutons de navigation du
passage principal ne l'ouvrent pas automatiquement. Après la fusion,
les étapes de restitution déjà terminées sont marquées à revalider.
Pour enchaîner les reprises, deux boutons sont disponibles dans ce parcours :
- =Nouvelle reprise= vide la sélection et remet les étapes de reprise à zéro.
- =Refaire la même sélection= conserve les copies et les questions du dernier
enregistrement, mais remet également les étapes de reprise à zéro.
Les choix du passage principal et de présentation des PDF sont conservés.
Enregistrer ensuite la sélection avant de poursuivre.
Attention : appeler =Nouvelle reprise= seulement après avoir importé les
résultats et exécuté =Mettre à jour les copies finales=. La même précaution
s'applique à =Refaire la même sélection=. Un avertissement est affiché avant
les deux actions, avec une mention supplémentaire si la fusion n'est pas
marquée réussie. Annuler conserve la reprise actuelle. Après une fusion
réussie, utiliser ces boutons pour recommencer, plutôt que modifier la
sélection du passage terminé.
Chaque nouveau passage utilise =Reprises/reprise-DATE-HEURE-ID/BRnot=.
Le passage précédent garde ses PDF, ses retours manuscrits, sa sélection
et une copie de la progression du GUI. Au premier changement de passage,
l'ancien =BRnot= à la racine est également copié dans =Reprises=.
Ces archives concernent les fichiers de vérification, pas un mécanisme
permettant d'annuler les modifications des copies finales.
Le fichier =refaire-session.json= désigne le passage actif ; les commandes
habituelles =--refaire= le suivent automatiquement. Sans ce fichier, le
fonctionnement historique dans =BRnot= à la racine reste disponible.
Dans la suite, =BRnot= désigne le dossier de la reprise active.
L'export d'un passage identifié utilise son propre sous-dossier dans
=EXPORT_DIR/Évaluation= et préfixe les noms des PDF par son identifiant.
Conserver les noms complets au retour et placer les PDF directement dans
=IMPORT_DIR=. L'import ignore les retours des autres passages ; aucun retour
du passage actif donne un résultat partiel. Ainsi, deux reprises de la même
question ne partagent pas les mêmes noms de fichiers exportés.
Ce flux fonctionne après =annotate-grouped= (=BGnot=),
=annotate-checks= (=Bnot=) ou =annotate-simple= (=Anot=).
Terminer d'abord la lecture des annotations du passage principal
(=read-grouped= ou =read-annotations= pour les modes à cases).
Conserver les dossiers d'annotation et leurs fichiers de référence.
1. Si nécessaire, reprendre le découpage :
+ =python -m copienator review-labels Interro/Copies/Copie01.pdf=
+ =python -m copienator split-answers Interro/Copies/Copie01.pdf=
Vérifier les réponses découpées avant de relancer la correction,
notamment les fichiers =_new= et =_old= issus de résolutions manuelles.
2. Créer =Interro/refaire.json= :
: [["Copie02", []],
: ["Copie01", ["Ex 1 : 1)"]]]
Une liste vide sélectionne toute la copie ; sinon donner les labels
exacts des questions (pas seulement le nom de l'exercice).
3. =python -m copienator correct Interro --refaire=
Crée des groupes individuels et remplace les corrections sélectionnées.
Les anciennes corrections sont conservées dans =overwritten_correction.json=.
Cette étape peut être omise si les corrections sont modifiées à la main.
4. Générer les PDF de vérification, selon la présentation souhaitée :
+ Par question : =python -m copienator annotate-grouped Interro --refaire --overwrite=
+ Par copie : =python -m copienator annotate-checks Interro --refaire --overwrite=
Les deux commandes produisent uniquement les réponses sélectionnées
dans =BRnot=, avec des cases à cocher, quel que soit le mode du passage
principal. Le mode groupé garde les identifiants des élèves et regroupe
les réponses par label sans demander de modifier =label_groups=.
Cela ne nécessite pas d'avoir généré =Bnot= ou =BGnot= auparavant.
Attention : =--overwrite= remplace le contenu du =BRnot= actif,
y compris ses annotations manuscrites, mais pas les autres reprises. Une génération incomplète
conserve l'ancien =BRnot=. Sans =--overwrite=, un =BRnot= existant est refusé.
5. Vider les dossiers personnels d'export/import des anciens fichiers,
puis =python -m copienator export Interro --refaire=.
Annoter les PDF sur la tablette, puis placer les PDF retournés dans
=IMPORT_DIR= en conservant leur nom exporté (nom de groupe ou =Copie01.pdf=).
Même sans modification manuscrite, retourner le PDF pour valider ce passage.
6. =python -m copienator import Interro --refaire=
7. Fusionner dans le dossier du passage principal :
+ Groupé : =python -m copienator read-grouped Interro --refaire=
+ Cases : =python -m copienator read-grouped Interro --refaire --annotation-dir Bnot=
+ Simple : =python -m copienator read-grouped Interro --refaire --annotation-dir Anot=
Le lecteur reconnaît les retours par question comme les retours par
copie grâce aux métadonnées et réattribue les cases et notes à chaque
élève. Un groupe manquant laisse intactes les copies qui en dépendent.
Il reconstruit la copie complète, conserve les réponses non
sélectionnées et leurs scores, et remplace les réponses sélectionnées
par celles de =BRnot=. Dans la compilation filtrée, les images déjà
enregistrées des questions non sélectionnées sont conservées par
prudence, même si leur score est parfait, pour ne pas perdre de notes.
Les anciennes cases et notes manuscrites des
questions refaites sont remplacées. Les autres copies restent intactes.
En mode simple, une image =Concat_annotated.jpg= ou =.jpeg= importée
doit conserver les dimensions de l'image exportée ; les parties non
sélectionnées sont conservées. Le fichier =refaire_simple_layout.json=
mémorise le découpage de cette image pour les passages suivants.
Les sorties finales (=Concat.jpg=, images par question, =score.json=
et compilation filtrée) sont mises à jour dans =BGnot=, =Bnot= ou
=Anot= ; les PDF et références du passage principal restent ceux de
ce passage. Pour une nouvelle retouche, reprendre ce flux =--refaire=,
sans relire ensuite les anciennes annotations avec le lecteur normal.
Relancer ensuite les étapes habituelles de calcul des notes et de diffusion.
=refaire.json=, =BRnot= et le dossier du passage principal sont
obligatoires (code de sortie 3 s'ils manquent). Une copie dont les
fichiers de retour sont incomplets est laissée intacte (code 4).
Ne pas ajouter =--update-score= sauf pour imposer volontairement les
anciens scores, y compris ceux des questions refaites.
-116
View File
@@ -1,116 +0,0 @@
import argparse
import math
import sys
import os
import shutil
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
# Configuration constants
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/simple_eval.ods"
OUTPUT_DIR = Path("/home/sebastien/Rust/Server/copies")
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Standard Linux font path
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def process_images(base_dir):
# 1. Load Data
try:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for img_path in sorted(search_path.glob("*/*.jpg")):
student_name = img_path.stem # Filename without extension
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
continue
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
continue
# 5. Process Image
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, height = img.size
# Dynamic font size (15% of image height)
font_size = int(width * 0.08)
try:
font = ImageFont.truetype(FONT_PATH, font_size)
except IOError:
# Fallback if specific font not found
font = ImageFont.load_default()
print(f"Warning: System font not found, using default for {student_name}")
text = str(score)
# Calculate text size and position (Top Right)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# 30px padding
x = width - text_w - 30
y = 30
# Draw Text (Red)
draw.text((x, y), text, fill=(255, 0, 0), font=font)
# Save
save_path = OUTPUT_DIR / f"{student_name}.jpg"
img.save(save_path)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in sorted(search_path.glob("*/*.pdf")):
student_name = pdf_path.stem # Filename without extension
save_path = OUTPUT_DIR / f"{student_name}.pdf"
shutil.copy(str(pdf_path), str(save_path))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args()
OUTPUT_DIR = OUTPUT_DIR / args.dir
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
process_images(args.dir)
-247
View File
@@ -1,247 +0,0 @@
import sys
import os
import json
import shutil
import argparse
import concurrent.futures
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
import annotating
import annotating_with_checks
from utils import natural_key
MAX_HEIGHT_PX = 25000 # Can be increased by 10%.
def render_item(item):
student_id, label, content = item
pdf_path = content['pdf_path']
if not os.path.exists(pdf_path):
print("no pdf path for ", pdf_path)
return None
base_img, _, _ = annotating.make_base_image(pdf_path)
cb_renderer = annotating_with_checks.CheckboxRenderer(label)
final_img, header_h = annotating.compose_label_image(
base_img, label, content['result'], content['coordinates'][0],
draw_callback=cb_renderer.callback,
more_right=True,
with_id=student_id
)
if final_img is None:
return None
return (student_id, label, final_img, header_h, cb_renderer.checkboxes)
def save_batch(batch, prefix, group_id, root_dir, overwrite):
output_dir = os.path.join(root_dir, "BGnot", f"{prefix} G{group_id}")
if os.path.exists(output_dir):
if not overwrite:
print(f"Skipping {output_dir}: Output already exists.")
return
shutil.rmtree(output_dir)
print(f"Generating Group PDF: {prefix} G{group_id} ({len(batch)} elements)")
os.makedirs(output_dir)
max_w = max(item[2].width for item in batch)
total_h = sum(item[2].height for item in batch)
concat_img = Image.new("RGB", (max_w, total_h), "white")
draw = ImageDraw.Draw(concat_img)
final_json_map = []
bnote_entries = []
current_y = 0
last_sid = None
for sid, label, img, header_h, boxes in batch:
concat_img.paste(img, (0, current_y))
if sid != last_sid:
draw.rectangle([0, current_y, max_w, current_y + 4], fill="purple")
last_sid = sid
bnote_entries.append({
"id": sid,
"label": label,
"header_height": header_h,
"hmin": current_y,
"hmax": current_y + img.height
})
for item in boxes:
b = item.get('final_box') or item.get('rel_box')
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
item['student_id'] = sid # Required to map checkbox to the correct student
final_json_map.append(item)
current_y += img.height
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
json.dump({"width": max_w, "height": total_h, "images": bnote_entries}, f, indent=2)
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
json.dump(final_json_map, f, indent=2)
temp_img_path = os.path.join(output_dir, "Reference.jpg")
concat_img.save(temp_img_path, quality=90)
pdf_path = os.path.join(output_dir, "Concat.pdf")
w, h = concat_img.size
c = canvas.Canvas(pdf_path, pagesize=(w, h))
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
c.save()
def main():
parser = argparse.ArgumentParser(description="Generate annotated PDFs grouped by labels.")
parser.add_argument("input_path", help="Directory containing Bnot structure")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
args = parser.parse_args()
root_dir = args.input_path
results = annotating.make_dictionary(root_dir)
label_groups = os.path.join(root_dir, "label_groups")
all_labels = os.path.join(root_dir, "labels")
if not os.path.exists(label_groups):
print(f"Warning: Labels file '{label_groups}' not found, making it out of '{all_labels}'")
if not os.path.exists(all_labels):
print(f"Error: {all_labels} not found.")
sys.exit(1)
with open(all_labels, 'r') as f:
lines = [l.strip() for l in f if l.strip()]
groups = {}
for line in lines:
# Key is the part before the colon, or the whole line if no colon
key = line.split(' : ')[0] if ' : ' in line else line
groups.setdefault(key, []).append(line)
with open(label_groups, 'w') as f:
for items in groups.values():
f.write(",".join(items) + "\n")
with open(label_groups, "r") as f:
lines = [line.strip() for line in f if line.strip()]
bgnot_dir = os.path.join(root_dir, "BGnot")
if args.overwrite and os.path.exists(bgnot_dir):
shutil.rmtree(bgnot_dir)
os.makedirs(bgnot_dir, exist_ok=True)
used_prefixes = set()
previous_prefix = None
for line in lines:
labels = [l.strip() for l in line.split(',') if l.strip()]
safe_labels = [l.replace(":", "").strip() for l in line.split(',') if l.strip()]
if not labels:
continue
base_prefix = os.path.commonprefix(safe_labels).strip()
if not base_prefix:
base_prefix = "Group"
unique_prefix = base_prefix
if unique_prefix[-1] == "i":
unique_prefix = unique_prefix[:-1]
counter = 2
while unique_prefix in used_prefixes:
unique_prefix = f"{base_prefix}-{counter}"
counter += 1
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
used_prefixes.add(unique_prefix)
existing_items = set()
max_existing_group = 0
if not args.overwrite and os.path.exists(bgnot_dir):
for d in os.listdir(bgnot_dir):
if d.startswith(f"{unique_prefix} G"):
try:
g_id = int(d.split(' G')[-1])
max_existing_group = max(max_existing_group, g_id)
except ValueError:
pass
bnote_path = os.path.join(bgnot_dir, d, "bnote.json")
if os.path.exists(bnote_path):
with open(bnote_path, "r") as bf:
bdata = json.load(bf)
for img in bdata.get("images", []):
existing_items.add((img["id"], img["label"]))
items_to_render = []
for sid, lbls in results.items():
for lbl in labels:
if lbl in lbls:
# Only add if it hasn't been generated yet
if (sid, lbl) not in existing_items:
items_to_render.append((sid, lbl, lbls[lbl]))
if not items_to_render:
continue
# Sort structurally: by student id and label
items_to_render.sort(key=lambda x: (natural_key(x[0]), natural_key(x[1])))
# Render images in parallel using the pre-existing lock & render function
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
rendered = list(executor.map(render_item, items_to_render))
rendered = [r for r in rendered if r is not None]
if not rendered:
continue
# Split into constrained height batches
batches = []
current_batch = []
current_h = 0
for r in rendered:
sid = r[0]
img_h = r[2].height
# Split if we exceed max height AND we are on a new student
if current_batch and current_h + img_h > MAX_HEIGHT_PX and sid != last_sid:
batches.append(current_batch)
current_batch = []
current_h = 0
current_batch.append(r)
current_h += img_h
last_sid = sid
if current_batch:
batches.append(current_batch)
batches2 = []
current_batch2 = []
current_h2 = 0
last_sid2 = None
for r in rendered:
sid = r[0]
img_h = r[2].height
# Split if we exceed max height AND we are on a new student
if current_batch2 and current_h2 + img_h > 1.1 *MAX_HEIGHT_PX \
and sid != last_sid2:
batches2.append(current_batch2)
current_batch2 = []
current_h2 = 0
current_batch2.append(r)
current_h2 += img_h
last_sid2 = sid
if current_batch2:
batches2.append(current_batch2)
if len(batches2) < len(batches):
batches = batches2
for i, batch in enumerate(batches, 1):
save_batch(batch, unique_prefix, max_existing_group + i, root_dir, args.overwrite)
if __name__ == "__main__":
main()
-279
View File
@@ -1,279 +0,0 @@
import sys
import os
import json
import shutil
import concurrent.futures
import threading
import img2pdf
from reportlab.pdfgen import canvas
# Fix for Matplotlib in threads: Set backend to non-interactive 'Agg'
import matplotlib
matplotlib.use('Agg')
from PIL import Image, ImageDraw, ImageFont
import annotating
from annotating import MARGIN_LEFT, ANNOT_WIDTH
# Global lock for Matplotlib/Latex rendering to prevent race conditions
LATEX_LOCK = threading.Lock()
DPI = 100
BOX_SIZE = 30
SCORE_BOX_SIZE = 40
SCORES = [x * 0.5 for x in range(10)] # 0.0 to 4.5
try:
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
except IOError:
try:
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
except IOError:
CHECKBOX_FONT = ImageFont.load_default()
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
if label:
draw.text((x - BOX_SIZE-5, y + 2), str(label), fill="black", font=CHECKBOX_FONT)
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
return [x, y, x + size, y + size]
def safe_render_latex(*args, **kwargs):
"""Thread-safe wrapper for latex rendering."""
# with LATEX_LOCK:
# return annotating.render_latex_text(*args, **kwargs)
return annotating.render_real_latex_text(*args, **kwargs)
class CheckboxRenderer:
def __init__(self, label_name):
self.label = label_name
self.checkboxes = [] # List of {type, box, etc.}
def callback(self, kind, draw, pos, meta):
"""
Called by compose_label_image during rendering.
pos contains {x, y, w, h} or {box}.
meta contains {data, index, etc.}
"""
if kind == "header_item":
# meta['data'] is either result object (for score) or feedback object
if meta.get("type") == "score":
# Draw score boxes
start_x = pos['w'] + 20
for val in SCORES:
box = draw_checkbox(draw, start_x, pos['y'] + 25,
SCORE_BOX_SIZE, str(val))
self.checkboxes.append({
"type": "score", "label": self.label, "value": val,
"rel_box": box # Will be adjusted for global Y later
})
start_x += SCORE_BOX_SIZE + 45
start_x += SCORE_BOX_SIZE + 60
box = draw_checkbox(draw, start_x, pos['y'] + 25, SCORE_BOX_SIZE, "clr")
self.checkboxes.append({
"type": "clear_all", "label": self.label,
"rel_box": box
})
elif meta.get("type") == "global_fb":
# Draw delete box for global feedback
bx = pos['w'] - BOX_SIZE - 5
by = pos['y'] + 5
box = draw_checkbox(draw, bx, by, BOX_SIZE)
self.checkboxes.append({
"type": "del_global", "label": self.label, "index": meta["index"],
"rel_box": box, "text_preview": meta["data"]["text"][:20]
})
elif kind == "local_rect":
# Delete rect checkbox
b = pos['box'] # [xmin, ymin, xmax, ymax]
box = draw_checkbox(draw, b[2] - BOX_SIZE, b[1], BOX_SIZE)
self.checkboxes.append({
"type": "del_local_rect", "label": self.label, "index": meta["index"],
"final_box": box, "text_preview": meta["data"]["text"][:20]
})
elif kind == "local_text":
# Delete whole local feedback checkbox
bx = pos['x'] + pos['w'] - BOX_SIZE
by = pos['y']
box = draw_checkbox(draw, bx, by, BOX_SIZE)
self.checkboxes.append({
"type": "del_local", "label": self.label, "index": meta["index"],
"final_box": box, "text_preview": meta["data"]["text"][:20]
})
from utils import natural_key
def process_student(args):
"""Thread worker: Processes one student."""
root_dir, student_id, labels, overwrite, sub_folder = args
output_dir = os.path.join(root_dir, sub_folder, f"Copie{student_id}")
if os.path.exists(output_dir):
if not overwrite:
print(f"Skipping {student_id}: Output already exists.")
return
shutil.rmtree(output_dir)
print(f"Generating Checkable PDF for: {student_id}")
os.makedirs(output_dir)
label_images = []
# ... (rest of the function remains exactly the same)
all_checkboxes = []
bnote_entries = [] # For bnote.json
sorted_labels = sorted(labels.items(), key=lambda x: natural_key(x[0]))
for label, content in sorted_labels:
pdf_path = content['pdf_path']
if not os.path.exists(pdf_path): continue
base_img, _, _ = annotating.make_base_image(pdf_path)
# Initialize the hook
cb_renderer = CheckboxRenderer(label)
# Render using the shared engine
final_img, header_h = annotating.compose_label_image(
base_img, label, content['result'], content['coordinates'][0],
draw_callback=cb_renderer.callback
)
if final_img == None:
continue
label_images.append(final_img)
all_checkboxes.append(cb_renderer.checkboxes)
bnote_entries.append({
"id": student_id,
"label": label,
"header_height": header_h,
# hmin/hmax will be filled during concatenation
"img_h": final_img.height
})
if not label_images: return
# Concatenate
max_w = max(i.width for i in label_images)
total_h = sum(i.height for i in label_images)
concat_img = Image.new("RGB", (max_w, total_h), "white")
final_json_map = []
current_y = 0
for idx, (img, boxes) in enumerate(zip(label_images, all_checkboxes)):
concat_img.paste(img, (0, current_y))
bnote_entries[idx]["hmin"] = current_y
bnote_entries[idx]["hmax"] = current_y + img.height
del bnote_entries[idx]["img_h"] # Clean up temp data
# Adjust coordinates for concatenated image
for item in boxes:
# item might have 'rel_box' (header) or 'final_box' (local)
# Both were relative to the label image. We just add current_y.
b = item.get('final_box') or item.get('rel_box')
item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y]
final_json_map.append(item)
current_y += img.height
bnote_data = {
"width": max_w,
"height": total_h,
"images": bnote_entries
}
with open(os.path.join(output_dir, "bnote.json"), "w") as f:
json.dump(bnote_data, f, indent=2)
with open(os.path.join(output_dir, "checkboxes.json"), "w") as f:
json.dump(final_json_map, f, indent=2)
temp_img_path = os.path.join(output_dir, "Reference.jpg") # Can't use png here
concat_img.save(temp_img_path, quality=90)
pdf_path = os.path.join(output_dir, "Concat.pdf")
w, h = concat_img.size
c = canvas.Canvas(pdf_path, pagesize=(w, h))
c.drawImage(temp_img_path, 0, 0, width=w, height=h)
c.save()
import argparse # Added
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate annotated PDFs.")
parser.add_argument("input_path", help="Directory or specific file path")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files")
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") # ADD THIS LINE
args = parser.parse_args()
input_path = args.input_path
overwrite = args.overwrite # Capture flag
target_id = None
# Detect if input is a specific file
if os.path.isfile(input_path):
root_dir = os.path.dirname(input_path) or "."
# Extract ID from filename (e.g., Copie40.pdf -> 40)
match = re.search(r'Copie(\d+)', os.path.basename(input_path))
if match:
target_id = match.group(1)
else:
print("Error: Could not extract student ID from filename.")
sys.exit(1)
else:
root_dir = input_path
if not args.refaire:
results = annotating.make_dictionary(root_dir)
if args.refaire:
refaire_path = os.path.join(root_dir, "refaire.json")
if os.path.exists(refaire_path):
with open(refaire_path, "r", encoding="utf-8") as f:
refaire_list = json.load(f)
results = annotating.make_dictionary(root_dir,
refaire=True,refaire_list=refaire_list)
filtered_results = {}
for copie_name, labels_to_redo in refaire_list:
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
if sid in results:
if not labels_to_redo:
# Empty list: keep all labels for this Copie
filtered_results[sid] = results[sid]
else:
# Keep only the requested labels
filtered_results[sid] = {
lbl: data for lbl, data in results[sid].items()
if lbl in labels_to_redo
}
results = filtered_results
else:
print(f"Warning: --refaire flag used, but {refaire_path} not found.")
elif target_id:
if target_id in results:
results = {target_id: results[target_id]}
else:
print(f"Student ID {target_id} not found in directory scan.")
results = {}
sub_folder = "BRnot" if args.refaire else "Bnot"
tasks = sorted([(root_dir, sid, lbls, overwrite, sub_folder)
for sid, lbls in results.items()])
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
results = executor.map(process_student, tasks)
try:
for _ in results:
pass
except Exception:
import traceback
traceback.print_exc()
-88
View File
@@ -1,88 +0,0 @@
import os
import sys
import argparse
from google import genai
if "GEMINI_API_KEY" not in os.environ:
sys.exit("Error: GEMINI_API_KEY environment variable not set.")
client = genai.Client()
def list_jobs():
print("Fetching recent batch jobs...\n")
try:
batch_jobs = client.batches.list()
jobs_found = False
for job in batch_jobs:
jobs_found = True
state = job.state.name if hasattr(job.state, 'name') else job.state
print("-" * 60)
print(f"Job Name: {job.name}")
if hasattr(job, 'display_name') and job.display_name:
print(f"Display Name: {job.display_name}")
print(f"State: {state}")
if state == 'JOB_STATE_FAILED' and hasattr(job, 'error'):
print(f"Error: {job.error}")
if state == 'JOB_STATE_SUCCEEDED' and hasattr(job, 'dest') and job.dest:
if hasattr(job.dest, 'file_name') and job.dest.file_name:
print(f"Output File: {job.dest.file_name}")
if not jobs_found:
print("No batch jobs found.")
else:
print("-" * 60)
print("\nTo download a completed job, run:")
print("python batch_status.py --download batches/<YOUR_BATCH_ID>")
except Exception as e:
sys.exit(f"An error occurred while listing jobs: {e}")
def download_job(job_name):
print(f"Checking status for {job_name}...\n")
try:
job = client.batches.get(name=job_name)
state = job.state.name if hasattr(job.state, 'name') else job.state
print(f"State: {state}")
if state != 'JOB_STATE_SUCCEEDED':
print("Job is not ready yet or has failed.")
if state == 'JOB_STATE_FAILED' and hasattr(job, 'error'):
print(f"Error: {job.error}")
return
if hasattr(job, 'dest') and job.dest and hasattr(job.dest, 'file_name') and job.dest.file_name:
result_file_name = job.dest.file_name
print(f"Downloading results from {result_file_name}...")
file_content_bytes = client.files.download(file=result_file_name)
output_path = f"results_{job_name.replace('/', '_')}.jsonl"
with open(output_path, "wb") as f:
f.write(file_content_bytes)
print(f"Success! Saved to {output_path}")
print(f"You can now feed this to your correction script using: --deal-with-batched {output_path}")
else:
print("Job succeeded but no output file was found.")
except Exception as e:
sys.exit(f"An error occurred while fetching the job: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Manage Gemini Batch Jobs")
parser.add_argument("--download", type=str, metavar="JOB_NAME",
help="Download the results for a specific batch job (e.g. batches/123456)")
args = parser.parse_args()
if args.download:
download_job(args.download)
else:
list_jobs()
+47
View File
@@ -0,0 +1,47 @@
"""Core building blocks shared by Copienator scripts and interfaces."""
from .cli import (
CliError,
ExitCode,
evaluation_parser,
evaluation_workspace,
execute,
standard_parser,
target_parser,
workspace_from_args,
workspace_from_target,
)
from .json_io import (
JsonLockTimeout,
atomic_update_json,
atomic_write_bytes,
atomic_write_json,
atomic_write_text,
read_json,
)
from .workspace import (
EvaluationWorkspace,
WorkspaceNotFoundError,
WorkspaceValidationError,
)
__all__ = [
"CliError",
"EvaluationWorkspace",
"ExitCode",
"JsonLockTimeout",
"WorkspaceNotFoundError",
"WorkspaceValidationError",
"atomic_update_json",
"atomic_write_bytes",
"atomic_write_json",
"atomic_write_text",
"evaluation_parser",
"evaluation_workspace",
"execute",
"read_json",
"standard_parser",
"target_parser",
"workspace_from_args",
"workspace_from_target",
]
+5
View File
@@ -0,0 +1,5 @@
from .dispatcher import main
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
from pathlib import Path
from typing import Any
from .json_io import read_json
from .feedback_boxes import valid_feedback_box
Log = Callable[[str], None]
def apply_checkbox_actions(
labels_data: dict[str, dict[str, Any]],
actions: list[dict[str, Any]],
log: Log,
) -> set[str]:
actions_by_label: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
for action in actions:
actions_by_label[str(action.get("label", ""))].append(action)
dirty_labels: set[str] = set()
for label, label_actions in actions_by_label.items():
if label not in labels_data:
continue
result = labels_data[label]["result"]
feedbacks = result.get("feedback", [])
# Match the renderer's fallback for invalid boxes so checkbox indices
# still address the right comment when returned annotations are read.
global_feedbacks = [item for item in feedbacks if not valid_feedback_box(item.get("box_2d"))]
local_feedbacks = [item for item in feedbacks if valid_feedback_box(item.get("box_2d"))]
local_feedbacks.sort(key=lambda item: item["box_2d"][0])
for action in label_actions:
action_type = action.get("type")
if action_type == "score":
result["score"] = action.get("value")
dirty_labels.add(label)
log(f" > Updated score for {label} to {action.get('value')}")
elif action_type == "clear_all":
for feedback in feedbacks:
feedback["to_delete"] = True
if feedback.get("box_2d"):
feedback["norectangle"] = True
dirty_labels.add(label)
log(f" > Cleared all feedbacks in {label}")
elif action_type == "del_global":
index = int(action.get("index", -1))
if 0 <= index < len(global_feedbacks):
global_feedbacks[index]["to_delete"] = True
dirty_labels.add(label)
log(f" > Deleted global feedback in {label}")
elif action_type in {"del_local", "del_local_rect"}:
index = int(action.get("index", -1))
if 0 <= index < len(local_feedbacks):
target = local_feedbacks[index]
if action_type == "del_local":
target["to_delete"] = True
log(f" > Deleted local feedback in {label}")
else:
target["norectangle"] = True
log(f" > Deleted rectangle in {label}")
dirty_labels.add(label)
return dirty_labels
def apply_score_overrides(
labels_data: dict[str, dict[str, Any]],
score_path: Path,
log: Log,
) -> set[str]:
if not score_path.exists():
return set()
loaded = read_json(score_path)
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object in {score_path}")
dirty: set[str] = set()
for label, score in loaded.items():
if label not in labels_data:
continue
current = str(labels_data[label]["result"].get("score", 0))
if current != str(score):
labels_data[label]["result"]["score"] = score
dirty.add(label)
log(f" > Overrode score for {label} to {score} from score.json")
return dirty
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from PIL import Image
from .json_io import read_json
from .feedback_boxes import valid_feedback_box
from .workspace import EvaluationWorkspace
AnnotationData = dict[str, dict[str, dict[str, Any]]]
RefaireList = list[list[Any]]
@dataclass(frozen=True, slots=True)
class GroupCoordinates:
minimum: int
maximum: int
width: int
height: int
@dataclass(slots=True)
class AnnotationLoadResult:
data: AnnotationData
warnings: list[str]
def _coordinate_index(
workspace: EvaluationWorkspace,
) -> tuple[dict[tuple[str, str], GroupCoordinates], list[str]]:
index: dict[tuple[str, str], GroupCoordinates] = {}
warnings: list[str] = []
if not workspace.groups_dir.is_dir():
return index, [f"Group directory not found: {workspace.groups_dir}"]
# A redo appends a new numbered group; its coordinates supersede the old group.
for metadata_path in sorted(
workspace.groups_dir.glob("*/Group_*.json"),
key=lambda path: int(path.stem.removeprefix("Group_")),
reverse=True,
):
image_path = metadata_path.with_suffix(".jpg")
try:
entries = read_json(metadata_path)
if not isinstance(entries, list):
raise TypeError("expected a JSON array")
with Image.open(image_path) as image:
width, height = image.size
for entry in entries:
copy_id = str(entry[0])
minimum = int(entry[1])
maximum = int(entry[2])
label = str(entry[4])
index.setdefault(
(label, copy_id),
GroupCoordinates(minimum, maximum, width, height),
)
except (IndexError, OSError, TypeError, ValueError) as exc:
warnings.append(f"Could not read group metadata {metadata_path}: {exc}")
return index, warnings
def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None):
scaled = copy.deepcopy(result)
if coordinates is None:
return scaled
for feedback in scaled.get("feedback", []):
box = feedback.get("box_2d")
if not box or not valid_feedback_box(box):
continue
box[0] = int(box[0] * coordinates.height) // 1000
box[2] = int(box[2] * coordinates.height) // 1000
box[1] = int(box[1] * coordinates.width) // 1000
box[3] = int(box[3] * coordinates.width) // 1000
return scaled
def _answer_pdf(
workspace: EvaluationWorkspace,
copy_id: str,
label: str,
suffix: str = "",
) -> Path:
copy_dir = workspace.copies_dir / f"Copie{copy_id}"
preferred = copy_dir / f"{label}{suffix}.pdf"
if preferred.exists():
return preferred
for candidate in (
copy_dir / f"{label}.pdf",
copy_dir / f"{label}_new.pdf",
):
if candidate.exists():
return candidate
return preferred
def _dummy_entry(
workspace: EvaluationWorkspace,
copy_id: str,
label: str,
) -> dict[str, Any]:
return {
"pdf_path": _answer_pdf(workspace, copy_id, label),
"result": {
"score": 0.0,
"feedback": [],
"error": "non traité",
},
"coordinates": (0, 0),
"issues": ["No correction result was available for this answer."],
}
def _apply_refaire_filter(
workspace: EvaluationWorkspace,
data: AnnotationData,
refaire_list: RefaireList,
warnings: list[str],
) -> AnnotationData:
filtered: AnnotationData = {}
for raw_entry in refaire_list:
if not isinstance(raw_entry, list) or len(raw_entry) != 2:
warnings.append(f"Ignoring malformed refaire entry: {raw_entry!r}")
continue
copy_name, requested_labels = raw_entry
copy_id = str(copy_name).removeprefix("Copie")
available = data.get(copy_id, {})
if not requested_labels:
filtered[copy_id] = dict(available)
continue
selected: dict[str, dict[str, Any]] = {}
for raw_label in requested_labels:
label = str(raw_label)
selected[label] = (
available[label]
if label in available
else _dummy_entry(workspace, copy_id, label)
)
filtered[copy_id] = selected
return filtered
def load_annotation_data(
workspace: EvaluationWorkspace,
*,
refaire_list: RefaireList | None = None,
copy_id: str | None = None,
) -> AnnotationLoadResult:
workspace.require_files("correction.json")
corrections = read_json(workspace.correction_file)
if not isinstance(corrections, dict):
raise TypeError("correction.json must contain a JSON object")
coordinate_index, warnings = _coordinate_index(workspace)
data: AnnotationData = {}
for label, raw_batches in corrections.items():
if not isinstance(raw_batches, list):
warnings.append(f"Ignoring malformed correction batches for {label!r}")
continue
for raw_batch in raw_batches:
if not isinstance(raw_batch, list):
warnings.append(f"Ignoring malformed correction batch for {label!r}")
continue
for item in raw_batch:
if not isinstance(item, dict) or not isinstance(
item.get("result"), dict
):
warnings.append(f"Ignoring malformed correction item for {label!r}")
continue
student_id = str(item.get("id", ""))
if not student_id:
warnings.append(
f"Ignoring correction item without an id for {label!r}"
)
continue
result = item["result"]
suffix = str(result.get("suffix", ""))
if suffix == "_old":
continue
coordinates = coordinate_index.get((str(label), student_id))
issues: list[str] = []
if coordinates is None:
issues.append("Group coordinates were not found.")
pdf_path = _answer_pdf(workspace, student_id, str(label), suffix)
if not pdf_path.exists():
issues.append(f"Answer PDF not found: {pdf_path}")
data.setdefault(student_id, {})[str(label)] = {
"pdf_path": pdf_path,
"result": _scaled_result(result, coordinates),
"coordinates": (
(coordinates.minimum, coordinates.maximum)
if coordinates is not None
else (0, 0)
),
"issues": issues,
}
warnings.extend(
f"Copie{student_id} {label}: {issue}" for issue in issues
)
if refaire_list is not None:
data = _apply_refaire_filter(workspace, data, refaire_list, warnings)
if copy_id is not None:
data = {copy_id: data[copy_id]} if copy_id in data else {}
if not data:
warnings.append(f"Copy id {copy_id} was not found in correction.json")
return AnnotationLoadResult(data, warnings)
+22
View File
@@ -0,0 +1,22 @@
from collections.abc import Collection, Mapping
from typing import Any
def build_answer_info(
scores: Mapping[str, str],
present_labels: Collection[str],
rendered_labels: Collection[str],
touched: Mapping[str, bool] | None = None,
) -> dict[str, dict[str, Any]]:
"""Describe every question using the answers actually compiled for a copy."""
present = set(present_labels)
rendered = set(rendered_labels)
return {
label: {
"present": label in present,
"not_empty": label in rendered,
"touched": (touched or {}).get(label, False),
"score": score,
}
for label, score in scores.items()
}
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import argparse
import sys
import traceback
from collections.abc import Callable, Sequence
from enum import IntEnum
from pathlib import Path
from typing import Any
from .workspace import (
EvaluationWorkspace,
WorkspaceNotFoundError,
WorkspaceValidationError,
)
class ExitCode(IntEnum):
SUCCESS = 0
FAILURE = 1
INVALID_ARGUMENTS = 2
INVALID_WORKSPACE = 3
PARTIAL = 4
INTERRUPTED = 130
class CliError(Exception):
def __init__(self, message: str, exit_code: ExitCode = ExitCode.FAILURE) -> None:
self.exit_code = exit_code
super().__init__(message)
def standard_parser(description: str) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"--verbose",
action="store_true",
help="Show a traceback when an unexpected error occurs",
)
return parser
def evaluation_parser(description: str) -> argparse.ArgumentParser:
parser = standard_parser(description)
parser.add_argument(
"evaluation",
type=Path,
help="Evaluation directory",
)
return parser
def target_parser(description: str) -> argparse.ArgumentParser:
parser = standard_parser(description)
parser.add_argument(
"target",
type=Path,
help="Evaluation directory or nested file to process",
)
return parser
def evaluation_workspace(
path: str | Path,
*,
repository: str | Path | None = None,
) -> EvaluationWorkspace:
root = Path(path).expanduser().resolve()
if not root.exists():
raise CliError(
f"Evaluation directory does not exist: {root}",
ExitCode.INVALID_WORKSPACE,
)
if not root.is_dir():
raise CliError(
f"Evaluation path is not a directory: {root}",
ExitCode.INVALID_WORKSPACE,
)
return EvaluationWorkspace(root, Path(repository) if repository is not None else None)
def workspace_from_args(
args: argparse.Namespace,
*,
repository: str | Path | None = None,
) -> EvaluationWorkspace:
return evaluation_workspace(args.evaluation, repository=repository)
def workspace_from_target(
args: argparse.Namespace,
*,
repository: str | Path | None = None,
) -> tuple[EvaluationWorkspace, Path]:
target = Path(args.target).expanduser().resolve()
if not target.exists():
raise CliError(f"Target does not exist: {target}", ExitCode.INVALID_WORKSPACE)
repository_path = Path(repository) if repository is not None else None
if target.is_dir() and EvaluationWorkspace.looks_like_evaluation(target):
return EvaluationWorkspace(target, repository_path), target
workspace = EvaluationWorkspace.discover(target, repository=repository_path)
return workspace, target
def execute(
parser: argparse.ArgumentParser,
argv: Sequence[str] | None,
handler: Callable[[argparse.Namespace], int | ExitCode | None],
) -> int:
args = parser.parse_args(argv)
try:
result = handler(args)
except CliError as exc:
print(f"Error: {exc}", file=sys.stderr)
return int(exc.exit_code)
except (WorkspaceNotFoundError, WorkspaceValidationError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return int(ExitCode.INVALID_WORKSPACE)
except KeyboardInterrupt:
print("Interrupted by user.", file=sys.stderr)
return int(ExitCode.INTERRUPTED)
except Exception as exc: # noqa: BLE001 - executable boundary
if getattr(args, "verbose", False):
traceback.print_exc()
else:
print(f"Error: {exc}", file=sys.stderr)
return int(ExitCode.FAILURE)
return int(ExitCode.SUCCESS if result is None else result)
def stderr(message: Any) -> None:
print(message, file=sys.stderr)
+5
View File
@@ -0,0 +1,5 @@
"""Executable Copienator commands.
Command modules expose a ``main(argv=None)`` entry point and may also expose
their processing functions for reuse and tests.
"""
+167
View File
@@ -0,0 +1,167 @@
import argparse
import math
import shutil
import sys
from pathlib import Path
import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator.configuration import (
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
FINAL_SCORE_ODS_PATH,
FINAL_SCORE_OUTPUT_DIR,
)
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser()
def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates:
if not candidate:
continue
try:
return ImageFont.truetype(str(candidate), size)
except OSError:
continue
return ImageFont.load_default()
def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
except (ValueError, TypeError):
return None
def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None:
"""Copy the metadata and optional individual answers for one student."""
for filename in ("score.json", "info.json"):
source = source_dir / filename
if source.is_file():
shutil.copy2(source, destination_dir / filename)
else:
print(f"Warning: Missing '{source}'.")
answers_source = source_dir / "answers"
answers_destination = destination_dir / "answers"
if answers_destination.is_symlink():
answers_destination.unlink()
elif answers_destination.is_dir():
shutil.rmtree(answers_destination)
if answers_source.is_dir():
shutil.copytree(answers_source, answers_destination)
def copy_histogram(output_dir: Path) -> None:
"""Copy the score histogram beside the per-student output folders."""
if not HISTOGRAM_PATH.is_file():
print(f"Warning: Missing histogram '{HISTOGRAM_PATH}'.")
return
destination = output_dir / "histogramme.pdf"
shutil.copy2(HISTOGRAM_PATH, destination)
print(f"Copied histogram: {destination}")
def process_images(base_dir, output_dir):
# 1. Load Data
try:
# header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
# Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
sys.exit(1)
# 2. Prepare Output Directory
output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg
search_path = base_dir / "A Rendre"
if not search_path.exists():
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()):
image_paths = sorted(student_source.glob("*.jpg"))
pdf_paths = sorted(student_source.glob("*.pdf"))
media_paths = image_paths or pdf_paths
if not media_paths:
print(f"Error: No JPG or PDF found in '{student_source}'.")
continue
student_name = media_paths[0].stem
student_output = output_dir / student_name
student_output.mkdir(parents=True, exist_ok=True)
# Remove files produced by the former flat output layout when migrating
# an existing export directory.
for suffix in (".jpg", ".pdf"):
legacy_output = output_dir / f"{student_name}{suffix}"
if legacy_output.is_file() or legacy_output.is_symlink():
legacy_output.unlink()
copy_return_artifacts(student_source, student_output)
# 4. Find Score
if student_name not in score_db:
print(f"Error: Student '{student_name}' not found in ODS file.")
else:
raw_score = score_db[student_name]
score = get_rounded_score(raw_score)
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
else:
# 5. Process Images
for img_path in image_paths:
try:
with Image.open(img_path) as img:
img = img.convert("RGB")
draw = ImageDraw.Draw(img)
width, _height = img.size
font_size = int(width * 0.08)
font = score_font(font_size)
text = str(score)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0]
# 30px padding, top right.
x = width - text_w - 30
y = 30
draw.text((x, y), text, fill=(255, 0, 0), font=font)
img.save(student_output / img_path.name)
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
copy_histogram(output_dir)
def main(argv=None):
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,127 +1,54 @@
import sys
from __future__ import annotations
import argparse
import io
import os
import json
import glob
from pathlib import Path
import re
import subprocess
from PIL import Image
import tempfile
from collections.abc import Sequence
from pathlib import Path
import matplotlib
import matplotlib.colors as mcolors
import PIL.ImageOps
matplotlib.use("Agg")
from matplotlib import pyplot as plt
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.answer_info import build_answer_info
from copienator.filesystem import staged_directory
from copienator.feedback_boxes import valid_feedback_box
from copienator.utils import natural_key
MARGIN_LEFT = 300
ANNOT_WIDTH = 600
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
# The gemini_result coordinates should be un-normalized !
def make_dictionary(root_dir, refaire=False, refaire_list=[]):
correction_path = os.path.join(root_dir, "correction.json")
# Load correction data
try:
with open(correction_path, 'r', encoding='utf-8') as f:
corrections = json.load(f)
except FileNotFoundError:
print(f"Error: {correction_path} not found.")
sys.exit(1)
# Dictionary: keys are IDs
result_data = {}
def make_dictionary(root_dir, refaire=False, refaire_list=None):
"""Compatibility wrapper used by the annotation-reading scripts."""
workspace = EvaluationWorkspace(Path(root_dir))
loaded = load_annotation_data(
workspace,
refaire_list=(refaire_list or []) if refaire else None,
)
return loaded.data
# Iterate through labels and items in correction.json
for label, items in corrections.items():
items = sum(items, []) # Flatten
for item in items:
# print(item)
student_id = item['id']
result_obj = item['result']
# Find coordinates
coordinates = None
height,width= None, None
label_dir = Path(root_dir) / "Par label" / label
# Search all json files in Dir/label
json_files = glob.glob(os.path.join(label_dir, "*.json"))
for jf in json_files:
try:
with open(jf, 'r', encoding='utf-8') as f:
coord_list = json.load(f)
# Format: [["id", x, y, width_r, "label"], ...]
for entry in coord_list:
if entry[0] == student_id:
coordinates = (entry[1], entry[2])
img_path = os.path.splitext(jf)[0] + ".jpg"
with Image.open(img_path) as img:
width, height = img.size
break
except json.JSONDecodeError:
continue
if coordinates:
break
# Construct PDF path: Dir/Copie{id}/{label}.pdf
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] = {}
fb = result_obj.get("feedback", [])
for i in range(len(fb)):
el = fb[i]
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
el["box_2d"][1] = (el["box_2d"][1] * width)//1000
el["box_2d"][3] = (el["box_2d"][3] * width)//1000
# Populate the object
result_data[student_id][label] = {
"pdf_path": pdf_path,
"result": result_obj,
"coordinates": coordinates
}
if refaire:
for copie_name, labels_to_redo in refaire_list:
sid = copie_name.replace("Copie", "") # Extract "01" from "Copie01"
if sid in result_data:
# Si des labels à refaire ne sont pas présent dans la correction
# On ajoute des dummies
if labels_to_redo: # Si la liste est non vide
for lbl in labels_to_redo:
pdf_path = Path(root_dir) / "Copies" / f"Copie{sid}" / f"{lbl}.pdf"
if not Path(pdf_path).exists():
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)
}
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():
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)
}
return result_data
def make_base_image(pdf_path):
pages = convert_from_path(pdf_path)
@@ -139,20 +66,6 @@ def make_base_image(pdf_path):
current_y += page.height
return (base_img, total_h, max_w)
import io
import shutil
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
import matplotlib
matplotlib.use('Agg') # Force headless rendering
import matplotlib.pyplot as plt
# plt.rcParams.update({ "text.usetex": True,
# "text.latex.preamble": r"\usepackage{bbold}"})
import re
import textwrap
def normalize_mathtext(text):
"""
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
@@ -261,30 +174,16 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
final_img.alpha_composite(img)
return final_img
import os
import tempfile
import subprocess
import PIL.ImageOps
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.substitute(
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')
@@ -294,11 +193,12 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
f.write(latex_template)
# Compile to PDF
result = subprocess.run(
subprocess.run(
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
stderr=subprocess.DEVNULL,
check=False,
)
if not os.path.exists(pdf_path):
@@ -327,12 +227,6 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
return final_img
import io
from PIL import Image
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from highlight_text import ax_text
def color(score):
t = max(0.0, min(1.0, float(score) / 4.0))
t = t*1.5 - 0.25
@@ -341,8 +235,6 @@ def color(score):
green = 150 * t
return mcolors.to_hex((red/255, green/255, 0))
from PIL import Image, ImageDraw, ImageFont
def render_score_text(label, score, error, width_px, fontsize=30,
bg_color=(255, 255, 255, 255),
with_error=True, id=None):
@@ -371,7 +263,7 @@ def render_score_text(label, score, error, width_px, fontsize=30,
try:
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
except IOError:
except OSError:
# Fallback for systems without specific TTFs readily available
print("here")
try:
@@ -420,7 +312,6 @@ def compose_label_image(base_img, label, result, hmin,
if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width
left_pad = min(total_missing, MARGIN_LEFT)
right_pad = total_missing - left_pad
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
new_base.paste(base_img, (left_pad, 0))
@@ -436,6 +327,16 @@ def compose_label_image(base_img, label, result, hmin,
# Filter deleted items (used by reading_annotations.py)
feedbacks = [f for f in feedbacks if "to_delete" not in f]
# Never guess where an invalid rectangle belongs, or lose its comment.
# Use a copy so rendering cannot mutate saved correction data.
normalized = []
for feedback in feedbacks:
box = feedback.get("box_2d")
if box is not None and not valid_feedback_box(box):
print(f"Warning: Copie{with_id or ''} {label}: invalid feedback box {box!r}; displaying the comment without a rectangle.")
feedback = {**feedback, "box_2d": None}
normalized.append(feedback)
feedbacks = normalized
global_fb = [f for f in feedbacks if not f.get('box_2d')]
local_fb = [f for f in feedbacks if f.get('box_2d')]
@@ -540,112 +441,123 @@ def compose_label_image(base_img, label, result, hmin,
return final_img, header_height
from utils import natural_key
import concurrent.futures
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
"""Helper function to process a single student."""
# Prepare output directory: Dir/Anot_CopieID
output_dir = os.path.join(root_dir, "Anot", f"Copie{student_id}")
output_dir = Path(root_dir) / "Anot" / f"Copie{student_id}"
# Check if already processed (Concat.jpg exists)
concat_path = os.path.join(output_dir, "Concat.jpg")
if os.path.exists(concat_path) and not overwrite:
concat_path = output_dir / "Concat.jpg"
if concat_path.exists() and not overwrite:
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
return
return "skipped"
print("Processing :", student_id)
problems = False
with staged_directory(output_dir) as staging:
d_notes = dict.fromkeys(all_labels, "")
label_images = []
answer_labels = []
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
# Clean folder if re-processing
if os.path.exists(output_dir):
shutil.rmtree(output_dir)
os.makedirs(output_dir)
for label, content in sorted_labels:
pdf_full_path = content.get('pdf_path')
if not pdf_full_path or not Path(pdf_full_path).exists():
print(f"File not found: {pdf_full_path}")
problems = True
continue
try:
base_img, _, _ = make_base_image(pdf_full_path)
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
print(f"Error converting {pdf_full_path}: {exc}")
problems = True
continue
d_notes = dict.fromkeys(all_labels, "")
label_images = []
result = content.get('result', {})
coordinates = content.get('coordinates', (0, 0))
d_notes[label] = str(result.get('score', 0))
final_img, _ = compose_label_image(
base_img,
label,
result,
coordinates[0],
with_empty=True,
render_fn=render_real_latex_text,
)
final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer":
label_images.append(final_img)
answer_labels.append(label)
# !! Trier par l'ordre des labels plutôt
sorted_labels = sorted(list(labels_data.items()), key=natural_key)
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"
if not os.path.exists(pdf_full_path):
print(f"File not found: {pdf_full_path}")
continue
# 2. Convert PDF to Image
try:
(base_img, _, _) = make_base_image(pdf_full_path)
except Exception as e:
print(f"Error converting {pdf_full_path}: {e}")
continue
result = content.get('result', {})
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
score = result.get('score', 0)
d_notes[label] = str(score)
final_img, _ = compose_label_image(base_img, label, result, coordinates[0],
with_empty=True,
render_fn=render_real_latex_text)
# 7. Save Image
save_path = os.path.join(output_dir, f"{label}.jpg")
final_img.save(save_path)
if result.get('error', "") != "empty-answer":
label_images.append(final_img)
# Save scores
with open(os.path.join(output_dir, "score.json"), "w") as f:
json.dump(d_notes, f, indent=4)
# Concatenate
if label_images:
max_w = max(i.width for i in label_images)
total_h = sum(i.height for i in label_images)
canvas = Image.new('RGB', (max_w, total_h))
cy = 0
for img in label_images:
canvas.paste(img, (0, cy))
cy += img.height
canvas.save(concat_path)
atomic_write_json(staging / "score.json", d_notes)
atomic_write_json(staging / "info.json", build_answer_info(
d_notes, labels_data, answer_labels
))
if label_images:
max_w = max(image.width for image in label_images)
total_h = sum(image.height for image in label_images)
canvas = Image.new('RGB', (max_w, total_h))
current_y = 0
for image in label_images:
canvas.paste(image, (0, current_y))
current_y += image.height
canvas.save(staging / "Concat.jpg")
elif labels_data:
problems = True
return "partial" if problems else "success"
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
for student_id, labels in sorted(data.items()):
statuses = [
process_student(student_id, labels, root_dir, all_labels, overwrite)
for student_id, labels in sorted(data.items())
]
return ExitCode.PARTIAL if "partial" in statuses else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate simple annotated copies.")
parser.add_argument(
"--overwrite",
action="store_true",
help="Replace existing student output directories",
)
return parser
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
result = process_correction(
workspace.root,
loaded.data,
labels,
overwrite=overwrite,
)
if loaded.warnings and result == ExitCode.SUCCESS:
return ExitCode.PARTIAL
return result
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
)
import argparse
import utils
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Annotate copies")
parser.add_argument("root_dir", help="Directory containing the copies")
parser.add_argument("--overwrite", action="store_true", help="Reprocess even if Concat.jpg exists")
args = parser.parse_args()
root_dir = args.root_dir
labels = utils.read_all_labels(root_dir)
results = make_dictionary(root_dir)
# Results is : Copie id -> label -> {pdf_path, gemini_result, coordinates}
# Coordinates are the real coordinates (hmin, hmax) of the image in the Group
# print(results,"\n\n\n")
process_correction(root_dir, results, labels,overwrite=args.overwrite)
raise SystemExit(main())
+422
View File
@@ -0,0 +1,422 @@
from __future__ import annotations
import argparse
import concurrent.futures
import os
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
from reportlab.pdfgen import canvas
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
atomic_write_text,
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating, annotating_with_checks
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000
def render_item(item):
student_id, label, content = item
pdf_path = Path(content["pdf_path"])
if not pdf_path.exists():
print(f"Warning: answer PDF not found: {pdf_path}")
return None
base_image, _, _ = annotating.make_base_image(pdf_path)
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
final_image, header_height = annotating.compose_label_image(
base_image,
label,
content["result"],
content["coordinates"][0],
draw_callback=checkbox_renderer.callback,
more_right=True,
with_id=student_id,
)
if final_image is None:
return None
return (
student_id,
label,
final_image,
header_height,
checkbox_renderer.checkboxes,
)
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
output_dir = output_root / f"{prefix} G{group_id}"
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width for item in batch)
total_height = sum(item[2].height for item in batch)
concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated)
checkbox_map: list[dict[str, Any]] = []
bnote_entries: list[dict[str, Any]] = []
current_y = 0
previous_student = None
for student_id, label, image, header_height, checkboxes in batch:
concatenated.paste(image, (0, current_y))
if student_id != previous_student:
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
previous_student = student_id
bnote_entries.append(
{
"id": student_id,
"label": label,
"header_height": header_height,
"hmin": current_y,
"hmax": current_y + image.height,
}
)
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
box[0],
box[1] + current_y,
box[2],
box[3] + current_y,
]
item["student_id"] = student_id
checkbox_map.append(item)
current_y += image.height
with staged_directory(output_dir) as staging:
atomic_write_json(
staging / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(staging / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage(
str(reference),
0,
0,
width=max_width,
height=total_height,
)
pdf_canvas.save()
def _initial_label_groups(labels: list[str]) -> str:
groups: dict[str, list[str]] = {}
for label in labels:
key = label.split(" : ")[0] if " : " in label else label
groups.setdefault(key, []).append(label)
return "".join(",".join(items) + "\n" for items in groups.values())
def _gemini_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]] | None:
source = workspace.gemini_exam_items_file
if not source.is_file():
return None
groups: list[list[str]] = []
current: list[str] = []
try:
source_lines = source.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
print(f"Warning: could not read Gemini question groups from {source}: {exc}")
return None
for raw_line in source_lines:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line == "---":
if current:
groups.append(current)
current = []
continue
if " ### " not in line:
continue
label = line.split(" ### ", 1)[0].strip()
if label and label != "CONTEXT":
current.append(label)
if current:
groups.append(current)
flattened = [label for group in groups for label in group]
known = set(labels)
if (
not flattened
or len(flattened) != len(set(flattened))
or set(flattened) != known
):
missing = sorted(known.difference(flattened), key=natural_key)
unknown = sorted(set(flattened).difference(known), key=natural_key)
details = []
if missing:
details.append("missing: " + ", ".join(missing))
if unknown:
details.append("unknown: " + ", ".join(unknown))
if len(flattened) != len(set(flattened)):
details.append("duplicate labels")
print(
f"Warning: ignoring incompatible Gemini question groups in {source}"
+ (f" ({'; '.join(details)})" if details else "")
)
return None
return groups
def _serialize_label_groups(groups: list[list[str]]) -> str:
return "".join(",".join(group) + "\n" for group in groups)
def _load_label_groups(
workspace: EvaluationWorkspace, labels: list[str]
) -> list[list[str]]:
label_groups = workspace.label_groups_file
if not label_groups.exists():
gemini_groups = _gemini_label_groups(workspace, labels)
if gemini_groups is not None:
initial_content = _serialize_label_groups(gemini_groups)
source_description = "the groups selected in gemini_for_enonce.py"
else:
initial_content = _initial_label_groups(labels)
source_description = "the label-prefix fallback"
atomic_write_text(label_groups, initial_content)
print(
f"Created {label_groups} from {source_description}; "
"review the groups before continuing."
)
utils.edit_file_and_enter(label_groups)
known_labels = set(labels)
groups: list[list[str]] = []
unknown: set[str] = set()
for line in label_groups.read_text(encoding="utf-8").splitlines():
group = [label.strip() for label in line.split(",") if label.strip()]
unknown.update(label for label in group if label not in known_labels)
if group:
groups.append(group)
if unknown:
raise CliError(
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
)
return groups
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
used: set[str] = set()
result: list[tuple[str, list[str]]] = []
previous: str | None = None
for labels in groups:
safe_labels = [label.replace(":", "").strip() for label in labels]
base = os.path.commonprefix(safe_labels).strip() or "Group"
if base and previous is not None and natural_key(base) < natural_key(previous):
candidate = f"{safe_labels[0]}+"
if natural_key(candidate) > natural_key(previous):
base = candidate
prefix = base.removesuffix("i")
counter = 2
while prefix in used:
prefix = f"{base}-{counter}"
counter += 1
if counter == 2 and previous and previous in prefix:
prefix = f"{previous}-{counter}"
elif counter == 2:
previous = prefix
used.add(prefix)
result.append((prefix, labels))
return result
def _existing_group_state(
output_root: Path,
prefix: str,
) -> tuple[set[tuple[str, str]], int]:
existing_items: set[tuple[str, str]] = set()
maximum_group = 0
if not output_root.is_dir():
return existing_items, maximum_group
for directory in output_root.iterdir():
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
continue
try:
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
except ValueError:
continue
metadata = directory / "bnote.json"
if not metadata.exists():
continue
loaded = read_json(metadata)
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object in {metadata}")
for image in loaded.get("images", []):
existing_items.add((str(image["id"]), str(image["label"])))
return existing_items, maximum_group
def split_batches(rendered):
def split(maximum_height: float):
batches = []
current = []
current_height = 0
previous_student = None
for item in rendered:
student_id = item[0]
image_height = item[2].height
if (
current
and current_height + image_height > maximum_height
and student_id != previous_student
):
batches.append(current)
current = []
current_height = 0
current.append(item)
current_height += image_height
previous_student = student_id
if current:
batches.append(current)
return batches
strict = split(MAX_HEIGHT_PX)
relaxed = split(1.1 * MAX_HEIGHT_PX)
return relaxed if len(relaxed) < len(strict) else strict
def _generate_groups(
output_root: Path,
data,
groups: list[list[str]],
*,
resume: bool,
) -> tuple[int, bool]:
generated = 0
problems = False
for prefix, labels in _unique_prefixes(groups):
existing_items, maximum_group = (
_existing_group_state(output_root, prefix) if resume else (set(), 0)
)
items = [
(student_id, label, student_labels[label])
for student_id, student_labels in data.items()
for label in labels
if label in student_labels and (student_id, label) not in existing_items
]
if not items:
continue
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
rendered = list(executor.map(render_item, items))
if any(item is None for item in rendered):
problems = True
successful = [item for item in rendered if item is not None]
for index, batch in enumerate(split_batches(successful), start=1):
save_batch(batch, prefix, maximum_group + index, output_root)
generated += 1
return generated, problems
def run(
workspace: EvaluationWorkspace, *, overwrite: bool = False, refaire: bool = False
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
labels = utils.read_all_labels(workspace.root)
refaire_list = annotating_with_checks._load_refaire(workspace) if refaire else None
loaded = load_annotation_data(workspace, refaire_list=refaire_list)
groups = (
[
[label]
for label in sorted(
{label for answers in loaded.data.values() for label in answers},
key=natural_key,
)
]
if refaire
else _load_label_groups(workspace, labels)
)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_root = workspace.annotation_dir("refaire" if refaire else "grouped")
if refaire and output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
if overwrite or refaire:
class IncompleteGroupedOutput(Exception):
pass
try:
with staged_directory(output_root) as staging:
generated, problems = _generate_groups(
staging,
loaded.data,
groups,
resume=False,
)
if generated == 0 or problems or loaded.warnings:
raise IncompleteGroupedOutput
except IncompleteGroupedOutput:
print(
f"Warning: grouped overwrite was incomplete; previous {output_root.name} was preserved."
)
return ExitCode.PARTIAL
else:
output_root.mkdir(parents=True, exist_ok=True)
generated, problems = _generate_groups(
output_root,
loaded.data,
groups,
resume=True,
)
if problems:
return ExitCode.PARTIAL
if generated == 0:
print("No new grouped annotations were required.")
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
parser.add_argument(
"--overwrite", action="store_true", help="Replace annotation outputs safely"
)
parser.add_argument(
"--refaire",
action="store_true",
help="Group only the answers in refaire.json, writing to BRnot",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
),
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,373 @@
from __future__ import annotations
import argparse
import concurrent.futures
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import matplotlib
matplotlib.use("Agg")
from PIL import Image, ImageFont
from reportlab.pdfgen import canvas
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
utils,
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.commands import annotating
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
BOX_SIZE = 30
SCORE_BOX_SIZE = 40
SCORES = [value * 0.5 for value in range(10)]
EXPECTED_OUTPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat.pdf")
try:
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
except OSError:
try:
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
except OSError:
CHECKBOX_FONT = ImageFont.load_default()
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
if label:
draw.text(
(x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT
)
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
return [x, y, x + size, y + size]
class CheckboxRenderer:
def __init__(self, label_name):
self.label = label_name
self.checkboxes = []
def callback(self, kind, draw, pos, meta):
if kind == "header_item":
if meta.get("type") == "score":
start_x = pos["w"] + 20
for value in SCORES:
box = draw_checkbox(
draw,
start_x,
pos["y"] + 25,
SCORE_BOX_SIZE,
str(value),
)
self.checkboxes.append(
{
"type": "score",
"label": self.label,
"value": value,
"rel_box": box,
}
)
start_x += SCORE_BOX_SIZE + 45
start_x += SCORE_BOX_SIZE + 60
box = draw_checkbox(
draw,
start_x,
pos["y"] + 25,
SCORE_BOX_SIZE,
"clr",
)
self.checkboxes.append(
{"type": "clear_all", "label": self.label, "rel_box": box}
)
elif meta.get("type") == "global_fb":
box = draw_checkbox(
draw,
pos["w"] - BOX_SIZE - 5,
pos["y"] + 5,
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_global",
"label": self.label,
"index": meta["index"],
"rel_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
elif kind == "local_rect":
rectangle = pos["box"]
box = draw_checkbox(
draw,
rectangle[2] - BOX_SIZE,
rectangle[1],
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_local_rect",
"label": self.label,
"index": meta["index"],
"final_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
elif kind == "local_text":
box = draw_checkbox(
draw,
pos["x"] + pos["w"] - BOX_SIZE,
pos["y"],
BOX_SIZE,
)
self.checkboxes.append(
{
"type": "del_local",
"label": self.label,
"index": meta["index"],
"final_box": box,
"text_preview": meta["data"]["text"][:20],
}
)
def _output_complete(output_dir: Path) -> bool:
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
def _render_student(
workspace: EvaluationWorkspace,
student_id: str,
labels: dict[str, dict[str, Any]],
*,
overwrite: bool,
output_mode: str,
output_root: Path | None = None,
) -> str:
output_dir = (
output_root
if output_root is not None
else workspace.annotation_dir(output_mode)
) / f"Copie{student_id}"
if _output_complete(output_dir) and not overwrite:
print(f"Skipping {student_id}: output is complete.")
return "skipped"
print(f"Generating checkable PDF for: {student_id}")
label_images: list[Image.Image] = []
checkbox_groups: list[list[dict[str, Any]]] = []
bnote_entries: list[dict[str, Any]] = []
problems = False
for label, content in sorted(labels.items(), key=lambda item: natural_key(item[0])):
pdf_path = Path(content["pdf_path"])
if not pdf_path.exists():
print(f"Warning: answer PDF not found: {pdf_path}")
problems = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
checkbox_renderer = CheckboxRenderer(label)
final_image, header_height = annotating.compose_label_image(
base_image,
label,
content["result"],
content["coordinates"][0],
draw_callback=checkbox_renderer.callback,
)
if final_image is None:
problems = True
continue
label_images.append(final_image)
checkbox_groups.append(checkbox_renderer.checkboxes)
bnote_entries.append(
{
"id": student_id,
"label": label,
"header_height": header_height,
"img_h": final_image.height,
}
)
if not label_images:
print(f"Warning: no annotations could be rendered for Copie{student_id}")
return "partial"
max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images)
concatenated = Image.new("RGB", (max_width, total_height), "white")
checkbox_map: list[dict[str, Any]] = []
current_y = 0
for index, (image, checkboxes) in enumerate(
zip(label_images, checkbox_groups, strict=True)
):
concatenated.paste(image, (0, current_y))
bnote_entries[index]["hmin"] = current_y
bnote_entries[index]["hmax"] = current_y + image.height
del bnote_entries[index]["img_h"]
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
box[0],
box[1] + current_y,
box[2],
box[3] + current_y,
]
checkbox_map.append(item)
current_y += image.height
with staged_directory(output_dir) as staging:
atomic_write_json(
staging / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(staging / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage(
str(reference),
0,
0,
width=max_width,
height=total_height,
)
pdf_canvas.save()
return "partial" if problems else "success"
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
if target == workspace.root:
return None
match = re.search(r"Copie(\d+)", target.name)
if match is None:
raise CliError(f"Could not extract a copy id from target: {target}")
return match.group(1)
def _load_refaire(workspace: EvaluationWorkspace):
workspace.require_files("refaire.json")
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise CliError("refaire.json must contain a JSON array")
return loaded
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
overwrite: bool = False,
refaire: bool = False,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label")
utils.read_all_labels(workspace.root)
copy_id = _copy_id_from_target(workspace, target)
refaire_list = _load_refaire(workspace) if refaire else None
loaded = load_annotation_data(
workspace,
refaire_list=refaire_list,
copy_id=None if refaire else copy_id,
)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("Warning: no annotation data was found.")
return ExitCode.PARTIAL
output_mode = "refaire" if refaire else "checks"
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
if refaire:
output_root = workspace.annotation_dir("refaire")
if output_root.exists() and not overwrite:
raise CliError(
"BRnot already exists; use --overwrite to replace the previous redo."
)
class IncompleteRedo(Exception):
pass
try:
with staged_directory(output_root) as staging:
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
_render_student,
workspace,
student_id,
labels,
overwrite=True,
output_mode="refaire",
output_root=staging,
)
for student_id, labels in tasks
]
statuses = [future.result() for future in futures]
if loaded.warnings or any(status != "success" for status in statuses):
raise IncompleteRedo
except IncompleteRedo:
print("Warning: incomplete redo generation; previous BRnot was preserved.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
statuses: list[str] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
_render_student,
workspace,
student_id,
labels,
overwrite=overwrite,
output_mode=output_mode,
)
for student_id, labels in tasks
]
for future in futures:
statuses.append(future.result())
if loaded.warnings or "partial" in statuses:
return ExitCode.PARTIAL
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Generate annotated PDFs with checkboxes.")
parser.add_argument(
"--overwrite", action="store_true", help="Replace existing outputs"
)
parser.add_argument(
"--refaire",
action="store_true",
help="Process only entries from refaire.json",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handler(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(
workspace,
target,
overwrite=args.overwrite,
refaire=args.refaire,
)
return execute(parser, argv, handler)
if __name__ == "__main__":
raise SystemExit(main())
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
execute,
read_json,
standard_parser,
workspace_from_args,
)
def _client():
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
return genai.Client(api_key=config.API_KEY)
def list_jobs(*, client=None) -> ExitCode:
client = client or _client()
print("Fetching recent batch jobs...")
jobs = list(client.batches.list())
for job in jobs:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.name}: {state}")
if getattr(job, "display_name", None):
print(f" Display name: {job.display_name}")
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f" Error: {job.error}")
destination = getattr(job, "dest", None)
if state == "JOB_STATE_SUCCEEDED" and getattr(
destination, "file_name", None
):
print(f" Output file: {destination.file_name}")
if not jobs:
print("No batch jobs found.")
return ExitCode.SUCCESS
def check_evaluation_jobs(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
"""Only report readiness after checking every job recorded for this evaluation."""
if not workspace.batch_jobs_file.is_file():
print("Impossible de vérifier les batchs : batch_jobs.json est absent.")
return ExitCode.PARTIAL
manifest = read_json(workspace.batch_jobs_file)
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
if not isinstance(jobs, dict):
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
if not jobs:
print("Aucun job enregistré pour cette évaluation.")
return ExitCode.PARTIAL
if any(not isinstance(entry, dict) or not isinstance(entry.get("name"), str)
or not entry["name"].strip() for entry in jobs.values()):
raise CliError(f"Invalid batch job in {workspace.batch_jobs_file}")
client = client or _client()
ready = True
for tier, entry in jobs.items():
job = client.batches.get(name=entry["name"])
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{tier}{entry['name']}: {state}")
if state != "JOB_STATE_SUCCEEDED":
ready = False
if getattr(job, "error", None):
print(f" Erreur : {job.error}")
elif not getattr(getattr(job, "dest", None), "file_name", None):
ready = False
print(" Le fichier de résultats nest pas encore disponible.")
if ready:
print("Tous les batchs de l’évaluation ont réussi. Les résultats sont prêts à récupérer.")
return ExitCode.SUCCESS
print("Les résultats ne sont pas tous prêts. Consultez à nouveau cette étape plus tard.")
return ExitCode.PARTIAL
def download_job(
job_name: str,
*,
output: Path | None = None,
client=None,
) -> ExitCode:
client = client or _client()
job = client.batches.get(name=job_name)
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"State: {state}")
if state != "JOB_STATE_SUCCEEDED":
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
print(f"Error: {job.error}")
return ExitCode.PARTIAL
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print("Job succeeded but no output file was found.")
return ExitCode.PARTIAL
payload = client.files.download(file=file_name)
output_path = output or Path(f"results_{job_name.replace('/', '_')}.jsonl")
atomic_write_bytes(output_path, payload)
print(f"Saved batch results to {output_path}")
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = standard_parser("List or download Gemini correction batch jobs")
parser.add_argument("--download", metavar="JOB_NAME")
parser.add_argument("--output", type=Path, help="Downloaded JSONL destination")
parser.add_argument("--evaluation", type=Path,
help="Check readiness of jobs recorded in this evaluation's batch_jobs.json")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.evaluation is not None:
if args.download or args.output is not None:
raise CliError("--evaluation cannot be combined with --download or --output",
ExitCode.INVALID_ARGUMENTS)
return check_evaluation_jobs(workspace_from_args(args))
if args.output is not None and not args.download:
raise CliError("--output requires --download", ExitCode.INVALID_ARGUMENTS)
if args.download:
return download_job(args.download, output=args.output)
return list_jobs()
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import argparse
import os
import shutil
import uuid
from collections import Counter
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
configuration,
evaluation_parser,
execute,
workspace_from_args,
)
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
STATEMENT_ROOT_FILES = {"enonce.tex", "correction.tex", "labels", "label_groups"}
STATEMENT_TEXT_DIRECTORIES = {"Text", "Sol", "Persp", "Cache", "Tmp"}
@dataclass(frozen=True, slots=True)
class CleanupPlan:
kept_files: tuple[Path, ...]
deleted_files: tuple[Path, ...]
deleted_directories: tuple[Path, ...]
bytes_to_delete: int
def _workspace_entries(root: Path) -> tuple[list[Path], list[Path]]:
"""List files/symlinks and real directories without following symlinks."""
files: list[Path] = []
directories: list[Path] = []
for current, directory_names, file_names in os.walk(
root, topdown=True, followlinks=False
):
current_path = Path(current)
traversable: list[str] = []
for name in directory_names:
path = current_path / name
if path.is_symlink():
files.append(path)
else:
directories.append(path)
traversable.append(name)
directory_names[:] = traversable
files.extend(current_path / name for name in file_names)
return files, directories
def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
return_dir = workspace.return_dir
if not return_dir.is_dir() or return_dir.is_symlink():
raise CliError(
f"Return directory not found or invalid: {return_dir}",
ExitCode.INVALID_WORKSPACE,
)
student_directories = sorted(
(
path
for path in return_dir.iterdir()
if path.is_dir() and not path.is_symlink()
),
key=lambda path: path.name.casefold(),
)
if not student_directories:
raise CliError(
f"No student directories found in {return_dir}",
ExitCode.INVALID_WORKSPACE,
)
artifacts: list[Path] = []
incomplete: list[str] = []
for directory in student_directories:
files = [path for path in directory.rglob("*") if path.is_file()]
images = [
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
]
scores = [path for path in files if path.name.casefold() == "score.json"]
pdfs = [path for path in files if path.suffix.casefold() == ".pdf"]
if (configuration.RETURN_JPEG_ENABLED and not images) or not scores:
missing = []
if configuration.RETURN_JPEG_ENABLED and not images:
missing.append("image")
if not scores:
missing.append("score.json")
incomplete.append(f"{directory.name} ({', '.join(missing)})")
artifacts.extend(images)
artifacts.extend(scores)
artifacts.extend(pdfs)
artifacts.extend(path for path in files if path.name.casefold() == "info.json")
if incomplete:
details = "\n".join(f" - {item}" for item in incomplete)
raise CliError(
"A Rendre is incomplete; cleanup was refused:\n" + details,
ExitCode.INVALID_WORKSPACE,
)
return artifacts
def _is_statement_text_file(workspace: EvaluationWorkspace, path: Path) -> bool:
relative = path.relative_to(workspace.root)
if len(relative.parts) == 1:
return relative.name in STATEMENT_ROOT_FILES
top_level = relative.parts[0]
if top_level in STATEMENT_TEXT_DIRECTORIES:
return path.suffix.casefold() in {"", ".json", ".tex", ".txt"}
if top_level in {"Text2", "Sol2"}:
return path.suffix.casefold() == ".tex"
return False
def _is_log_file(workspace: EvaluationWorkspace, path: Path) -> bool:
if path.is_relative_to(workspace.logs_dir):
return True
relative = path.relative_to(workspace.root)
return len(relative.parts) == 1 and (
path.suffix.casefold() == ".log"
or path.name.casefold().endswith("_log")
)
def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
processed_copies = sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if path.is_file()
),
key=lambda path: path.name.casefold(),
)
if not processed_copies:
raise CliError(
f"No processed PDF copies found in {workspace.copies_dir}",
ExitCode.INVALID_WORKSPACE,
)
if not workspace.correction_file.is_file():
raise CliError(
f"Correction result not found: {workspace.correction_file}",
ExitCode.INVALID_WORKSPACE,
)
files, directories = _workspace_entries(workspace.root)
kept_files = set(processed_copies)
kept_files.add(workspace.correction_file)
kept_files.update(_return_artifacts(workspace))
kept_files.update(
path
for path in files
if _is_statement_text_file(workspace, path)
or _is_log_file(workspace, path)
)
kept_directories = {workspace.root}
for path in kept_files:
kept_directories.update(
parent
for parent in path.parents
if parent == workspace.root or workspace.root in parent.parents
)
deleted_files = tuple(sorted(set(files) - kept_files, key=str))
deleted_directories = tuple(
sorted(
set(directories) - kept_directories,
key=lambda path: (len(path.parts), str(path)),
reverse=True,
)
)
bytes_to_delete = sum(
path.stat(follow_symlinks=False).st_size
for path in deleted_files
if path.exists() or path.is_symlink()
)
return CleanupPlan(
kept_files=tuple(sorted(kept_files, key=str)),
deleted_files=deleted_files,
deleted_directories=deleted_directories,
bytes_to_delete=bytes_to_delete,
)
def _human_size(size: int) -> str:
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if value < 1024 or unit == "TiB":
return f"{value:.1f} {unit}"
value /= 1024
return f"{size} B"
def _top_level_counts(
workspace: EvaluationWorkspace, paths: Iterable[Path]
) -> Counter[str]:
counts: Counter[str] = Counter()
for path in paths:
relative = path.relative_to(workspace.root)
counts[relative.parts[0]] += 1
return counts
def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bool) -> None:
processed_count = sum(path.parent == workspace.copies_dir for path in plan.kept_files)
return_count = sum(
path.is_relative_to(workspace.return_dir) for path in plan.kept_files
)
statement_count = sum(
_is_statement_text_file(workspace, path) for path in plan.kept_files
)
log_count = sum(_is_log_file(workspace, path) for path in plan.kept_files)
print(f"Evaluation: {workspace.root}")
print("Will keep:")
print(f" - {processed_count} processed PDF copies in Copies")
print(f" - {statement_count} textual statement files")
print(" - correction.json")
print(f" - {log_count} log files")
print(f" - {return_count} image/PDF/score artifacts in A Rendre")
print(
f"Will delete {len(plan.deleted_files)} files and "
f"{len(plan.deleted_directories)} directories "
f"({_human_size(plan.bytes_to_delete)} in file entries)."
)
counts = _top_level_counts(workspace, plan.deleted_files)
if counts:
print("Files removed by top-level location:")
for name, count in sorted(counts.items(), key=lambda item: item[0].casefold()):
print(f" - {name}: {count}")
if verbose:
print("Deletion list:")
for path in plan.deleted_files:
print(f" - {path.relative_to(workspace.root)}")
def _materialize_return_links(workspace: EvaluationWorkspace, paths: Iterable[Path]) -> None:
for path in paths:
if not path.is_relative_to(workspace.return_dir) or not path.is_symlink():
continue
try:
source = path.resolve(strict=True)
except OSError as exc:
raise CliError(f"Broken return link {path}: {exc}") from exc
if not source.is_file():
raise CliError(f"Return link does not target a file: {path} -> {source}")
temporary = path.with_name(f".{path.name}.materialize-{uuid.uuid4().hex}.tmp")
try:
shutil.copy2(source, temporary)
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
print(f"Materialized: {path.relative_to(workspace.root)}")
def apply_cleanup(workspace: EvaluationWorkspace, plan: CleanupPlan) -> None:
_materialize_return_links(workspace, plan.kept_files)
for path in plan.deleted_files:
path.unlink(missing_ok=True)
for path in plan.deleted_directories:
try:
path.rmdir()
except FileNotFoundError:
pass
print(
f"Cleanup complete: deleted {len(plan.deleted_files)} files and "
f"{len(plan.deleted_directories)} directories."
)
def run(
workspace: EvaluationWorkspace,
*,
dry_run: bool = False,
assume_yes: bool = False,
verbose: bool = False,
) -> ExitCode:
plan = build_cleanup_plan(workspace)
print_plan(workspace, plan, verbose=verbose)
if dry_run:
print("Dry run: nothing was deleted.")
return ExitCode.SUCCESS
if not assume_yes:
expected = workspace.name
answer = input(
f"This cannot be undone. Type {expected!r} to confirm cleanup: "
).strip()
if answer != expected:
print("Cleanup cancelled; nothing was deleted.")
return ExitCode.SUCCESS
apply_cleanup(workspace, plan)
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser(
"Archive an evaluation by deleting regenerable and intermediate files."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be kept and deleted without changing anything",
)
parser.add_argument(
"--yes",
action="store_true",
help="Skip the interactive evaluation-name confirmation",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(
workspace_from_args(args),
dry_run=args.dry_run,
assume_yes=args.yes,
verbose=args.verbose,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import argparse
import uuid
from collections.abc import Sequence
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from copienator import (
EvaluationWorkspace,
ExitCode,
execute,
standard_parser,
workspace_from_args,
)
def copy_pdfs(directory: Path) -> list[Path]:
directory = directory.expanduser().resolve()
if not directory.is_dir():
raise NotADirectoryError(f"Dossier introuvable : {directory}")
return sorted(
(
path
for path in directory.glob("*.pdf")
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
),
key=lambda path: path.name.casefold(),
)
def rotate_pdf(path: Path) -> None:
temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf")
try:
with path.open("rb") as source, temporary.open("wb") as destination:
reader = PdfReader(source)
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page.rotate(180))
if reader.metadata:
metadata = {
str(key): str(value)
for key, value in reader.metadata.items()
if value is not None
}
writer.add_metadata(metadata)
writer.write(destination)
temporary.replace(path)
finally:
if temporary.exists():
temporary.unlink()
def rotate_all(directory: Path) -> int:
files = copy_pdfs(directory)
for path in files:
rotate_pdf(path)
print(f"Rotated: {path.name}")
if not files:
print("No PDF copies found.")
return len(files)
def rename_all(directory: Path) -> list[tuple[Path, Path]]:
directory = directory.expanduser().resolve()
files = copy_pdfs(directory)
width = max(2, len(str(len(files))))
plan = [
(source, directory / f"Copie{index:0{width}d}.pdf")
for index, source in enumerate(files, start=1)
]
staged: list[tuple[Path, Path, Path]] = []
try:
for source, destination in plan:
temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf"
source.replace(temporary)
staged.append((source, temporary, destination))
completed: list[tuple[Path, Path, Path]] = []
try:
for source, temporary, destination in staged:
temporary.replace(destination)
completed.append((source, temporary, destination))
print(f"Renamed: {source.name} -> {destination.name}")
except OSError:
for _source, temporary, destination in completed:
if destination.exists():
destination.replace(temporary)
raise
except OSError:
for source, temporary, _destination in staged:
if temporary.exists():
temporary.replace(source)
raise
if not plan:
print("No PDF copies found.")
return [(source, destination) for source, _temporary, destination in staged]
def build_parser() -> argparse.ArgumentParser:
parser = standard_parser("Prepare scanned PDF copies.")
subparsers = parser.add_subparsers(dest="operation", required=True)
for operation in ("rotate", "rename"):
subparser = subparsers.add_parser(operation)
subparser.add_argument("evaluation", type=Path, help="Evaluation directory")
subparser.add_argument(
"--verbose",
action="store_true",
default=argparse.SUPPRESS,
help="Show a traceback when an unexpected error occurs",
)
return parser
def run(workspace: EvaluationWorkspace, *, operation: str) -> ExitCode:
if operation == "rotate":
rotate_all(workspace.root)
else:
rename_all(workspace.root)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(workspace_from_args(args), operation=args.operation),
)
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
"""Optionally replace split-answer PDFs with large bottom crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import shutil
import signal
import tempfile
from collections.abc import Sequence
from pathlib import Path
import cv2
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_exercise_bottoms import _full_page_height, process_exercise_pdf
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
resolved = target.resolve()
if resolved not in {workspace.root.resolve(), workspace.copies_dir.resolve()}:
raise CliError(
"La cible doit être l’évaluation ou son dossier Copies.",
ExitCode.INVALID_ARGUMENTS,
)
files = sorted(
workspace.copies_dir.glob("Copie*/*.pdf"),
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
return files
def _initialize_worker() -> None:
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_file(job: tuple[Path, Path, float]) -> list[dict]:
source, destination, full_height = job
records = process_exercise_pdf(
source, destination, None, full_height, dpi=200, padding_mm=6
)
changed = sum(record["status"] == "cropped" for record in records)
if changed:
print(
f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
flush=True,
)
return records
def process_files(
workspace: EvaluationWorkspace,
files: list[Path],
staging: Path,
workers: int,
) -> list[dict]:
heights: dict[str, float] = {}
for copy_name in sorted({source.parent.name for source in files}):
copy_pdf = workspace.copies_dir / f"{copy_name}.pdf"
if not copy_pdf.is_file():
raise CliError(f"PDF source introuvable : {copy_pdf}")
heights[copy_name] = _full_page_height(copy_pdf)
jobs = [
(
source,
staging / source.relative_to(workspace.copies_dir),
heights[source.parent.name],
)
for source in files
]
count = min(workers, len(jobs))
print(
f"Analyse de {len(files)} PDF de réponses avec {count} traitement(s) en parallèle.",
flush=True,
)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_file(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
with multiprocessing.get_context("spawn").Pool(
count, _initialize_worker
) as pool:
batches = list(pool.imap_unordered(_process_file, jobs))
order = {source.as_posix(): index for index, source in enumerate(files)}
return sorted(
(record for batch in batches for record in batch),
key=lambda record: (order[record["file"]], record["page"]),
)
def _publish(
workspace: EvaluationWorkspace,
changed_files: list[Path],
staging: Path,
records: list[dict],
) -> Path:
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
run_dir = Path(
tempfile.mkdtemp(prefix="crop-exercise-bottoms-", dir=workspace.runs_dir)
)
backup_root = run_dir / "Copies"
replaced: list[Path] = []
try:
for source in changed_files:
backup = backup_root / source.relative_to(workspace.copies_dir)
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, backup)
(run_dir / "report.json").write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
for source in changed_files:
prepared = staging / source.relative_to(workspace.copies_dir)
prepared.replace(source)
replaced.append(source)
except BaseException:
for source in replaced:
backup = backup_root / source.relative_to(workspace.copies_dir)
if backup.is_file():
shutil.copy2(backup, source)
shutil.rmtree(run_dir, ignore_errors=True)
raise
return backup_root
def crop_statistics(records: list[dict]) -> tuple[int, float]:
"""Return cropped exercise count and mean removed percentage per exercise."""
totals: dict[str, list[float]] = {}
cropped_files: set[str] = set()
for record in records:
total_height, removed_height = totals.setdefault(record["file"], [0.0, 0.0])
totals[record["file"]] = [
total_height + float(record["height_lines"]),
removed_height + float(record["bottom_removed_lines"]),
]
if record["status"] == "cropped":
cropped_files.add(record["file"])
percentages = [
min(100.0, totals[file_name][1] / totals[file_name][0] * 100)
for file_name in cropped_files
if totals[file_name][0] > 0
]
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
return len(cropped_files), mean_percentage
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError(
"Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS,
)
files = selected_files(workspace, target)
if not files:
raise CliError(
"Aucun PDF de réponse trouvé dans Copies/CopieXX/.",
ExitCode.INVALID_ARGUMENTS,
)
with tempfile.TemporaryDirectory(
prefix=".crop-exercise-bottoms-", dir=workspace.root
) as directory:
staging = Path(directory)
records = process_files(workspace, files, staging, workers)
changed_names = {
record["file"] for record in records if record["status"] == "cropped"
}
changed_files = [source for source in files if source.as_posix() in changed_names]
digests = {record["file"]: record["source_sha256"] for record in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.as_posix()]:
raise CliError(
f"{source} a changé pendant lanalyse. Aucun PDF remplacé."
)
cropped_exercises, mean_percentage = crop_statistics(records)
if not changed_files:
print("Terminé : aucun exercice ne remplit les critères de rognage.", flush=True)
print(
"Rognage moyen des exercices modifiés : 0.0 %.", flush=True
)
return ExitCode.SUCCESS
backup = _publish(workspace, changed_files, staging, records)
cropped_pages = sum(record["status"] == "cropped" for record in records)
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
print(
f"Terminé : {cropped_exercises} exercice(s) rogné(s), soit "
f"{cropped_pages} page(s) dans {len(changed_files)} PDF remplacé(s).",
flush=True,
)
print(
f"Rognage moyen des exercices modifiés : {mean_percentage:.1f} %.",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser(
"Rogner les grands espaces vides au bas des réponses déjà découpées"
)
parser.add_argument(
"--workers",
type=int,
default=5,
help="Nombre de PDF traités en parallèle (défaut : 5)",
)
def handle(arguments: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(arguments)
return run(workspace, target, workers=arguments.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
"""Optional preprocessing: replace split copies with ink-guided crops."""
from __future__ import annotations
import argparse
import hashlib
import json
import multiprocessing
import signal
import shutil
import tempfile
import cv2
from collections.abc import Sequence
from pathlib import Path
from copienator.cli import CliError, ExitCode, execute, target_parser, workspace_from_target
from copienator.crop_blank_margins import process_pdf
from copienator.filesystem import staged_files
from copienator.workspace import EvaluationWorkspace
def selected_files(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
copies = workspace.copies_dir.resolve()
target = target.resolve()
if target.is_file():
if target.parent != copies or target.suffix.lower() != ".pdf":
raise CliError("La cible doit être un PDF du dossier Copies.", ExitCode.INVALID_ARGUMENTS)
files = [target]
elif target in (workspace.root, copies):
files = sorted(copies.glob("*.pdf"), key=lambda path: path.name.casefold())
else:
raise CliError("Cible attendue : évaluation, dossier Copies ou PDF dans Copies.",
ExitCode.INVALID_ARGUMENTS)
for source in files:
if source.is_symlink():
raise CliError(f"Lien symbolique non pris en charge : {source}")
if source.with_suffix(".json").exists():
raise CliError(
f"{source.name} possède déjà des coordonnées de labels. "
"Le rognage doit précéder leur détection. Pour reprendre le prétraitement, "
"mettez de côté le JSON associé, puis régénérez la découpe des marges et les labels. "
"Aucun PDF na été remplacé.")
return files
def _initialize_worker() -> None:
# Five copies should use five cores, not five OpenCV thread pools. MuPDF
# must also stay isolated in separate processes rather than Python threads.
cv2.setNumThreads(1)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def _process_copy(job: tuple[Path, Path]) -> list[dict]:
source, destination = job
def progress(page, total, row):
removed = row["top_removed_mm"]+row["bottom_removed_mm"]
print(f"{source.name} — Page {page}/{total} : {removed:.1f} mm retirés", flush=True)
return process_pdf(source, destination, None, 200, 6, 5, progress=progress)
def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]:
jobs = [(source, staging/source.name) for source in files]
count = min(workers, len(jobs))
print(f"Rognage de {len(files)} copies avec {count} traitement(s) en parallèle.", flush=True)
if count == 1:
previous_threads = cv2.getNumThreads()
cv2.setNumThreads(1)
try:
batches = [_process_copy(job) for job in jobs]
finally:
cv2.setNumThreads(previous_threads)
else:
# spawn works on Windows and avoids inheriting GUI/native-library state.
# Pool's context terminates and joins workers on errors or cancellation
# before staged_files removes the unpublished PDFs.
with multiprocessing.get_context("spawn").Pool(count, _initialize_worker) as pool:
batches = list(pool.imap_unordered(_process_copy, jobs))
order = {source.name: i for i, source in enumerate(files)}
return sorted((row for batch in batches for row in batch),
key=lambda row: (order[row["file"]], row["page"]))
def crop_statistics(records: list[dict]) -> tuple[int, float, int]:
"""Return cropped page count, their mean removed percentage, and >30% count."""
percentages: list[float] = []
for record in records:
removed_mm = record["top_removed_mm"] + record["bottom_removed_mm"]
if removed_mm <= 0:
continue
x0, y0, x1, y1 = record["original_cropbox"]
original_height_points = (
x1 - x0 if record.get("rotation", 0) % 180 else y1 - y0
)
if original_height_points <= 0:
continue
original_height_mm = original_height_points * 25.4 / 72
percentages.append(min(100.0, removed_mm / original_height_mm * 100))
mean_percentage = sum(percentages) / len(percentages) if percentages else 0.0
over_thirty = sum(percentage > 30 for percentage in percentages)
return len(percentages), mean_percentage, over_thirty
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
if workers < 1:
raise CliError("Le nombre de traitements parallèles doit être positif.",
ExitCode.INVALID_ARGUMENTS)
files = selected_files(workspace, target)
if not files:
raise CliError("Aucun PDF trouvé dans Copies.", ExitCode.INVALID_ARGUMENTS)
# Prepare the whole batch before replacing any copy. A detection failure or
# interruption leaves the working PDFs intact; commit errors roll back.
with staged_files(workspace.copies_dir) as staging:
records = process_copies(files, staging, workers)
# Detect edits made while the batch was being analysed, before saving
# backups or publishing results derived from an obsolete source.
digests = {row["file"]: row["source_sha256"] for row in records}
for source in files:
if hashlib.sha256(source.read_bytes()).hexdigest() != digests[source.name]:
raise CliError(f"{source.name} a changé pendant lanalyse. Aucun PDF remplacé.")
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix="crop-margins-", dir=workspace.runs_dir))
originals = backup/"Copies"
originals.mkdir()
for source in files:
shutil.copy2(source, originals/source.name)
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
cropped, mean_percentage, over_thirty = crop_statistics(records)
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
f"{len(files)} PDF remplacés dans Copies.", flush=True)
print(
f"Rognage moyen des pages modifiées : {mean_percentage:.1f} % ; "
f"{over_thirty} page(s) rognée(s) de plus de 30 %.",
flush=True,
)
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = target_parser("Rogner les zones vides des PDF dans Copies, avant les labels")
parser.add_argument("--workers", type=int, default=5,
help="Nombre de copies traitées en parallèle (défaut : 5)")
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, workers=args.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+412
View File
@@ -0,0 +1,412 @@
from __future__ import annotations
import argparse
import threading
import tkinter as tk
from tkinter import messagebox
from collections.abc import Sequence
from functools import lru_cache
from pathlib import Path
from queue import Empty, Queue
from threading import Thread
from pdf2image import convert_from_path
from PIL import Image, ImageTk
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
OUTPUT_SIZE = (1800, 1000)
CROP_SHIFT_STEP = 50
CROP_WIDTH_STEP = 50
pdf_cache_lock = threading.Lock()
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
"""Distribute pages into balanced chunks no larger than max_per_file."""
if total_pages == 0:
return []
number_of_files = (total_pages + max_per_file - 1) // max_per_file
base_count, remainder = divmod(total_pages, number_of_files)
return [
base_count + (1 if index < remainder else 0)
for index in range(number_of_files)
]
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
if not image_list:
return None
total_width = sum(image.width for image in image_list)
total_width += (len(image_list) - 1) * DELIMITER_WIDTH
max_height = max(image.height for image in image_list)
combined = Image.new("RGB", (total_width, max_height), color="white")
x_offset = 0
for index, image in enumerate(image_list):
combined.paste(image, (x_offset, 0))
x_offset += image.width
if index < len(image_list) - 1:
delimiter = Image.new(
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
)
combined.paste(delimiter, (x_offset, 0))
x_offset += DELIMITER_WIDTH
return combined
@lru_cache(maxsize=3)
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
# Label coordinates use the full, displayed MediaBox, including on PDFs
# previously cropped by crop-margins. Keep this in sync with split-answers.
return convert_from_path(pdf_path, use_cropbox=False)
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
"""Thread-safe wrapper around the small PDF conversion cache."""
with pdf_cache_lock:
return _get_pdf_pages_cached(pdf_path)
def process_single_pdf(
pdf_path: Path,
shift_offset: int = 0,
max_per_file: int = 5,
width_offset: int = 0,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
cropped_images = []
for image in get_pdf_pages(pdf_path):
width, height = image.size
if max_per_file == 1:
left, right = 0, width
else:
left = max(0, 100 + shift_offset)
right = min(
width,
width // 3 + 100 + shift_offset + max(0, width_offset),
)
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
if not cropped_images:
return None
distribution = distribute_pages(len(cropped_images), max_per_file)
split_images = []
current_index = 0
for count in distribution:
stitched = stitch_images(cropped_images[current_index : current_index + count])
if stitched is not None:
split_images.append(stitched)
current_index += count
full_stitch = stitch_images(cropped_images)
if full_stitch is None:
return None
preview = full_stitch.resize(OUTPUT_SIZE, Image.Resampling.BILINEAR)
schema: dict[str, object] = {
"original_filename": pdf_path.name,
"total_pages": len(cropped_images),
"number_of_files": len(split_images),
"columns_per_file": distribution,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
print(f"Error processing {pdf_path.name}: {exc}")
return None
def _previous_cutleft_outputs(output_dir: Path, base_name: str) -> set[str]:
if not output_dir.is_dir():
return set()
result = {f"{base_name}_schema.json"}
for path in output_dir.glob(f"{base_name}_*.jpg"):
suffix = path.stem.removeprefix(f"{base_name}_")
if suffix.isdigit():
result.add(path.name)
return result
def save_results(
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
output_dir: Path,
) -> None:
"""Atomically replace every Cutleft output associated with one copy."""
_, splits, schema = result
base_name = pdf_path.stem
previous = _previous_cutleft_outputs(output_dir, base_name)
with staged_files(output_dir, remove=previous) as staging:
for index, image in enumerate(splits, start=1):
filename = f"{base_name}_{index:02d}.jpg"
image.save(staging / filename, "JPEG", quality=95)
atomic_write_json(staging / f"{base_name}_schema.json", schema)
for index in range(1, len(splits) + 1):
print(f"Saved: {base_name}_{index:02d}.jpg")
print(f"Saved schema: {base_name}_schema.json")
class ImageReviewer:
def __init__(
self,
files: list[Path],
output_dir: Path,
default_max_per_file: int = 5,
) -> None:
self.files = files
self.output_dir = output_dir
self.workspace = EvaluationWorkspace(output_dir.parent)
self.completed = False
self.had_errors = False
self.stop_prefetch = threading.Event()
self.current_result = None
self.index = 0
self.current_shift = 0
self.current_width_offset = 0
self.default_max_per_file = default_max_per_file
self.current_max_per_file = default_max_per_file
self.current_preview: Image.Image | None = None
self.is_processing = False
self.manual_queue: Queue[
tuple[Image.Image, list[Image.Image], dict[str, object]] | None
] = Queue()
self.root = tk.Tk()
self.root.title("PDF Cropper")
self.root.geometry("+100+100")
self.label_img = tk.Label(self.root)
self.label_img.pack()
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
self.label_info.pack(pady=5)
self.root.bind("<Return>", self.on_next)
self.root.bind("s", self.on_skip)
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("N", lambda _event: self.on_shift(2 * CROP_SHIFT_STEP))
self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
self.root.bind("l", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
self.root.bind("1", lambda _event: self.on_set_max_pages(1))
Thread(target=self.prefetch_worker, daemon=True).start()
self.load_current_image()
self.root.lift()
self.root.focus_force()
self.root.mainloop()
def on_set_max_pages(self, count: int) -> None:
if self.is_processing:
return
self.current_max_per_file = count
print(f"Setting max pages per file: {count}")
self.trigger_processing(self.files[self.index], self.current_shift)
def prefetch_worker(self) -> None:
processed_index = -1
while not self.stop_prefetch.is_set():
target = self.index + 1
if target < len(self.files) and target != processed_index:
try:
get_pdf_pages(self.files[target])
except Exception:
pass # The foreground review reports and flags conversion errors.
processed_index = target
self.stop_prefetch.wait(0.05)
def load_current_image(self) -> None:
if self.index >= len(self.files):
print("All files processed.")
self.completed = True
self.on_close()
return
self.is_processing = False
self.current_shift = 0
self.current_width_offset = 0
self.current_result = None
self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
self.is_processing = True
self.label_info.configure(
text=f"Processing {pdf_path.name} (Shift {shift})... Please wait.",
fg="red",
)
def worker() -> None:
self.manual_queue.put(
process_single_pdf(
pdf_path,
shift,
self.current_max_per_file,
self.current_width_offset,
)
)
Thread(target=worker, daemon=True).start()
self.check_manual_queue(pdf_path)
def check_manual_queue(self, pdf_path: Path) -> None:
try:
result = self.manual_queue.get_nowait()
self.is_processing = False
if result is None:
print(f"Failed to process {pdf_path.name}, skipping.")
self._mark_error(pdf_path, "Échec de la conversion pour la découpe des marges")
self._advance()
else:
self.handle_processing_result(result, pdf_path)
except Empty:
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
def handle_processing_result(
self,
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
pdf_path: Path,
) -> None:
self.current_preview = result[0]
self.current_result = result
self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None:
if self.current_preview is None:
return
tk_image = ImageTk.PhotoImage(self.current_preview)
self.label_img.configure(image=tk_image)
self.label_img.image = tk_image
self.label_info.configure(
text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | "
f"Shift: {self.current_shift}px | "
f"Extra width: {self.current_width_offset}px\n"
f"Files: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n"
"Enter: Save and next | s: flag error and skip | n: +50 | N: +100 | t: -50 | "
"l: widen by 50 | 1: use full pages"
),
fg="black",
)
def on_shift(self, amount: int) -> None:
if self.is_processing:
return
self.current_shift += amount
print(f"Applying shift: {self.current_shift}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_enlarge(self, amount: int) -> None:
if self.is_processing:
return
self.current_width_offset += amount
print(f"Applying extra width: {self.current_width_offset}")
self.trigger_processing(self.files[self.index], self.current_shift)
def on_next(self, _event: object) -> None:
if self.is_processing or self.current_result is None:
return
pdf_path = self.files[self.index]
try:
save_results(self.current_result, pdf_path, self.output_dir)
clear_copy_error(self.workspace, pdf_path)
except Exception as exc:
self._mark_error(pdf_path, f"Échec de lenregistrement : {exc}")
messagebox.showerror("Enregistrement impossible", str(exc), parent=self.root)
return
self._advance()
def _mark_error(self, pdf_path: Path, reason: str) -> None:
mark_copy_error(self.workspace, pdf_path, reason)
self.had_errors = True
print(f"[Copie signalée] {pdf_path.name}: {reason}")
def on_skip(self, _event=None) -> None:
if self.is_processing or self.index >= len(self.files):
return
self._mark_error(self.files[self.index], "Problème repéré pendant la découpe des marges")
self._advance()
def on_close(self) -> None:
self.stop_prefetch.set()
self.root.destroy()
def _advance(self) -> None:
self.index += 1
self.current_shift = 0
self.current_width_offset = 0
self.current_max_per_file = self.default_max_per_file
self.load_current_image()
def _selected_files(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(
(
path
for path in workspace.copies_dir.glob("*.pdf")
if "nonc" not in path.name.casefold()
),
key=lambda path: path.name.casefold(),
)
def run(
workspace: EvaluationWorkspace,
target: Path,
*,
fullpage: bool = False,
marked: bool = False,
) -> ExitCode:
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
if not files:
print("No PDF files found.")
return ExitCode.SUCCESS
workspace.cutleft_dir.mkdir(parents=True, exist_ok=True)
_get_pdf_pages_cached.cache_clear()
reviewer = ImageReviewer(
files,
workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5,
)
if not reviewer.completed:
return ExitCode.INTERRUPTED
return ExitCode.PARTIAL if reviewer.had_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear each flag after saving with Enter")
parser.add_argument(
"--fullpage",
action="store_true",
help="Use each complete page instead of cropping the label margin",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, fullpage=args.fullpage, marked=args.marked)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,56 +1,26 @@
import sys
import os
from __future__ import annotations
import argparse
import glob
import json
import urllib.request
import os
import re
import subprocess
import tempfile
import shutil
import urllib.request
from collections.abc import Sequence
from uuid import uuid4
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)
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import WindowsLabelError, validate_windows_labels
from copienator.utils import compile_to_pdf
with open(tex_path, 'w', encoding='utf-8') as f:
f.write(latex_template)
# Set TEXINPUTS so pdflatex can find commands.sty if it's in the current dir
# env = os.environ.copy()
# current_dir = os.getcwd()
# env['TEXINPUTS'] = f".:{current_dir}:"
try:
subprocess.run(
['pdflatex', '-interaction=nonstopmode', tex_filename],
cwd=temp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False
)
generated_pdf = os.path.join(temp_dir, pdf_filename)
if os.path.exists(generated_pdf):
shutil.move(generated_pdf, output_pdf_path)
except Exception as e:
print(f"Compilation error for {output_pdf_path}: {e}")
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
@@ -70,6 +40,7 @@ def fetch_and_save_sub_text(ex_id, indices, label, text_path):
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
@@ -89,6 +60,7 @@ def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
compile_to_pdf(content, pdf_file)
except Exception as e:
print(f"Error fetching sub-text from {url}: {e}")
raise
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
@@ -168,32 +140,41 @@ def save_split_content(text, path, base_fname, problem):
f.write(chunk)
def process_directory(directory):
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
directory = str(workspace.root)
# Find the first .tex file in the directory
tex_files = glob.glob(os.path.join(directory, "*.tex"))
enonce = workspace.root / "enonce.tex"
tex_files = [str(enonce)] if enonce.is_file() else sorted(glob.glob(os.path.join(directory, "*.tex")))
if not tex_files:
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
int_name = directory[:-1] if directory.endswith("/") else directory
int_name = workspace.root.name
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
if os.path.exists(tex_path):
tex_file = tex_path
else:
print("Not found in ", tex_path)
return
raise CliError(
f"No .tex input found in {workspace.root}",
ExitCode.INVALID_WORKSPACE,
)
else:
tex_file = tex_files[0]
# 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():
os.makedirs(p, exist_ok=True)
labels_file = os.path.join(directory, "labels")
labels_file = workspace.labels_file
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
current_ex_num = 1
had_errors = False
exercise_groups = []
# Read entirely to allow chunking
with open(tex_file, 'r', encoding='utf-8') as f_in:
@@ -201,8 +182,11 @@ def process_directory(directory):
# Split by the specific SHEETINFO tag
blocks = content.split("%%SHEETINFO :")
if len(blocks) == 1:
print(f"No SHEETINFO blocks found in {tex_file}")
return ExitCode.PARTIAL
with open(labels_file, 'w', encoding='utf-8') as f_labels:
with open(labels_staging, 'w', encoding='utf-8') as f_labels:
# Skip blocks[0] (content before first SHEETINFO)
for block in blocks[1:]:
parts_line = block.split("\n", 1)
@@ -216,6 +200,7 @@ def process_directory(directory):
try:
data = json.loads(json_str)
block_labels = []
# Construct 'ids' parameter
ex_id = str(data['id'])
selection = data.get('select')
@@ -231,16 +216,18 @@ def process_directory(directory):
indexes = data.get('indexes', [])
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'])
validate_windows_labels([label])
block_labels.append(label)
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'])
validate_windows_labels([label])
block_labels.append(label)
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)
@@ -281,16 +268,34 @@ def process_directory(directory):
save_split_content(s_text, paths['Sol'], base_filename, problem)
save_split_content(p_text, paths['Persp'], base_filename, problem)
for label in block_labels:
f_labels.write(f"{label}\n")
exercise_groups.append(block_labels)
current_ex_num += 1
except WindowsLabelError:
labels_staging.unlink(missing_ok=True)
raise
except json.JSONDecodeError:
print(f"Error decoding JSON in block: {json_str}")
except Exception as e:
had_errors = True
except Exception as e: # noqa: BLE001 - one malformed exercise is partial
print(f"Error processing block {ex_id if 'ex_id' in locals() else 'unknown'}: {e}")
had_errors = True
labels_staging.replace(labels_file)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in exercise_groups))
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Generate statement metadata from SHEETINFO blocks")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: process_directory(workspace_from_args(args)))
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python script.py <Dir>")
sys.exit(1)
process_directory(sys.argv[1])
raise SystemExit(main())
+97
View File
@@ -0,0 +1,97 @@
import argparse
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.configuration import EXPORT_DIR
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def export_directory(
workspace: EvaluationWorkspace,
source_dir_name: str,
) -> ExitCode:
workspace.require_directories(source_dir_name)
source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
if session_id:
sync_dir /= session_id
sync_dir.mkdir(parents=True, exist_ok=True)
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
if source_dir_name == "BGnot" and subdirs:
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
if not all_start_with_copie:
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0
for subdir in subdirs:
concat_file = next(
(
candidate
for candidate in (subdir / "Concat.pdf", subdir / "Concat.jpg")
if candidate.is_file()
),
None,
)
if concat_file is None:
print(
f"Warning: no Concat.pdf or Concat.jpg found in {subdir}",
file=sys.stderr,
)
missing_outputs += 1
continue
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Export annotated PDFs to the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory to export (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return export_directory(workspace, "BRnot" if refaire else annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,83 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
matching = []
if workspace.batch_jobs_file.is_file():
manifest = read_json(workspace.batch_jobs_file)
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
if not isinstance(jobs, dict):
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
matching = [
client.batches.get(name=entry["name"])
for entry in jobs.values()
if isinstance(entry, dict) and isinstance(entry.get("name"), str)
]
else:
matching = [
job
for job in client.batches.list()
if workspace.name in str(getattr(job, "display_name", ""))
]
if not matching:
raise CliError(
f"No batch jobs found for evaluation {workspace.name!r}"
)
for job in matching:
state = job.state.name if hasattr(job.state, "name") else job.state
print(f"{job.display_name}: {state}")
if state != "JOB_STATE_SUCCEEDED":
print("Not all matching jobs have succeeded yet.")
return ExitCode.PARTIAL
chunks = []
incomplete = False
for job in matching:
destination = getattr(job, "dest", None)
file_name = getattr(destination, "file_name", None)
if not file_name:
print(f"Warning: {job.display_name} has no output file.")
incomplete = True
continue
payload = client.files.download(file=file_name)
chunks.append(payload.rstrip(b"\n"))
if not chunks:
return ExitCode.PARTIAL
output_path = workspace.batched_correction_result_file
atomic_write_bytes(output_path, b"\n".join(chunks) + b"\n")
print(f"Saved combined batch results to {output_path}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Download and combine correction batch results")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+954
View File
@@ -0,0 +1,954 @@
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_text,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.platform import validate_windows_labels
from copienator.filesystem import staged_directory
from copienator.utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str:
i = 0
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
i += 1
lcp = s1[:i]
if ')' in s1 or ')' in s2:
last_paren = lcp.rfind(')')
if last_paren != -1:
return lcp[:last_paren + 1]
return lcp
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
# --- Modèles pour la Requête 1 ---
class QuestionOnlyItem(BaseModel):
label: str = Field(description="Label unique de la question (par exemple '1.a' ou 'Exercice 1').")
question_content: str = Field(description="Texte source de la question, extrait exactement du fichier d’énoncé, SANS le label lui-même.")
class ExamQuestions(BaseModel):
questions: list[QuestionOnlyItem]
# --- Modèles pour la Requête 2 ---
class SolutionOnlyItem(BaseModel):
label: str = Field(description="Label exact de la question fourni en entrée, à conserver sans traduction.")
solution_content: str = Field(description="Texte source de la solution, extrait exactement du fichier de correction.")
class ExamSolutions(BaseModel):
solutions: list[SolutionOnlyItem]
# --- Modèles pour la Requête 3 ---
class ExtractedContext(BaseModel):
target_question_label: str = Field(description="Label exact de la PREMIÈRE question située immédiatement APRÈS cette information dans l’énoncé.")
last_question_label: str = Field(description="Label exact de la DERNIÈRE question qui utilise cette information.")
context_content: str = Field(description="Texte source des définitions, notations ou hypothèses, extrait de l’énoncé.")
class ExamContext(BaseModel):
contexts: list[ExtractedContext]
# --- Modèles pour la Requête 4 (Barèmes) ---
class RubricItem(BaseModel):
label: str = Field(description="Label exact de la question, à conserver sans traduction.")
rubric_content: str = Field(description="Barème détaillé sur 4 points : toutes les consignes, explications et justifications doivent être rédigées en français.")
class GroupRubrics(BaseModel):
rubrics: list[RubricItem]
class LabelGroups(BaseModel):
groups: list[list[str]]
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é pour CHAQUE question.
Rédige intégralement en français le contenu de chaque champ `rubric_content`,
y compris les consignes de notation, les explications et les justifications,
même si certains textes fournis sont dans une autre langue.
Conserve les formules mathématiques, les labels exacts des questions et les
clés JSON `rubrics`, `label` et `rubric_content` sans les traduire.
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
Il est inutile d'indiquer dans `rubric_content` que le barème totalise 4 points :
ce total est toujours implicite.
N'utilise pas de caractères mathématiques Unicode dans `rubric_content`.
Écris les expressions mathématiques en LaTeX, par exemple
`$\\lfloor \\sqrt{k} \\rfloor$` plutôt qu'avec des symboles Unicode.
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 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
Renvoie uniquement un objet JSON contenant une liste `rubrics`. Pour chaque
question fournie, cette liste contient un objet avec son `label` exact et
son barème en français dans `rubric_content`.
"""
# --- Modèle fusionné (pour le reste du script) ---
class QuestionItem(BaseModel):
label: str
question_content: str
solution_content: str
class ContextItem(BaseModel):
target_question_label: str
last_question_label: str
content: str # Juste une string encapsulée pour le différencier facilement
class ExamExtraction(BaseModel):
items: list[QuestionItem | ContextItem] # Liste mixte
class GroupedExamExtraction(BaseModel):
groups: list[list[QuestionItem | ContextItem]]
PROMPT_1 = """Je te fournis :
1. Le PDF d'un examen (`enonce.pdf`).
2. Le code source de ses questions (fichier `enonce`).
Ta tâche :
1. Identifie tous les labels distincts des questions à l'aide du PDF.
Ils doivent être uniques : utilise par exemple `Ex 1 : 1)a)` ou `I)1)b)`.
2. Pour chaque label, extrais exactement le texte de la question
correspondante dans le fichier source `enonce`. N'inclus ni le label
lui-même, ni les commandes de liste LaTeX comme `item`, ni les marques
de liste org-mode comme `2.`.
Ne reformule pas et ne traduis pas le texte extrait ; conserve le LaTeX.
Renvoie les questions dans l'ordre exact de lecture du document, dans la
liste `questions` de l'objet JSON attendu. Conserve les clés `label` et
`question_content`.
"""
PROMPT_2 = """Je te fournis :
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
2. Le code source du corrigé de l'examen (fichier `correction`).
Pour chaque label fourni, extrais exactement le texte de la solution
correspondante dans le fichier source `correction`. Ne reformule pas et
ne traduis pas le texte extrait ; conserve le LaTeX.
Renvoie les solutions dans le même ordre que les questions, dans la liste
`solutions` de l'objet JSON attendu. Conserve les clés `label` et
`solution_content` ainsi que les labels exacts des questions.
"""
PROMPT_3 = """Je te fournis :
1. Une liste JSON des labels des questions d'un examen et de leurs textes.
2. Le code source des questions de l'examen (fichier `enonce`).
Extrais les informations importantes nécessaires à la compréhension des
questions, mais qui ne font PAS partie des textes des questions :
définitions des objets, notations générales, hypothèses ou contexte.
Ces informations figurent souvent dans un \\item précédent qui ne constitue
pas lui-même une question, mais contient une liste de questions.
Par exemple, dans ce code LaTeX :
\\item Soient N et M deux matrices qui commutent.
\\begin{itemize}
\\item Montrer que N et M ont un vecteur propre commun.
\\item Montrer que N et M sont simultanément trigonalisables.
\\end{itemize}
La phrase « Soient N et M deux matrices qui commutent » n'est pas une
question ; elle est nécessaire pour comprendre les deux questions suivantes.
Pour chaque information extraite, identifie :
1. Le label de la PREMIÈRE question située immédiatement APRÈS cette
information dans l'énoncé (`target_question_label`).
2. Le label de la DERNIÈRE question qui utilise cette information
(`last_question_label`).
Conserve le texte source dans `context_content`, sans le reformuler ni le
traduire, et conserve le LaTeX ainsi que les labels exacts.
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
"""
def find_file(folder: Path, base_name: str) -> Path | None:
for ext in [".org", ".tex"]:
path = folder / f"{base_name}{ext}"
if path.is_file():
return path
return None
def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
"""Use the same rubric request for full and selective statement generation."""
response = client.models.generate_content(
model=MODEL_ID,
contents=[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=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.",
temperature=0.2,
response_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(),
),
)
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
if len({item.label for item in rubrics}) != len(rubrics):
raise ValueError("Gemini returned duplicate rubric labels")
return {item.label: item.rubric_content for item in rubrics}
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
flattened = [label for group in groups for label in group]
if (not groups or any(not group for group in groups)
or len(flattened) != len(set(flattened)) or set(flattened) != set(labels)):
raise CliError("Groups must contain every existing label exactly once")
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None) -> ExitCode:
"""Regroup or replace rubrics without regenerating statements or solutions."""
labels = workspace.read_labels()
if not labels or len(labels) != len(set(labels)):
raise CliError("Generate unique question labels before refining the statement")
validate_windows_labels(labels)
questions = {}
for label in labels:
safe_label = label.replace("/", "_")
parts = []
for directory, title in (("Text2", "Question"), ("Sol2", "Correction")):
path = workspace.root / directory / f"{safe_label}.tex"
if not path.is_file():
raise CliError(f"Missing {path}; generate statements and solutions first")
parts.append(f"{title} [{label}]:\n{path.read_text(encoding='utf-8')}")
questions[label] = "\n".join(parts)
context = utils.enonce_total(workspace.root)
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
if mode == "groups":
response = api_client.models.generate_content(
model=MODEL_ID,
contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
"Regroupe ces questions dexamen en groupes cohérents pour la correction "
"et lannotation, selon leurs dépendances et leur contexte commun. "
"Ne mélange pas des exercices différents. Conserve lordre des questions. "
"Chaque label doit apparaître exactement une fois, sans modification. "
"Renvoie uniquement un objet JSON groups contenant des listes de labels.\n\n"
+ context + "\n\n" + "\n\n".join(questions.values())
))])],
config=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, response_mime_type="application/json",
response_json_schema=LabelGroups.model_json_schema(),
),
)
groups = LabelGroups.model_validate_json(response.text).groups
validate_groups(groups, labels)
atomic_write_text(workspace.label_groups_file,
"".join(", ".join(group) + "\n" for group in groups))
print(f"Updated label_groups: {len(groups)} Gemini groups.")
elif mode == "persp":
if not workspace.label_groups_file.is_file():
raise CliError("Generate label_groups before generating rubrics")
groups = [[label.strip() for label in line.split(",") if label.strip()]
for line in workspace.label_groups_file.read_text(encoding="utf-8").splitlines()
if line.strip()]
validate_groups(groups, labels)
with staged_directory(workspace.root / "Persp") as staging:
for group in groups:
print(f"Generating rubric (Persp) for group: {', '.join(group)}...")
group_content = (
"Contexte général de lexamen, fourni uniquement pour comprendre les questions :\n"
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
+ ", ".join(group) + "\n\n" + "\n\n".join(questions[label] for label in group)
)
rubrics = generate_rubrics(api_client, group_content)
if set(rubrics) != set(group) or any(not value.strip() for value in rubrics.values()):
raise CliError("Incomplete or unexpected Gemini rubrics; previous Persp preserved")
for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8")
print("Replaced Persp with Gemini rubrics.")
else:
raise ValueError(f"Unknown statement refinement: {mode}")
return ExitCode.SUCCESS
def process_exam(
workspace: EvaluationWorkspace,
restart: bool = False,
*,
api_client=None,
) -> ExitCode:
folder = workspace.root
cache_dir = folder / "Cache"
tmp_dir = folder / "Tmp"
cache_dir.mkdir(exist_ok=True)
tmp_dir.mkdir(exist_ok=True)
cache_q_file = cache_dir / "gemini_questions.json"
cache_s_file = cache_dir / "gemini_solutions.json"
cache_c_file = cache_dir / "gemini_context.json"
# 1. Resolve files
pdf_path = folder / "enonce.pdf"
enonce_path = find_file(folder, "enonce")
correction_path = find_file(folder, "correction")
missing = []
if not pdf_path.is_file(): missing.append("enonce.pdf")
if not enonce_path: missing.append("enonce.org or enonce.tex")
if not correction_path: missing.append("correction.org or correction.tex")
if missing:
raise CliError(
f"Missing files in {folder}: {', '.join(missing)}",
ExitCode.INVALID_WORKSPACE,
)
print("Reading files...")
pdf_bytes = pdf_path.read_bytes()
enonce_text = enonce_path.read_text(encoding="utf-8")
correction_text = correction_path.read_text(encoding="utf-8")
if api_client is None:
if not api_key:
raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key)
client = api_client
# ==========================================
# REQUÊTE 1 : Extraction des Énoncés
# ==========================================
contents_1 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_1),
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
],
)
]
config_1 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamQuestions.model_json_schema(),
)
if cache_q_file.is_file() and not restart:
print("Loading cached questions from Cache/gemini_questions.json...")
response_q_text = cache_q_file.read_text(encoding="utf-8")
else:
print("Sending request 1 (Questions) to Gemini...")
response_q = client.models.generate_content(
model=MODEL_ID,
contents=contents_1,
config=config_1
)
response_q_text = response_q.text
print("Saving questions to cache...")
atomic_write_text(cache_q_file, response_q_text)
questions_data = ExamQuestions.model_validate_json(response_q_text)
# ==========================================
# REQUÊTE 2 : Extraction des Corrections
# ==========================================
extracted_questions_json = questions_data.model_dump_json(indent=2)
contents_2 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_2),
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
],
)
]
config_2 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamSolutions.model_json_schema(),
)
if cache_s_file.is_file() and not restart:
print("Loading cached solutions from Cache/gemini_solutions.json...")
response_s_text = cache_s_file.read_text(encoding="utf-8")
else:
print("Sending request 2 (Solutions) to Gemini...")
response_s = client.models.generate_content(
model=MODEL_ID,
contents=contents_2,
config=config_2
)
response_s_text = response_s.text
print("Saving solutions to cache...")
atomic_write_text(cache_s_file, response_s_text)
solutions_data = ExamSolutions.model_validate_json(response_s_text)
# ==========================================
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
# ==========================================
contents_3 = [
types.Content(
role="user",
parts=[
types.Part.from_text(text=PROMPT_3),
types.Part.from_text(text=f"--- QUESTIONS EXTRAITES ---\n{extracted_questions_json}"),
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
],
)
]
config_3 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1,
response_mime_type="application/json",
response_json_schema=ExamContext.model_json_schema(),
)
if cache_c_file.is_file() and not restart:
print("Loading cached context from Cache/gemini_context.json...")
response_c_text = cache_c_file.read_text(encoding="utf-8")
else:
print("Sending request 3 (Context) to Gemini...")
response_c = client.models.generate_content(
model=MODEL_ID,
contents=contents_3,
config=config_3
)
response_c_text = response_c.text
print("Saving context to cache...")
atomic_write_text(cache_c_file, response_c_text)
context_data = ExamContext.model_validate_json(response_c_text)
# ==========================================
# FUSION des trois résultats
# ==========================================
sol_map = {s.label: s.solution_content for s in solutions_data.solutions}
# Map labels to their index to validate ordering
label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)}
for c in context_data.contexts:
first_idx = label_to_idx.get(c.target_question_label, -1)
last_idx = label_to_idx.get(c.last_question_label, -1)
# Enforce LAST is after (or equal to) FIRST
if first_idx != -1 and last_idx != -1 and last_idx < first_idx:
print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.")
c.last_question_label = c.target_question_label
elif last_idx == -1: # Fallback if invalid
c.last_question_label = c.target_question_label
# Grouper les contextes par label cible
ctx_map = {}
for c in context_data.contexts:
if c.target_question_label in ctx_map:
ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content
# Keep the furthest LAST question label
curr_last = ctx_map[c.target_question_label]['last']
if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1):
ctx_map[c.target_question_label]['last'] = c.last_question_label
else:
ctx_map[c.target_question_label] = {
'content': c.context_content,
'last': c.last_question_label
}
merged_items = []
for q in questions_data.questions:
if q.label in ctx_map:
merged_items.append(ContextItem(
target_question_label=q.label,
last_question_label=ctx_map[q.label]['last'],
content=ctx_map[q.label]['content']
))
sol_content = sol_map.get(q.label, "")
merged_items.append(QuestionItem(
label=q.label,
question_content=q.question_content,
solution_content=sol_content
))
extracted_data = ExamExtraction(items=merged_items)
# ==========================================
# INITIAL GROUPING COMPUTATION
# ==========================================
items_file = tmp_dir / "exam_items.txt"
full_items_file = tmp_dir / "exam_items_full.txt"
trunc_map = {}
# --- INITIAL GROUPING COMPUTATION ---
# 1. Normalize labels first
for item in extracted_data.items:
if isinstance(item, QuestionItem):
item.label = item.label.replace("Exercice", "Ex").replace(".", ")")
# 2. Extract questions and compute grouping indices
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
# questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
q_group_indices = []
if questions_only:
n = len(questions_only)
if n == 1:
q_group_indices = [[0]]
else:
adj_lcp = [get_lcp(questions_only[i].label, questions_only[i+1].label) for i in range(n - 1)]
current_g = [0]
for i in range(n - 1):
p = adj_lcp[i]
prev_p = adj_lcp[i - 1] if i > 0 else ""
next_p = adj_lcp[i + 1] if i < n - 2 else ""
# Group i and i+1 together if p is non-empty and at least as specific as adjacent LCPs
if p and len(p) >= len(prev_p) and len(p) >= len(next_p):
current_g.append(i + 1)
else:
q_group_indices.append(current_g)
current_g = [i + 1]
q_group_indices.append(current_g)
# Build list of unique ContextItems from extracted data
all_contexts = [item for item in extracted_data.items if isinstance(item, ContextItem)]
initial_groups = []
for g_indices in q_group_indices:
group_items = []
first_q_idx = g_indices[0]
for q_idx in g_indices:
q_item = questions_only[q_idx]
# 1. Collect contexts targeting this specific question
# 2. Or contexts carried over from an earlier group (only added at the start of the group)
for ctx in all_contexts:
target_idx = label_to_idx.get(ctx.target_question_label, -1)
last_idx = label_to_idx.get(ctx.last_question_label, -1)
if target_idx != -1 and last_idx != -1:
is_exact_target = (target_idx == q_idx)
is_carried_over = (q_idx == first_q_idx and target_idx < first_q_idx and last_idx >= first_q_idx)
if is_exact_target or is_carried_over:
group_items.append(ContextItem(
target_question_label=ctx.target_question_label,
last_question_label=ctx.last_question_label,
content=ctx.content
))
group_items.append(q_item)
initial_groups.append(group_items)
# ---- Transform labels, and check uniqueness
seen_labels = set()
label_updates = {}
for group in initial_groups:
for item in group:
if isinstance(item, QuestionItem):
orig_label = item.label
# 2. Ensure uniqueness (prefix with XX)
while item.label in seen_labels:
item.label = f"XX{item.label}"
seen_labels.add(item.label)
label_updates[orig_label] = item.label
# Sync the modified labels to ContextItem
for group in initial_groups:
for item in group:
if isinstance(item, ContextItem):
item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label)
item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label)
# --- WRITE TEXT FILES ---
print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
with open(items_file, "w", encoding="utf-8") as f, \
open(full_items_file, "w", encoding="utf-8") as f_full:
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
f.write(header)
f_full.write(header)
for g_idx, group in enumerate(initial_groups):
if g_idx > 0:
f.write("\n---\n\n")
f_full.write("\n---\n\n")
for item in group:
if isinstance(item, QuestionItem):
safe_content = item.question_content.replace('\n', ' \\n ')
f_full.write(f"{item.label} ### {safe_content}\n")
if len(safe_content) > 65:
trunc_content = safe_content[:64] + ""
trunc_map[trunc_content] = safe_content
else:
trunc_content = safe_content
f.write(f"{item.label} ### {trunc_content}\n")
elif isinstance(item, ContextItem):
safe_content = item.content.replace('\n', ' \\n ')
f.write(f"CONTEXT ### {safe_content}\n")
f_full.write(f"CONTEXT ### {safe_content}\n")
# --- OPEN EDITOR AND PARSE ---
while True:
print("Opening items file for editing...")
utils.edit_file_and_enter(items_file)
print(f"Parsing edited items from {items_file.name}...")
with open(items_file, "r", encoding="utf-8") as f:
edited_lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
# 1. Validation for XX labels
has_xx = False
for line in edited_lines:
if " ### " in line:
lbl = line.split(" ### ", 1)[0].strip()
if lbl.startswith("XX"):
has_xx = True
break
if has_xx:
print("\n!!! ERROR: Some labels still start with 'XX'. Please remove the 'XX' prefixes to ensure unique, valid labels.")
input("Press ENTER to return to the editor...")
continue
# Map original contexts by normalized content
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
# 2. Actual Parsing
grouped_items = []
all_new_q_labels = []
# Pass 1: Read all edited lines and collect question labels in sequence
for line in edited_lines:
if line == "---" or " ### " not in line:
continue
lbl, _content_raw = line.split(" ### ", 1)
lbl = lbl.strip()
if lbl != "CONTEXT":
all_new_q_labels.append(lbl)
# Mapping from original question index to new label
idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))}
orig_q_idx = 0
current_group = []
for line in edited_lines:
if line == "---":
if current_group:
grouped_items.append(current_group)
current_group = []
continue
if " ### " not in line:
continue
new_label, edited_content_raw = line.split(" ### ", 1)
new_label = new_label.strip()
if "" in edited_content_raw and edited_content_raw in trunc_map:
edited_content_raw = trunc_map[edited_content_raw]
edited_content = edited_content_raw.replace(' \\n ', '\n')
if new_label == "CONTEXT":
current_group.append(('CONTEXT', edited_content))
else:
sol_content = questions_only[orig_q_idx].solution_content if orig_q_idx < len(questions_only) else ""
current_group.append(QuestionItem(
label=new_label,
question_content=edited_content,
solution_content=sol_content
))
orig_q_idx += 1
if current_group:
grouped_items.append(current_group)
# Pass 2: Resolve ContextItem target/last labels per group
final_grouped_items = []
for group in grouped_items:
final_group = []
q_in_group = [item for item in group if isinstance(item, QuestionItem)]
g_first_label = q_in_group[0].label if q_in_group else ""
g_last_label = q_in_group[-1].label if q_in_group else ""
for i, item in enumerate(group):
if isinstance(item, tuple) and item[0] == 'CONTEXT':
c_text = item[1]
norm_text = c_text.strip()
# Find next question label in group following this context
next_q_label = g_first_label
for successor in group[i+1:]:
if isinstance(successor, QuestionItem):
next_q_label = successor.label
break
if norm_text in orig_contexts:
orig_c = orig_contexts[norm_text]
orig_target_idx = label_to_idx.get(orig_c.target_question_label, -1)
orig_last_idx = label_to_idx.get(orig_c.last_question_label, -1)
mapped_target = idx_to_new_label.get(orig_target_idx, next_q_label)
mapped_last = idx_to_new_label.get(orig_last_idx, g_last_label)
# Check if context's last question is BEFORE the first question of this group
first_q_idx_in_exam = all_new_q_labels.index(g_first_label) if g_first_label in all_new_q_labels else -1
last_q_idx_in_exam = all_new_q_labels.index(mapped_last) if mapped_last in all_new_q_labels else -1
if last_q_idx_in_exam != -1 and first_q_idx_in_exam != -1 and last_q_idx_in_exam < first_q_idx_in_exam:
mapped_last = g_last_label
final_group.append(ContextItem(
target_question_label=mapped_target,
last_question_label=mapped_last,
content=c_text
))
else:
# New context created by user
final_group.append(ContextItem(
target_question_label=next_q_label,
last_question_label=g_last_label,
content=c_text
))
else:
final_group.append(item)
final_grouped_items.append(final_group)
grouped_items = final_grouped_items
break
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
validate_windows_labels(labels_list)
# Save labels and proceed
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
# 2. Setup output directories
text_dir = folder / "Text"
sol_dir = folder / "Sol"
text2_dir = folder / "Text2"
sol2_dir = folder / "Sol2"
persp_dir = folder / "Persp"
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
import shutil
# Ask only if any directory already exists
if any(d.exists() for d in dirs):
answer = input(
"Output directories already exist. Delete their contents? [y/N] "
).strip().lower()
if answer not in ("y", "yes"):
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
# Empty each directory
for d in dirs:
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
else:
# Create them if they don't exist
for d in dirs:
d.mkdir(parents=True)
text_dir.mkdir(exist_ok=True)
sol_dir.mkdir(exist_ok=True)
print("Writing grouped question and solution files...")
processing_errors = []
for group in grouped_extraction.groups:
q_items = [item for item in group if isinstance(item, QuestionItem)]
labels = [q.label for q in q_items]
if not labels:
continue # Skip if a group has no questions (only contexts)
# ==========================================
# REQUÊTE 4 : Génération du Barème pour le groupe
# ==========================================
group_text_parts = []
for item in group:
if isinstance(item, QuestionItem):
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
elif isinstance(item, ContextItem):
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
group_context_text = "\n\n---\n\n".join(group_text_parts)
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
try:
rubrics_map = generate_rubrics(client, group_context_text)
except Exception as e: # noqa: BLE001 - remote API boundary
print(f"Error generating rubric for group {labels[0]}: {e}")
processing_errors.append(str(e))
rubrics_map = {}
# 1. Compute the common prefix for the group
prefix = labels[0]
for lbl in labels[1:]:
prefix = get_lcp(prefix, lbl)
# 2. Format the Text filename: prefix [label1, label2]
labels_str = ",".join([label[len(prefix):] for label in labels])
group_filename = f"{prefix}[{labels_str}]"
safe_group_filename = group_filename.replace("/", "_")
text_content_lines = []
# 3. Process each item in the group
for item in group:
if isinstance(item, QuestionItem):
# 1. Prepare tabulated content:
# Start with a tab, then replace every newline+whitespace with newline+tab
raw_content = item.question_content.strip()
tabulated = "\t" + re.sub(r'\n\s*', '\n\t', raw_content)
# 2. Build Text entry
text_content_lines.append(f"{item.label} :")
text_content_lines.append(tabulated)
# Write individual Sol file (remains unchanged)
safe_label = item.label.replace("/", "_")
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol:
f_sol.write(f"{item.label}\n{item.solution_content}")
with open(text2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_t2:
f_t2.write(f"\\textbf{{{item.label}}} {item.question_content}")
with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2:
f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}")
# --- Écriture du Barème (Persp) ---
rubric_text = rubrics_map.get(item.label, "")
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp:
f_persp.write(f"{item.label}\n{rubric_text}")
elif isinstance(item, ContextItem):
raw_ctx = item.content.strip()
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
text_content_lines.append("CONTEXT :")
text_content_lines.append(tabulated_ctx)
# --- Save context to Text2 (Concatenating if exists) ---
safe_first = item.target_question_label.replace("/", "_")
safe_last = item.last_question_label.replace("/", "_")
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
ctxt_path = text2_dir / ctxt_filename
# If file exists, prepend some spacing before appending
prefix = "\n\n" if ctxt_path.exists() else ""
with open(ctxt_path, "a", encoding="utf-8") as f_c2:
f_c2.write(prefix + item.content)
# 4. Write the grouped Text file
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
f_text.write("\n".join(text_content_lines))
print(f"Success! Processed {len(grouped_extraction.groups)} groups.")
# ==========================================
# PDF COMPILATION (4 Threads)
# ==========================================
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
def compile_worker(tex_file: Path) -> str | None:
"""Helper to read content and call the utility function."""
try:
content = tex_file.read_text(encoding="utf-8")
pdf_path = tex_file.with_suffix(".pdf")
compile_to_pdf(content, pdf_path)
except Exception as e: # noqa: BLE001 - compiler worker boundary
return f"Error compiling {tex_file.name}: {e}"
return None
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
with ThreadPoolExecutor(max_workers=4) as executor:
compile_errors = [
error for error in executor.map(compile_worker, all_tex_files) if error
]
for error in compile_errors:
print(error)
processing_errors.extend(compile_errors)
atomic_write_text(
workspace.labels_file,
"".join(f"{label}\n" for label in labels_list),
)
atomic_write_text(
workspace.label_groups_file,
"".join(", ".join(item.label for item in group if isinstance(item, QuestionItem)) + "\n"
for group in grouped_extraction.groups
if any(isinstance(item, QuestionItem) for item in group)),
)
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Extract exam and solution code via Gemini")
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--groups-only", action="store_true",
help="Regroup existing questions with Gemini; update only label_groups")
actions.add_argument("--persp-only", action="store_true",
help="Replace only Persp with Gemini rubrics for existing groups")
parser.add_argument(
"--restart",
action="store_true",
help="Ignore cached Gemini extraction responses",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: refine_existing(workspace_from_args(args),
"groups" if args.groups_only else "persp")
if args.groups_only or args.persp_only else process_exam(
workspace_from_args(args),
restart=args.restart,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+599
View File
@@ -0,0 +1,599 @@
from __future__ import annotations
import argparse
import re
import time
import typing
from collections import defaultdict
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_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.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts. If they are
horizontally far apart (example : if the `1)` is to the left and the
`Exercice i` is either to the right, or in the middle) then give only
the bounding box of the question label `1)` part. You should still
label it as `Exercice i : 1)` though.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels. Order the
box_2d by their position in the page, column by column : first column
(top to bottom), then second column, etc.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
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
##names##"""
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
This image is one part of a sequence (e.g., part 2 of 3) for a single
student. Here is the list of labels found in the *previous* parts of
this copy:
[
##prev_context##
]
If the first column starts with a number like =3)= or =c)=, look at
the labels in the list above. If the last relevant label was =Ex 4 :
2)=, you should label the new box =Ex 4 : 3)=.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`."""
class BoxItem(BaseModel):
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
label: str = Field(description="The label associated with the specific box")
class AnnotationData(BaseModel):
name: str = Field(description="The name identifier")
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
description="List of bounding box items"
)
TEXT_CONTEXT_MAX_CHARS = 4000
def _label_filename(path: Path) -> str:
return path.stem if path.suffix.casefold() in {".tex", ".txt"} else path.name
def _common_prefix_length(left: str, right: str) -> int:
left_folded = left.casefold()
right_folded = right.casefold()
limit = min(len(left_folded), len(right_folded))
for index in range(limit):
if left_folded[index] != right_folded[index]:
return index
return limit
def closest_text_context(
workspace: EvaluationWorkspace, wrong_labels: list[str]
) -> tuple[Path | None, str]:
"""Return a bounded excerpt from the Text file closest to an invalid label."""
text_dir = workspace.root / "Text"
if not wrong_labels or not text_dir.is_dir():
return None, ""
ranked: list[tuple[int, str, Path]] = []
for path in text_dir.iterdir():
if not path.is_file() or path.suffix.casefold() == ".pdf":
continue
filename = _label_filename(path)
prefix_length = max(
_common_prefix_length(filename, wrong_label)
for wrong_label in wrong_labels
)
if prefix_length:
ranked.append((prefix_length, filename.casefold(), path))
for _prefix_length, _filename, path in sorted(
ranked, key=lambda item: (-item[0], item[1])
):
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
if len(content) > TEXT_CONTEXT_MAX_CHARS:
content = content[:TEXT_CONTEXT_MAX_CHARS] + "\n[excerpt truncated]"
return path, content
return None, ""
def generate_request(
file,
labels,
names,
context_labels,
wrong_labels,
wrong_label_text_context="",
wrong_label_text_file: Path | None = None,
seed: int = 0,
):
"""Generates request for Gemini with context."""
image_path = Path(file)
# Format context list as a string
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
if context_labels == []:
text = my_prompt.replace("##labels##", labels)\
.replace("##names##", names)
else:
text = my_prompt2.replace("##labels##", labels)\
.replace("##prev_context##", context_str)
if wrong_labels:
formatted_wrong_labels = "\n".join(f'- "{label}"' for label in wrong_labels)
text = text.replace(
"##wrong_labels##",
"On the previous request for this image, you answered with these "
"invalid labels:\n"
f"{formatted_wrong_labels}\n"
"They are wrong because they do not exactly match any label in the "
"valid list above.\n\n"
"CRITICAL RETRY CONSTRAINT: NEVER return any of the invalid labels "
"listed above again. Your answer must use only exact labels copied "
"verbatim from the valid list. If the handwriting resembles an "
"invalid label, choose the closest exact valid label instead.",
)
else:
text = text.replace("##wrong_labels##", "")
if wrong_label_text_context and wrong_label_text_file:
text = text.replace(
"##wrong_label_text_context##",
"Here is an excerpt from the exam text file whose name has the "
"longest prefix in common with the invalid label(s), "
f"`{wrong_label_text_file.name}`:\n\n"
"<exam_text_excerpt>\n"
f"{wrong_label_text_context}\n"
"</exam_text_excerpt>\n\n"
"Use this excerpt as extra context for identifying the handwritten "
"label, but return only an exact label from the valid list above.",
)
else:
text = text.replace("##wrong_label_text_context##", "")
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=text),
],
)
]
generate_content_config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0,
top_p=0.95,
seed=seed,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema= AnnotationData.model_json_schema(),
)
return (contents, generate_content_config)
TARGET_INTERVAL = 3.5
Sleep = Callable[[float], None]
def selected_images(
workspace: EvaluationWorkspace,
targets: list[Path],
) -> tuple[list[Path], list[str]]:
"""Resolve evaluation, copy-PDF, or Cutleft-image targets."""
workspace.require_directories("Copies", "Cutleft")
images: list[Path] = []
warnings: list[str] = []
for target in targets:
if target.is_dir():
copy_pdfs = sorted(
workspace.copies_dir.glob("Copie*.pdf"), key=natural_key
)
if not copy_pdfs:
warnings.append(f"No Copie*.pdf files found in {workspace.copies_dir}")
stems = [path.stem for path in copy_pdfs]
elif target.suffix.casefold() in {".jpg", ".jpeg"}:
if target.parent != workspace.cutleft_dir:
raise CliError(
f"Image target is not in {workspace.cutleft_dir}: {target}",
ExitCode.INVALID_ARGUMENTS,
)
images.append(target)
continue
elif target.suffix.casefold() == ".pdf":
stems = [target.stem]
else:
raise CliError(
f"Unsupported target for label detection: {target}",
ExitCode.INVALID_ARGUMENTS,
)
for stem in stems:
found = sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"), key=natural_key
)
if found:
images.extend(found)
else:
warnings.append(
f"No Cutleft image variants found for {stem} in "
f"{workspace.cutleft_dir}"
)
return list(dict.fromkeys(images)), warnings
def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
groups: defaultdict[str, list[Path]] = defaultdict(list)
for image in image_files:
match = re.match(r"(.+)_(\d+)$", image.stem)
groups[match.group(1) if match else image.stem].append(image)
for files in groups.values():
files.sort(key=natural_key)
return dict(groups)
def sort_boxes_for_image(
workspace: EvaluationWorkspace,
image_file: Path,
boxes: list[BoxItem],
) -> list[BoxItem]:
"""Sort boxes in the image's page-column reading order when schema exists."""
match = re.match(r"(.+)_(\d+)$", image_file.stem)
if not match:
return boxes
schema_path = workspace.cutleft_dir / f"{match.group(1)}_schema.json"
try:
schema = read_json(schema_path)
columns_per_file = schema["columns_per_file"]
column_count = int(columns_per_file[int(match.group(2)) - 1])
if column_count < 1:
return boxes
except (OSError, KeyError, IndexError, TypeError, ValueError):
return boxes
def position(item: BoxItem) -> tuple[int, int, int]:
ymin, xmin, _ymax, xmax = item.box_2d
center_x = (xmin + xmax) // 2
column = min(column_count - 1, max(0, center_x * column_count // 1000))
return column, ymin, xmin
return sorted(boxes, key=position)
def _existing_context(output_json: Path) -> list[str]:
try:
loaded = read_json(output_json)
if not isinstance(loaded, dict):
return []
return [
str(item["label"])
for item in loaded.get("list", [])
if isinstance(item, dict) and "label" in item
]
except (OSError, TypeError, ValueError):
return []
def process_copy_group(
workspace: EvaluationWorkspace,
group_key: str,
files: list[Path],
*,
client,
labels_text: str,
names_text: str,
valid_labels: set[str],
valid_names: set[str],
overwrite: bool,
sleep: Sleep = time.sleep,
target_interval: float = TARGET_INTERVAL,
) -> int:
"""Process one student's image parts sequentially to preserve context."""
accumulated_labels: list[str] = []
generated = 0
for image_file in files:
started = time.monotonic()
output_json = workspace.copies_dir / f"{image_file.stem}.json"
if output_json.exists() and not overwrite:
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
accumulated_labels.extend(_existing_context(output_json))
continue
print(
f"[{group_key}] Processing {image_file.name} with "
f"{len(accumulated_labels)} accumulated labels..."
)
attempt = 0
label_retry_count = 0
wrong_labels: list[str] = []
while True:
if attempt > 0:
sleep(10 * attempt)
try:
text_context_file, text_context = closest_text_context(
workspace, wrong_labels
)
if text_context_file:
print(
f"[{group_key}] Retry context for {image_file.name}: "
f"{text_context_file.relative_to(workspace.root)}"
)
request_seed = max(0, label_retry_count - 1)
contents, request_config = generate_request(
image_file,
labels_text,
names_text,
accumulated_labels,
wrong_labels,
text_context,
text_context_file,
seed=request_seed,
)
response = client.models.generate_content(
model=MODEL_ID,
contents=contents,
config=request_config,
)
annotation = AnnotationData.model_validate_json(response.text)
unknown = [
item.label
for item in annotation.list
if item.label not in valid_labels
]
if unknown:
print(
f"Error: {image_file.name} contained unknown labels: "
f"{unknown}"
)
unique_unknown = list(dict.fromkeys(unknown))
if (
label_retry_count >= 2
and set(unique_unknown) == set(wrong_labels)
):
for item in annotation.list:
if item.label in unique_unknown:
item.label = f"??{item.label}"
print(
f"Warning: {image_file.name} repeated the same unknown "
"label(s) on the third try; keeping them with a ?? prefix."
)
else:
wrong_labels = unique_unknown
label_retry_count += 1
attempt += 1
continue
if annotation.name not in valid_names:
print(
f"Error: {image_file.name} returned unknown name: "
f"{annotation.name}"
)
if attempt == 0:
attempt += 1
continue
annotation.name = "Unknown"
annotation.list = sort_boxes_for_image(
workspace, image_file, annotation.list
)
atomic_write_json(output_json, annotation.model_dump())
accumulated_labels.extend(box.label for box in annotation.list)
generated += 1
break
except KeyboardInterrupt:
raise
except Exception as exc: # noqa: BLE001 - remote API retry boundary
print(
f"Error processing {image_file.name}: {exc}\n"
"\tIt will be retried."
)
attempt += 1
sleep(max(0.0, target_interval - (time.monotonic() - started)))
return generated
def run(
workspace: EvaluationWorkspace,
targets: list[Path],
*,
overwrite: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
) -> ExitCode:
workspace.require_files("labels")
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
if not images:
return ExitCode.PARTIAL
all_labels = read_all_labels(workspace.root)
labels_text = "\n".join(all_labels) + "\n"
names_path = workspace.names_file()
if not names_path.is_file():
raise CliError(f"Names file not found: {names_path}", ExitCode.INVALID_WORKSPACE)
names_text = names_path.read_text(encoding="utf-8")
valid_names = {
line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"}
if client is None:
client = genai.Client(api_key=api_key)
groups = group_images(images)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
process_copy_group,
workspace,
group_key,
files,
client=client,
labels_text=labels_text,
names_text=names_text,
valid_labels=set(all_labels),
valid_names=valid_names,
overwrite=overwrite,
sleep=sleep,
)
for group_key, files in groups.items()
]
for future in futures:
future.result()
return ExitCode.PARTIAL if warnings else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Detect handwritten question labels with Gemini")
parser.add_argument(
"additional_targets",
nargs="*",
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Regenerate JSON outputs that already exist",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2]
)
targets = [target]
for additional in args.additional_targets:
resolved = additional.expanduser().resolve()
if not resolved.exists():
raise CliError(
f"Target does not exist: {resolved}",
ExitCode.INVALID_WORKSPACE,
)
additional_workspace = EvaluationWorkspace.discover(
resolved, repository=workspace.repository
)
if additional_workspace.root != workspace.root:
raise CliError(
"All targets must belong to the same evaluation",
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
configuration,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.platform import replace_with_link_or_copy, safe_filename
from copienator.return_answers import publish_answer_returns
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Assign student names and prepare the return directory.")
parser.add_argument(
"annotation_dir",
choices=ANNOTATION_CHOICES,
help="Annotation directory to use",
)
parser.add_argument(
"--update",
action="store_true",
help=(
"Update only the individual images in existing A Rendre/answers "
"directories, matching folders by their trailing copy ID"
),
)
return parser
RETURN_COPY_ID = re.compile(r"\((\d+)\)$")
def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
names_path = workspace.names_file()
if not names_path.exists():
print(
f"Warning: names file not found in {workspace.root} or the current directory.",
file=sys.stderr,
)
return set()
return {
line.strip()
for line in names_path.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def _annotation_source(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
copy_id: str,
) -> Path | None:
selected = workspace.root / annotation_dir_name / f"Copie{copy_id}"
fallback = workspace.annotation_dir("simple") / f"Copie{copy_id}"
for candidate in (selected, fallback):
if (candidate / "score.json").is_file() and (
(candidate / "Concat.jpg").is_file()
or (candidate / "info.json").is_file()
):
return candidate
return None
def update_named_return_answers(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
"""Refresh only answers/ in existing returns, preserving manual names."""
workspace.require_directories(annotation_dir_name, "A Rendre")
had_errors = False
found = False
for destination in sorted(workspace.return_dir.iterdir()):
if not destination.is_dir():
continue
match = RETURN_COPY_ID.search(destination.name)
if match is None:
print(
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
file=sys.stderr,
)
had_errors = True
continue
found = True
copy_id = match.group(1)
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
print(
f"Warning: no annotation source found for Copie{copy_id}; skipped",
file=sys.stderr,
)
had_errors = True
continue
try:
publish_answer_returns(
workspace.root,
source_folder,
destination,
answers_only=True,
)
print(f"Updated answers for {destination.name} from Copie{copy_id}")
except (OSError, TypeError, ValueError) as exc:
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
if not found:
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
had_errors = True
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def prepare_named_returns(
workspace: EvaluationWorkspace,
annotation_dir_name: str,
) -> ExitCode:
workspace.require_directories("Copies", annotation_dir_name)
workspace.return_dir.mkdir(parents=True, exist_ok=True)
expected_names = _read_expected_names(workspace)
copies_map: defaultdict[str, list[str]] = defaultdict(list)
pattern = re.compile(r"^Copie(\d+)\.json$")
had_errors = False
for json_path in workspace.copies_dir.iterdir():
match = pattern.match(json_path.name)
if not match:
continue
try:
data = read_json(json_path)
if not isinstance(data, dict):
raise TypeError("expected a JSON object")
name = str(data.get("name", "Unknown")).strip()
copies_map[name].append(match.group(1))
except (OSError, TypeError, ValueError) as exc:
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
had_errors = True
assigned_names: set[str] = set()
for name, copy_ids in copies_map.items():
if name == "Unknown":
print(
f"Warning: unknown name for copies: {', '.join(copy_ids)}",
file=sys.stderr,
)
elif len(copy_ids) > 1:
print(
f"Warning: name {name!r} is assigned to multiple copies: "
f"{', '.join(copy_ids)}",
file=sys.stderr,
)
safe_name = safe_filename(name)
for copy_id in copy_ids:
source_folder = _annotation_source(
workspace, annotation_dir_name, copy_id
)
if source_folder is None:
continue
assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination.mkdir(parents=True, exist_ok=True)
try:
publish_answer_returns(workspace.root, source_folder, destination)
except (OSError, TypeError, ValueError) as exc:
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
continue
links = (
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
("score.json", "score.json", True),
)
for source_name, destination_name, enabled in links:
source = source_folder / source_name
target = destination / destination_name
try:
if not enabled:
# Remove only the named return entry, never its link target.
target.unlink(missing_ok=True)
continue
if not source.exists():
target.unlink(missing_ok=True)
continue
method = replace_with_link_or_copy(
source,
target,
prefer="symlink",
)
if method == "copy":
print(
f"Copied {source_name} for {destination.name} "
"(links unavailable)"
)
except OSError as exc:
print(
f"Error linking {source} for {destination.name}: {exc}",
file=sys.stderr,
)
had_errors = True
unassigned = expected_names - assigned_names
if unassigned:
print("Names from the list that were not assigned:", file=sys.stderr)
for name in sorted(unassigned):
print(f" - {name}", file=sys.stderr)
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str,
update: bool = False,
) -> ExitCode:
if update:
return update_named_return_answers(workspace, annotation_dir)
return prepare_named_returns(workspace, annotation_dir)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args, repository=Path.cwd()),
annotation_dir=args.annotation_dir,
update=args.update,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+44 -23
View File
@@ -1,13 +1,22 @@
import argparse
import os
import json
import re
import sys
import shutil
from pathlib import Path
from collections import defaultdict
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from PIL import Image, ImageDraw, ImageFont
from pdf2image import convert_from_path, pdfinfo_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
workspace_from_args,
)
# Configuration
DPI = 200 # Good balance for readability and size
@@ -34,7 +43,7 @@ def get_pdf_height(path):
# Return total height
return single_page_px * num_pages
except Exception as e:
except Exception as e: # noqa: BLE001 - pdfinfo may raise backend-specific errors
print(f"Error reading {path}: {e}")
return 0
@@ -78,7 +87,7 @@ def group_files(file_list):
groups = []
for item in sorted_files:
dd, path, height = item
_, _, height = item
placed = False
# 2. Try to fit item into an existing group (First Fit)
@@ -140,7 +149,7 @@ def create_jpg(identifier, group_index, group, root_dir):
combined_img = stitch_pdf_pages(imgs)
if combined_img:
images.append((dd, combined_img))
except Exception as e:
except Exception as e: # noqa: BLE001 - PDF/image backends vary by platform
print(f"Failed to convert {path}: {e}")
if not images:
@@ -159,7 +168,7 @@ def create_jpg(identifier, group_index, group, root_dir):
# Try loading a font, fallback to default
try:
font = ImageFont.truetype("DejaVuSans.ttf", 40)
except IOError:
except OSError:
print("font not found")
font = ImageFont.load_default()
@@ -193,8 +202,7 @@ def create_jpg(identifier, group_index, group, root_dir):
# Save JSON metadata
json_filename = f"Group_{group_index+1}.json"
json_path = os.path.join(target_folder, json_filename)
with open(json_path, 'w') as f:
json.dump(metadata, f)
atomic_write_json(json_path, metadata, indent=None)
# Save with size constraints
output_filename = f"Group_{group_index+1}.jpg"
@@ -211,7 +219,7 @@ def create_jpg(identifier, group_index, group, root_dir):
print(f"Saved {output_path} with {len(group)} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
from utils import natural_key
from copienator.utils import natural_key
def process_identifier(identifier, files_info, output_dir):
@@ -227,18 +235,16 @@ def process_identifier(identifier, files_info, output_dir):
for idx, group in enumerate(file_groups):
create_jpg(identifier, idx, group, output_dir)
def main():
if len(sys.argv) < 2:
print("Usage: python app.py <Path_to_Dir>")
sys.exit(1)
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Group copy extracts by question label.")
root_dir = Path(sys.argv[1])
copies_dir = root_dir / "Copies"
par_label_dir = root_dir / "Par label"
def run(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies")
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
print("Scanning files...")
data = collect_files(copies_dir)
data = collect_files(workspace.copies_dir)
print(f"Found {len(data)} identifiers. Processing...")
@@ -247,11 +253,26 @@ def main():
# Process using 8 threads
with ThreadPoolExecutor(max_workers=8) as executor:
for identifier in sorted_identifiers:
executor.submit(process_identifier, identifier, data[identifier],
par_label_dir)
futures = [
executor.submit(
process_identifier,
identifier,
data[identifier],
workspace.groups_dir,
)
for identifier in sorted_identifiers
]
for future in futures:
future.result()
print("Done.")
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
main()
raise SystemExit(main())
+105
View File
@@ -0,0 +1,105 @@
import argparse
import shutil
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
workspace_from_args,
)
from copienator.configuration import IMPORT_DIR
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
def sync_annotated(
workspace: EvaluationWorkspace,
*,
annotation_dir_name: str,
import_dir: Path,
) -> ExitCode:
workspace.require_directories(annotation_dir_name)
annotation_dir = workspace.annotation_dir("refaire") if annotation_dir_name == "BRnot" else workspace.root / annotation_dir_name
session_id = workspace.refaire_session_id if annotation_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else ""
accepted = 0
annotated_dir = Path(import_dir).expanduser()
if not annotated_dir.is_dir():
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
annotated_files = sorted(
(
path
for path in annotated_dir.iterdir()
if path.is_file() and path.suffix.casefold() in {".pdf", ".jpg", ".jpeg"}
),
key=lambda path: path.name.casefold(),
)
for annotated_file in annotated_files:
if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}")
continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
missing_targets += 1
else:
suffix = annotated_file.suffix.lower()
dest_file = target_subdir / f"Concat_annotated{suffix}"
print(f"Copying {annotated_file} to {dest_file}")
shutil.copy2(annotated_file, dest_file)
accepted += 1
if prefix and not accepted:
print(f"No returns for the active pass {session_id} were imported.")
return ExitCode.PARTIAL
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
parser.add_argument(
"annotation_dir",
nargs="?",
choices=ANNOTATION_DIRECTORIES,
default="BGnot",
help="Annotation directory receiving imported PDFs (default: BGnot)",
)
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
return parser
def run(
workspace: EvaluationWorkspace,
*,
annotation_dir: str = "BGnot",
refaire: bool = False,
) -> ExitCode:
return sync_annotated(
workspace,
annotation_dir_name="BRnot" if refaire else annotation_dir,
import_dir=Path(IMPORT_DIR),
)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(
parser,
argv,
lambda args: run(
workspace_from_args(args),
annotation_dir=args.annotation_dir,
refaire=args.refaire,
),
)
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
import json
import sys
import concurrent.futures
from pathlib import Path
from copienator.commands import correction
def get_missing_tasks():
"""
Identifies tasks (groups) where NONE of the student IDs in that group
appear in the existing results for that label in correction.json.
"""
missing = []
# correction.results is already loaded from correction.json during 'from copienator.commands import correction'
# correction.tasks is populated with (filepath, label) during 'from copienator.commands import correction'
for task in correction.tasks:
file_path, label = task
# Find the group metadata file (Group_X.json) to know which IDs are inside
meta_path = Path(file_path).with_suffix('.json')
if not meta_path.exists():
print("Missing meta_path :", meta_path)
continue
with open(meta_path, 'r', encoding="utf-8") as f:
# group_data entries: [pid, ymin, ymax, width_ratio]
group_data = json.load(f)
pids_in_group = [str(item[0]) for item in group_data]
# Check correction.json results for this specific label
label_results = correction.results.get(label, [])
# Collect all student IDs that have already been processed for this label
covered_ids = set()
for result_list in label_results:
for entry in result_list:
covered_ids.add(str(entry.get('id')))
# Logic: Only process if EVERY ID in this group is missing from correction.json
if all(pid not in covered_ids for pid in pids_in_group):
missing.append(task)
return missing
def main(argv=None):
if argv:
print("missing-correction does not accept command-line arguments.", file=sys.stderr)
return 2
missing_tasks = get_missing_tasks()
print("\n Total nb of tasks : ", len(correction.tasks))
if not missing_tasks:
print("All groups are already present in correction.json. Nothing to do.")
return
print("\nThe following groups are missing from correction.json:")
for path, label in missing_tasks:
print(f" - [{label}] {path}")
confirm = input(f"\nFound {len(missing_tasks)} missing groups. Start processing? (y/N): ")
if confirm.lower() != 'y':
print("Aborted.")
return
print(f"Processing {len(missing_tasks)} tasks with {correction.NB_THREADS} threads...")
with concurrent.futures.ThreadPoolExecutor(max_workers=correction.NB_THREADS) as executor:
# Map tasks to the processing function defined in correction.py
futures = {executor.submit(correction.process_single_task, t): t for t in missing_tasks}
for future in concurrent.futures.as_completed(futures):
try:
# Handle potential sub-tasks (like label errors) generated during processing
new_generated_tasks = future.result()
if new_generated_tasks:
for nt in new_generated_tasks:
executor.submit(correction.process_single_task, nt)
except Exception as e:
t = futures[future]
print(f"Error processing {t[0]}: {e}")
print("\nProcessing complete.")
if __name__ == "__main__":
raise SystemExit(main())
+679
View File
@@ -0,0 +1,679 @@
from __future__ import annotations
import argparse
import glob
import os
import re
import shutil
import tempfile
import tkinter as tk
import uuid
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
import pymupdf # PyMuPDF
from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter
from copienator.configuration import PAGE_SPLITTER_KB
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
target_parser,
workspace_from_target,
)
from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths
# Keep the new shortcut available with older personal configuration files.
PAGE_SPLITTER_KB = {"reverse_pages": "i", **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: str | Path) -> list[Path]:
paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold())
return [path for path in paths if "enonce" not in path.name.casefold()]
def _temporary_sibling(path: Path, purpose: str) -> Path:
return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp")
def commit_processed_pdf(
workspace: EvaluationWorkspace,
original_path: Path,
generated_path: Path,
) -> Path:
"""Commit a processed copy and its original backup with rollback."""
backup_path = workspace.original_copies_dir / original_path.name
output_path = workspace.copies_dir / original_path.name
workspace.original_copies_dir.mkdir(parents=True, exist_ok=True)
workspace.copies_dir.mkdir(parents=True, exist_ok=True)
staged_backup = None
if original_path.resolve() != backup_path.resolve():
staged_backup = _temporary_sibling(backup_path, "new-original")
shutil.copy2(original_path, staged_backup)
saved_backup = _temporary_sibling(backup_path, "old-original")
saved_output = _temporary_sibling(output_path, "old-output")
backup_replaced = False
output_replaced = False
try:
if staged_backup is not None:
if backup_path.exists():
backup_path.replace(saved_backup)
staged_backup.replace(backup_path)
backup_replaced = True
if output_path.exists():
output_path.replace(saved_output)
generated_path.replace(output_path)
output_replaced = True
if original_path.resolve() not in {
backup_path.resolve(),
output_path.resolve(),
}:
original_path.unlink()
except Exception:
if output_replaced and output_path.exists():
output_path.unlink()
if saved_output.exists():
saved_output.replace(output_path)
if backup_replaced and backup_path.exists():
backup_path.unlink()
if saved_backup.exists():
saved_backup.replace(backup_path)
raise
finally:
for temporary in (staged_backup, saved_backup, saved_output):
if temporary is not None and temporary.exists():
temporary.unlink()
return output_path
class PDFPreviewer:
def setup_next_file(self):
self.num += 1
if len(self.inputs) == 0:
return False
self.pdf_path = self.inputs.pop()
self.file_rotation = 0
self.base_name = self.pdf_path.stem
self._temporary_directory = tempfile.TemporaryDirectory(
prefix=f".{self.base_name}.page-splitter.",
dir=self.workspace.root,
)
working_dir = Path(self._temporary_directory.name)
self.split_dir = working_dir / "split"
self.reorder_dir = working_dir / "reorder"
self.final_file = working_dir / f"{self.base_name}.pdf"
self.current_page_index = 0
self.page_settings = []
self.processing = False # Flag to prevent multiple finish calls
try:
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
self.failed = True
self._temporary_directory.cleanup()
messagebox.showerror("Error", f"Failed to open PDF file: {e}")
self.master.destroy()
return
self.master.title(f"PDF Splitter - {self.pdf_path.name}")
return True
def __init__(
self,
master: tk.Tk,
workspace: EvaluationWorkspace,
inputs: list[Path],
) -> None:
"""
Initializes the application.
Args:
master (tk.Tk): The root Tkinter window.
pdf_path (str): The path to the input PDF file.
"""
self.workspace = workspace
self.inputs = inputs
self.output_dir = None
self.master = master
self.num = 0
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
self.history = []
self.failed = False
if not self.setup_next_file():
print(f"No PDF files found in {workspace.root}")
master.destroy()
return
self._resize_job = None # For debouncing resize events
self._initialize_current_page_settings()
# --- UI Setup ---
# 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 = (
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"
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
)
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
self.info_label.pack(pady=5, side=tk.TOP)
self.page_label = tk.Label(master, text="", font=("Helvetica", 12))
self.page_label.pack(pady=5, side=tk.TOP)
# Canvas for PDF page preview
self.canvas = tk.Canvas(master, bg="gray")
self.canvas.pack(fill="both", expand=True)
# --- Bindings ---
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,
"reverse_pages": self.reverse_pages,
"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
self.canvas.bind("<Configure>", self.on_resize)
self.current_zoom = 1.0
def start_arranger(self):
try:
launch_pdf_arranger(self.pdf_path)
except FileNotFoundError as exc:
messagebox.showerror("PDF Arranger", str(exc))
def on_resize(self, event):
"""
Handles window resize events by reloading the page.
Uses a "debounce" mechanism to avoid excessive redrawing.
"""
if self._resize_job:
self.master.after_cancel(self._resize_job)
self._resize_job = self.master.after(250, self.load_page) # Redraw after 250ms of no resizing
def _initialize_current_page_settings(self):
"""Initializes or resets the settings for the current page."""
if self.current_page_index < len(self.doc):
page = self.doc.load_page(self.current_page_index)
self.current_line_x = page.rect.width / 2
self.current_rotation = 0
def load_page(self):
"""Loads and displays the current page on the canvas, scaled to fit."""
if self.current_page_index >= len(self.doc):
if not self.processing:
self.processing = True
self.finish_and_process()
return
page = self.doc.load_page(self.current_page_index)
self.page_label.config(text=f"Page {self.current_page_index + 1} of {len(self.doc)}")
# --- Calculate Scaling ---
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
# Don't try to render if the canvas has no size yet.
if canvas_width <= 1 or canvas_height <= 1:
return
page_rect = page.rect
zoom_x = canvas_width / page_rect.width
zoom_y = canvas_height / page_rect.height
# Use 98% of the smallest zoom factor to leave a small margin
self.current_zoom = min(zoom_x, zoom_y) * 0.98
# --- Render Page ---
mat = pymupdf.Matrix(self.current_zoom, self.current_zoom)
pix = page.get_pixmap(matrix=mat, alpha=False)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
# Apply rotation if needed *after* drawing the line
if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0:
img = img.rotate(180, expand=True)
# --- Draw Line and Rotate ---
draw = ImageDraw.Draw(img)
# The line position is scaled by the same zoom factor
line_x_scaled = self.current_line_x * self.current_zoom
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], fill="red", width=3)
# --- Display on Canvas ---
self.photo_img = ImageTk.PhotoImage(img)
self.canvas.delete("all")
# Center the image on the canvas
self.canvas.create_image(canvas_width / 2, canvas_height / 2, anchor="center",
image=self.photo_img)
def restart_current_file(self, event=None):
"""Restarts the processing of the current file."""
# Close the modified in-memory document
if hasattr(self, 'doc'):
self.doc.close()
# Re-open the file from disk to reset changes (like moved pages)
try:
self.doc = pymupdf.open(self.pdf_path)
except (OSError, RuntimeError, ValueError) as e:
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
self.master.destroy()
return
# Reset state variables for the current file
self.file_rotation = 0
self.current_page_index = 0
self.page_settings = []
self.processing = False
# Reload UI
self._initialize_current_page_settings()
self.load_page()
def reverse_pages(self, event=None):
"""Reverse the current document and discard earlier page decisions."""
if self.processing or not len(self.doc):
return
self.doc.select(list(reversed(range(len(self.doc)))))
self.current_page_index = 0
self.page_settings = []
self._initialize_current_page_settings()
self.load_page()
def move_line_left(self, event=None):
"""Moves the split line to the left."""
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
self.load_page()
def move_line_right(self, event=None):
"""Moves the split line to the right."""
page = self.doc.load_page(self.current_page_index)
self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2)
self.load_page()
def rotate_page(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.current_rotation = 180 if self.current_rotation == 0 else 0
self.load_page()
def rotate_all_pages(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.file_rotation = 180 if self.file_rotation == 0 else 0
self.load_page()
def rotate_all_files(self, event=None):
"""Toggles the page rotation between 0 and 180 degrees."""
self.global_rotation = 180 if self.global_rotation == 0 else 0
self.load_page()
def keep_left(self, event=None):
self.confirm_and_next_page(keep="left")
def keep_right(self, event=None):
self.confirm_and_next_page(keep="right")
def discard_page(self, event=None):
self.confirm_and_next_page(keep="none")
def keep_as_is(self, event=None):
self.confirm_and_next_page(keep="as_is")
def send_page_end(self, event=None):
# Do nothing if we are already at or past the last page
if self.current_page_index >= len(self.doc) - 1:
return
# Move the current page to the end of the document
# -1 as the destination puts it after the last page
self.doc.move_page(self.current_page_index, -1)
# Initialize settings for the page that shifted into the current slot
self._initialize_current_page_settings()
# Reload the canvas to show the new page
self.load_page()
def confirm_and_next_page(self, event=None, keep="both"):
"""Saves the settings for the current page and moves to the next."""
self.page_settings.append({
"line_x": self.current_line_x,
"rotation": self.current_rotation,
"keep": keep
})
self.current_page_index += 1
if self.current_page_index < len(self.doc):
self._initialize_current_page_settings()
self.load_page()
else:
if not self.finish_and_process():
return
self.history.append(self.pdf_path)
if self.setup_next_file():
self._initialize_current_page_settings()
self.load_page()
else:
self.master.destroy()
def finish_and_process(self) -> bool:
"""Render and transactionally install the processed PDF."""
try:
self.split_pdf()
self.reorder_pdfs()
self.concate_files()
commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
except Exception as exc: # noqa: BLE001 - interactive boundary
self.failed = True
self.processing = False
print(f"Failed to process {self.pdf_path}: {exc}")
messagebox.showerror("Error", f"Failed to process PDF: {exc}")
self._temporary_directory.cleanup()
self.master.destroy()
return False
else:
self._temporary_directory.cleanup()
return True
def go_to_previous_file(self, event=None):
"""Goes back to the beginning of the previously completed file."""
if not self.history:
return # Nowhere to go back to
# Close the currently open document to avoid lock issues
if hasattr(self, 'doc'):
self.doc.close()
if hasattr(self, "_temporary_directory"):
self._temporary_directory.cleanup()
# 1. Push current file back onto the stack so it processes next
self.inputs.append(self.pdf_path)
# 2. Reprocess the previous file from its preserved original backup
prev_file = self.history.pop()
backup = self.workspace.original_copies_dir / Path(prev_file).name
self.inputs.append(backup if backup.is_file() else Path(prev_file))
# 3. Reload environment (setup_next_file will pop prev_file back off the stack)
self.setup_next_file()
self._initialize_current_page_settings()
self.load_page()
def split_filename_left(self, i):
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}l.pdf")
def split_filename_right(self, i):
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}r.pdf")
def reorder_filename(self, i):
return os.path.join(self.reorder_dir, f"{self.base_name}_{i+1}.pdf")
def clean_up_dir(self, dir, make=True):
if make:
os.makedirs(dir, exist_ok=True)
pdf_files = glob.glob(os.path.join(dir, "*.pdf"))
for pdf in pdf_files:
try:
os.remove(pdf)
except OSError as e:
print(f"Error deleting {pdf}: {e}")
def split_pdf(self):
"""Splits each page of the PDF according to the saved settings."""
print("Starting PDF processing...")
self.clean_up_dir(self.split_dir)
for i, settings in enumerate(self.page_settings):
page = self.doc.load_page(i)
line_x = settings['line_x']
rotation_settings = settings['rotation']
keep = settings['keep']
rotation = (page.rotation + rotation_settings +
self.file_rotation + self.global_rotation) % 360
if keep == "as_is":
doc_full = pymupdf.open()
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
page_full.show_pdf_page(page_full.rect, self.doc, i)
page_full.set_rotation(rotation)
output_path_full = self.split_filename_left(i)
doc_full.save(output_path_full)
doc_full.close()
continue # Skip left/right generation
# --- Create Left Part ---
if rotation == 0:
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
else:
rect_left = pymupdf.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
if (keep == "both" or keep == "left") and line_x > 0:
doc_left = pymupdf.open()
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
page_left.set_rotation(rotation)
output_path_left = self.split_filename_left(i)
doc_left.save(output_path_left)
doc_left.close()
# --- Create Right Part ---
if rotation == 0:
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
else:
rect_right = pymupdf.Rect(0, 0, page.rect.width-line_x, page.rect.height)
if (keep == "both" or keep == "right") and line_x < page.rect.width:
doc_right = pymupdf.open()
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
page_right.set_rotation(rotation)
output_path_right = self.split_filename_right(i)
doc_right.save(output_path_right)
doc_right.close()
self.doc.close()
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
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):
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
# 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
# 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
# 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:
# 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 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()
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]
pdf_files = sorted(
glob.glob(os.path.join(self.reorder_dir, "*.pdf")),
key=natural_key
)
for pdf in pdf_files:
reader = PdfReader(pdf)
for page in reader.pages:
writer.add_page(page)
if self.output_dir != None:
os.makedirs(os.path.dirname(self.final_file), exist_ok=True)
with open(self.final_file, "wb") as f:
writer.write(f)
print(f"Created merged PDF: {self.final_file}")
def _selected_inputs(
workspace: EvaluationWorkspace,
target: Path,
) -> list[Path]:
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
backup = workspace.original_copies_dir / target.name
return [backup if backup.is_file() else target]
directory = target
if target == workspace.copies_dir:
candidates = list_pdf_files(workspace.copies_dir)
candidates = [
(
workspace.original_copies_dir / path.name
if (workspace.original_copies_dir / path.name).is_file()
else path
)
for path in candidates
]
else:
candidates = list_pdf_files(directory)
return list(reversed(candidates))
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
if not inputs:
print(f"No PDF files found in {target}")
return ExitCode.SUCCESS
root = tk.Tk()
application = PDFPreviewer(root, workspace, inputs)
root.mainloop()
return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively split and reorder scanned PDF pages")
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target, marked=args.marked)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+578
View File
@@ -0,0 +1,578 @@
from __future__ import annotations
import argparse
import copy
import queue
import re
import threading
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from tkinter import messagebox
from PIL import Image, ImageDraw, ImageFont, ImageTk
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.platform import open_path
from copienator.utils import natural_key, read_all_labels
# --- Configuration & Globals ---
padding = 60
MISSING_LABEL_COLOR = "orange"
COMMON_MISSING_LABEL_COLOR = "#403a00"
COMMON_MISSING_THRESHOLD = 0.66
try:
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
except OSError:
font = ImageFont.load_default()
# --- Helper Functions (Shared) ---
def page_number(b, nb_pages):
column_width = 1000 // nb_pages
center_x = (b[1] + b[3]) // 2
return center_x // column_width
def sort_bounding_boxes(bounding_boxes, nb_pages):
"""Return label boxes in reading order: columns first, then top to bottom."""
return sorted(
bounding_boxes,
key=lambda entry: (
page_number(entry["box_2d"], nb_pages),
entry["box_2d"][0],
entry["box_2d"][1],
),
)
def convert_box2d(b, pn_ori, npn, tot_ori, tot_dest):
l = b.copy()
l[1] = (l[1] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
+ (1000 // tot_dest) * (npn - 1)
l[3] = (l[3] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
+ (1000 // tot_dest) * (npn - 1)
return l
def convert_list(l, group_id, json_schema):
ll = []
nb_pages = json_schema["columns_per_file"][group_id-1]
nb_previous_pages = sum([json_schema["columns_per_file"][i] for i in range(group_id-1)])
nb_tot_pages = sum([e for e in json_schema["columns_per_file"]])
for e in l:
ee = e.copy()
pn = page_number(e["box_2d"], nb_pages)
npn = pn + nb_previous_pages
ee["box_2d"] = convert_box2d(ee["box_2d"], pn, npn, nb_pages, nb_tot_pages)
ee["part"] = group_id
ee["pn"] = npn
ll.append(ee)
return ll
def normalized_labels(entries):
return [
str(value["label"]).removeprefix("|").removesuffix("|")
for value in entries
if str(value["label"]) != "_"
]
def frequently_missing_labels(
copies_dir: Path,
all_labels: list[str],
threshold: float = COMMON_MISSING_THRESHOLD,
) -> set[str]:
"""Return labels absent from at least ``threshold`` of detected copies."""
labels_by_copy: dict[str, set[str]] = {}
for json_path in copies_dir.glob("*.json"):
match = re.fullmatch(r"(.+)_\d+", json_path.stem)
if match is None:
continue
try:
data = read_json(json_path)
entries = data["list"]
if not isinstance(entries, list):
continue
present = set(normalized_labels(entries))
except (OSError, KeyError, TypeError, ValueError):
continue
labels_by_copy.setdefault(match.group(1), set()).update(present)
copy_count = len(labels_by_copy)
if copy_count == 0:
return set()
return {
label
for label in all_labels
if sum(label not in present for present in labels_by_copy.values())
>= threshold * copy_count
}
def label_color(
label: str | None,
all_labels: list[str],
last_label_index: int,
common_missing: set[str],
) -> tuple[str, int]:
"""Choose a label color and return the updated chronological index."""
color = "black"
if not label or label not in all_labels:
return color, last_label_index
current_index = all_labels.index(label)
if current_index < last_label_index or (
last_label_index == -1 and current_index != 0
):
color = "red"
elif current_index > last_label_index + 1:
only_previous_is_missing = current_index == last_label_index + 2
previous_label = all_labels[current_index - 1]
color = (
COMMON_MISSING_LABEL_COLOR
if only_previous_is_missing and previous_label in common_missing
else MISSING_LABEL_COLOR
)
return color, current_index
def prepare_image(
image_path: str,
bounding_boxes,
all_labels,
nb_pages,
last_label_index,
common_missing: set[str] | None = None,
):
im = Image.open(image_path)
im.load()
width, height = im.size
new_im = Image.new(im.mode, (width + padding, height), "white")
new_im.paste(im, (0, 0))
draw = ImageDraw.Draw(new_im)
common_missing = common_missing or set()
for bbox in sort_bounding_boxes(bounding_boxes, nb_pages):
raw_y_min = int(bbox["box_2d"][0] * height / 1000)
raw_x_min = int(bbox["box_2d"][1] * width / 1000)
raw_y_max = int(bbox["box_2d"][2] * height / 1000)
raw_x_max = int(bbox["box_2d"][3] * width / 1000)
abs_y_min = max(0, raw_y_min - 10)
abs_x_min = max(0, raw_x_min - 10)
abs_y_max = min(height, raw_y_max + 10)
abs_x_max = min(width, raw_x_max + 10)
label = bbox.get("label")
color, last_label_index = label_color(
label,
all_labels,
last_label_index,
common_missing,
)
draw.rectangle(((abs_x_min, abs_y_min), (abs_x_max, abs_y_max)), outline=color, width=4)
if label:
if abs_y_min > 80:
draw.text((abs_x_min + 8, abs_y_min - 30), label, fill=color, font=font)
else:
draw.text((abs_x_min + 8, abs_y_max + 6), label, fill=color, font=font)
return (new_im, last_label_index)
# --- Processing Logic (Worker Thread) ---
def _worker_items(base_dir, files_to_process, all_labels, output_queue):
"""
Iterates through files, prepares VISUALS only, and puts metadata in queue.
Does NOT write final JSON files anymore.
"""
previous_copie = None
last_label_index = None
common_missing = frequently_missing_labels(base_dir / "Copies", all_labels)
for img_path in files_to_process:
json_path = base_dir / "Copies" / f"{img_path.stem}.json"
copie_part = int(img_path.stem[-2:])
copie = img_path.stem[:-3]
if copie != previous_copie:
last_label_index = -1
previous_copie = copie
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
try:
json_schema = read_json(json_schema_path)
except (OSError, TypeError, ValueError):
print("No json_schema : ", json_schema_path)
continue
nb_pages = json_schema["columns_per_file"][copie_part-1]
if json_path.exists():
# Read strictly for visualization purposes
bb_list = []
json_name = ""
try:
json_result = read_json(json_path)
bb_list = json_result.get("list", [])
json_name = json_result.get("name", "")
except Exception as e: # noqa: BLE001 - malformed user-editable JSON
print(f"Warning: {json_path.name} is malformed! Loading blank. {e}")
# We do NOT skip; we continue so the user can fix it in the GUI
try:
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,
common_missing,
)
error_msg = None
except Exception as e: # noqa: BLE001 - keep the item editable in the GUI
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,
"error": error_msg
}
output_queue.put((pil_image, json_path, metadata))
def worker_thread(base_dir, files_to_process, all_labels, output_queue):
"""Prepare queue items and always terminate the GUI stream."""
failure = None
try:
_worker_items(base_dir, files_to_process, all_labels, output_queue)
except Exception as exc: # noqa: BLE001 - worker boundary
failure = str(exc)
print(f"Plotting worker failed: {exc}")
finally:
metadata = {"worker_error": failure} if failure else None
output_queue.put((None, None, metadata))
# --- GUI Logic (Main Thread) ---
class ImageViewer:
def __init__(self, root, workspace, valid_labels, input_queue):
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.workspace = workspace
self.base_dir = workspace.root
self.valid_labels = valid_labels
self.image_queue = input_queue
self.root.title("Bounding Box Viewer")
self.label = tk.Label(root, text="Waiting for images...")
self.label.pack(expand=True, fill="both")
# Display State
self.current_image = None
self.current_json_path = None
self.current_meta = None # Stores schema/copie info
self.is_viewing = False
self.scale_factor = 1.0
self.orig_size = (1, 1)
# Data Aggregation State
self.active_copie_name = None
self.accumulated_results = None # Dict with "name" and "list"
# To go back
self.history = []
self.forward_stack = []
self.current_pil_image = None
self.failed = False
from config import PLOTTING_KB
# Bindings
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 original pdf"], self.on_open_ori_pdf)
self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
self.root.bind('<Escape>', lambda _event: self.close())
self.root.protocol("WM_DELETE_WINDOW", self.close)
self.label.bind('<Button-1>', self.on_click)
self.poll_queue()
def poll_queue(self):
if not self.is_viewing:
try:
# pil_image, json_path, metadata = image_queue.get_nowait()
if self.forward_stack:
pil_image, json_path, metadata = self.forward_stack.pop()
else:
pil_image, json_path, metadata = self.image_queue.get_nowait()
# Handle End of Stream
if pil_image is None:
if metadata and metadata.get("worker_error"):
self.failed = True
messagebox.showerror(
"Processing Error", metadata["worker_error"]
)
self.save_current_batch() # Save any remaining data
print("All images processed.")
self.root.quit()
return
# Check if we switched to a new "Copie" group
if self.active_copie_name != metadata["copie"]:
self.save_current_batch() # Write previous group to disk
# Start new batch
self.active_copie_name = metadata["copie"]
self.accumulated_results = {"name": metadata["name"], "list": []}
self.display_image(pil_image, json_path, metadata)
except queue.Empty:
pass
self.root.after(100, self.poll_queue)
def save_current_batch(self):
"""Writes the accumulated data to the main JSON file."""
if self.active_copie_name and self.accumulated_results:
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
print(f"Writing aggregated result to {main_json_path}")
atomic_write_json(main_json_path, self.accumulated_results)
self.accumulated_results = None
def close(self):
self.root.quit()
def on_previous(self, event):
if self.is_viewing and self.history:
print("Going back to previous image...")
(
prev_pil,
prev_json,
prev_meta,
previous_copie_name,
previous_results,
) = self.history.pop()
# Push current image to the forward stack so we don't lose it
self.forward_stack.append((self.current_pil_image,
self.current_json_path, self.current_meta))
# Restore the aggregation exactly as it was before validating the
# previous image. This also makes navigation across copies safe.
self.active_copie_name = previous_copie_name
self.accumulated_results = previous_results
# Display the previous image immediately
self.display_image(prev_pil, prev_json, prev_meta)
def display_image(self, pil_image, json_path, metadata):
self.current_pil_image = pil_image # ADD THIS LINE
self.orig_size = pil_image.size
self.scale_factor = 1.0
screen_h = self.root.winfo_screenheight() - 100
if pil_image.height > screen_h:
self.scale_factor = screen_h / pil_image.height
pil_image = pil_image.resize((int(pil_image.width * self.scale_factor),
int(pil_image.height * self.scale_factor)))
self.tk_image = ImageTk.PhotoImage(pil_image)
self.label.config(image=self.tk_image, text=f"Processing: {json_path.name}")
self.current_json_path = json_path
self.current_meta = metadata
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}...")
previous_copie_name = self.active_copie_name
previous_results = copy.deepcopy(self.accumulated_results)
try:
current_data = read_json(self.current_json_path)
items = current_data["list"]
# Perform the conversion now, post-edit
converted_items = convert_list(
items,
self.current_meta["part"],
self.current_meta["schema"]
)
labels = normalized_labels(items)
false_labels = [
label for label in labels if label not in self.valid_labels
]
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
# Add to accumulator
if self.accumulated_results:
self.accumulated_results["list"].extend(converted_items)
# Update name just in case (though usually consistent per group)
if "name" in current_data and current_data["name"] != "Continued":
self.accumulated_results["name"] = current_data["name"]
except Exception as e: # noqa: BLE001 - interactive validation boundary
# Warn user and STOP (do not advance to next image)
msg = f"Error reading {self.current_json_path.name}:\n\n{e}\n\nPlease press 'e' to fix it, then press Enter again."
print(msg)
messagebox.showerror("JSON Error", msg)
return # Abort advancement
self.history.append(
(
self.current_pil_image,
self.current_json_path,
self.current_meta,
previous_copie_name,
previous_results,
)
)
# Advance UI
self.is_viewing = False
self.label.config(image="", text="Loading next...")
def on_open_pdf(self, event):
if self.is_viewing and self.current_json_path:
a = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.current_json_path.with_name(a)
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_open_interro(self, event):
if self.is_viewing and self.current_json_path:
# Check local directory first
local_accent = self.base_dir / "énoncé.pdf"
local_plain = self.base_dir / "enonce.pdf"
if local_accent.exists():
pdf_path = str(local_accent)
elif local_plain.exists():
pdf_path = local_plain
else:
messagebox.showerror(
"PDF not found",
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
)
return
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_open_ori_pdf(self, event):
if self.is_viewing and self.current_json_path:
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.base_dir / "Copies Originales" / new_filename
print(f"Opening {pdf_path}")
open_path(pdf_path)
def on_edit(self, event):
if self.is_viewing and self.current_json_path:
print(f"Opening {self.current_json_path}")
open_path(self.current_json_path)
def on_click(self, event):
if not self.is_viewing: return
x = int(event.x / self.scale_factor)
y = int(event.y / self.scale_factor)
w, h = self.orig_size
box = [
int(max(0, y - 5) / h * 1000),
int(max(0, x - 5) / (w- padding) * 1000),
int(min(h, y + 5) / h * 1000),
int(min(w, x + 5) / (w - padding) * 1000),
]
box_str = "{ \"box_2d\": " + str(box) + ", \"label\": \"\" },"
print(f"Copied box at ({x},{y}): {box_str}")
self.root.clipboard_clear()
self.root.clipboard_append(box_str)
def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Cutleft", "Copies")
if target.is_file():
stem = target.stem
exact = workspace.cutleft_dir / f"{stem}.jpg"
if exact.is_file():
return [exact]
return sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"),
key=natural_key,
)
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
all_labels = read_all_labels(workspace.root)
files_to_process = _selected_images(workspace, target)
if not files_to_process:
print(f"No Cutleft images found for {target}")
return ExitCode.PARTIAL
print(
"o to open pdf, O original pdf, e to edit part, p to go back, "
"i to open the statement, click for coordinates"
)
input_queue = queue.Queue(maxsize=5)
worker = threading.Thread(
target=worker_thread,
args=(workspace.root, files_to_process, all_labels, input_queue),
daemon=True,
)
worker.start()
root = tk.Tk()
application = ImageViewer(root, workspace, set(all_labels), input_queue)
root.mainloop()
return ExitCode.PARTIAL if application.failed else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return target_parser("Interactively verify detected label coordinates")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import argparse
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_bytes,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
MATH_PATTERN = re.compile(
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
re.DOTALL,
)
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Clean encoding and LaTeX issues in correction.json.")
def escape_latex_underscores(text: str) -> str:
r"""Escape underscores outside math without double-escaping existing ones."""
def escape_plain(value: str) -> str:
# Collapse any existing escape run as well, making cleanup idempotent.
return re.sub(r"\\*_", lambda _match: r"\_", value)
parts: list[str] = []
last_end = 0
for match in MATH_PATTERN.finditer(text):
start, end = match.span()
parts.append(escape_plain(text[last_end:start]))
parts.append(match.group(0))
last_end = end
parts.append(escape_plain(text[last_end:]))
return "".join(parts)
def normalize_overescaped_latex_commands(text: str) -> str:
r"""Collapse doubled command escapes inside LaTeX math environments.
Model responses occasionally contain ``\\mathbb`` after JSON decoding where
LaTeX requires ``\mathbb``. A doubled backslash followed by whitespace is a
legitimate row break (for example in ``cases``), so it must be preserved.
"""
def normalize_math(match: re.Match[str]) -> str:
return re.sub(r"\\\\(?=[A-Za-z{}])", r"\\", match.group(0))
return MATH_PATTERN.sub(normalize_math, text)
def build_lookup_map(word_list_path: Path = WORD_LIST_FILE) -> dict[str, str]:
words = word_list_path.read_text(encoding="utf-8").splitlines()
lookup: dict[str, str] = {}
for word in words:
broken_key = ACCENT_PATTERN.sub("\x00", word)
if "\x00" in broken_key:
lookup[broken_key.lower()] = word
return lookup
def fast_fix(text: str, lookup: dict[str, str]) -> str:
def replacer(match: re.Match[str]) -> str:
broken_word = match.group(0)
return lookup.get(broken_word.lower(), broken_word)
return re.sub(r"[a-zA-Z\x00]+", replacer, text)
def fix_hex_corruption_safe(text: str) -> str:
return re.sub(
r"\x00([eEfF][0-9a-fA-F])",
lambda match: chr(int(match.group(1), 16)),
text,
)
def repair_json_escape_corruption(text: str) -> str:
r"""Restore observed LaTeX commands consumed as JSON control escapes."""
replacements = (
("\x0crac", r"\frac"),
("\x0ceuille", r"\equiv"),
("\theta", r"\theta"),
("\times", r"\times"),
("\textbackslash ", "\\"),
("\negthinspace", r"\negthinspace"),
("\neq", r"\neq"),
("\not", r"\not"),
("", r"\ensuremath{\in}"),
("", r"\ensuremath{\subset}"),
)
for broken, repaired in replacements:
text = text.replace(broken, repaired)
return text
def clean_string(text: str, lookup: dict[str, str]) -> str:
text = fix_hex_corruption_safe(text)
text = text.replace("\x19", "\x00")
text = text.replace("\x18", "\x00")
text = text.replace("\x00\x00", "\x00")
text = re.sub(r" \x00{1,2} ", " à ", text)
if "\x00" in text:
text = fast_fix(text, lookup).replace("\x00", "")
text = repair_json_escape_corruption(text)
text = normalize_overescaped_latex_commands(text)
return escape_latex_underscores(text)
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
if isinstance(value, str):
return clean_string(value, lookup)
if isinstance(value, list):
return [clean_obj(item, lookup) for item in value]
if isinstance(value, dict):
return {
key: item if key == "suffix" else clean_obj(item, lookup)
for key, item in value.items()
}
return value
def run(
workspace: EvaluationWorkspace,
*,
word_list_path: Path = WORD_LIST_FILE,
) -> ExitCode:
workspace.require_files("correction.json")
lookup = build_lookup_map(word_list_path)
data = read_json(workspace.correction_file)
cleaned = clean_obj(data, lookup)
backup = workspace.root / "correction_precleanup.json"
atomic_write_bytes(backup, workspace.correction_file.read_bytes())
print(f"Original JSON backed up to {backup}")
atomic_write_json(workspace.correction_file, cleaned)
print(f"Fixed JSON saved to {workspace.correction_file}")
return ExitCode.SUCCESS
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+362
View File
@@ -0,0 +1,362 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import numpy as np
from pdf2image import convert_from_path
from PIL import Image, ImageChops, ImageDraw, ImageFilter
from copienator.commands import annotating
from copienator import configuration, utils
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
Image.MAX_IMAGE_PIXELS = None
def detect_checks_and_notes(
output_dir: str | Path,
) -> tuple[list[dict[str, Any]], Image.Image | None]:
"""Detect checked boxes and extract handwritten notes from an annotated PDF."""
directory = Path(output_dir)
pdf_path = directory / "Concat_annotated.pdf"
reference_path = directory / "Reference.jpg"
boxes_path = directory / "checkboxes.json"
missing = [
path.name
for path in (pdf_path, reference_path, boxes_path)
if not path.is_file()
]
if missing:
print(f"\tMissing annotation input in {directory}: {', '.join(missing)}")
return [], None
boxes = read_json(boxes_path)
if not isinstance(boxes, list):
raise TypeError(f"Expected a JSON array in {boxes_path}")
with Image.open(reference_path) as opened_reference:
reference = opened_reference.convert("RGB").copy()
try:
pages = convert_from_path(pdf_path, dpi=72)
except Exception as exc: # noqa: BLE001 - PDF backends expose many errors
print(f"Error reading PDF {pdf_path}: {exc}")
return [], None
if not pages:
print(f"Error reading PDF {pdf_path}: no page found")
return [], None
user_image = Image.new("RGB", (pages[0].width, sum(page.height for page in pages)))
current_y = 0
for page in pages:
user_image.paste(page.convert("RGB"), (0, current_y))
current_y += page.height
if user_image.size != reference.size:
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
# Keep the full-size difference in uint8. Converting both tall group images
# to the platform ``int`` dtype used several gigabytes per scan worker.
difference = np.asarray(
ImageChops.difference(reference, user_image), dtype=np.uint8
)
keep_mask = Image.new("L", reference.size, 255)
mask_draw = ImageDraw.Draw(keep_mask)
actions: list[dict[str, Any]] = []
for raw_box in boxes:
if not isinstance(raw_box, dict) or "global_box" not in raw_box:
continue
x1, y1, x2, y2 = map(int, raw_box["global_box"])
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(reference.width, x2), min(reference.height, y2)
region = difference[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
if region.size == 0:
continue
# Preserve the previous mean-across-RGB threshold, but allocate its
# temporary float array only for the small checkbox region.
density = np.count_nonzero(np.mean(region, axis=2) > 30) / (
region.shape[0] * region.shape[1]
)
if density > 0.05:
actions.append(raw_box)
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
else:
mask_draw.rectangle([x1 - 2, y1 - 2, x2 + 2, y2 + 2], fill=0)
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
del difference
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
del reference_blur, user_blur
alpha = np.asarray(diff_image, dtype=np.uint8).copy()
np.greater(alpha, 50, out=alpha)
alpha *= np.uint8(255)
np.minimum(alpha, np.asarray(keep_mask, dtype=np.uint8), out=alpha)
notes = user_image.convert("RGBA")
notes.putalpha(Image.fromarray(alpha))
return actions, notes
def has_significant_notes(note_img: Image.Image | None, threshold: int = 20) -> bool:
"""Return whether an RGBA note layer contains enough visible pixels."""
if note_img is None or note_img.mode != "RGBA":
return False
alpha = np.array(note_img)[:, :, 3]
return bool(np.sum(alpha > 50) > threshold)
def concatenate(images: list[Image.Image]) -> Image.Image | None:
if not images:
return None
result = Image.new(
"RGB",
(max(image.width for image in images), sum(image.height for image in images)),
"white",
)
current_y = 0
for image in images:
result.paste(image, (0, current_y))
current_y += image.height
return result
def apply_actions_and_regenerate(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
notes_layer: Image.Image | None,
all_labels: list[str],
*,
update_score: bool = False,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
bnote_path = output_dir / "bnote.json"
if not bnote_path.is_file():
print(f" Missing {bnote_path}")
return ExitCode.PARTIAL
bnote_data = read_json(bnote_path)
if not isinstance(bnote_data, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id]
apply_checkbox_actions(labels_data, actions, print)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
apply_score_overrides(labels_data, score_path, print)
scores = dict.fromkeys(all_labels, "")
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
concatenated: list[Image.Image] = []
filtered: list[Image.Image] = []
incomplete = False
for image_info in bnote_data.get("images", []):
if not isinstance(image_info, dict):
incomplete = True
continue
label = str(image_info.get("label", ""))
if label not in labels_data:
incomplete = True
continue
content = labels_data[label]
result = content["result"]
scores[label] = str(result.get("score", 0))
if result.get("error") == "empty-answer":
continue
sub_note = None
if notes_layer is not None:
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
has_notes = has_significant_notes(sub_note)
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
print(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
if has_notes and sub_note is not None:
old_header_height = int(image_info.get("header_height", 0))
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
dirty_images[label] = final_image
answer_labels.append(label)
concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []):
filtered.append(final_image)
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf",
"touched.json", "answer_labels.json")) as staging:
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels
))
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_image is not None:
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
print(f" Preserved existing score.json in {output_dir}")
print(f" Saved regenerated files in {output_dir}")
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
def run(
workspace: EvaluationWorkspace,
*,
update_score: bool = False,
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", "Bnot")
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
for student_id in sorted(loaded.data, key=utils.natural_key):
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
if not output_dir.is_dir():
print(f"Warning: missing annotation directory {output_dir}")
status = ExitCode.PARTIAL
continue
print(f"Processing annotations for: {student_id}")
actions, notes = detect_checks_and_notes(output_dir)
if notes is None and not actions and not update_score:
print(" No readable annotation input found.")
status = ExitCode.PARTIAL
continue
result = apply_actions_and_regenerate(
workspace,
loaded.data,
student_id,
actions,
notes,
all_labels,
update_score=update_score,
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read checked annotations and regenerate copies")
parser.add_argument(
"--update-score",
action="store_true",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
return run(
workspace_from_args(args),
update_score=args.update_score,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,681 @@
from __future__ import annotations
import argparse
import concurrent.futures
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
utils,
workspace_from_args,
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.answer_info import build_answer_info
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.commands import annotating
from copienator.commands.reading_annotations import (
concatenate,
detect_checks_and_notes,
has_significant_notes,
)
from copienator.filesystem import staged_files
from copienator.return_answers import save_return_answer_options
LabelNotes = dict[str, dict[str, Any]]
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
SCAN_WORKERS = 2
def get_extra_pdfs_as_images(
root_dir: str | Path,
label: str,
annotating_module: Any,
all_labels: list[str],
) -> list[Image.Image]:
"""Convert the context, question and solution PDFs associated with a label."""
paths = [
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
utils.pdf_image_of_enonce(root_dir, label),
utils.pdf_image_of_solution(root_dir, label),
]
images = []
for path in paths:
if path:
image, _, _ = annotating_module.make_base_image(path)
if image is not None:
images.append(image)
return images
def save_paginated_pdf(
image_groups: list[list[Image.Image]], output_path: Path
) -> None:
"""Paginate vertically concatenated image groups and save them as a PDF."""
non_empty = [group for group in image_groups if group]
if not non_empty:
return
max_width = max(image.width for group in non_empty for image in group)
max_page_height = int(max_width * 1.414 * 1.25)
border = int((0.2 / 2.54) * 100)
left_margin = int((0.3 / 2.54) * 100)
vertical_margin = int((0.2 / 2.54) * 100)
max_content_height = max_page_height - 2 * vertical_margin
pages: list[Image.Image] = []
page_images: list[Image.Image] = []
page_height = 0
def finish_page() -> None:
nonlocal page_images, page_height
if not page_images:
return
page = Image.new(
"RGB",
(max_width + left_margin, page_height + 2 * vertical_margin),
"white",
)
current_y = vertical_margin
for image in page_images:
page.paste(image, (left_margin, current_y))
current_y += image.height
pages.append(page)
page_images = []
page_height = 0
for group in non_empty:
processed: list[Image.Image] = []
for index, image in enumerate(group):
if index in (0, 1):
image = image.copy()
color = "black" if index == 0 else "blue"
ImageDraw.Draw(image).rectangle(
[0, 0, image.width - 1, image.height - 1],
outline=color,
width=border,
)
processed.append(image)
group_height = sum(image.height for image in processed)
if page_images and page_height + group_height > max_content_height:
finish_page()
page_images.extend(processed)
page_height += group_height
finish_page()
pages[0].save(
output_path,
"PDF",
resolution=100.0,
save_all=True,
append_images=pages[1:],
)
def _scan_annotation_directory(
directory: Path,
only_ids: set[str] | None = None,
default_student_id: str | None = None,
*,
required: bool = False,
) -> ScanResult:
bnote_path = directory / "bnote.json"
if not bnote_path.is_file():
raise FileNotFoundError(f"Missing {bnote_path}")
bnote = read_json(bnote_path)
if not isinstance(bnote, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
images = [item for item in bnote.get("images", []) if isinstance(item, dict)]
if only_ids and not any(
str(item.get("id", default_student_id)) in only_ids for item in images
):
return {}, {}
actions, notes_image = detect_checks_and_notes(directory)
if notes_image is None:
if required:
raise ValueError(f"Could not read annotations in {directory}")
return {}, {}
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
for action in actions:
raw_student_id = action.get("student_id", default_student_id)
if raw_student_id is not None:
actions_by_student[str(raw_student_id)].append(action)
for image_info in images:
student_id = str(image_info.get("id", default_student_id or ""))
label = str(image_info.get("label", ""))
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
if student_id and label and hmax > hmin:
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
if has_significant_notes(crop):
notes_by_student[student_id][label] = {
"img": crop,
"old_header_h": int(image_info.get("header_height", 0)),
}
return dict(actions_by_student), dict(notes_by_student)
def _merge_scan_result(
target_actions: dict[str, list[dict[str, Any]]],
target_notes: dict[str, LabelNotes],
result: ScanResult,
) -> None:
actions, notes = result
for student_id, student_actions in actions.items():
target_actions[student_id].extend(student_actions)
for student_id, student_notes in notes.items():
target_notes[student_id].update(student_notes)
def apply_actions_and_regenerate_grouped(
workspace: EvaluationWorkspace,
data: AnnotationData,
student_id: str,
actions: list[dict[str, Any]],
label_notes: LabelNotes,
all_labels: list[str],
*,
update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
) -> tuple[ExitCode, str]:
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {})
apply_checkbox_actions(labels_data, actions, logs.append)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
if update_score:
apply_score_overrides(
labels_data, score_path, logs.append
)
selected_labels = selected_labels if selected_labels is not None else set()
simple_layout = None
simple_annotated = None
if selected_labels and annotation_dir == "Anot":
imported = next(
(
output_dir / name
for name in ("Concat_annotated.jpg", "Concat_annotated.jpeg")
if (output_dir / name).is_file()
),
None,
)
if imported is not None:
layout_path = output_dir / "refaire_simple_layout.json"
if layout_path.is_file():
simple_layout = read_json(layout_path)
else:
simple_layout = {"images": {}, "replaced": []}
y = 0
for label in sorted(labels_data, key=utils.natural_key):
path = output_dir / f"{label}.jpg"
if (
path.is_file()
and labels_data[label]["result"].get("error") != "empty-answer"
):
with Image.open(path) as saved:
simple_layout["images"][label] = [y, y + saved.height]
y += saved.height
with Image.open(imported) as saved:
simple_annotated = saved.convert("RGB").copy()
expected_height = max(
(bounds[1] for bounds in simple_layout["images"].values()), default=0
)
if simple_annotated.height != expected_height:
raise ValueError(
"Imported simple image height does not match the original copy layout"
)
old_scores = (
read_json(output_dir / "score.json")
if selected_labels and (output_dir / "score.json").is_file()
else {}
)
scores = dict.fromkeys(all_labels, "")
touched = dict.fromkeys(all_labels, False)
answer_labels: list[str] = []
dirty_images: dict[str, Image.Image] = {}
concat_images: list[Image.Image] = []
filtered_groups: list[list[Image.Image]] = []
incomplete = False
for label, content in sorted(
labels_data.items(), key=lambda item: utils.natural_key(item[0])
):
result = content["result"]
if (
selected_labels
and label not in selected_labels
and old_scores.get(label, "") != ""
):
result["score"] = old_scores[label]
scores[label] = str(result.get("score", 0))
touched[label] = False
if result.get("error") == "empty-answer":
continue
saved_image = output_dir / f"{label}.jpg"
if selected_labels and label not in selected_labels and saved_image.is_file():
with Image.open(saved_image) as saved:
final_image = saved.convert("RGB").copy()
if (
simple_annotated is not None
and label in simple_layout["images"]
and label not in simple_layout["replaced"]
):
hmin, hmax = simple_layout["images"][label]
final_image = simple_annotated.crop(
(0, hmin, simple_annotated.width, hmax)
)
dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label]))
concat_images.append(final_image)
answer_labels.append(label)
# Keep previously reviewed content, including handwriting.
if annotation_dir == "BGnot":
extras = get_extra_pdfs_as_images(
workspace.root, label, annotating, all_labels
)
filtered_groups.append([*extras, final_image])
touched[label] = True
else:
filtered_groups.append([final_image])
continue
pdf_path = Path(content["pdf_path"])
if not pdf_path.is_file():
logs.append(f" Missing answer PDF: {pdf_path}")
incomplete = True
continue
base_image, _, _ = annotating.make_base_image(pdf_path)
final_image, new_header_height = annotating.compose_label_image(
base_image,
label,
result,
content["coordinates"][0],
with_error=False,
)
if final_image is None:
incomplete = True
continue
has_notes = False
if label in label_notes:
sub_note = label_notes[label]["img"]
old_header_height = int(label_notes[label]["old_header_h"])
has_notes = has_significant_notes(sub_note)
if has_notes:
width, height = sub_note.size
if old_header_height > 0:
header = sub_note.crop(
(0, 0, width, min(height, old_header_height))
)
final_image.paste(header, (0, 0), mask=header)
if height > old_header_height:
body = sub_note.crop((0, old_header_height, width, height))
final_image.paste(body, (0, new_header_height), mask=body)
# Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image
answer_labels.append(label)
concat_images.append(final_image)
feedbacks = result.get("feedback", [])
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels)
if annotation_dir == "BGnot"
else []
)
filtered_groups.append([*extras, final_image])
touched[label] = annotation_dir == "BGnot"
concat_image = concatenate(concat_images)
if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.pdf", "Concat_F.jpg",
"touched.json", "answer_labels.json")) as staging:
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
)
atomic_write_json(staging / "refaire_simple_layout.json", simple_layout)
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
atomic_write_json(staging / "score.json", scores)
atomic_write_json(staging / "info.json", build_answer_info(
scores, labels_data, answer_labels, touched
))
if concat_image is not None:
concat_image.save(staging / "Concat.jpg")
if filtered_groups:
if annotation_dir == "BGnot":
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
else:
filtered_image = concatenate(
[image for group in filtered_groups for image in group]
)
filtered_image.save(staging / "Concat_F.jpg")
if preserve_score_file:
logs.append(f" Preserved existing score.json in {output_dir}")
logs.append(f" Saved regenerated files in {output_dir}")
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
return status, "\n".join(logs)
def _read_refaire(
workspace: EvaluationWorkspace,
) -> tuple[RefaireList, dict[str, list[str]]]:
loaded = read_json(workspace.refaire_file)
if not isinstance(loaded, list):
raise TypeError("refaire.json must contain a JSON array")
entries: RefaireList = []
by_student: dict[str, list[str]] = {}
for entry in loaded:
if (
not isinstance(entry, list)
or len(entry) != 2
or not isinstance(entry[1], list)
):
raise TypeError(f"Malformed refaire entry: {entry!r}")
copy_name, labels = entry
student_id = str(copy_name).removeprefix("Copie")
normalized_labels = [str(label) for label in labels]
entries.append([str(copy_name), normalized_labels])
by_student[student_id] = normalized_labels
return entries, by_student
def _scan_redo_annotations(
directory: Path,
expected: dict[str, set[str]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes], set[str]]:
"""Read either grouped or per-copy redo PDFs using their student/label metadata."""
actions: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes: dict[str, LabelNotes] = defaultdict(dict)
seen: dict[str, set[str]] = defaultdict(set)
incomplete: set[str] = set()
plans = []
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
for path in sorted(directory.iterdir()):
if not path.is_dir():
continue
default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None
)
try:
metadata = read_json(path / "bnote.json")
pairs = [
(str(item.get("id", default_id)), str(item["label"]))
for item in metadata["images"]
]
except (OSError, ValueError, TypeError, KeyError) as exc:
print(f"Warning: unreadable redo metadata in {path}: {exc}")
incomplete.update(expected)
continue
students = {
student_id for student_id, _label in pairs if student_id in expected
}
if not students:
continue
for student_id, label in pairs:
if student_id not in expected:
continue
if label in seen[student_id]:
incomplete.add(student_id)
seen[student_id].add(label)
if any(not (path / name).is_file() for name in required):
print(f"Warning: missing returned redo inputs in {path}")
incomplete.update(students)
else:
plans.append((path, default_id, students))
for student_id, labels in expected.items():
if seen[student_id] != labels:
print(
f"Warning: redo labels do not match refaire.json for Copie{student_id}; regenerate BRnot"
)
incomplete.add(student_id)
for path, default_id, students in plans:
if students <= incomplete:
continue
try:
result = _scan_annotation_directory(
path, default_student_id=default_id, required=True
)
_merge_scan_result(actions, notes, result)
except (OSError, ValueError, TypeError) as exc:
print(f"Warning: could not read redo annotations in {path}: {exc}")
incomplete.update(students)
return dict(actions), dict(notes), incomplete
def run(
workspace: EvaluationWorkspace,
*,
refaire: bool = False,
update_score: bool = False,
annotation_dir: str = "BGnot",
return_answers_context: bool | None = None,
return_answers_question: bool | None = None,
return_answers_solution: bool | None = None,
) -> ExitCode:
workspace.require_files("labels", "correction.json")
workspace.require_directories("Copies", "Par label", annotation_dir)
refaire_list: RefaireList | None = None
refaire_by_student: dict[str, list[str]] = {}
if refaire:
workspace.require_files("refaire.json")
workspace.require_directories("BRnot")
refaire_list, refaire_by_student = _read_refaire(workspace)
if configuration.RETURN_ANSWERS_ENABLED:
save_return_answer_options(
workspace.root,
context=(
configuration.RETURN_ANSWERS_CONTEXT
if return_answers_context is None
else return_answers_context
),
question=(
configuration.RETURN_ANSWERS_QUESTION
if return_answers_question is None
else return_answers_question
),
solution=(
configuration.RETURN_ANSWERS_SOLUTION
if return_answers_solution is None
else return_answers_solution
),
)
all_labels = utils.read_all_labels(workspace.root)
loaded = load_annotation_data(workspace)
if refaire_list:
# Add explicitly requested answers without filtering out the rest of a copy.
selected_data = load_annotation_data(workspace, refaire_list=refaire_list)
for student_id, labels in selected_data.data.items():
loaded.data.setdefault(student_id, {}).update(labels)
for warning in loaded.warnings:
print(f"Warning: {warning}")
if not loaded.data:
print("No annotation data found.")
return ExitCode.PARTIAL
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
only_ids = set(refaire_by_student) or None
group_dirs = [
path
for path in (workspace.root / annotation_dir).iterdir()
if annotation_dir == "BGnot"
and path.is_dir()
and not path.name.startswith("Copie")
]
# Each worker decodes a full-height returned group and its reference image.
# Keep this stage deliberately narrow; answer regeneration below has its own
# parallel executor and a much smaller per-task memory footprint.
with concurrent.futures.ThreadPoolExecutor(max_workers=SCAN_WORKERS) as executor:
futures = [
executor.submit(_scan_annotation_directory, path, only_ids)
for path in group_dirs
]
for future in concurrent.futures.as_completed(futures):
_merge_scan_result(actions_by_student, notes_by_student, future.result())
if annotation_dir == "Bnot":
for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir():
_merge_scan_result(
actions_by_student,
notes_by_student,
_scan_annotation_directory(
directory, default_student_id=student_id
),
)
skipped_students: set[str] = set()
if refaire:
expected = {
student_id: set(labels or loaded.data.get(student_id, {}))
for student_id, labels in refaire_by_student.items()
}
redo_actions, redo_notes, skipped_students = _scan_redo_annotations(
workspace.annotation_dir("refaire"), expected
)
for student_id, selected in expected.items():
if student_id in skipped_students:
continue
actions_by_student[student_id] = [
action
for action in actions_by_student[student_id]
if str(action.get("label")) not in selected
]
for label in selected:
notes_by_student[student_id].pop(label, None)
actions_by_student[student_id].extend(
action
for action in redo_actions.get(student_id, [])
if str(action.get("label")) in selected
)
notes_by_student[student_id].update(
{
label: note
for label, note in redo_notes.get(student_id, {}).items()
if label in selected
}
)
status = (
ExitCode.PARTIAL if loaded.warnings or skipped_students else ExitCode.SUCCESS
)
student_ids = (
list(refaire_by_student)
if refaire
else sorted(loaded.data, key=utils.natural_key)
)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(
apply_actions_and_regenerate_grouped,
workspace,
loaded.data,
student_id,
actions_by_student[student_id],
notes_by_student[student_id],
all_labels,
update_score=update_score,
annotation_dir=annotation_dir,
selected_labels=(
set(refaire_by_student[student_id] or loaded.data[student_id])
if refaire
else None
),
): student_id
for student_id in student_ids
if student_id in loaded.data and student_id not in skipped_students
}
for future in concurrent.futures.as_completed(futures):
result, output = future.result()
print(output)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def build_parser() -> argparse.ArgumentParser:
parser = evaluation_parser("Read grouped annotations and regenerate copies")
parser.add_argument(
"--annotation-dir",
choices=("BGnot", "Bnot", "Anot"),
default="BGnot",
help="Original annotation directory for --refaire (default: BGnot)",
)
parser.add_argument(
"--refaire",
action="store_true",
help="Use refaire.json and merge annotations from BRnot",
)
parser.add_argument(
"--update-score",
action="store_true",
help=(
"Regenerate images with current statement/solution PDFs while "
"preserving and applying existing score.json values"
),
)
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
help="Include the current solution PDF in individual answer exports",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
if args.annotation_dir != "BGnot" and not args.refaire:
parser.error("--annotation-dir requires --refaire")
return run(
workspace_from_args(args),
refaire=args.refaire,
update_score=args.update_score,
annotation_dir=args.annotation_dir,
return_answers_context=args.return_answers_context,
return_answers_question=args.return_answers_question,
return_answers_solution=args.return_answers_solution,
)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import argparse
import re
import shutil
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pypdf import PdfWriter
from copienator.pdf_cut import split_pdf
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
CUT_PATTERN = re.compile(r"c\{(\d+(?:\.\d+)?)\}([12])([x>])")
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs|c\{\d+(?:\.\d+)?\}[12][x>])\s+")
COPY_PATTERN = re.compile(r"Copie(\d+)\s+(.+)")
@dataclass(frozen=True, slots=True)
class ManualInstruction:
copy_id: str
old_label: str
operator: str
new_label: str
pipe_first: bool
@property
def cut(self) -> tuple[float, int] | None:
match = CUT_PATTERN.fullmatch(self.operator)
return (float(match[1]), int(match[2])) if match else None
@property
def should_merge(self) -> bool:
return self.operator.endswith(">")
@property
def should_copy(self) -> bool:
return not self.should_merge and "s" not in self.operator
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Apply the instructions from manual_resolutions.txt.")
def parse_instructions(path: Path) -> list[ManualInstruction]:
return parse_instruction_text(path.read_text(encoding="utf-8"))
def parse_instruction_text(text: str) -> list[ManualInstruction]:
instructions: list[ManualInstruction] = []
malformed: list[int] = []
for line_number, raw_line in enumerate(
text.splitlines(), start=1
):
line = raw_line.strip()
if not line or line.startswith("###"):
continue
operator_match = OPERATOR_PATTERN.search(line)
if operator_match is None:
malformed.append(line_number)
continue
left = line[: operator_match.start()].strip()
right = line[operator_match.end() :].strip()
copy_match = COPY_PATTERN.fullmatch(left)
new_label = right.strip("|").strip()
cut_match = CUT_PATTERN.fullmatch(operator_match.group(1))
if (copy_match is None or not new_label
or (cut_match and (not 0 < float(cut_match[1]) < 100
or copy_match.group(2).strip() == new_label))):
malformed.append(line_number)
continue
instructions.append(
ManualInstruction(
copy_id=copy_match.group(1),
old_label=copy_match.group(2).strip(),
operator=operator_match.group(1),
new_label=new_label,
pipe_first=right.startswith("|"),
)
)
if malformed:
lines = ", ".join(str(number) for number in malformed)
raise CliError(f"Malformed manual resolution instruction at line(s): {lines}")
return instructions
def set_suffix_and_clean_error(
results: dict[str, Any],
copy_id: str,
label: str,
suffix: str | None,
new_label_target: str | None = None,
) -> None:
for batch in results.get(label, []):
for item in batch:
if item["id"] != copy_id:
continue
if suffix:
item["result"]["suffix"] = suffix
error = item["result"].get("error", "")
if new_label_target:
# This instruction acknowledges this source/target conflict,
# including decisions to keep/discard PDFs without merging.
# Leave other pending targets (and other copies) untouched.
result = item["result"]
if "delayed" in result:
pending = [entry for entry in result["delayed"]
if entry not in (["wrong-label", new_label_target],
["add-label", new_label_target])]
if pending:
result["delayed"] = pending
else:
result.pop("delayed")
if error in {f"wrg-lbl:{new_label_target}?",
f"wrg-lbl:{new_label_target}?delayed",
f"wrg-lbl:{new_label_target}?exists"}:
error = f"wrg-lbl-moved-to:{new_label_target}"
error = error.replace(f"(delayed){new_label_target}", f"(->){new_label_target}")
error = error.replace(f"(->){new_label_target}?", f"(->){new_label_target}")
result["error"] = error
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
for candidate in (
base,
base.with_name(f"{label}_new.pdf"),
base.with_name(f"{label}_old.pdf"),
):
if candidate.exists():
return candidate
return base
def safe_strip_suffix(stem: str) -> str:
if stem.endswith(("_new", "_old")):
return stem[:-4]
return stem
def _validate_pdf_inputs(
instructions: list[ManualInstruction],
initial_paths: dict[tuple[str, str], Path],
) -> None:
missing: set[Path] = set()
for instruction in instructions:
source = initial_paths[(instruction.copy_id, instruction.old_label)]
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
if instruction.should_merge:
if not source.exists():
missing.add(source)
if not destination.exists():
missing.add(destination)
elif instruction.should_copy and not source.exists():
missing.add(source)
if missing:
rendered = ", ".join(str(path) for path in sorted(missing))
raise CliError(f"PDF input(s) required by manual resolutions not found: {rendered}")
def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_files("manual_resolutions.txt", "correction.json")
workspace.require_directories("Copies")
loaded = read_json(workspace.correction_file)
if not isinstance(loaded, dict):
raise CliError("correction.json must contain a JSON object")
results: dict[str, Any] = loaded
instructions = parse_instructions(workspace.manual_resolutions_file)
cut_sources = [(item.copy_id, item.old_label) for item in instructions if item.cut]
if len(set(cut_sources)) != len(cut_sources):
raise CliError("Une seule coupe par label source est autorisée dans une résolution.")
initial_paths: dict[tuple[str, str], Path] = {}
current_paths: dict[tuple[str, str], Path] = {}
for instruction in instructions:
for label in (instruction.old_label, instruction.new_label):
key = (instruction.copy_id, label)
if key not in initial_paths:
path = get_actual_pdf(workspace.copies_dir, *key)
initial_paths[key] = path
current_paths[key] = path
_validate_pdf_inputs(instructions, initial_paths)
files_to_old: set[Path] = set()
temp_files: list[Path] = []
try:
for instruction in instructions:
key_old = (instruction.copy_id, instruction.old_label)
key_new = (instruction.copy_id, instruction.new_label)
source = initial_paths[key_old]
destination = current_paths[key_new]
if instruction.cut:
percent, keep = instruction.cut
first = source.parent / f"temp_{len(temp_files)}.pdf"
second = source.parent / f"temp_{len(temp_files) + 1}.pdf"
temp_files.extend((first, second))
split_pdf(source, percent, first, second)
retained, source = (first, second) if keep == 1 else (second, first)
current_paths[key_old] = retained
files_to_old.add(initial_paths[key_old])
temp_output = (
workspace.copies_dir
/ f"Copie{instruction.copy_id}"
/ f"temp_{len(temp_files)}.pdf"
)
if instruction.operator.startswith("x"):
files_to_old.add(initial_paths[key_old])
if instruction.operator.endswith("x"):
files_to_old.add(initial_paths[key_new])
if instruction.should_merge:
writer = PdfWriter()
try:
if instruction.pipe_first:
writer.append(source)
writer.append(destination)
else:
writer.append(destination)
writer.append(source)
writer.write(temp_output)
except Exception:
temp_output.unlink(missing_ok=True)
raise
finally:
writer.close()
current_paths[key_new] = temp_output
temp_files.append(temp_output)
files_to_old.add(initial_paths[key_new])
elif instruction.should_copy:
shutil.copy(source, temp_output)
current_paths[key_new] = temp_output
temp_files.append(temp_output)
except Exception:
for temporary in temp_files:
temporary.unlink(missing_ok=True)
raise
for pdf in files_to_old:
if not pdf.exists():
continue
copy_id = pdf.parent.name.removeprefix("Copie")
label = safe_strip_suffix(pdf.stem)
old_name = pdf.with_name(f"{label}_old.pdf")
if pdf != old_name:
old_name.unlink(missing_ok=True)
shutil.move(str(pdf), str(old_name))
set_suffix_and_clean_error(results, copy_id, label, "_old")
for instruction in instructions:
set_suffix_and_clean_error(
results,
instruction.copy_id,
instruction.old_label,
None,
instruction.new_label,
)
refaire_by_copy: dict[str, list[str]] = {}
for (copy_id, label), current_path in current_paths.items():
if not current_path.name.startswith("temp_"):
continue
final_name = workspace.copies_dir / f"Copie{copy_id}" / f"{label}_new.pdf"
final_name.unlink(missing_ok=True)
shutil.move(str(current_path), str(final_name))
set_suffix_and_clean_error(results, copy_id, label, "_new")
labels = refaire_by_copy.setdefault(f"Copie{copy_id}", [])
if label not in labels:
labels.append(label)
used_temps = set(current_paths.values())
for temporary in temp_files:
if temporary not in used_temps:
temporary.unlink(missing_ok=True)
atomic_write_json(workspace.correction_file, results)
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
if refaire_tasks:
atomic_write_json(workspace.refaire_file, refaire_tasks)
workspace.manual_resolutions_file.unlink()
print("Manual resolutions successfully applied.")
if refaire_tasks:
print(
f"File {workspace.refaire_file.name} generated. Run "
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
"to process updates."
)
else:
print("No new corrections required.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return resolve_manual(workspace)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import argparse
import math
import shutil
import tempfile
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
import pymupdf
from pypdf import PdfReader, PdfWriter
from copienator import utils
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.filesystem import staged_directory
SQUARE = 1000 // 38
ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4
Coordinate = tuple[str, int, int, int, int, int]
ParsedCoordinate = tuple[str, str, int, int, int, int, int]
def decode_json(pdf_file: str | Path) -> tuple[str, list[Coordinate]]:
"""Read verified label coordinates associated with one copy PDF."""
pdf_path = Path(pdf_file)
loaded = read_json(pdf_path.with_suffix(".json"))
if not isinstance(loaded, dict):
raise TypeError(f"Expected a JSON object for {pdf_path}")
boxes = loaded.get("list")
if not isinstance(boxes, list):
raise TypeError(f"Expected a list of labels for {pdf_path}")
page_count = len(PdfReader(pdf_path).pages)
if page_count == 0:
raise ValueError(f"PDF contains no pages: {pdf_path}")
column_width = 1000 // page_count
result: list[Coordinate] = []
for entry in boxes:
if not isinstance(entry, dict):
raise TypeError(f"Malformed label entry for {pdf_path}: {entry!r}")
box = entry["box_2d"]
label = str(entry["label"])
page_number = ((box[1] + box[3]) // 2) // column_width
result.append(
(label, page_number, box[0] - SQUARE, box[2] - SQUARE, box[1], box[3])
)
result.sort(key=lambda item: (item[1], item[2]))
return str(loaded.get("name", "")), result
def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
parsed: list[ParsedCoordinate] = []
for label, page, y0, y1, x0, x1 in coords_list:
if label.startswith("|"):
kind, clean_label = "L", label[1:]
elif label.endswith("|"):
kind, clean_label = "R", label[:-1]
else:
kind, clean_label = "N", label
parsed.append((clean_label, kind, page, y0, y1, x0, x1))
filtered: list[ParsedCoordinate] = []
for item in parsed:
if not filtered or item[0] != filtered[-1][0]:
filtered.append(item)
return filtered
def _save_cropped_page(
document: pymupdf.Document,
page_number: int,
x0: float,
y0: float,
x1: float,
y1: float,
output_path: Path,
) -> None:
# The source has been normalized by _prepare_split_pages: no rotation or
# CropBox translation remains in the coordinates passed to show_pdf_page.
visual_crop = pymupdf.Rect(x0, y0, x1, y1)
cropped = pymupdf.open()
try:
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
target_page.show_pdf_page(
target_page.rect,
document,
page_number,
clip=visual_crop,
)
cropped.save(output_path)
finally:
cropped.close()
def _prepare_split_pages(document: pymupdf.Document) -> list[pymupdf.Rect]:
"""Use the label preview's full-page coordinates; retain visible bounds.
Work only on the in-memory document. Baking rotation into the content after
restoring the MediaBox avoids show_pdf_page's rotated CropBox offsets.
Intersecting with the saved bounds later preserves prior margin cropping.
"""
visible_bounds = []
for page in document:
crop = page.cropbox
media = page.mediabox
full_crop = pymupdf.Rect(media.x0, 0, media.x1, media.height)
page.set_cropbox(full_crop)
crop -= (full_crop.x0, full_crop.y0, full_crop.x0, full_crop.y0)
visible_bounds.append(crop * page.rotation_matrix)
page.remove_rotation()
return visible_bounds
def _render_split_outputs(
input_pdf: Path,
coords_list: list[Coordinate],
staging: Path,
) -> set[str]:
"""Render every current answer into an otherwise empty staging directory."""
document = pymupdf.open(input_pdf)
try:
visible_bounds = _prepare_split_pages(document)
parsed = _parse_coordinates(coords_list)
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory)
for index, item in enumerate(parsed):
clean_label, kind, start_page, y_start, _y_end, x0_raw, _x1_raw = item
if clean_label == "_":
continue
if not 0 <= start_page < document.page_count:
raise ValueError(
f"Invalid page {start_page} for {input_pdf.name}"
)
end_page = document.page_count - 1
end_y = 1000
for next_item in parsed[index + 1 :]:
_next_label, next_kind, next_page, next_y, *_rest = next_item
if (
(kind == "L" and next_kind in {"L", "N"})
or (kind == "R" and next_kind in {"R", "N"})
or kind == "N"
):
end_page = next_page
end_y = min(next_y + int(1.5 * SQUARE), 1000)
break
column_width = 1000 / document.page_count
if kind == "L":
fraction_x0 = (x0_raw % column_width) / column_width
fraction_x1 = 1.0
end_y = min(1000, end_y + 40)
elif kind == "R":
fraction_x0 = 0.0
left_labels = [entry for entry in parsed if entry[1] == "L"]
if left_labels:
closest = min(left_labels, key=lambda entry: abs(entry[3] - y_start))
center = (closest[5] + closest[6]) / 2.0
fraction_x1 = (center % column_width) / column_width
if fraction_x1 <= fraction_x0:
fraction_x1 = 1.0
else:
fraction_x1 = 1.0
else:
fraction_x0, fraction_x1 = 0.0, 1.0
for page_number in range(start_page, end_page + 1):
page = document[page_number]
y0 = (
math.floor(max(
0,
(y_start / 1000) * page.rect.height
- ANSWER_TOP_PADDING_POINTS,
))
if page_number == start_page
else 0
)
y1 = (end_y / 1000) * page.rect.height if page_number == end_page else page.rect.height
clip = pymupdf.Rect(
fraction_x0 * page.rect.width, y0,
fraction_x1 * page.rect.width, y1,
) & visible_bounds[page_number] & page.rect
if clip.is_empty or clip.height <= 1 or clip.width <= 1:
continue
part_path = temporary / f"part-{index}-{page_number}.pdf"
_save_cropped_page(
document,
page_number,
clip.x0,
clip.y0,
clip.x1,
clip.y1,
part_path,
)
parts_by_label[clean_label].append(part_path)
generated: set[str] = set()
for label, parts in parts_by_label.items():
filename = f"{label}.pdf"
merger = PdfWriter()
try:
for part in parts:
merger.append(part)
merger.write(staging / filename)
finally:
merger.close()
generated.add(filename)
return generated
finally:
document.close()
def _preserve_previous_outputs(
output_dir: Path,
staging: Path,
generated_files: set[str],
) -> None:
if not output_dir.is_dir():
return
for directory in (path for path in output_dir.iterdir() if path.is_dir()):
shutil.copytree(directory, staging / directory.name, dirs_exist_ok=True)
missing_dir = staging / "Missing"
for item in (path for path in output_dir.iterdir() if path.is_file()):
if item.name in generated_files:
continue
print(f"ALERT: File '{item.name}' not generated. Moving to {missing_dir}")
missing_dir.mkdir(exist_ok=True)
shutil.copy2(item, missing_dir / item.name)
def split_an_interro(
workspace: EvaluationWorkspace,
input_pdf: Path,
coords_list: list[Coordinate],
) -> None:
"""Regenerate one copy's answers and preserve obsolete ones under Missing."""
output_dir = workspace.copies_dir / input_pdf.stem
with staged_directory(output_dir) as staging:
generated = _render_split_outputs(input_pdf, coords_list, staging)
_preserve_previous_outputs(output_dir, staging, generated)
def _selected_pdfs(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
workspace.require_directories("Copies")
if target.is_file():
if target.suffix.casefold() != ".pdf":
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
return [target]
return sorted(workspace.copies_dir.glob("*.pdf"), key=lambda path: path.name.casefold())
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
workspace.require_files("labels")
utils.read_all_labels(workspace.root)
pdf_files = _selected_pdfs(workspace, target)
status = ExitCode.SUCCESS
for pdf_path in pdf_files:
json_path = pdf_path.with_suffix(".json")
if not json_path.is_file():
print(f"Warning: No JSON found for {pdf_path.name}")
status = ExitCode.PARTIAL
continue
name, coordinates = decode_json(pdf_path)
print(f"Decoded name: {name}")
split_an_interro(workspace, pdf_path, coordinates)
if not pdf_files:
print("No PDF copies found.")
return status
def build_parser() -> argparse.ArgumentParser:
return target_parser("Split verified PDF copies into answers by label")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(args)
return run(workspace, target)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from google import genai
from google.genai import types
from copienator import configuration as config
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
if client is None:
if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured")
client = genai.Client(api_key=config.API_KEY)
batches = (
(
"flash",
workspace.root / "batch_requests_flash.jsonl",
config.MODEL_FLASH_ID,
f"flash-correction-{workspace.name}",
),
(
"pro",
workspace.root / "batch_requests_pro.jsonl",
config.MODEL_PRO_ID,
f"pro-correction-{workspace.name}",
),
)
manifest = {
"version": 1,
"evaluation": workspace.name,
"jobs": {},
}
if workspace.batch_jobs_file.is_file():
previous = read_json(workspace.batch_jobs_file)
if isinstance(previous, dict) and isinstance(previous.get("jobs"), dict):
manifest["jobs"] = previous["jobs"]
started = 0
for tier, file_path, model_id, display_name in batches:
if not file_path.is_file():
print(f"Skipping {model_id}: {file_path.name} does not exist.")
continue
if file_path.stat().st_size == 0:
print(f"Skipping {model_id}: {file_path.name} is empty.")
continue
print(f"Uploading {file_path.name} for model {model_id}...")
uploaded = client.files.upload(
file=str(file_path),
config=types.UploadFileConfig(
display_name=f"{display_name}-input",
mime_type="jsonl",
),
)
job = client.batches.create(
model=model_id,
src=uploaded.name,
config={"display_name": display_name},
)
started += 1
manifest["jobs"][tier] = {
"name": job.name,
"display_name": display_name,
"model": model_id,
"request_file": file_path.name,
}
atomic_write_json(workspace.batch_jobs_file, manifest)
print(f"Started batch job: {job.name}")
if not started:
print("No non-empty batch request files were found.")
return ExitCode.PARTIAL
return ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Upload correction JSONL files and start Gemini batches")
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,22 +1,23 @@
import argparse
import json
import os
import sys
import json
import ezodf
import re
from pathlib import Path
from utils import natural_key, read_all_labels
import ezodf
from copienator.configuration import CURRENT_SCORE_ODS_PATH
from copienator.utils import read_all_labels
# Configuration
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/current_eval.ods"
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre"
def main():
def main(argv=None):
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
args = parser.parse_args()
args = parser.parse_args(argv)
work_dir = os.path.abspath(args.work_dir)
@@ -34,7 +35,7 @@ def main():
print(f"Opening ODS file: {ODS_PATH}...")
try:
doc = ezodf.opendoc(ODS_PATH)
doc = ezodf.opendoc(str(ODS_PATH))
except Exception as e:
print(f"Failed to open ODS: {e}")
sys.exit(1)
@@ -168,4 +169,4 @@ def main():
print("Done.")
if __name__ == "__main__":
main()
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import argparse
import re
import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import (
EvaluationWorkspace,
ExitCode,
evaluation_parser,
execute,
read_json,
workspace_from_args,
)
COPY_PATTERN = re.compile(r"Copie(\d+)")
def build_parser() -> argparse.ArgumentParser:
return evaluation_parser("Verify that every answer PDF appears in group metadata.")
def collect_source_pdfs(copies_dir: Path) -> set[tuple[str, str]]:
source_pdfs: set[tuple[str, str]] = set()
for copy_dir in copies_dir.iterdir():
match = COPY_PATTERN.fullmatch(copy_dir.name)
if not match or not copy_dir.is_dir():
continue
copy_id = match.group(1)
for pdf_path in copy_dir.glob("*.pdf"):
source_pdfs.add((pdf_path.stem, copy_id))
return source_pdfs
def collect_grouped_pdfs(groups_dir: Path) -> tuple[set[tuple[str, str]], int]:
grouped: set[tuple[str, str]] = set()
read_errors = 0
for json_path in groups_dir.glob("*/Group_*.json"):
try:
data = read_json(json_path)
if not isinstance(data, list):
raise TypeError("expected a JSON array")
for entry in data:
grouped.add((str(entry[4]), str(entry[0])))
except (IndexError, OSError, TypeError, ValueError) as exc:
print(f"Error reading {json_path}: {exc}", file=sys.stderr)
read_errors += 1
return grouped, read_errors
def verify_groups(workspace: EvaluationWorkspace) -> ExitCode:
workspace.require_directories("Copies", "Par label")
source_pdfs = collect_source_pdfs(workspace.copies_dir)
grouped_pdfs, read_errors = collect_grouped_pdfs(workspace.groups_dir)
missing = source_pdfs - grouped_pdfs
if missing:
print(f"Verification failed: {len(missing)} files missing from groups:")
for label, copy_id in sorted(missing):
print(f"Copie{copy_id}/{label}.pdf")
return ExitCode.FAILURE
if read_errors:
print("Verification incomplete because some metadata could not be read.")
return ExitCode.PARTIAL
print("Verification successful: all files accounted for.")
return ExitCode.SUCCESS
def run(workspace: EvaluationWorkspace) -> ExitCode:
return verify_groups(workspace)
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
if __name__ == "__main__":
raise SystemExit(main())
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from types import ModuleType
def _user_config_path() -> Path | None:
explicit = os.environ.get("COPIENATOR_CONFIG", "").strip()
if explicit:
return Path(explicit).expanduser().resolve()
local = Path.cwd() / "config.py"
return local.resolve() if local.is_file() else None
def _load_user_config(path: Path) -> ModuleType:
if not path.is_file():
raise FileNotFoundError(f"Copienator configuration not found: {path}")
spec = importlib.util.spec_from_file_location("_copienator_user_config", path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load Copienator configuration: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
CONFIG_PATH = _user_config_path()
if CONFIG_PATH is None:
import default_config as _configuration
else:
_configuration = _load_user_config(CONFIG_PATH)
# Keep new optional settings compatible with older personal configuration files.
ALWAYS_CROP = False
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
for _name in dir(_configuration):
if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name)
+48
View File
@@ -0,0 +1,48 @@
"""Persistent copy flags shared by page splitting, margin review and the GUI."""
from pathlib import Path
from copienator import CliError, EvaluationWorkspace, atomic_update_json, read_json
def _path(workspace: EvaluationWorkspace) -> Path:
return workspace.metadata_dir / "copy_errors.json"
def _validate(value) -> dict[str, str]:
if not isinstance(value, dict) or any(
not isinstance(name, str) or not isinstance(reason, str)
or "/" in name or "\\" in name or Path(name).suffix.casefold() != ".pdf"
for name, reason in value.items()
):
raise CliError("Invalid copy_errors.json: expected PDF filenames and error descriptions")
return value
def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
return _validate(read_json(_path(workspace), default={}))
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str) -> None:
def update(errors):
_validate(errors)[pdf.name] = reason
atomic_update_json(_path(workspace), update, default_factory=dict)
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path) -> None:
if not _path(workspace).exists():
return
def update(errors):
_validate(errors).pop(pdf.name, None)
atomic_update_json(_path(workspace), update, default_factory=dict)
def marked_copy_paths(workspace: EvaluationWorkspace, *, originals: bool = False) -> list[Path]:
directories = ([workspace.original_copies_dir, workspace.copies_dir, workspace.root]
if originals else [workspace.copies_dir])
result = []
for name in sorted(copy_errors(workspace), key=str.casefold):
path = next((directory / name for directory in directories if (directory / name).is_file()), None)
if path is None:
raise CliError(f"Marked copy not found: {name}")
result.append(path)
return result

Some files were not shown because too many files have changed in this diff Show More