Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a453403cf | ||
|
|
5b8215e7f5 | ||
|
|
9b22a8a137 | ||
|
|
0a86403ca6 | ||
|
|
5080274e8f | ||
|
|
d60d5479d6 | ||
|
|
e8b8a11c5b | ||
|
|
060859ddef | ||
|
|
dfce79f240 | ||
|
|
f7b3689f23 | ||
|
|
0a167072ed | ||
|
|
3969580e01 | ||
|
|
359c62004c | ||
|
|
13a08f6cbd | ||
|
|
bf05272797 | ||
|
|
db4ed2ef31 | ||
|
|
06d7bad04e | ||
|
|
2313d51a62 | ||
|
|
2ff1a9b7b9 | ||
|
|
a9897606ac | ||
|
|
15d1319374 | ||
|
|
576e3c9452 | ||
|
|
2921d5ec8e | ||
|
|
b9e9d00e7d | ||
|
|
dc25b5fefa | ||
|
|
5a7ddd407f | ||
|
|
92a9f9883e | ||
|
|
9af05f2c86 | ||
|
|
ce9f385a0a | ||
|
|
81dc658640 | ||
|
|
3a8d0fe3ff | ||
|
|
b19d3b0db6 | ||
|
|
18d1e5e2bb | ||
|
|
aa40e58dd1 | ||
|
|
644e287586 | ||
|
|
bcba5facc8 | ||
|
|
8b087bb3e4 | ||
|
|
63f690b353 | ||
|
|
0a95afacdd | ||
|
|
352d36e38c | ||
|
|
2f1cd00e32 | ||
|
|
ac4ab782b2 | ||
|
|
7c366a9ca4 | ||
|
|
f7067d8585 | ||
|
|
c535febbc4 | ||
|
|
f345f8bb4f | ||
|
|
137a9a7868 | ||
|
|
9d7a4f37b6 | ||
|
|
09befe1d55 | ||
|
|
fd35675e4c | ||
|
|
121bd0714a | ||
|
|
27c0dae20e | ||
|
|
a80187ba80 | ||
|
|
80d06e4693 | ||
|
|
9e8b9ac191 | ||
|
|
c2e915226e |
@@ -0,0 +1,13 @@
|
||||
OLD/
|
||||
/Interro*/
|
||||
/DS*/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
config.py
|
||||
.copienator-gui.json
|
||||
.copienator/
|
||||
tmp/
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 574 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
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": ""
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.8 MiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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).$$
|
||||
@@ -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 +0,0 @@
|
||||
La condition est $\dim \Ker u \geq 2 + \dim \Ker u \cap \Im u$.
|
||||
@@ -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.
|
||||
@@ -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$.
|
||||
@@ -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$.
|
||||
@@ -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.
|
||||
@@ -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}$.
|
||||
@@ -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$.
|
||||
@@ -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$.
|
||||
@@ -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 +0,0 @@
|
||||
Soient $A\in\M_{n,p}(\K)$, $B\in\M_{p,n}(\K)$. Montrer que $\tr(AB) = \tr (BA)$.
|
||||
@@ -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.
|
||||
@@ -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)$.
|
||||
@@ -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}$.
|
||||
@@ -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 +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$.
|
||||
@@ -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}}$.
|
||||
@@ -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$.
|
||||
@@ -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$.
|
||||
@@ -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$.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
l’interface, depuis n’importe quel répertoire. Il utilise le Python de
|
||||
=.venv= s’il 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=. S’il n’y a aucun dossier admissible, l’interface démarre
|
||||
sans évaluation. Un chemin explicite reste utilisable même s’il 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 d’entrée du dossier.
|
||||
Pour reprendre le découpage d’une 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 l’original conservé.
|
||||
Dans la fenêtre de découpage, =i= inverse l’ordre 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 d’exercices 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .dispatcher import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 n’est 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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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 l’analyse. 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())
|
||||
@@ -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 n’a é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 l’analyse. 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())
|
||||
@@ -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 l’enregistrement : {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())
|
||||
@@ -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())
|
||||
@@ -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 d’examen en groupes cohérents pour la correction "
|
||||
"et l’annotation, selon leurs dépendances et leur contexte commun. "
|
||||
"Ne mélange pas des exercices différents. Conserve l’ordre 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 l’examen, 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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||