Compare commits
23
Commits
b9e9d00e7d
...
master
| 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 |
+13
@@ -0,0 +1,13 @@
|
||||
OLD/
|
||||
/Interro*/
|
||||
/DS*/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
config.py
|
||||
.copienator-gui.json
|
||||
.copienator/
|
||||
tmp/
|
||||
+1
-1
@@ -35,7 +35,7 @@ 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.
|
||||
un verrou inter-processus Linux, Windows et macOS.
|
||||
|
||||
* Convention des scripts standardisés
|
||||
|
||||
|
||||
+162
-16
@@ -33,26 +33,112 @@ question précédente ou autre.
|
||||
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
|
||||
pip install -e .
|
||||
brew install python@3.13 python-tk@3.13
|
||||
#+END_SRC
|
||||
|
||||
La seconde commande installe les exécutables =copienator= et
|
||||
=copienator-gui=. Sans installation, les mêmes commandes restent
|
||||
accessibles avec =python -m copienator=.
|
||||
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.
|
||||
|
||||
*** Poppler (for pdf2image)
|
||||
Créer et activer un environnement virtuel est recommandé :
|
||||
|
||||
+ Linux : install poppler-utils
|
||||
+ Windows : Download from: https://github.com/oschwartz10612/poppler-windows
|
||||
and add it to your PATH
|
||||
#+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
|
||||
|
||||
@@ -87,6 +173,62 @@ Lancer l'assistant avec :
|
||||
python -m copienator gui
|
||||
#+END_SRC
|
||||
|
||||
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.
|
||||
|
||||
=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).
|
||||
|
||||
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.
|
||||
|
||||
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=.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -104,10 +246,12 @@ absent, vide ou ne contient que des commentaires. Ces automatismes ne
|
||||
se répètent pas lors d'un retour en arrière.
|
||||
|
||||
Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX,
|
||||
PDF Arranger et la configuration Gemini. Sous Windows, les exécutables
|
||||
externes doivent être accessibles depuis =PATH=. 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.
|
||||
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.
|
||||
|
||||
Les chemins des étapes personnelles peuvent être adaptés avec
|
||||
=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=,
|
||||
@@ -115,6 +259,8 @@ Les chemins des étapes personnelles peuvent être adaptés avec
|
||||
|
||||
* Documentation complémentaire
|
||||
|
||||
- [[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,
|
||||
|
||||
+186
-33
@@ -9,7 +9,7 @@ les parcours alternatifs.
|
||||
- [[file:Readme.org][Guide de démarrage]]
|
||||
- [[file:Architecture.org][Architecture et conventions de développement]]
|
||||
|
||||
* Étapes et Script
|
||||
* É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.
|
||||
@@ -119,11 +119,11 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~
|
||||
2. =python -m copienator review-labels Interro=
|
||||
|
||||
Permet de vérifier visuellement les labels trouvés.
|
||||
+ Sous linux, on peut faire =e= pour ouvrir le fichier .json et
|
||||
+ 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
|
||||
(sous linux…), puis on peut l'ajouter à la main.
|
||||
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.
|
||||
@@ -290,8 +290,31 @@ OU
|
||||
3. =python -m copienator giving-names Interro BGnot=
|
||||
|
||||
Crée un dossier =A Rendre= avec des liens symboliques vers
|
||||
+ La copie à rendre
|
||||
+ =<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=.
|
||||
@@ -314,40 +337,170 @@ OU
|
||||
+ 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 seule copie (peu testé)
|
||||
** Recorrection d'une copie ou de quelques questions
|
||||
|
||||
!! Attention, refaire ne marchera pas si tu fais une annotation non
|
||||
groupée into refaire !!
|
||||
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.
|
||||
|
||||
1. Redécoupage
|
||||
+ =python -m copienator review-labels InterroTest/Copie01.pdf=
|
||||
+ =python -m copienator split-answers InterroTest/Copie20.pdf=
|
||||
2. Créer =refaire.json=, avec un contenu comme
|
||||
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)"]]]
|
||||
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).
|
||||
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=
|
||||
|
||||
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 =python -m copienator annotate-checks --refaire --overwrite=
|
||||
6. =python -m copienator export --refaire Interro24=
|
||||
6. =python -m copienator import --refaire Interro24=
|
||||
7. =python -m copienator read-grouped --refaire Interro24=
|
||||
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.
|
||||
|
||||
Avec =--refaire=, =refaire.json= et le dossier =BRnot= sont des
|
||||
prérequis obligatoires ; leur absence produit le code de sortie 3.
|
||||
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.
|
||||
|
||||
** Exemple de replotting, refaire d'une copie
|
||||
|
||||
1. replot it.
|
||||
2. `python -m copienator split-answers DS09VA/Copies/Copie25.pdf`
|
||||
this will get rid of old/new.
|
||||
!! Attention, et si ça dégage un new : bad bad bad.
|
||||
3. Make `refaire.json`, avec la copie, et les labels à refaire.
|
||||
4. `python -m copienator correct DS09VA --refaire`
|
||||
5. `python -m copienator annotate-checks DS09VA --refaire`
|
||||
6. `python -m copienator import Interro24 --refaire`
|
||||
=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.
|
||||
|
||||
@@ -6,6 +6,7 @@ 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]
|
||||
|
||||
@@ -25,8 +26,10 @@ def apply_checkbox_actions(
|
||||
continue
|
||||
result = labels_data[label]["result"]
|
||||
feedbacks = result.get("feedback", [])
|
||||
global_feedbacks = [item for item in feedbacks if not item.get("box_2d")]
|
||||
local_feedbacks = [item for item in feedbacks if item.get("box_2d")]
|
||||
# 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:
|
||||
|
||||
@@ -8,6 +8,7 @@ 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]]]
|
||||
@@ -36,7 +37,12 @@ def _coordinate_index(
|
||||
if not workspace.groups_dir.is_dir():
|
||||
return index, [f"Group directory not found: {workspace.groups_dir}"]
|
||||
|
||||
for metadata_path in sorted(workspace.groups_dir.glob("*/Group_*.json")):
|
||||
# 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)
|
||||
@@ -64,7 +70,7 @@ def _scaled_result(result: dict[str, Any], coordinates: GroupCoordinates | None)
|
||||
return scaled
|
||||
for feedback in scaled.get("feedback", []):
|
||||
box = feedback.get("box_2d")
|
||||
if not box or len(box) != 4:
|
||||
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
|
||||
@@ -160,12 +166,16 @@ def load_annotation_data(
|
||||
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):
|
||||
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}")
|
||||
warnings.append(
|
||||
f"Ignoring correction item without an id for {label!r}"
|
||||
)
|
||||
continue
|
||||
result = item["result"]
|
||||
suffix = str(result.get("suffix", ""))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -7,11 +7,17 @@ from pathlib import Path
|
||||
import pandas as pd
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from copienator.configuration import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
|
||||
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):
|
||||
@@ -33,6 +39,35 @@ def get_rounded_score(score):
|
||||
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:
|
||||
@@ -56,60 +91,64 @@ def process_images(base_dir, output_dir):
|
||||
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
|
||||
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.")
|
||||
continue
|
||||
else:
|
||||
raw_score = score_db[student_name]
|
||||
score = get_rounded_score(raw_score)
|
||||
|
||||
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
|
||||
|
||||
if score is None:
|
||||
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
|
||||
continue
|
||||
font_size = int(width * 0.08)
|
||||
font = score_font(font_size)
|
||||
text = str(score)
|
||||
|
||||
# 5. Process Image
|
||||
try:
|
||||
with Image.open(img_path) as img:
|
||||
img = img.convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
width, height = img.size
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
|
||||
# Dynamic font size (15% of image height)
|
||||
font_size = int(width * 0.08)
|
||||
# 30px padding, top right.
|
||||
x = width - text_w - 30
|
||||
y = 30
|
||||
draw.text((x, y), text, fill=(255, 0, 0), font=font)
|
||||
|
||||
font = score_font(font_size)
|
||||
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}")
|
||||
|
||||
text = str(score)
|
||||
for pdf_path in pdf_paths:
|
||||
shutil.copy2(pdf_path, student_output / pdf_path.name)
|
||||
|
||||
# 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))
|
||||
copy_histogram(output_dir)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
||||
@@ -30,7 +30,9 @@ from copienator import (
|
||||
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
|
||||
@@ -325,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')]
|
||||
@@ -446,6 +458,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
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]))
|
||||
|
||||
for label, content in sorted_labels:
|
||||
@@ -475,8 +488,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
answer_labels.append(label)
|
||||
|
||||
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)
|
||||
|
||||
@@ -10,9 +10,6 @@ from typing import Any
|
||||
from PIL import Image, ImageDraw
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator.commands import annotating_with_checks
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
@@ -22,9 +19,11 @@ from copienator import (
|
||||
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
|
||||
|
||||
@@ -184,7 +183,9 @@ 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]]:
|
||||
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)
|
||||
@@ -328,20 +329,38 @@ def _generate_groups(
|
||||
return generated, problems
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
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)
|
||||
groups = _load_label_groups(workspace, labels)
|
||||
loaded = load_annotation_data(workspace)
|
||||
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("grouped")
|
||||
if overwrite:
|
||||
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
|
||||
|
||||
@@ -356,7 +375,9 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
if generated == 0 or problems or loaded.warnings:
|
||||
raise IncompleteGroupedOutput
|
||||
except IncompleteGroupedOutput:
|
||||
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
|
||||
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)
|
||||
@@ -375,7 +396,14 @@ def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely")
|
||||
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
|
||||
|
||||
|
||||
@@ -384,7 +412,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||
lambda args: run(
|
||||
workspace_from_args(args), overwrite=args.overwrite, refaire=args.refaire
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ matplotlib.use("Agg")
|
||||
from PIL import Image, ImageFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
@@ -24,9 +22,11 @@ from copienator import (
|
||||
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
|
||||
|
||||
@@ -46,7 +46,9 @@ except OSError:
|
||||
|
||||
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.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]
|
||||
|
||||
@@ -150,8 +152,13 @@ def _render_student(
|
||||
*,
|
||||
overwrite: bool,
|
||||
output_mode: str,
|
||||
output_root: Path | None = None,
|
||||
) -> str:
|
||||
output_dir = workspace.annotation_dir(output_mode) / f"Copie{student_id}"
|
||||
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"
|
||||
@@ -178,6 +185,7 @@ def _render_student(
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
)
|
||||
if final_image is None:
|
||||
problems = True
|
||||
continue
|
||||
label_images.append(final_image)
|
||||
checkbox_groups.append(checkbox_renderer.checkboxes)
|
||||
@@ -280,6 +288,39 @@ def run(
|
||||
|
||||
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 = [
|
||||
@@ -302,7 +343,9 @@ def run(
|
||||
|
||||
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(
|
||||
"--overwrite", action="store_true", help="Replace existing outputs"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refaire",
|
||||
action="store_true",
|
||||
@@ -328,4 +371,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +46,41 @@ def list_jobs(*, client=None) -> ExitCode:
|
||||
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,
|
||||
*,
|
||||
@@ -73,6 +111,8 @@ 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
|
||||
|
||||
|
||||
@@ -80,6 +120,11 @@ 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:
|
||||
|
||||
@@ -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())
|
||||
@@ -5,6 +5,7 @@ import base64
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
@@ -18,6 +19,7 @@ from google import genai
|
||||
from copienator import configuration as config
|
||||
from copienator.commands import grouping
|
||||
from copienator import prompting
|
||||
from copienator.feedback_boxes import valid_feedback_box
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
@@ -44,15 +46,22 @@ COPIES_DIR = Path()
|
||||
GROUPS_DIR = Path()
|
||||
output_path = Path()
|
||||
progress_path = Path()
|
||||
pending_responses_path = Path()
|
||||
tasks: list[tuple] = []
|
||||
tasks_to_process: list[tuple] = []
|
||||
results: dict = {}
|
||||
completed_tasks: list = []
|
||||
errors_summary: list = []
|
||||
pending_responses: dict[str, str] = {}
|
||||
overwrite = False
|
||||
limit = None
|
||||
client = None
|
||||
start_time = 0.0
|
||||
stop_requested = threading.Event()
|
||||
|
||||
|
||||
class CorrectionStopRequested(Exception):
|
||||
"""Raised in a worker before it starts another Gemini request."""
|
||||
|
||||
# --- Thread-safe Logging ---
|
||||
log_lock = threading.Lock()
|
||||
@@ -81,9 +90,30 @@ def flush_thread_log(tid=None):
|
||||
f.write("\n".join(thread_logs[tid]) + "\n\n")
|
||||
thread_logs[tid].clear()
|
||||
|
||||
|
||||
def report_group_progress(completed: int, total: int) -> None:
|
||||
"""Emit a stable, human-readable progress line for the GUI and CLI."""
|
||||
print(
|
||||
f"[Progression correction] Groupes traités : {completed}/{total}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def request_graceful_stop(_signum=None, _frame=None) -> None:
|
||||
"""Stop scheduling Gemini calls while allowing in-flight calls to finish."""
|
||||
if not stop_requested.is_set():
|
||||
stop_requested.set()
|
||||
print(
|
||||
"\n[Interruption] Arrêt des nouveaux appels Gemini demandé. "
|
||||
"Attente des appels en cours et sauvegarde de leurs résultats…",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# --- Lock for thread-safe file writing ---
|
||||
io_lock = threading.Lock()
|
||||
pro_lock = threading.Lock()
|
||||
group_index_lock = threading.Lock()
|
||||
reserved_group_indices: dict[str, int] = {}
|
||||
pro_count = 0
|
||||
flash_count = 0
|
||||
pro_quota_exhausted = False
|
||||
@@ -137,6 +167,7 @@ def configure_runtime(
|
||||
api_client=None,
|
||||
) -> None:
|
||||
global INPUT_DIR, COPIES_DIR, GROUPS_DIR, output_path, progress_path
|
||||
global pending_responses_path, pending_responses
|
||||
global tasks, tasks_to_process, results, completed_tasks, errors_summary
|
||||
global overwrite, limit, client, start_time
|
||||
global pro_count, flash_count, pro_quota_exhausted
|
||||
@@ -146,17 +177,21 @@ def configure_runtime(
|
||||
GROUPS_DIR = workspace.groups_dir
|
||||
output_path = workspace.correction_file
|
||||
progress_path = workspace.correction_progress_file
|
||||
pending_responses_path = workspace.correction_pending_responses_file
|
||||
tasks = list(discovered_tasks)
|
||||
overwrite = bool(args.overwrite)
|
||||
limit = args.limit
|
||||
start_time = time.time()
|
||||
errors_summary = []
|
||||
pending_responses = {}
|
||||
completed_tasks = []
|
||||
results = {label: [] for _file, label in tasks}
|
||||
thread_logs.clear()
|
||||
reserved_group_indices.clear()
|
||||
pro_count = 0
|
||||
flash_count = 0
|
||||
pro_quota_exhausted = False
|
||||
stop_requested.clear()
|
||||
|
||||
if not overwrite:
|
||||
if progress_path.is_file():
|
||||
@@ -170,6 +205,20 @@ def configure_runtime(
|
||||
raise TypeError("correction.json must contain a JSON object")
|
||||
results = loaded_results
|
||||
|
||||
# A response saved during a graceful interruption is not a completed
|
||||
# correction. Resume its auxiliary checks even with --overwrite instead of
|
||||
# paying for the same primary request again.
|
||||
if pending_responses_path.is_file():
|
||||
loaded_pending = read_json(pending_responses_path)
|
||||
if not isinstance(loaded_pending, dict) or not all(
|
||||
isinstance(key, str) and isinstance(value, str)
|
||||
for key, value in loaded_pending.items()
|
||||
):
|
||||
raise TypeError(
|
||||
"correction_pending_responses.json must contain a JSON object"
|
||||
)
|
||||
pending_responses = loaded_pending
|
||||
|
||||
completed_set = {(str(file_path), label) for file_path, label in completed_tasks}
|
||||
tasks_to_process = [
|
||||
task for task in tasks if (str(task[0]), task[1]) not in completed_set
|
||||
@@ -180,7 +229,11 @@ def configure_runtime(
|
||||
def reset_workspace(workspace: EvaluationWorkspace) -> None:
|
||||
"""Apply the explicitly requested correction reset."""
|
||||
print("--- Running Reset ---")
|
||||
for path in (workspace.correction_file, workspace.correction_progress_file):
|
||||
for path in (
|
||||
workspace.correction_file,
|
||||
workspace.correction_progress_file,
|
||||
workspace.correction_pending_responses_file,
|
||||
):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
print(f"Deleted: {path}")
|
||||
@@ -209,6 +262,8 @@ def call_gemini_with_retries(model_id, contents, config,
|
||||
delays = [60, 300]
|
||||
|
||||
for attempt in range(3):
|
||||
if stop_requested.is_set():
|
||||
raise CorrectionStopRequested
|
||||
# Switch to fallback immediately if quota was exhausted by another thread
|
||||
if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id:
|
||||
model_id = fallback_model_id
|
||||
@@ -224,6 +279,8 @@ def call_gemini_with_retries(model_id, contents, config,
|
||||
full_response_text += chunk.text
|
||||
return full_response_text
|
||||
except Exception as e:
|
||||
if stop_requested.is_set():
|
||||
raise CorrectionStopRequested from e
|
||||
error_msg = str(e).lower()
|
||||
is_quota_error = "429" in error_msg or "quota" in error_msg or "exhausted" in error_msg
|
||||
is_minute_limit = "minute" in error_msg or "rpm" in error_msg or "tpm" in error_msg
|
||||
@@ -235,7 +292,8 @@ def call_gemini_with_retries(model_id, contents, config,
|
||||
wait_time = float(retry_match.group(1)) + 1.0 if retry_match else delays[attempt]
|
||||
|
||||
tprint(f"\tGemini Pro minute limit hit. Waiting {wait_time:.1f}s...")
|
||||
time.sleep(wait_time)
|
||||
if stop_requested.wait(wait_time):
|
||||
raise CorrectionStopRequested
|
||||
continue # Retry same model
|
||||
|
||||
# Immediately fallback to Flash without waiting if it's a Pro quota error
|
||||
@@ -247,7 +305,8 @@ def call_gemini_with_retries(model_id, contents, config,
|
||||
|
||||
if attempt < 2:
|
||||
tprint(f"\tGemini API failure: {e}. Retrying in {delays[attempt]} seconds...")
|
||||
time.sleep(delays[attempt])
|
||||
if stop_requested.wait(delays[attempt]):
|
||||
raise CorrectionStopRequested
|
||||
else:
|
||||
tprint(f"\tGemini API failure: {e}. Maximum retries reached.")
|
||||
raise
|
||||
@@ -267,6 +326,9 @@ def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
|
||||
for f in corrected_feedbacks:
|
||||
b = f.get("box_2d")
|
||||
if b:
|
||||
if not valid_feedback_box(b) or any(value < 0 or value > 1000 for value in b):
|
||||
f["box_2d"] = None
|
||||
continue
|
||||
ymin_s, xmin_s, ymax_s, xmax_s = b
|
||||
|
||||
# Y mapping: Add the group Y-offset (yming), then normalize to total_height
|
||||
@@ -290,6 +352,16 @@ def get_next_group_idx(label):
|
||||
if not existing: return 0
|
||||
return max([int(f.stem.split("_")[1]) for f in existing])
|
||||
|
||||
|
||||
def reserve_next_group_idx(label: str) -> int:
|
||||
"""Reserve a unique zero-based group index for this correction run."""
|
||||
with group_index_lock:
|
||||
if label not in reserved_group_indices:
|
||||
reserved_group_indices[label] = get_next_group_idx(label)
|
||||
idx = reserved_group_indices[label]
|
||||
reserved_group_indices[label] = idx + 1
|
||||
return idx
|
||||
|
||||
def handle_label_errors(pid, label, res, pdf_path):
|
||||
"""Handles Gemini labeling errors, moves/copies files, and returns new tasks."""
|
||||
new_tasks = []
|
||||
@@ -330,7 +402,7 @@ def handle_label_errors(pid, label, res, pdf_path):
|
||||
if pdf_path != old_pdf_path:
|
||||
shutil.move(str(pdf_path), str(old_pdf_path))
|
||||
|
||||
idx = get_next_group_idx(new_label)
|
||||
idx = reserve_next_group_idx(new_label)
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
tprint(f"\t\tMaking {new_label} group {idx+1}")
|
||||
@@ -342,6 +414,8 @@ def handle_label_errors(pid, label, res, pdf_path):
|
||||
tprint(f"\tHandling additional-answer for {pid} {label}")
|
||||
try:
|
||||
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config))
|
||||
except CorrectionStopRequested:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - invalid auxiliary model response
|
||||
add_labels = []
|
||||
|
||||
@@ -362,7 +436,7 @@ def handle_label_errors(pid, label, res, pdf_path):
|
||||
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
|
||||
shutil.copy(str(pdf_path), str(add_pdf_path))
|
||||
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
|
||||
idx = get_next_group_idx(add_label)
|
||||
idx = reserve_next_group_idx(add_label)
|
||||
tprint(f"\t\tMaking {add_label} group {idx+1}")
|
||||
height = grouping.get_pdf_height(str(add_pdf_path))
|
||||
grouping.create_jpg(add_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
|
||||
@@ -400,6 +474,9 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
total_height = group_data[-1][2]
|
||||
use_flash = n >= 4 or total_height <= 500
|
||||
|
||||
if precomputed_response is None and stop_requested.is_set():
|
||||
raise CorrectionStopRequested
|
||||
|
||||
# Only apply limits and counts if we are making a live call
|
||||
if precomputed_response is None:
|
||||
if not use_flash:
|
||||
@@ -420,9 +497,11 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro
|
||||
|
||||
if precomputed_response:
|
||||
tprint(f"Using batched response for: {label} {group_name}")
|
||||
tprint(f"Using saved response for: {label} {group_name}")
|
||||
full_response_text = precomputed_response
|
||||
else:
|
||||
if stop_requested.is_set():
|
||||
raise CorrectionStopRequested
|
||||
tprint(f"Asking Gemini {'Flash' if use_flash else 'Pro '}: {label} {group_name}")
|
||||
full_response_text = call_gemini_with_retries(model_to_use, contents, config)
|
||||
|
||||
@@ -461,8 +540,28 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
if res["error"] != "":
|
||||
tprint("\tError :", res["error"], "for Copie", pid, group_name)
|
||||
|
||||
if can_spawn_tasks and res.get("error") in ["wrong-label", "additional-answer"]:
|
||||
new_tasks.extend(handle_label_errors(pid, label, res, pdf_path))
|
||||
if can_spawn_tasks and res.get("error") in [
|
||||
"wrong-label",
|
||||
"additional-answer",
|
||||
]:
|
||||
if stop_requested.is_set():
|
||||
with io_lock:
|
||||
pending_responses[file_path] = json.dumps(json_data)
|
||||
atomic_write_json(
|
||||
pending_responses_path, pending_responses
|
||||
)
|
||||
raise CorrectionStopRequested
|
||||
try:
|
||||
new_tasks.extend(
|
||||
handle_label_errors(pid, label, res, pdf_path)
|
||||
)
|
||||
except CorrectionStopRequested:
|
||||
with io_lock:
|
||||
pending_responses[file_path] = json.dumps(json_data)
|
||||
atomic_write_json(
|
||||
pending_responses_path, pending_responses
|
||||
)
|
||||
raise
|
||||
# Si "wrong-label" a déplacé le fichier courant vers _old
|
||||
if res.get("error", "").startswith("wrg-lbl-moved-to:"):
|
||||
current_suffix = "_old"
|
||||
@@ -475,6 +574,9 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
for (i,f) in enumerate(res["feedback"]):
|
||||
b = f.get("box_2d")
|
||||
if b:
|
||||
if not valid_feedback_box(b):
|
||||
needs_correction.append(i)
|
||||
continue
|
||||
ymin, _xmin, ymax, xmax = b
|
||||
ymin = ymin * total_height // 1000
|
||||
ymax = ymax * total_height // 1000
|
||||
@@ -484,9 +586,11 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
pid, label, group_name)
|
||||
continue
|
||||
|
||||
if (ymin < yming - 50 or ymax > ymaxg + 50 or xmax / 1000 > width_r):
|
||||
if (ymin < yming - 50 or ymax > ymaxg + 50
|
||||
or ymin > ymaxg + 50 or ymax < yming - 50
|
||||
or _xmin < 0 or xmax / 1000 > width_r):
|
||||
needs_correction.append(i)
|
||||
break
|
||||
continue
|
||||
if ymin < yming - 5:
|
||||
ymin = yming - 5
|
||||
b[0] = ymin * 1000 // total_height
|
||||
@@ -498,6 +602,8 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
if needs_correction:
|
||||
tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...")
|
||||
try:
|
||||
if stop_requested.is_set():
|
||||
raise CorrectionStopRequested
|
||||
# Pensez à passer pdf_path à la fonction modifiée !
|
||||
res["feedback"] = correct_boxes_with_gemini(
|
||||
pid, label, pdf_path, res["feedback"],
|
||||
@@ -520,7 +626,12 @@ def process_single_task(task_tuple, precomputed_response=None):
|
||||
# To track progress
|
||||
completed_tasks.append((file_path, label))
|
||||
atomic_write_json(progress_path, completed_tasks)
|
||||
if file_path in pending_responses:
|
||||
del pending_responses[file_path]
|
||||
atomic_write_json(pending_responses_path, pending_responses)
|
||||
|
||||
except CorrectionStopRequested:
|
||||
raise
|
||||
except json.JSONDecodeError:
|
||||
tprint(f"Error decoding JSON for {file_path}", file=sys.stderr)
|
||||
with io_lock:
|
||||
@@ -575,7 +686,7 @@ def resolve_delayed_moves():
|
||||
if pdf_path != old_pdf_path:
|
||||
shutil.move(str(pdf_path), str(old_pdf_path))
|
||||
|
||||
idx = get_next_group_idx(target_label)
|
||||
idx = reserve_next_group_idx(target_label)
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(target_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
|
||||
@@ -593,7 +704,7 @@ def resolve_delayed_moves():
|
||||
resolved_any = True
|
||||
|
||||
shutil.copy(str(pdf_path), str(add_pdf_path))
|
||||
idx = get_next_group_idx(target_label)
|
||||
idx = reserve_next_group_idx(target_label)
|
||||
height = grouping.get_pdf_height(str(add_pdf_path))
|
||||
grouping.create_jpg(target_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
|
||||
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
|
||||
@@ -679,7 +790,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
# pdf_path = copie_dir / f"{label}_old.pdf"
|
||||
|
||||
if pdf_path.exists():
|
||||
idx = get_next_group_idx(label)
|
||||
idx = reserve_next_group_idx(label)
|
||||
height = grouping.get_pdf_height(str(pdf_path))
|
||||
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
|
||||
new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg")
|
||||
@@ -814,36 +925,82 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
else:
|
||||
print(f"Warning: Batch results file {batch_results_path} not found.", file=sys.stderr)
|
||||
|
||||
report_live_progress = not any(
|
||||
(args.batch, args.batch_from, args.deal_with_batched, args.refaire)
|
||||
)
|
||||
progress_total = len(tasks)
|
||||
progress_completed = max(0, progress_total - len(tasks_to_process))
|
||||
if report_live_progress:
|
||||
report_group_progress(progress_completed, progress_total)
|
||||
|
||||
made_progress = True
|
||||
while tasks_to_process or made_progress:
|
||||
if tasks_to_process:
|
||||
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
|
||||
waiting_tasks = list(tasks_to_process)
|
||||
futures = {}
|
||||
for task in tasks_to_process:
|
||||
file_path = task[0]
|
||||
precomp = batched_responses.get(file_path)
|
||||
futures[executor.submit(process_single_task, task, precomp)] = task
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
new_generated_tasks = future.result()
|
||||
if new_generated_tasks:
|
||||
for new_task in new_generated_tasks:
|
||||
futures[executor.submit(process_single_task, new_task)] = new_task
|
||||
except Exception as e: # noqa: BLE001 - future boundary
|
||||
print(f"Exception during task execution: {e}", file=sys.stderr)
|
||||
failed_task = futures[future]
|
||||
with io_lock:
|
||||
errors_summary.append((str(e), failed_task[0]))
|
||||
def submit_available_tasks() -> None:
|
||||
while (
|
||||
waiting_tasks
|
||||
and len(futures) < NB_THREADS
|
||||
and not stop_requested.is_set()
|
||||
):
|
||||
task = waiting_tasks.pop(0)
|
||||
file_path = task[0]
|
||||
precomp = pending_responses.get(
|
||||
file_path, batched_responses.get(file_path)
|
||||
)
|
||||
futures[
|
||||
executor.submit(process_single_task, task, precomp)
|
||||
] = task
|
||||
|
||||
submit_available_tasks()
|
||||
while futures:
|
||||
completed_futures, _pending = concurrent.futures.wait(
|
||||
tuple(futures),
|
||||
return_when=concurrent.futures.FIRST_COMPLETED,
|
||||
)
|
||||
for future in completed_futures:
|
||||
failed_task = futures.pop(future)
|
||||
task_completed = False
|
||||
try:
|
||||
new_generated_tasks = future.result()
|
||||
task_completed = True
|
||||
if new_generated_tasks and not stop_requested.is_set():
|
||||
if report_live_progress:
|
||||
progress_total += len(new_generated_tasks)
|
||||
waiting_tasks.extend(new_generated_tasks)
|
||||
except CorrectionStopRequested:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001 - future boundary
|
||||
print(
|
||||
f"Exception during task execution: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
with io_lock:
|
||||
errors_summary.append((str(e), failed_task[0]))
|
||||
if report_live_progress and task_completed:
|
||||
progress_completed += 1
|
||||
report_group_progress(
|
||||
progress_completed, progress_total
|
||||
)
|
||||
submit_available_tasks()
|
||||
|
||||
tasks_to_process = [] # Vider la liste une fois traitée
|
||||
|
||||
# Après avoir traité toutes les tâches actuelles (live ou batched),
|
||||
# on tente de débloquer les mouvements qui étaient en attente
|
||||
if stop_requested.is_set():
|
||||
break
|
||||
|
||||
delayed_tasks = resolve_delayed_moves()
|
||||
if delayed_tasks:
|
||||
print(f"Resolved {len(delayed_tasks)} delayed moves! Running executor for new tasks...")
|
||||
if report_live_progress:
|
||||
progress_total += len(delayed_tasks)
|
||||
report_group_progress(progress_completed, progress_total)
|
||||
tasks_to_process.extend(delayed_tasks)
|
||||
made_progress = True
|
||||
else:
|
||||
@@ -852,7 +1009,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
# Check for remaining unresolved delayed tasks
|
||||
unresolved_delayed = []
|
||||
with io_lock:
|
||||
for label, batches in results.items():
|
||||
for label, batches in sorted(results.items()):
|
||||
for batch in batches:
|
||||
for p in batch:
|
||||
res = p.get("result", {})
|
||||
@@ -869,7 +1026,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
manual_path = INPUT_DIR / "manual_resolutions.txt"
|
||||
atomic_write_text(
|
||||
manual_path,
|
||||
"### Use -> x>, -x, ss, sx, xx, xs\n"
|
||||
"### Use -> x>, -x, ss, sx, xx, xs, c{43}1>, c{43}2x\n"
|
||||
+ "\n".join(unresolved_delayed)
|
||||
+ "\n",
|
||||
)
|
||||
@@ -885,6 +1042,12 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
print(err, file=sys.stderr)
|
||||
escaped_path = shlex.quote(str(file))
|
||||
print(f"Run : python -m copienator correct {escaped_path}")
|
||||
if stop_requested.is_set():
|
||||
print(
|
||||
"[Interruption] Appels en cours terminés ; résultats disponibles sauvegardés.",
|
||||
flush=True,
|
||||
)
|
||||
return ExitCode.INTERRUPTED
|
||||
return ExitCode.PARTIAL if errors_summary else ExitCode.SUCCESS
|
||||
|
||||
|
||||
@@ -909,9 +1072,17 @@ def run(
|
||||
configure_runtime(workspace, discovered, args, api_client=api_client)
|
||||
if not discovered and not args.refaire:
|
||||
return ExitCode.PARTIAL
|
||||
previous_handlers = {}
|
||||
for signal_name in ("SIGINT", "SIGBREAK"):
|
||||
interrupt_signal = getattr(signal, signal_name, None)
|
||||
if interrupt_signal is not None:
|
||||
previous_handlers[interrupt_signal] = signal.getsignal(interrupt_signal)
|
||||
signal.signal(interrupt_signal, request_graceful_stop)
|
||||
try:
|
||||
status = run_configured(args)
|
||||
finally:
|
||||
for interrupt_signal, previous_handler in previous_handlers.items():
|
||||
signal.signal(interrupt_signal, previous_handler)
|
||||
for thread_id in list(thread_logs):
|
||||
flush_thread_log(thread_id)
|
||||
if warnings and status == ExitCode.SUCCESS:
|
||||
@@ -961,6 +1132,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
if args.refaire and args.overwrite:
|
||||
raise CliError(
|
||||
"--overwrite cannot be used with --refaire; --refaire already "
|
||||
"replaces the corrections selected in refaire.json",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
workspace, target = workspace_from_target(args)
|
||||
targets = [target]
|
||||
for additional in args.additional_targets:
|
||||
|
||||
@@ -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())
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -23,10 +23,13 @@ from copienator import (
|
||||
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()
|
||||
|
||||
|
||||
@@ -64,7 +67,9 @@ def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
|
||||
return convert_from_path(pdf_path)
|
||||
# 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]:
|
||||
@@ -77,6 +82,7 @@ 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:
|
||||
@@ -87,7 +93,10 @@ def process_single_pdf(
|
||||
left, right = 0, width
|
||||
else:
|
||||
left = max(0, 100 + shift_offset)
|
||||
right = min(width, width // 3 + 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:
|
||||
@@ -156,8 +165,14 @@ class ImageReviewer:
|
||||
) -> 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
|
||||
@@ -174,9 +189,12 @@ class ImageReviewer:
|
||||
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("n", lambda _event: self.on_shift(50))
|
||||
self.root.bind("N", lambda _event: self.on_shift(100))
|
||||
self.root.bind("t", lambda _event: self.on_shift(-50))
|
||||
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()
|
||||
@@ -194,20 +212,26 @@ class ImageReviewer:
|
||||
|
||||
def prefetch_worker(self) -> None:
|
||||
processed_index = -1
|
||||
while True:
|
||||
while not self.stop_prefetch.is_set():
|
||||
target = self.index + 1
|
||||
if target < len(self.files) and target != processed_index:
|
||||
get_pdf_pages(self.files[target])
|
||||
try:
|
||||
get_pdf_pages(self.files[target])
|
||||
except Exception:
|
||||
pass # The foreground review reports and flags conversion errors.
|
||||
processed_index = target
|
||||
time.sleep(0.05)
|
||||
self.stop_prefetch.wait(0.05)
|
||||
|
||||
def load_current_image(self) -> None:
|
||||
if self.index >= len(self.files):
|
||||
print("All files processed.")
|
||||
self.root.destroy()
|
||||
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:
|
||||
@@ -219,7 +243,12 @@ class ImageReviewer:
|
||||
|
||||
def worker() -> None:
|
||||
self.manual_queue.put(
|
||||
process_single_pdf(pdf_path, shift, self.current_max_per_file)
|
||||
process_single_pdf(
|
||||
pdf_path,
|
||||
shift,
|
||||
self.current_max_per_file,
|
||||
self.current_width_offset,
|
||||
)
|
||||
)
|
||||
|
||||
Thread(target=worker, daemon=True).start()
|
||||
@@ -228,13 +257,13 @@ class ImageReviewer:
|
||||
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.index += 1
|
||||
self.load_current_image()
|
||||
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)
|
||||
self.is_processing = False
|
||||
except Empty:
|
||||
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
|
||||
|
||||
@@ -244,7 +273,7 @@ class ImageReviewer:
|
||||
pdf_path: Path,
|
||||
) -> None:
|
||||
self.current_preview = result[0]
|
||||
save_results(result, pdf_path, self.output_dir)
|
||||
self.current_result = result
|
||||
self.update_display(pdf_path.name, result[2])
|
||||
|
||||
def update_display(self, filename: str, schema: dict[str, object]) -> None:
|
||||
@@ -256,10 +285,12 @@ class ImageReviewer:
|
||||
self.label_info.configure(
|
||||
text=(
|
||||
f"[{self.index + 1}/{len(self.files)}] {filename} | "
|
||||
f"Shift: {self.current_shift}px\nFiles: {schema['number_of_files']} | "
|
||||
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: Next | n: +50 | N: +100 | t: -50 | "
|
||||
"1: use single column"
|
||||
"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",
|
||||
)
|
||||
@@ -271,11 +302,45 @@ class ImageReviewer:
|
||||
print(f"Applying shift: {self.current_shift}")
|
||||
self.trigger_processing(self.files[self.index], self.current_shift)
|
||||
|
||||
def on_next(self, _event: object) -> None:
|
||||
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()
|
||||
|
||||
@@ -304,23 +369,27 @@ def run(
|
||||
target: Path,
|
||||
*,
|
||||
fullpage: bool = False,
|
||||
marked: bool = False,
|
||||
) -> ExitCode:
|
||||
files = _selected_files(workspace, target)
|
||||
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()
|
||||
ImageReviewer(
|
||||
reviewer = ImageReviewer(
|
||||
files,
|
||||
workspace.cutleft_dir,
|
||||
default_max_per_file=1 if fullpage else 5,
|
||||
)
|
||||
return ExitCode.SUCCESS
|
||||
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",
|
||||
@@ -334,11 +403,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target, fullpage=args.fullpage)
|
||||
return run(workspace, target, fullpage=args.fullpage, marked=args.marked)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
@@ -142,10 +143,11 @@ def save_split_content(text, path, base_fname, problem):
|
||||
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.removesuffix("/")
|
||||
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
|
||||
@@ -172,6 +174,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
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:
|
||||
@@ -267,6 +270,7 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
|
||||
for label in block_labels:
|
||||
f_labels.write(f"{label}\n")
|
||||
exercise_groups.append(block_labels)
|
||||
current_ex_num += 1
|
||||
|
||||
except WindowsLabelError:
|
||||
@@ -280,6 +284,8 @@ def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
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
|
||||
|
||||
|
||||
@@ -293,4 +299,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator.configuration import EXPORT_DIR
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
@@ -11,6 +10,7 @@ from copienator import (
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.configuration import EXPORT_DIR
|
||||
from copienator.platform import replace_with_link_or_copy
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
@@ -21,8 +21,12 @@ def export_directory(
|
||||
source_dir_name: str,
|
||||
) -> ExitCode:
|
||||
workspace.require_directories(source_dir_name)
|
||||
source_dir = workspace.root / 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()]
|
||||
@@ -48,7 +52,7 @@ def export_directory(
|
||||
)
|
||||
missing_outputs += 1
|
||||
continue
|
||||
destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}"
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@ from copienator import (
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import validate_windows_labels
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
|
||||
@@ -42,47 +43,62 @@ api_key = config.API_KEY
|
||||
|
||||
# --- Modèles pour la Requête 1 ---
|
||||
class QuestionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
|
||||
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
|
||||
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="The exact unique label of the question provided in the input.")
|
||||
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
|
||||
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="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
|
||||
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
|
||||
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
|
||||
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="The exact label of the question.")
|
||||
rubric_content: str = Field(description="Le barème détaillé en français.")
|
||||
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é en français pour CHAQUE question.
|
||||
É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 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
- 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 le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
|
||||
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) ---
|
||||
@@ -102,50 +118,64 @@ class ExamExtraction(BaseModel):
|
||||
class GroupedExamExtraction(BaseModel):
|
||||
groups: list[list[QuestionItem | ContextItem]]
|
||||
|
||||
PROMPT_1 = """I am providing:
|
||||
1. A PDF of an exam (`enonce.pdf`)
|
||||
2. The source code of the exam questions (`enonce` file)
|
||||
PROMPT_1 = """Je te fournis :
|
||||
1. Le PDF d'un examen (`enonce.pdf`).
|
||||
2. Le code source de ses questions (fichier `enonce`).
|
||||
|
||||
Your task:
|
||||
1. Identify all distinct question labels using the PDF document.
|
||||
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
|
||||
2. For each label, extract its exact corresponding question text
|
||||
from the `enonce` source file. Do not include the label itself
|
||||
in this extracted text (nor LaTeX like `item` nor org-mode list
|
||||
labelling like `2.`).
|
||||
Return the result as a JSON list in the exact reading order of the document.
|
||||
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 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam solutions (`correction` file).
|
||||
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`).
|
||||
|
||||
Your task:
|
||||
For each question label provided in the JSON, extract its exact corresponding solution textual
|
||||
content from the `correction` source file. Return the result as a JSON list in the exact same order.
|
||||
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 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam questions (`enonce` file).
|
||||
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`).
|
||||
|
||||
Your task:
|
||||
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
|
||||
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.
|
||||
|
||||
For example, given LaTeX code like
|
||||
Par exemple, dans ce code LaTeX :
|
||||
|
||||
\\item Let N, M be two commutating matrices
|
||||
\\item Soient N et M deux matrices qui commutent.
|
||||
\\begin{itemize}
|
||||
\\item Prove that N, M have a common eigenvector
|
||||
\\item Prove that N, M are co-trigonalizable.
|
||||
\\item Montrer que N et M ont un vecteur propre commun.
|
||||
\\item Montrer que N et M sont simultanément trigonalisables.
|
||||
\\end{itemize}
|
||||
|
||||
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
|
||||
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.
|
||||
|
||||
For each extracted piece of information, identify:
|
||||
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
|
||||
2. The label of the LAST question that uses or relies on this information.
|
||||
Return the result as a JSON list.
|
||||
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:
|
||||
@@ -155,6 +185,106 @@ def find_file(folder: Path, base_name: str) -> Path | None:
|
||||
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,
|
||||
@@ -214,6 +344,7 @@ def process_exam(
|
||||
]
|
||||
|
||||
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(),
|
||||
@@ -245,13 +376,14 @@ def process_exam(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_2),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- 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(),
|
||||
@@ -281,13 +413,14 @@ def process_exam(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_3),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- 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(),
|
||||
@@ -680,31 +813,9 @@ def process_exam(
|
||||
|
||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||
|
||||
contents_4 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_4 = types.GenerateContentConfig(
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
)
|
||||
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||
try:
|
||||
response_r = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_4,
|
||||
config=config_4
|
||||
)
|
||||
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
||||
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
||||
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))
|
||||
@@ -800,12 +911,23 @@ def process_exam(
|
||||
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",
|
||||
@@ -819,7 +941,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: process_exam(
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -76,6 +76,8 @@ be missing.
|
||||
|
||||
##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
|
||||
|
||||
@@ -133,6 +135,8 @@ be missing.
|
||||
|
||||
##wrong_labels##
|
||||
|
||||
##wrong_label_text_context##
|
||||
|
||||
Since this copy isn't the first part of a sequence, simply set the
|
||||
name to `\"Continued\"`."""
|
||||
|
||||
@@ -147,7 +151,66 @@ class AnnotationData(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
def generate_request(file, labels, names, context_labels, wrong_labels):
|
||||
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)
|
||||
@@ -162,9 +225,36 @@ def generate_request(file, labels, names, context_labels, wrong_labels):
|
||||
text = my_prompt2.replace("##labels##", labels)\
|
||||
.replace("##prev_context##", context_str)
|
||||
if wrong_labels:
|
||||
text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.")
|
||||
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##\n\n", "")
|
||||
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 = [
|
||||
@@ -181,9 +271,10 @@ def generate_request(file, labels, names, context_labels, wrong_labels):
|
||||
]
|
||||
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=1.0,
|
||||
top_p=0.95,
|
||||
seed=0,
|
||||
seed=seed,
|
||||
max_output_tokens=65535,
|
||||
response_mime_type= "application/json",
|
||||
response_json_schema= AnnotationData.model_json_schema(),
|
||||
@@ -249,6 +340,34 @@ def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
|
||||
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)
|
||||
@@ -293,17 +412,30 @@ def process_copy_group(
|
||||
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,
|
||||
@@ -321,9 +453,23 @@ def process_copy_group(
|
||||
f"Error: {image_file.name} contained unknown labels: "
|
||||
f"{unknown}"
|
||||
)
|
||||
wrong_labels.extend(unknown)
|
||||
attempt += 1
|
||||
continue
|
||||
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: "
|
||||
@@ -334,6 +480,9 @@ def process_copy_group(
|
||||
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
|
||||
|
||||
@@ -10,12 +10,14 @@ 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")
|
||||
|
||||
@@ -27,9 +29,20 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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():
|
||||
@@ -45,6 +58,70 @@ def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
@@ -72,9 +149,6 @@ def prepare_named_returns(
|
||||
had_errors = True
|
||||
|
||||
assigned_names: set[str] = set()
|
||||
selected_annotations = workspace.root / annotation_dir_name
|
||||
fallback_annotations = workspace.annotation_dir("simple")
|
||||
|
||||
for name, copy_ids in copies_map.items():
|
||||
if name == "Unknown":
|
||||
print(
|
||||
@@ -90,34 +164,40 @@ def prepare_named_returns(
|
||||
|
||||
safe_name = safe_filename(name)
|
||||
for copy_id in copy_ids:
|
||||
selected = selected_annotations / f"Copie{copy_id}"
|
||||
fallback = fallback_annotations / f"Copie{copy_id}"
|
||||
source_folder = None
|
||||
for candidate in (selected, fallback):
|
||||
if (candidate / "Concat.jpg").exists() and (
|
||||
candidate / "score.json"
|
||||
).exists():
|
||||
source_folder = candidate
|
||||
break
|
||||
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"),
|
||||
("Concat_F.pdf", f"{safe_name}.pdf"),
|
||||
("score.json", "score.json"),
|
||||
("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 in links:
|
||||
for source_name, destination_name, enabled in links:
|
||||
source = source_folder / source_name
|
||||
if not source.exists():
|
||||
continue
|
||||
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,
|
||||
destination / destination_name,
|
||||
target,
|
||||
prefer="symlink",
|
||||
)
|
||||
if method == "copy":
|
||||
@@ -145,7 +225,10 @@ 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)
|
||||
|
||||
|
||||
@@ -157,10 +240,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
lambda args: run(
|
||||
workspace_from_args(args, repository=Path.cwd()),
|
||||
annotation_dir=args.annotation_dir,
|
||||
update=args.update,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator.configuration import IMPORT_DIR
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
@@ -12,6 +11,7 @@ from copienator import (
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.configuration import IMPORT_DIR
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
|
||||
@@ -23,7 +23,10 @@ def sync_annotated(
|
||||
import_dir: Path,
|
||||
) -> ExitCode:
|
||||
workspace.require_directories(annotation_dir_name)
|
||||
annotation_dir = workspace.root / 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)
|
||||
@@ -39,7 +42,10 @@ def sync_annotated(
|
||||
key=lambda path: path.name.casefold(),
|
||||
)
|
||||
for annotated_file in annotated_files:
|
||||
target_subdir = annotation_dir / annotated_file.stem
|
||||
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)
|
||||
@@ -49,6 +55,10 @@ def sync_annotated(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import pymupdf # PyMuPDF
|
||||
from PIL import Image, ImageDraw, ImageTk
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
@@ -26,6 +26,10 @@ from copienator import (
|
||||
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)
|
||||
@@ -112,7 +116,7 @@ class PDFPreviewer:
|
||||
self.page_settings = []
|
||||
self.processing = False # Flag to prevent multiple finish calls
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
self.doc = pymupdf.open(self.pdf_path)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
self.failed = True
|
||||
self._temporary_directory.cleanup()
|
||||
@@ -166,6 +170,7 @@ class PDFPreviewer:
|
||||
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)
|
||||
@@ -192,6 +197,7 @@ class PDFPreviewer:
|
||||
"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,
|
||||
@@ -271,7 +277,7 @@ class PDFPreviewer:
|
||||
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
||||
|
||||
# --- Render Page ---
|
||||
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
|
||||
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)
|
||||
|
||||
@@ -302,7 +308,7 @@ class PDFPreviewer:
|
||||
|
||||
# Re-open the file from disk to reset changes (like moved pages)
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
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()
|
||||
@@ -319,6 +325,16 @@ class PDFPreviewer:
|
||||
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)
|
||||
@@ -465,7 +481,7 @@ class PDFPreviewer:
|
||||
self.file_rotation + self.global_rotation) % 360
|
||||
|
||||
if keep == "as_is":
|
||||
doc_full = fitz.open()
|
||||
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)
|
||||
@@ -477,13 +493,13 @@ class PDFPreviewer:
|
||||
|
||||
# --- Create Left Part ---
|
||||
if rotation == 0:
|
||||
rect_left = fitz.Rect(0, 0, line_x, page.rect.height)
|
||||
rect_left = pymupdf.Rect(0, 0, line_x, page.rect.height)
|
||||
else:
|
||||
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
|
||||
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 = fitz.open()
|
||||
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)
|
||||
@@ -494,11 +510,11 @@ class PDFPreviewer:
|
||||
|
||||
# --- Create Right Part ---
|
||||
if rotation == 0:
|
||||
rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||
rect_right = pymupdf.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||
else:
|
||||
rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height)
|
||||
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 = fitz.open()
|
||||
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)
|
||||
@@ -632,8 +648,8 @@ def _selected_inputs(
|
||||
return list(reversed(candidates))
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
inputs = _selected_inputs(workspace, target)
|
||||
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
|
||||
@@ -644,7 +660,9 @@ def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Interactively split and reorder scanned PDF pages")
|
||||
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:
|
||||
@@ -652,7 +670,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
return run(workspace, target, marked=args.marked)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
+129
-26
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
@@ -24,6 +26,9 @@ 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)
|
||||
@@ -37,6 +42,19 @@ def page_number(b, 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\
|
||||
@@ -68,16 +86,83 @@ def normalized_labels(entries):
|
||||
if str(value["label"]) != "_"
|
||||
]
|
||||
|
||||
def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_label_index):
|
||||
|
||||
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)
|
||||
bounding_boxes.sort(key=lambda b: (page_number(b["box_2d"], nb_pages), b["box_2d"][0]))
|
||||
common_missing = common_missing or set()
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
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)
|
||||
@@ -87,15 +172,13 @@ def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_la
|
||||
abs_y_max = min(height, raw_y_max + 10)
|
||||
abs_x_max = min(width, raw_x_max + 10)
|
||||
|
||||
color = "black"
|
||||
label = bbox.get("label")
|
||||
if label and label in all_labels:
|
||||
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:
|
||||
color = "orange"
|
||||
last_label_index = current_index
|
||||
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:
|
||||
@@ -114,6 +197,7 @@ def _worker_items(base_dir, files_to_process, all_labels, output_queue):
|
||||
"""
|
||||
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:])
|
||||
@@ -146,7 +230,14 @@ def _worker_items(base_dir, files_to_process, all_labels, output_queue):
|
||||
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)
|
||||
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
|
||||
@@ -259,7 +350,6 @@ class ImageViewer:
|
||||
# Start new batch
|
||||
self.active_copie_name = metadata["copie"]
|
||||
self.accumulated_results = {"name": metadata["name"], "list": []}
|
||||
self.history.clear()
|
||||
|
||||
self.display_image(pil_image, json_path, metadata)
|
||||
except queue.Empty:
|
||||
@@ -281,16 +371,23 @@ class ImageViewer:
|
||||
def on_previous(self, event):
|
||||
if self.is_viewing and self.history:
|
||||
print("Going back to previous image...")
|
||||
prev_pil, prev_json, prev_meta, num_added = self.history.pop()
|
||||
|
||||
# Undo the accumulation to prevent duplicates when we hit Enter again
|
||||
if self.accumulated_results and num_added > 0:
|
||||
self.accumulated_results["list"] = self.accumulated_results["list"][:-num_added]
|
||||
(
|
||||
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):
|
||||
@@ -317,19 +414,21 @@ class ImageViewer:
|
||||
def on_enter(self, event):
|
||||
if self.is_viewing:
|
||||
print(f"Committing data for {self.current_json_path.name}...")
|
||||
num_added = 0 # ADD THIS LINE
|
||||
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(
|
||||
current_data["list"],
|
||||
items,
|
||||
self.current_meta["part"],
|
||||
self.current_meta["schema"]
|
||||
)
|
||||
|
||||
labels = normalized_labels(current_data["list"])
|
||||
labels = normalized_labels(items)
|
||||
false_labels = [
|
||||
label for label in labels if label not in self.valid_labels
|
||||
]
|
||||
@@ -339,8 +438,6 @@ class ImageViewer:
|
||||
print(msg)
|
||||
messagebox.showerror("Label Error", msg)
|
||||
return
|
||||
num_added = len(converted_items)
|
||||
|
||||
# Add to accumulator
|
||||
if self.accumulated_results:
|
||||
self.accumulated_results["list"].extend(converted_items)
|
||||
@@ -355,8 +452,15 @@ class ImageViewer:
|
||||
messagebox.showerror("JSON Error", msg)
|
||||
return # Abort advancement
|
||||
|
||||
self.history.append((self.current_pil_image, self.current_json_path,
|
||||
self.current_meta, num_added))
|
||||
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
|
||||
@@ -472,4 +576,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_bytes,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
@@ -18,6 +19,10 @@ from copienator import (
|
||||
|
||||
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:
|
||||
@@ -25,22 +30,37 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
def escape_latex_underscores(text: str) -> str:
|
||||
r"""Escape underscores outside LaTeX math environments."""
|
||||
math_pattern = re.compile(
|
||||
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
|
||||
re.DOTALL,
|
||||
)
|
||||
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):
|
||||
for match in MATH_PATTERN.finditer(text):
|
||||
start, end = match.span()
|
||||
parts.append(text[last_end:start].replace("_", r"\_"))
|
||||
parts.append(escape_plain(text[last_end:start]))
|
||||
parts.append(match.group(0))
|
||||
last_end = end
|
||||
parts.append(text[last_end:].replace("_", r"\_"))
|
||||
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] = {}
|
||||
@@ -67,8 +87,23 @@ def fix_hex_corruption_safe(text: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def some_other_replacements(text: str) -> str:
|
||||
return text.replace("\neq", "\\neq").replace("\not", "\\not")
|
||||
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:
|
||||
@@ -79,7 +114,9 @@ def clean_string(text: str, lookup: dict[str, str]) -> str:
|
||||
text = re.sub(r" \x00{1,2} ", " à ", text)
|
||||
if "\x00" in text:
|
||||
text = fast_fix(text, lookup).replace("\x00", "")
|
||||
return escape_latex_underscores(some_other_replacements(text))
|
||||
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:
|
||||
@@ -104,6 +141,9 @@ def run(
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import configuration, utils
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
@@ -21,8 +21,10 @@ from copienator import (
|
||||
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
|
||||
|
||||
@@ -68,10 +70,11 @@ def detect_checks_and_notes(
|
||||
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
|
||||
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
|
||||
|
||||
difference = np.abs(
|
||||
np.array(reference).astype(int) - np.array(user_image).astype(int)
|
||||
).astype(np.uint8)
|
||||
difference_gray = np.mean(difference, axis=2)
|
||||
# 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]] = []
|
||||
@@ -82,10 +85,14 @@ def detect_checks_and_notes(
|
||||
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_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||
region = difference[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||
if region.size == 0:
|
||||
continue
|
||||
density = np.sum(region > 30) / region.size
|
||||
# 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)
|
||||
@@ -94,13 +101,17 @@ def detect_checks_and_notes(
|
||||
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")
|
||||
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
|
||||
final_alpha = np.minimum(alpha, np.array(keep_mask))
|
||||
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(final_alpha))
|
||||
notes.putalpha(Image.fromarray(alpha))
|
||||
return actions, notes
|
||||
|
||||
|
||||
@@ -148,11 +159,14 @@ def apply_actions_and_regenerate(
|
||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||
|
||||
labels_data = data[student_id]
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
|
||||
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:
|
||||
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||
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] = []
|
||||
@@ -169,6 +183,8 @@ def apply_actions_and_regenerate(
|
||||
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:
|
||||
@@ -204,30 +220,63 @@ def apply_actions_and_regenerate(
|
||||
body = sub_note.crop((0, old_header_height, width, height))
|
||||
final_image.paste(body, (0, new_header_height), mask=body)
|
||||
|
||||
if label in dirty_labels or has_notes:
|
||||
dirty_images[label] = final_image
|
||||
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) as staging:
|
||||
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")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
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) -> ExitCode:
|
||||
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:
|
||||
@@ -268,7 +317,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--update-score",
|
||||
action="store_true",
|
||||
help="Override generated scores with values from existing score.json files",
|
||||
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
|
||||
|
||||
@@ -277,11 +347,16 @@ 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 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())
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import configuration
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
@@ -18,19 +17,24 @@ from copienator import (
|
||||
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.filesystem import staged_files
|
||||
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(
|
||||
@@ -54,7 +58,9 @@ def get_extra_pdfs_as_images(
|
||||
return images
|
||||
|
||||
|
||||
def save_paginated_pdf(image_groups: list[list[Image.Image]], output_path: Path) -> None:
|
||||
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:
|
||||
@@ -118,6 +124,8 @@ 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():
|
||||
@@ -133,6 +141,8 @@ def _scan_annotation_directory(
|
||||
|
||||
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)
|
||||
@@ -176,26 +186,112 @@ def apply_actions_and_regenerate_grouped(
|
||||
all_labels: list[str],
|
||||
*,
|
||||
update_score: bool = False,
|
||||
annotation_dir: str = "BGnot",
|
||||
selected_labels: set[str] | None = None,
|
||||
) -> tuple[ExitCode, str]:
|
||||
"""Apply grouped annotations and atomically merge regenerated student files."""
|
||||
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
|
||||
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
||||
output_dir = workspace.annotation_dir("grouped") / f"Copie{student_id}"
|
||||
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
||||
labels_data = data.get(student_id, {})
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
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:
|
||||
dirty_labels |= apply_score_overrides(
|
||||
labels_data, output_dir / "score.json", logs.append
|
||||
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])):
|
||||
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}")
|
||||
@@ -221,15 +317,17 @@ def apply_actions_and_regenerate_grouped(
|
||||
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)))
|
||||
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)
|
||||
|
||||
if label in dirty_labels or has_notes:
|
||||
dirty_images[label] = final_image
|
||||
logs.append(f" Saved dirty image: {label}.jpg")
|
||||
# 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", [])
|
||||
@@ -237,33 +335,62 @@ def apply_actions_and_regenerate_grouped(
|
||||
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
|
||||
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)
|
||||
with staged_files(output_dir) as staging:
|
||||
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")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
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:
|
||||
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
|
||||
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]]]:
|
||||
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):
|
||||
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")
|
||||
@@ -273,23 +400,114 @@ def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str
|
||||
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", "BGnot")
|
||||
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, refaire_list=refaire_list)
|
||||
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:
|
||||
@@ -301,10 +519,15 @@ def run(
|
||||
only_ids = set(refaire_by_student) or None
|
||||
group_dirs = [
|
||||
path
|
||||
for path in workspace.annotation_dir("grouped").iterdir()
|
||||
if path.is_dir() and not path.name.startswith("Copie")
|
||||
for path in (workspace.root / annotation_dir).iterdir()
|
||||
if annotation_dir == "BGnot"
|
||||
and path.is_dir()
|
||||
and not path.name.startswith("Copie")
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
# 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
|
||||
@@ -312,39 +535,58 @@ def run(
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
_merge_scan_result(actions_by_student, notes_by_student, future.result())
|
||||
|
||||
refaire_incomplete = False
|
||||
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:
|
||||
for student_id, requested_labels in refaire_by_student.items():
|
||||
selected = requested_labels or list(loaded.data.get(student_id, {}))
|
||||
selected_set = set(selected)
|
||||
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}"
|
||||
if not directory.is_dir():
|
||||
print(f"Warning: missing refaire annotation directory {directory}")
|
||||
refaire_incomplete = True
|
||||
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_set
|
||||
if str(action.get("label")) not in selected
|
||||
]
|
||||
for label in selected:
|
||||
notes_by_student[student_id].pop(label, None)
|
||||
refaire_actions, refaire_notes = _scan_annotation_directory(
|
||||
directory, default_student_id=student_id
|
||||
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
|
||||
}
|
||||
)
|
||||
for action in refaire_actions.get(student_id, []):
|
||||
if str(action.get("label")) in selected_set:
|
||||
actions_by_student[student_id].append(action)
|
||||
for label, note in refaire_notes.get(student_id, {}).items():
|
||||
if label in selected_set:
|
||||
notes_by_student[student_id][label] = note
|
||||
|
||||
status = (
|
||||
ExitCode.PARTIAL
|
||||
if loaded.warnings or refaire_incomplete
|
||||
else ExitCode.SUCCESS
|
||||
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)
|
||||
)
|
||||
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(
|
||||
@@ -356,9 +598,15 @@ def run(
|
||||
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
|
||||
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()
|
||||
@@ -370,6 +618,12 @@ def run(
|
||||
|
||||
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",
|
||||
@@ -378,7 +632,28 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--update-score",
|
||||
action="store_true",
|
||||
help="Override generated scores with values from existing score.json files",
|
||||
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
|
||||
|
||||
@@ -387,10 +662,16 @@ 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)
|
||||
@@ -398,4 +679,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfWriter
|
||||
from copienator.pdf_cut import split_pdf
|
||||
|
||||
from copienator import (
|
||||
CliError,
|
||||
@@ -22,7 +23,8 @@ from copienator import (
|
||||
)
|
||||
|
||||
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
|
||||
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs)\s+")
|
||||
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+(.+)")
|
||||
|
||||
|
||||
@@ -34,6 +36,11 @@ class ManualInstruction:
|
||||
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(">")
|
||||
@@ -48,10 +55,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
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(
|
||||
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||
text.splitlines(), start=1
|
||||
):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("###"):
|
||||
@@ -64,7 +75,10 @@ def parse_instructions(path: Path) -> list[ManualInstruction]:
|
||||
right = line[operator_match.end() :].strip()
|
||||
copy_match = COPY_PATTERN.fullmatch(left)
|
||||
new_label = right.strip("|").strip()
|
||||
if copy_match is None or not new_label:
|
||||
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(
|
||||
@@ -97,15 +111,25 @@ def set_suffix_and_clean_error(
|
||||
item["result"]["suffix"] = suffix
|
||||
error = item["result"].get("error", "")
|
||||
if new_label_target:
|
||||
if f"wrg-lbl:{new_label_target}?delayed" in error:
|
||||
item["result"]["error"] = (
|
||||
f"wrg-lbl-moved-to:{new_label_target}"
|
||||
)
|
||||
if f"(delayed){new_label_target}" in error:
|
||||
item["result"]["error"] = error.replace(
|
||||
f"(delayed){new_label_target}",
|
||||
f"(->){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:
|
||||
@@ -154,6 +178,9 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
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] = {}
|
||||
@@ -174,6 +201,15 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
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}"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 fitz
|
||||
import pymupdf
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from copienator import utils
|
||||
@@ -23,6 +24,7 @@ from copienator import (
|
||||
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]
|
||||
|
||||
@@ -72,7 +74,7 @@ def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
|
||||
|
||||
|
||||
def _save_cropped_page(
|
||||
document: fitz.Document,
|
||||
document: pymupdf.Document,
|
||||
page_number: int,
|
||||
x0: float,
|
||||
y0: float,
|
||||
@@ -80,38 +82,51 @@ def _save_cropped_page(
|
||||
y1: float,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
page = document[page_number]
|
||||
rotated_rectangle = page.rect * page.transformation_matrix
|
||||
visual_crop = fitz.Rect(
|
||||
rotated_rectangle.x0 + x0,
|
||||
y0,
|
||||
rotated_rectangle.x0 + x1,
|
||||
y1,
|
||||
)
|
||||
unrotated_clip = visual_crop * page.derotation_matrix
|
||||
cropped = fitz.open()
|
||||
# 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,
|
||||
rotate=-page.rotation,
|
||||
clip=unrotated_clip,
|
||||
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 = fitz.open(input_pdf)
|
||||
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:
|
||||
@@ -158,18 +173,30 @@ def _render_split_outputs(
|
||||
|
||||
for page_number in range(start_page, end_page + 1):
|
||||
page = document[page_number]
|
||||
y0 = (y_start / 1000) * page.rect.height if page_number == start_page else 0
|
||||
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
|
||||
if y1 <= y0 + 1:
|
||||
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,
|
||||
fraction_x0 * page.rect.width,
|
||||
y0,
|
||||
fraction_x1 * page.rect.width,
|
||||
y1,
|
||||
clip.x0,
|
||||
clip.y0,
|
||||
clip.x1,
|
||||
clip.y1,
|
||||
part_path,
|
||||
)
|
||||
parts_by_label[clean_label].append(part_path)
|
||||
@@ -264,4 +291,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
@@ -31,6 +31,15 @@ if CONFIG_PATH is None:
|
||||
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
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Propose conservative top/bottom crops for scanned, optionally ruled PDFs.
|
||||
|
||||
Run with ``python -m copienator.crop_blank_margins INPUT_DIR OUTPUT_DIR``.
|
||||
Only PDFs directly in INPUT_DIR are processed. Originals are never modified.
|
||||
Analysis is deskewed; output keeps the original scan and changes its CropBox.
|
||||
This is a heuristic review utility, not a guarantee that a scan contains no ink.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
|
||||
def apply_bounds(page: pymupdf.Page, top: float, bottom: float) -> None:
|
||||
"""Apply fractional bounds in displayed orientation, respecting old CropBox."""
|
||||
rect = page.rect
|
||||
visible = pymupdf.Rect(0, top*rect.height, rect.width, bottom*rect.height)
|
||||
box = visible * page.derotation_matrix
|
||||
box += (page.cropbox_position.x, page.cropbox_position.y,
|
||||
page.cropbox_position.x, page.cropbox_position.y)
|
||||
page.set_cropbox(box)
|
||||
|
||||
|
||||
def process_pdf(source: Path, destination: Path, review: Path | None,
|
||||
dpi: float, padding_mm: float, min_crop_mm: float,
|
||||
progress=None) -> list[dict]:
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
rows = []
|
||||
with pymupdf.open(source) as doc:
|
||||
for index, page in enumerate(doc):
|
||||
pix = page.get_pixmap(dpi=round(dpi), colorspace=pymupdf.csRGB, alpha=False)
|
||||
rgb = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
|
||||
result = detect_bounds(rgb, dpi, padding_mm, min_crop_mm)
|
||||
top, bottom = result.pop('top_px'), result.pop('bottom_px')
|
||||
row = dict(file=source.name, page=index+1, **result,
|
||||
top_removed_mm=round(top/pix.height*page.rect.height*25.4/72, 2),
|
||||
bottom_removed_mm=round((pix.height-bottom)/pix.height*page.rect.height*25.4/72, 2),
|
||||
original_cropbox=list(page.cropbox), rotation=page.rotation,
|
||||
source_sha256=digest)
|
||||
if review is not None:
|
||||
preview = Image.fromarray(rgb)
|
||||
preview.thumbnail((500, 700))
|
||||
overlay = Image.new('RGBA', preview.size)
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
y0, y1 = top/pix.height*preview.height, bottom/pix.height*preview.height
|
||||
if top:
|
||||
draw.rectangle((0, 0, preview.width, y0), fill=(255, 40, 40, 85))
|
||||
draw.line((0, y0, preview.width, y0), fill=(230, 0, 0, 255), width=2)
|
||||
if bottom < pix.height:
|
||||
draw.rectangle((0, y1, preview.width, preview.height), fill=(255, 40, 40, 85))
|
||||
draw.line((0, y1, preview.width, y1), fill=(230, 0, 0, 255), width=2)
|
||||
preview = Image.alpha_composite(preview.convert('RGBA'), overlay).convert('RGB')
|
||||
preview.save(review/f'{source.stem}-{index+1:03}.jpg', quality=85)
|
||||
if top or bottom < pix.height:
|
||||
apply_bounds(page, top/pix.height, bottom/pix.height)
|
||||
row['output_cropbox'] = list(page.cropbox)
|
||||
rows.append(row)
|
||||
if progress is not None:
|
||||
progress(index+1, len(doc), row)
|
||||
doc.save(destination, garbage=3, deflate=True)
|
||||
with pymupdf.open(destination) as check:
|
||||
if len(check) != len(rows):
|
||||
raise RuntimeError(f'Page count changed: {source}')
|
||||
for page in check:
|
||||
if page.rect.is_empty:
|
||||
raise RuntimeError(f'Empty output page: {destination}')
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
|
||||
raise RuntimeError(f'Source changed during processing: {source}')
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('input', type=Path)
|
||||
parser.add_argument('output', type=Path)
|
||||
parser.add_argument('--dpi', type=int, default=200)
|
||||
parser.add_argument('--padding-mm', type=float, default=6)
|
||||
parser.add_argument('--min-crop-mm', type=float, default=5)
|
||||
args = parser.parse_args()
|
||||
if args.dpi < 100 or args.padding_mm < 0 or args.min_crop_mm < 0:
|
||||
parser.error('Use dpi >= 100 and nonnegative margins.')
|
||||
sources = sorted(args.input.glob('*.pdf')) if args.input.is_dir() else [args.input]
|
||||
if not sources or any(not p.is_file() for p in sources):
|
||||
parser.error('No input PDFs found.')
|
||||
if any(p.resolve() == (args.output/p.name).resolve() for p in sources):
|
||||
parser.error('Output must not overwrite input PDFs.')
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
review = args.output/'review'
|
||||
review.mkdir(exist_ok=True)
|
||||
cv2.setNumThreads(2)
|
||||
rows = []
|
||||
for i, source in enumerate(sources):
|
||||
batch = process_pdf(source, args.output/source.name, review,
|
||||
args.dpi, args.padding_mm, args.min_crop_mm)
|
||||
rows.extend(batch)
|
||||
print(f'[{i+1}/{len(sources)}] {source.name}: {len(batch)} pages, '
|
||||
f'{sum(r["top_removed_mm"] > 0 or r["bottom_removed_mm"] > 0 for r in batch)} cropped',
|
||||
flush=True)
|
||||
(args.output/'report.json').write_text(json.dumps(rows, indent=2)+'\n')
|
||||
with (args.output/'report.csv').open('w') as stream:
|
||||
fieldnames = list(dict.fromkeys(key for row in rows for key in row))
|
||||
writer = csv.DictWriter(stream, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
cards = []
|
||||
for row in rows:
|
||||
stem = Path(row['file']).stem
|
||||
cards.append(f'<article><a href="{html.escape(row["file"])}#page={row["page"]}">'
|
||||
f'{html.escape(stem)} / {row["page"]}</a>'
|
||||
f'<p>Top: {row["top_removed_mm"]} mm · Bottom: {row["bottom_removed_mm"]} mm'
|
||||
f' · {row["status"]}</p>'
|
||||
f'<img loading="lazy" src="review/{html.escape(stem)}-{row["page"]:03}.jpg"></article>')
|
||||
(args.output/'index.html').write_text(
|
||||
'<!doctype html><meta charset="utf-8"><title>Crop review</title>'
|
||||
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
|
||||
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
|
||||
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
|
||||
'<h1>Crop review</h1><p>Red shading shows the removed areas on the original scan. '
|
||||
'Click a page title to open the processed PDF. Review statuses flag uncertain detections.</p>'
|
||||
'<main>'+''.join(cards)+'</main>')
|
||||
changed = sum(r['top_removed_mm'] > 0 or r['bottom_removed_mm'] > 0 for r in rows)
|
||||
print(f'Done: {len(sources)} PDFs, {len(rows)} pages, {changed} cropped. Review: {args.output / "index.html"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Propose large bottom-only crops for PDFs already split into exercises."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import multiprocessing
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator.crop_blank_margins import apply_bounds
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.ink_detection import detect_bounds
|
||||
|
||||
|
||||
LINES_PER_PAGE = 36
|
||||
MINIMUM_HEIGHT_LINES = 10
|
||||
IGNORED_BOTTOM_LINES = 0.75
|
||||
MINIMUM_CROP_LINES = 4
|
||||
|
||||
|
||||
def _displayed_media_height(page: pymupdf.Page) -> float:
|
||||
"""Return the uncropped sheet height in the page's displayed orientation."""
|
||||
return page.mediabox.width if page.rotation % 180 else page.mediabox.height
|
||||
|
||||
|
||||
def _full_page_height(copy_pdf: Path) -> float:
|
||||
with pymupdf.open(copy_pdf) as document:
|
||||
if not len(document):
|
||||
raise ValueError(f"PDF sans page : {copy_pdf}")
|
||||
return max(_displayed_media_height(page) for page in document)
|
||||
|
||||
|
||||
def _save_review(rgb: np.ndarray, bottom: int, destination: Path) -> None:
|
||||
preview = Image.fromarray(rgb)
|
||||
preview.thumbnail((500, 700))
|
||||
overlay = Image.new("RGBA", preview.size)
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
y = bottom / rgb.shape[0] * preview.height
|
||||
draw.rectangle((0, y, preview.width, preview.height), fill=(255, 40, 40, 85))
|
||||
draw.line((0, y, preview.width, y), fill=(230, 0, 0, 255), width=2)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.alpha_composite(preview.convert("RGBA"), overlay).convert("RGB").save(
|
||||
destination, quality=88
|
||||
)
|
||||
|
||||
|
||||
def process_exercise_pdf(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
review_dir: Path | None,
|
||||
full_page_height: float,
|
||||
*,
|
||||
dpi: int = 200,
|
||||
padding_mm: float = 6,
|
||||
) -> list[dict]:
|
||||
"""Crop qualifying pages and save the PDF only when at least one changes."""
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
line_points = full_page_height / LINES_PER_PAGE
|
||||
records: list[dict] = []
|
||||
changed = False
|
||||
with pymupdf.open(source) as document:
|
||||
page_count = len(document)
|
||||
for index, page in enumerate(document):
|
||||
visible_height = page.rect.height
|
||||
record = {
|
||||
"file": source.as_posix(),
|
||||
"page": index + 1,
|
||||
"page_count": page_count,
|
||||
"source_sha256": digest,
|
||||
"height_lines": round(visible_height / line_points, 2),
|
||||
"bottom_removed_lines": 0.0,
|
||||
"bottom_removed_mm": 0.0,
|
||||
"status": "skipped-short",
|
||||
}
|
||||
if visible_height + 1e-6 < MINIMUM_HEIGHT_LINES * line_points:
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
pixmap = page.get_pixmap(
|
||||
dpi=dpi, colorspace=pymupdf.csRGB, alpha=False
|
||||
)
|
||||
rgb = np.frombuffer(pixmap.samples, np.uint8).reshape(
|
||||
pixmap.height, pixmap.width, 3
|
||||
)
|
||||
pixels_per_point = pixmap.height / visible_height
|
||||
ignored_pixels = min(
|
||||
pixmap.height - 1,
|
||||
round(IGNORED_BOTTOM_LINES * line_points * pixels_per_point),
|
||||
)
|
||||
analysis_bottom = pixmap.height - ignored_pixels
|
||||
detection = detect_bounds(
|
||||
rgb[:analysis_bottom], dpi=dpi, padding_mm=padding_mm, min_crop_mm=0
|
||||
)
|
||||
proposed_bottom = detection["bottom_px"]
|
||||
removed_points = (pixmap.height - proposed_bottom) / pixels_per_point
|
||||
removed_lines = removed_points / line_points
|
||||
record["detector_status"] = detection["status"]
|
||||
record["proposed_bottom_removed_lines"] = round(removed_lines, 2)
|
||||
if removed_lines + 1e-6 < MINIMUM_CROP_LINES:
|
||||
record["status"] = "unchanged-small-crop"
|
||||
records.append(record)
|
||||
continue
|
||||
|
||||
apply_bounds(page, 0, proposed_bottom / pixmap.height)
|
||||
record.update(
|
||||
bottom_removed_lines=round(removed_lines, 2),
|
||||
bottom_removed_mm=round(removed_points * 25.4 / 72, 2),
|
||||
status="cropped",
|
||||
output_height_points=round(page.rect.height, 3),
|
||||
)
|
||||
if review_dir is not None:
|
||||
record["output"] = destination.relative_to(review_dir.parent).as_posix()
|
||||
review_path = review_dir / source.parent.name / (
|
||||
f"{source.stem}-p{index + 1:02}.jpg"
|
||||
)
|
||||
_save_review(rgb, proposed_bottom, review_path)
|
||||
record["review"] = review_path.relative_to(review_dir.parent).as_posix()
|
||||
changed = True
|
||||
records.append(record)
|
||||
|
||||
if changed:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
document.save(destination, garbage=3, deflate=True)
|
||||
|
||||
if changed:
|
||||
with pymupdf.open(destination) as check:
|
||||
if len(check) != page_count:
|
||||
raise RuntimeError(f"Nombre de pages modifié : {source}")
|
||||
for page in check:
|
||||
if page.rect.is_empty:
|
||||
raise RuntimeError(f"Page vide produite : {destination}")
|
||||
page.get_pixmap(matrix=pymupdf.Matrix(0.25, 0.25))
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
|
||||
raise RuntimeError(f"PDF source modifié pendant le rognage : {source}")
|
||||
return records
|
||||
|
||||
|
||||
def _initialize_worker() -> None:
|
||||
cv2.setNumThreads(1)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def _process_job(job: tuple[Path, Path, Path, float, int, float]) -> list[dict]:
|
||||
source, destination, review_dir, full_height, dpi, padding = job
|
||||
records = process_exercise_pdf(
|
||||
source, destination, review_dir, full_height, dpi=dpi, padding_mm=padding
|
||||
)
|
||||
changed = sum(record["status"] == "cropped" for record in records)
|
||||
print(f"{source.parent.name}/{source.name} : {changed}/{len(records)} page(s) rognée(s)",
|
||||
flush=True)
|
||||
return records
|
||||
|
||||
|
||||
def _write_index(output: Path, records: list[dict]) -> None:
|
||||
changed = [record for record in records if record["status"] == "cropped"]
|
||||
changed_files: dict[str, list[int]] = {}
|
||||
for record in changed:
|
||||
changed_files.setdefault(record["file"], []).append(record["page"])
|
||||
(output / "cropped-files.txt").write_text(
|
||||
"".join(
|
||||
f"{path} - page(s) {', '.join(map(str, pages))}\n"
|
||||
for path, pages in changed_files.items()
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cards = []
|
||||
for record in changed:
|
||||
relative = Path(record["file"])
|
||||
output_pdf = quote(record["output"])
|
||||
preview = quote(record["review"])
|
||||
name = html.escape(f"{relative.parent.name}/{relative.name} - page {record['page']}")
|
||||
cards.append(
|
||||
f'<article><a href="{output_pdf}#page={record["page"]}">{name}</a>'
|
||||
f'<p>{record["bottom_removed_lines"]:.2f} lignes '
|
||||
f'({record["bottom_removed_mm"]:.1f} mm) retirées</p>'
|
||||
f'<img loading="lazy" src="{preview}"></article>'
|
||||
)
|
||||
(output / "index.html").write_text(
|
||||
'<!doctype html><meta charset="utf-8"><title>Rognage bas des exercices</title>'
|
||||
'<style>body{font:15px system-ui;background:#eee;margin:24px}'
|
||||
'main{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:20px}'
|
||||
'article{background:white;padding:12px}img{width:100%}p{font-size:12px}</style>'
|
||||
f'<h1>{len(changed)} pages rognées</h1>'
|
||||
'<p>Le rouge montre la zone retirée. Seuls les PDF modifiés sont présents dans cropped/.</p>'
|
||||
'<main>' + ''.join(cards) + '</main>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
input_path: Path,
|
||||
output: Path,
|
||||
*,
|
||||
dpi: int = 200,
|
||||
padding_mm: float = 6,
|
||||
workers: int = 5,
|
||||
) -> list[dict]:
|
||||
copies = input_path / "Copies" if (input_path / "Copies").is_dir() else input_path
|
||||
if not copies.is_dir():
|
||||
raise ValueError(f"Dossier Copies introuvable : {input_path}")
|
||||
sources = sorted(
|
||||
copies.glob("Copie*/*.pdf"),
|
||||
key=lambda path: (path.parent.name.casefold(), path.name.casefold()),
|
||||
)
|
||||
if not sources:
|
||||
raise ValueError(f"Aucun PDF d'exercice trouvé dans {copies}")
|
||||
if workers < 1:
|
||||
raise ValueError("Le nombre de traitements parallèles doit être positif")
|
||||
|
||||
heights: dict[str, float] = {}
|
||||
for copy_name in sorted({source.parent.name for source in sources}):
|
||||
copy_pdf = copies / f"{copy_name}.pdf"
|
||||
if not copy_pdf.is_file():
|
||||
raise ValueError(f"PDF source introuvable : {copy_pdf}")
|
||||
heights[copy_name] = _full_page_height(copy_pdf)
|
||||
|
||||
with staged_directory(output) as staging:
|
||||
cropped_dir = staging / "cropped"
|
||||
review_dir = staging / "review"
|
||||
jobs = [
|
||||
(
|
||||
source,
|
||||
cropped_dir / source.relative_to(copies),
|
||||
review_dir,
|
||||
heights[source.parent.name],
|
||||
dpi,
|
||||
padding_mm,
|
||||
)
|
||||
for source in sources
|
||||
]
|
||||
count = min(workers, len(jobs))
|
||||
if count == 1:
|
||||
previous_threads = cv2.getNumThreads()
|
||||
cv2.setNumThreads(1)
|
||||
try:
|
||||
batches = [_process_job(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_job, jobs))
|
||||
order = {source.as_posix(): i for i, source in enumerate(sources)}
|
||||
records = sorted(
|
||||
(record for batch in batches for record in batch),
|
||||
key=lambda record: (order[record["file"]], record["page"]),
|
||||
)
|
||||
(staging / "report.json").write_text(
|
||||
json.dumps(records, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
with (staging / "report.csv").open("w", encoding="utf-8", newline="") as stream:
|
||||
fields = sorted({key for record in records for key in record})
|
||||
writer = csv.DictWriter(stream, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
_write_index(staging, records)
|
||||
return records
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path, help="Évaluation ou dossier Copies")
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--dpi", type=int, default=200)
|
||||
parser.add_argument("--padding-mm", type=float, default=6)
|
||||
parser.add_argument("--workers", type=int, default=5)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.dpi < 100 or arguments.padding_mm < 0:
|
||||
parser.error("Utilisez dpi >= 100 et une marge positive ou nulle")
|
||||
try:
|
||||
records = run(
|
||||
arguments.input,
|
||||
arguments.output,
|
||||
dpi=arguments.dpi,
|
||||
padding_mm=arguments.padding_mm,
|
||||
workers=arguments.workers,
|
||||
)
|
||||
except ValueError as error:
|
||||
parser.error(str(error))
|
||||
cropped = sum(record["status"] == "cropped" for record in records)
|
||||
files = len({record["file"] for record in records if record["status"] == "cropped"})
|
||||
print(f"Terminé : {cropped} page(s) dans {files} PDF rognée(s). Revue : "
|
||||
f"{arguments.output / 'index.html'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,6 +17,10 @@ COMMANDS: dict[str, Command] = {
|
||||
"statement-personal": Command("enonce_info", "Generate personal statement metadata"),
|
||||
"copies": Command("copies_tools", "Rotate or rename scanned copies"),
|
||||
"page-split": Command("page_splitter", "Split and reorder scanned PDF pages"),
|
||||
"crop-margins": Command("crop_margins", "Trim blank top and bottom margins in Copies"),
|
||||
"crop-answer-bottoms": Command(
|
||||
"crop_exercise_bottoms", "Trim large blank bottoms from split answers"
|
||||
),
|
||||
"crop-labels": Command("cutleft", "Crop the label margin from copies"),
|
||||
"labels": Command("gemini_for_labels", "Detect question labels with Gemini"),
|
||||
"review-labels": Command("plotting", "Review detected labels interactively"),
|
||||
@@ -39,6 +43,7 @@ COMMANDS: dict[str, Command] = {
|
||||
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
|
||||
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
|
||||
"add-final-score": Command("add_final_score", "Stamp final scores on copies"),
|
||||
"clean": Command("clean", "Delete intermediate files from a finished evaluation"),
|
||||
"gui": Command("@gui", "Launch the graphical workflow assistant"),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Geometry checks shared by correction and annotation rendering."""
|
||||
|
||||
import math
|
||||
from numbers import Real
|
||||
|
||||
|
||||
def valid_feedback_box(box) -> bool:
|
||||
return (
|
||||
isinstance(box, (list, tuple)) and len(box) == 4
|
||||
and all(isinstance(value, Real) and not isinstance(value, bool)
|
||||
and math.isfinite(value) for value in box)
|
||||
and box[0] < box[2] and box[1] < box[3]
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Detect top and bottom content bounds in scanned student work.
|
||||
|
||||
Strong coloured strokes are never erased merely because they coincide with
|
||||
paper ruling. The detector supports coloured handwriting and dark ruled scans;
|
||||
it remains conservative for pencil-only scans and heavily saturated grids.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from copienator.paper_background import _skew, _ruling, _foreground, _content_mask
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _paper_kernel(sigma: float) -> np.ndarray:
|
||||
"""Match OpenCV's uint8 Gaussian coefficients, including error diffusion.
|
||||
|
||||
See getGaussianKernelFixedPoint_ED in OpenCV's smooth.dispatch.cpp.
|
||||
The 8-bit coefficients allow exact sums in the faster float filter path.
|
||||
"""
|
||||
size = round(sigma*6+1) | 1
|
||||
kernel = cv2.getGaussianKernel(size, sigma).ravel()
|
||||
fixed = np.zeros(size, np.float32)
|
||||
error = 0.0
|
||||
for i in range(size//2):
|
||||
value = kernel[i]*256+error
|
||||
weight = round(value)
|
||||
error = value-weight
|
||||
fixed[i] = fixed[-1-i] = weight
|
||||
fixed[size//2] = 256-fixed.sum()
|
||||
fixed /= 256
|
||||
return fixed
|
||||
|
||||
|
||||
def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||
kernel = _paper_kernel(sigma)
|
||||
blurred = cv2.sepFilter2D(gray, cv2.CV_32F, kernel, kernel)
|
||||
# GaussianBlur rounds positive half-integers upward, rather than to even.
|
||||
np.add(blurred, .5, out=blurred)
|
||||
np.floor(blurred, out=blurred)
|
||||
return blurred.astype(np.uint8)
|
||||
|
||||
|
||||
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
"""Refine confirmed ruling, only accepting substantial extra blank margins.
|
||||
|
||||
Short directional openings tolerate locally bent/broken paper lines. A
|
||||
physical component-size threshold rejects their remaining tiny fragments.
|
||||
This is deliberately limited to already-confirmed ruled paper.
|
||||
"""
|
||||
px = dpi / 25.4
|
||||
height, width = gray.shape
|
||||
dark = 255-gray
|
||||
length = max(9, round(2.5*px))
|
||||
tolerance = max(3, round(.6*px) | 1)
|
||||
lines = []
|
||||
for horizontal in (True, False):
|
||||
broadened = cv2.dilate(dark, np.ones(
|
||||
(tolerance, 1) if horizontal else (1, tolerance), np.uint8))
|
||||
lines.append(cv2.morphologyEx(broadened, cv2.MORPH_OPEN, np.ones(
|
||||
(1, length) if horizontal else (length, 1), np.uint8)))
|
||||
residual = cv2.subtract(dark, np.maximum(*lines))
|
||||
n, labels, stats, centers = cv2.connectedComponentsWithStats(
|
||||
(residual > 35).astype(np.uint8), 8)
|
||||
keep = np.zeros(n, bool)
|
||||
xx = stats[1:, 0]
|
||||
ww, hh, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
|
||||
density = area / (ww*hh)
|
||||
shortest, longest = np.minimum(ww, hh), np.maximum(ww, hh)
|
||||
compact_ink = ((area >= .8*px*px) & (shortest >= .6*px)
|
||||
& (density > .3) & (longest < 4*shortest))
|
||||
# Ruling suppression can fragment faint pencil handwriting into sparse,
|
||||
# elongated components. Admit those moderately more readily in the central
|
||||
# 80% of the sheet. The outermost 9% deliberately uses a stricter filter:
|
||||
# punched holes, torn binding edges, and page numbers usually occur there.
|
||||
center_x = xx + ww/2
|
||||
central = (center_x > width*.1) & (center_x < width*.9)
|
||||
central_ink = (central & (area >= .55*px*px) & (shortest >= .45*px)
|
||||
& (density > .18) & (longest < 6*shortest))
|
||||
outer = (center_x < width*.09) | (center_x > width*.91)
|
||||
outer_ink = (outer & (area >= 1.2*px*px) & (shortest >= .8*px)
|
||||
& (density >= .4) & (longest < 3*shortest))
|
||||
keep[1:] = np.where(outer, outer_ink, compact_ink | central_ink)
|
||||
# Use side columns as a prior, then require repeated size and alignment. An
|
||||
# isolated note in the same column remains eligible to protect the margin.
|
||||
candidates = np.flatnonzero(
|
||||
((xx < 15*px) | (xx+ww > width-15*px))
|
||||
& (ww > px) & (ww < 9*px) & (hh > px) & (hh < 12*px)) + 1
|
||||
if len(candidates) > 128:
|
||||
# Bound the matching cost and retain ambiguous, very noisy margins.
|
||||
return clean, 0, False
|
||||
holes = set()
|
||||
for i in candidates:
|
||||
matches = []
|
||||
for j in candidates:
|
||||
if (abs(centers[i, 0]-centers[j, 0]) < 2*px
|
||||
and .6 < stats[j, 2]/stats[i, 2] < 1.6
|
||||
and .6 < stats[j, 3]/stats[i, 3] < 1.6):
|
||||
matches.append(j)
|
||||
if len(matches) >= 3 and np.ptp(centers[matches, 1]) > height*.35:
|
||||
holes.update(matches)
|
||||
keep[list(holes)] = False
|
||||
refined = keep[labels]
|
||||
# Directional opening also removes long fraction bars. Protect very dark,
|
||||
# thick straight strokes independently, even if ruling crosses their ends.
|
||||
long_strokes = cv2.morphologyEx((gray < 50).astype(np.uint8), cv2.MORPH_OPEN,
|
||||
np.ones((1, max(9, round(width*.1))), np.uint8))
|
||||
count, lab, st, _ = cv2.connectedComponentsWithStats(long_strokes, 8)
|
||||
bars = np.zeros(count, bool)
|
||||
bw, bh, ba = st[1:, 2], st[1:, 3], st[1:, 4]
|
||||
bars[1:] = ((bw > width*.1) & (bh >= .3*px) & (bh < height*.015)
|
||||
& (bw > 8*bh) & (ba/(bw*bh) > .5))
|
||||
strong_ruling, strong_lines = _ruling((gray < 50).astype(np.uint8)*255, True)
|
||||
if strong_lines:
|
||||
# A family of equally dark parallel lines is paper, not fraction bars.
|
||||
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
|
||||
bars &= overlap < st[:, 4]*.5
|
||||
refined |= bars[lab]
|
||||
ys = np.flatnonzero(np.any(refined, axis=1))
|
||||
original = np.flatnonzero(np.any(clean, axis=1))
|
||||
if not len(ys) or not len(original):
|
||||
return clean, 0, False
|
||||
# Leave ordinary small crops to the more permissive detector. Keep a
|
||||
# recovery neighbourhood around the refined bounds for broken/faint strokes.
|
||||
top, bottom = max(0, int(ys.min()-2*px)), min(height, int(ys.max()+1+2*px))
|
||||
result = clean.copy()
|
||||
changed = False
|
||||
if top-original.min() >= 30*px:
|
||||
result[:top] = 0
|
||||
changed = True
|
||||
if original.max()+1-bottom >= 30*px:
|
||||
result[bottom:] = 0
|
||||
changed = True
|
||||
return result, len(holes), changed
|
||||
|
||||
|
||||
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
|
||||
"""Clean confirmed dark ruling before it can seed whole pages.
|
||||
|
||||
Returns None for ordinary ink components or unconfirmed paper geometry.
|
||||
The mask is transformed back to the original displayed pixel coordinates.
|
||||
"""
|
||||
height, width = gray.shape
|
||||
# Only neutral darkness is relevant here: long blue equations are not
|
||||
# evidence of dark paper. Broken ruling can form several medium-sized
|
||||
# components instead of one page-spanning component. The broader threshold
|
||||
# also admits faded gray grids; periodic ruling must still be confirmed.
|
||||
_, _, stats, _ = cv2.connectedComponentsWithStats(
|
||||
((gray < 160) & (chroma < 30)).astype(np.uint8), 8)
|
||||
spans = np.maximum(stats[1:,2]/width, stats[1:,3]/height)
|
||||
if not (np.any(spans > .35) or np.count_nonzero(spans > .1) >= 3):
|
||||
return None, dict(paper_cleanup=False)
|
||||
angle = _skew(gray, angle_step=.5)
|
||||
matrix = cv2.getRotationMatrix2D((width/2, height/2), angle, 1)
|
||||
corners = np.array([[0,0],[width,0],[0,height],[width,height]], dtype=float)
|
||||
corners = cv2.transform(corners[None], matrix)[0]
|
||||
origin = np.floor(corners.min(axis=0))
|
||||
size = np.ceil(corners.max(axis=0)-origin).astype(int)
|
||||
matrix[:, 2] -= origin
|
||||
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
|
||||
background = _paper_blur(deskewed, dpi/8)
|
||||
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
|
||||
block = max(15, int(dpi/5) | 1)
|
||||
binary = cv2.adaptiveThreshold(normalized,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV,block,9)
|
||||
horizontal, nh = _ruling(binary,True)
|
||||
vertical, nv = _ruling(binary,False)
|
||||
if not (nh or nv):
|
||||
return None, dict(paper_cleanup=False)
|
||||
# The permissive mask retains faint, isolated marks.
|
||||
# Bands identify where ruling is expected, but only actual dark pixels
|
||||
# inside them may be suppressed. Erasing the complete band loses faint
|
||||
# writing alongside a dark grid line.
|
||||
paper_pixels = (horizontal|vertical) & ((normalized < 160).astype(np.uint8)*255)
|
||||
clean, holes = _content_mask(_foreground(normalized,dpi,9,paper_pixels),dpi,nh,nv)
|
||||
clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi)
|
||||
restored = cv2.warpAffine(clean, cv2.invertAffineTransform(matrix),
|
||||
(width,height), flags=cv2.INTER_NEAREST, borderValue=0)
|
||||
return restored > 0, dict(paper_cleanup=True, angle_deg=round(angle,3),
|
||||
horizontal_lines=nh,vertical_lines=nv,
|
||||
edge_artifacts=holes+repeated_holes,
|
||||
large_blank_refinement=refined)
|
||||
|
||||
|
||||
def _seed_mask(mask: np.ndarray, px: float) -> np.ndarray:
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
|
||||
keep = np.zeros(n, bool)
|
||||
keep[1:] = ((stats[1:, 4] >= max(4, .12*px*px))
|
||||
& (stats[1:, 2] >= max(2, round(.35*px)))
|
||||
& (stats[1:, 3] >= max(2, round(.35*px))))
|
||||
return keep[labels]
|
||||
|
||||
|
||||
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
min_crop_mm: float = 5) -> dict:
|
||||
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
|
||||
|
||||
Neutral punched-hole shadows usually have neither sufficient chroma nor
|
||||
sufficient darkness to seed a region. Nothing is discarded merely because
|
||||
it is in a side margin. Two seed thresholds expose unstable boundaries.
|
||||
"""
|
||||
px = dpi/25.4
|
||||
h, w = rgb.shape[:2]
|
||||
red, green, blue = cv2.split(rgb)
|
||||
lowest = cv2.min(cv2.min(red, green), blue)
|
||||
chroma = cv2.subtract(cv2.max(cv2.max(red, green), blue), lowest)
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
darkness = 255-lowest
|
||||
length = max(15, round(5.2*px)) | 1
|
||||
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
|
||||
np.ones((1, length), np.uint8))
|
||||
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
|
||||
np.ones((length, 1), np.uint8))
|
||||
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
|
||||
# Strong strokes bypass the line-background estimate entirely: even a long
|
||||
# isolated black or coloured fraction bar must survive. Local contrast is
|
||||
# used only to recover weaker surrounding strokes.
|
||||
neutral_ink = gray < 95
|
||||
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi)
|
||||
if cleaned is not None:
|
||||
neutral_ink = cleaned
|
||||
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
|
||||
radius = max(1, round(2*px))
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
|
||||
extents = []
|
||||
counts = []
|
||||
same_thresholds = not np.any((chroma > 100) & (chroma <= 115) & ~neutral_ink)
|
||||
for threshold in (100, 115):
|
||||
if threshold == 115 and same_thresholds:
|
||||
counts.append(counts[0])
|
||||
if extents:
|
||||
extents.append(extents[0])
|
||||
continue
|
||||
seeds = _seed_mask((chroma > threshold) | neutral_ink, px)
|
||||
counts.append(int(np.count_nonzero(seeds)))
|
||||
if not np.any(seeds):
|
||||
continue
|
||||
# Limit weak recovery to a physical neighbourhood: faint grid lines
|
||||
# connected to a letter cannot grow into a full-page foreground mask.
|
||||
nearby = cv2.dilate(seeds.astype(np.uint8), kernel)
|
||||
candidate = (weak & nearby) | seeds.astype(np.uint8)
|
||||
n, labels = cv2.connectedComponents(candidate, 8)
|
||||
seeded_labels = np.zeros(n, bool)
|
||||
seeded_labels[np.unique(labels[seeds])] = True
|
||||
seeded_labels[0] = False
|
||||
ys = np.flatnonzero(np.any(seeded_labels[labels], axis=1))
|
||||
extents.append((int(ys.min()), int(ys.max())+1))
|
||||
result = dict(top_px=0, bottom_px=h, angle_deg=None,
|
||||
horizontal_lines=0, vertical_lines=0, edge_artifacts=0,
|
||||
detector="ink", seed_pixels=counts, status="review-no-ink-seeds")
|
||||
result.update(paper_info)
|
||||
if not extents:
|
||||
return result
|
||||
pad = padding_mm*px
|
||||
top = max(0, int(np.floor(min(e[0] for e in extents)-pad)))
|
||||
bottom = min(h, int(np.ceil(max(e[1] for e in extents)+pad)))
|
||||
uncertain = []
|
||||
if len(extents) < 2:
|
||||
uncertain.append('seed-threshold')
|
||||
else:
|
||||
for edge, name in ((0, 'top'), (1, 'bottom')):
|
||||
if abs(extents[0][edge]-extents[1][edge]) > max(pad, 3*px):
|
||||
uncertain.append(name)
|
||||
if top < min_crop_mm*px:
|
||||
top = 0
|
||||
if h-bottom < min_crop_mm*px:
|
||||
bottom = h
|
||||
result.update(top_px=top, bottom_px=bottom,
|
||||
status=('review-'+'-'.join(uncertain) if uncertain else
|
||||
'cropped' if top or bottom < h else 'unchanged'))
|
||||
return result
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Shared geometry and foreground helpers for scanned paper backgrounds."""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _runs(values: np.ndarray) -> list[tuple[int, int]]:
|
||||
edges = np.diff(np.r_[False, values, False].astype(np.int8))
|
||||
return list(zip(np.flatnonzero(edges == 1), np.flatnonzero(edges == -1)))
|
||||
|
||||
|
||||
def _skew(gray: np.ndarray, angle_step: float = .1) -> float:
|
||||
"""Use the dominant near-horizontal/vertical Hough angle, at reduced size."""
|
||||
scale = min(1.0, 1200 / max(gray.shape))
|
||||
small = cv2.resize(gray, None, fx=scale, fy=scale)
|
||||
edges = cv2.Canny(small, 40, 120)
|
||||
lines = cv2.HoughLinesP(edges, 1, np.deg2rad(angle_step), 60,
|
||||
minLineLength=min(small.shape) * .16, maxLineGap=12)
|
||||
if lines is None:
|
||||
return 0.0
|
||||
angles, weights = [], []
|
||||
for x0, y0, x1, y1 in lines.reshape(-1, 4):
|
||||
a = (np.degrees(np.arctan2(y1-y0, x1-x0)) + 45) % 90 - 45
|
||||
if abs(a) <= 5:
|
||||
angles.append(a)
|
||||
weights.append(np.hypot(x1-x0, y1-y0))
|
||||
return _dominant_angle(angles, weights)
|
||||
|
||||
|
||||
def _dominant_angle(angles, weights) -> float:
|
||||
if len(angles) < 4:
|
||||
return 0.0
|
||||
angles, weights = np.array(angles), np.array(weights)
|
||||
bins = np.arange(-5.125, 5.126, .25)
|
||||
hist, _ = np.histogram(angles, bins, weights=weights)
|
||||
peak = (bins[hist.argmax()] + bins[hist.argmax()+1]) / 2
|
||||
near = abs(angles-peak) < .4
|
||||
if weights[near].sum() < .35 * weights.sum():
|
||||
return 0.0
|
||||
return float(np.average(angles[near], weights=weights[near]))
|
||||
|
||||
|
||||
def _ruling(binary: np.ndarray, horizontal: bool) -> tuple[np.ndarray, int]:
|
||||
"""Accept a family of long lines only when positions are largely periodic."""
|
||||
h, w = binary.shape
|
||||
length = max(25, int((w if horizontal else h) * .12))
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT,
|
||||
(length, 1) if horizontal else (1, length))
|
||||
connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
|
||||
np.ones((1, 3) if horizontal else (3, 1), np.uint8))
|
||||
lines = cv2.morphologyEx(connected, cv2.MORPH_OPEN, kernel)
|
||||
counts = np.count_nonzero(lines, axis=1 if horizontal else 0)
|
||||
bands = _runs(counts > (w if horizontal else h) * .18)
|
||||
if len(bands) < 5:
|
||||
return np.zeros_like(binary), 0
|
||||
centers = np.array([(a+b)/2 for a, b in bands])
|
||||
gaps = np.diff(centers)
|
||||
# Missing lines and major/minor rulings may have integer-multiple spacing.
|
||||
candidates = gaps[gaps >= 4]
|
||||
regular = any(np.mean(abs(gaps / d - np.round(gaps / d)) < .16) >= .75
|
||||
for d in candidates)
|
||||
if not regular:
|
||||
return np.zeros_like(binary), 0
|
||||
accepted = np.zeros_like(binary)
|
||||
for a, b in bands:
|
||||
if horizontal:
|
||||
accepted[max(0, a-2):b+2] = 255
|
||||
else:
|
||||
accepted[:, max(0, a-2):b+2] = 255
|
||||
# Real scans have local warp as well as global skew. Recover shorter line
|
||||
# segments close to the established ruling family, without extending the
|
||||
# entire family into large empty gaps.
|
||||
short = max(25, int((w if horizontal else h)*.035))
|
||||
joined = cv2.morphologyEx(binary, cv2.MORPH_CLOSE,
|
||||
np.ones((1, 7) if horizontal else (7, 1), np.uint8))
|
||||
tolerant = cv2.dilate(joined, np.ones((3, 1) if horizontal else (1, 3), np.uint8))
|
||||
fragments = cv2.morphologyEx(tolerant, cv2.MORPH_OPEN,
|
||||
np.ones((1, short) if horizontal else (short, 1), np.uint8))
|
||||
nearby = cv2.dilate(accepted, np.ones((15, 1) if horizontal else (1, 15), np.uint8))
|
||||
accepted |= fragments & nearby if horizontal else fragments
|
||||
return accepted, len(bands)
|
||||
|
||||
|
||||
def _foreground(gray: np.ndarray, dpi: float, threshold: int,
|
||||
ruling: np.ndarray) -> np.ndarray:
|
||||
block = max(15, int(dpi / 5) | 1)
|
||||
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV, block, threshold)
|
||||
# Only suppress near the fitted paper lines. Preserve unusually dark ink
|
||||
# crossing pale ruling by comparing against the typical line intensity.
|
||||
mask = cv2.dilate(ruling, np.ones((3, 3), np.uint8)) > 0
|
||||
if np.any(ruling):
|
||||
samples = ((ruling > 0) & (binary > 0)).astype(np.float32)
|
||||
window = max(31, int(dpi*.4) | 1)
|
||||
weight = cv2.boxFilter(samples, -1, (window, window))
|
||||
total = cv2.boxFilter(gray.astype(np.float32)*samples, -1, (window, window))
|
||||
typical = total / np.maximum(weight, 1e-6)
|
||||
excess_ink = (gray.astype(float) < typical - 40).astype(np.uint8)
|
||||
# A dark, one-pixel remnant of a paper line is still paper. Only retain
|
||||
# locally thicker excess strokes inside the suppression mask.
|
||||
excess_ink = cv2.erode(excess_ink, np.ones((2, 2), np.uint8)) > 0
|
||||
mask &= ~excess_ink
|
||||
binary[mask] = 0
|
||||
return binary
|
||||
|
||||
|
||||
def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0,
|
||||
nv: int = 0) -> tuple[np.ndarray, int]:
|
||||
"""Filter only tiny speckles and repeated, matching edge-hole components."""
|
||||
px = dpi / 25.4
|
||||
# Small closing reconnects strokes interrupted by paper-line suppression.
|
||||
grouped = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, 8)
|
||||
keep = np.zeros(n, dtype=bool)
|
||||
x, y, w, h, area = stats[1:].T
|
||||
keep[1:] = (area >= max(4, .035*px*px)) & (np.maximum(w, h) >= .35*px)
|
||||
# Thin straight residuals on confirmed ruled paper are not credible ink.
|
||||
thin = max(2, round(.3*px))
|
||||
if nv:
|
||||
keep[1:] &= ~((w <= thin) & (h >= 3*w))
|
||||
if nh:
|
||||
keep[1:] &= ~((h <= thin) & (w >= 3*h))
|
||||
# Hole shadows form recurring shapes close to a physical side edge. Never
|
||||
# discard an entire margin strip: other writing there must remain visible.
|
||||
height, width = binary.shape
|
||||
candidates = np.flatnonzero(keep[1:]
|
||||
& ((x+w < 12*px) | (x > width-12*px))
|
||||
& (w > .8*px) & (w < 9*px) & (h > px) & (h < 12*px)) + 1
|
||||
holes = set()
|
||||
for i in candidates:
|
||||
x, y, w, h, area = stats[i]
|
||||
similar = []
|
||||
a = cv2.resize((labels[y:y+h, x:x+w] == i).astype(np.uint8), (24, 32)) > 0
|
||||
for j in candidates:
|
||||
xx, yy, ww, hh, aa = stats[j]
|
||||
if abs(x-xx) > 2*px or not (.7 < ww/w < 1.4 and .7 < hh/h < 1.4):
|
||||
continue
|
||||
b = cv2.resize((labels[yy:yy+hh, xx:xx+ww] == j).astype(np.uint8), (24, 32)) > 0
|
||||
if np.count_nonzero(a & b) / max(1, np.count_nonzero(a | b)) > .60:
|
||||
similar.append(j)
|
||||
if len(similar) >= 4 and np.ptp(stats[similar, 1]) > height*.45:
|
||||
holes.update(similar)
|
||||
if holes:
|
||||
keep[list(holes)] = False
|
||||
# Bound original residual ink, not the expanded/grouped mask.
|
||||
return (keep[labels] & (binary > 0)).astype(np.uint8), len(holes)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Split a PDF along its cumulative visible page height, in reading order."""
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator.commands.splitting_int import _prepare_split_pages
|
||||
|
||||
# Percentages emitted by the GUI have six decimal places. Recover exact page
|
||||
# boundaries despite rounding, without turning nearby in-page cuts into breaks.
|
||||
BOUNDARY_TOLERANCE_PERCENT = 0.000001
|
||||
|
||||
|
||||
def cut_position(heights: list[float], percent: float) -> tuple[int, float]:
|
||||
"""Return (page index, offset); offset zero denotes an exact page break."""
|
||||
if not heights or any(height <= 0 for height in heights):
|
||||
raise ValueError("Le PDF doit contenir des pages non vides.")
|
||||
if not math.isfinite(percent) or not 0 < percent < 100:
|
||||
raise ValueError("Le pourcentage de coupe doit être strictement entre 0 et 100.")
|
||||
total = sum(heights)
|
||||
position = total * percent / 100
|
||||
start = 0.0
|
||||
for index, height in enumerate(heights):
|
||||
if index and abs(percent - start / total * 100) <= BOUNDARY_TOLERANCE_PERCENT:
|
||||
return index, 0.0
|
||||
end = start + height
|
||||
if index + 1 < len(heights) and abs(percent - end / total * 100) <= BOUNDARY_TOLERANCE_PERCENT:
|
||||
return index + 1, 0.0
|
||||
if position < end:
|
||||
return index, position - start
|
||||
start = end
|
||||
raise ValueError("Coupe hors du document.")
|
||||
|
||||
|
||||
def split_pdf(source: Path, percent: float, first_path: Path, second_path: Path) -> None:
|
||||
"""Preserve whole pages; clip only the page actually crossed by the cut."""
|
||||
with pymupdf.open(source) as document, pymupdf.open() as first, pymupdf.open() as second:
|
||||
index, offset = cut_position([page.rect.height for page in document], percent)
|
||||
if index:
|
||||
first.insert_pdf(document, from_page=0, to_page=index - 1)
|
||||
if offset == 0:
|
||||
second.insert_pdf(document, from_page=index)
|
||||
else:
|
||||
with pymupdf.open() as page_document:
|
||||
page_document.insert_pdf(document, from_page=index, to_page=index)
|
||||
visible = _prepare_split_pages(page_document)[0]
|
||||
for target, clip in (
|
||||
(first, pymupdf.Rect(visible.x0, visible.y0, visible.x1, visible.y0 + offset)),
|
||||
(second, pymupdf.Rect(visible.x0, visible.y0 + offset, visible.x1, visible.y1)),
|
||||
):
|
||||
page = target.new_page(width=clip.width, height=clip.height)
|
||||
page.show_pdf_page(page.rect, page_document, 0, clip=clip)
|
||||
if index + 1 < len(document):
|
||||
second.insert_pdf(document, from_page=index + 1)
|
||||
first.save(first_path)
|
||||
second.save(second_path)
|
||||
+51
-7
@@ -8,6 +8,12 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
MACOS_EXECUTABLE_DIRECTORIES = (
|
||||
Path("/opt/homebrew/bin"),
|
||||
Path("/usr/local/bin"),
|
||||
Path("/Library/TeX/texbin"),
|
||||
)
|
||||
|
||||
WINDOWS_RESERVED_NAMES = {
|
||||
"CON",
|
||||
"PRN",
|
||||
@@ -71,6 +77,36 @@ def safe_filename(value: str, fallback: str = "Unknown") -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def add_platform_executable_paths(
|
||||
environment: dict[str, str], platform_name: str | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Expose common desktop-installed executables to child processes."""
|
||||
result = dict(environment)
|
||||
if (platform_name or sys.platform) != "darwin":
|
||||
return result
|
||||
existing = result.get("PATH", "").split(os.pathsep)
|
||||
additions = [str(path) for path in MACOS_EXECUTABLE_DIRECTORIES if path.is_dir()]
|
||||
result["PATH"] = os.pathsep.join(dict.fromkeys([*additions, *existing]))
|
||||
return result
|
||||
|
||||
|
||||
def find_executable(
|
||||
*candidates: str, platform_name: str | None = None
|
||||
) -> str | None:
|
||||
"""Find a command in PATH or in standard macOS package locations."""
|
||||
for candidate in candidates:
|
||||
executable = shutil.which(candidate)
|
||||
if executable:
|
||||
return executable
|
||||
if (platform_name or sys.platform) == "darwin":
|
||||
for directory in MACOS_EXECUTABLE_DIRECTORIES:
|
||||
for candidate in candidates:
|
||||
executable = directory / candidate
|
||||
if executable.is_file() and os.access(executable, os.X_OK):
|
||||
return str(executable)
|
||||
return None
|
||||
|
||||
|
||||
def open_path(path: str | Path) -> None:
|
||||
"""Open a file with the desktop's default application."""
|
||||
target = str(Path(path).expanduser().resolve())
|
||||
@@ -78,17 +114,26 @@ def open_path(path: str | Path) -> None:
|
||||
os.startfile(target) # type: ignore[attr-defined]
|
||||
elif sys.platform.startswith("linux"):
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
elif sys.platform == "darwin":
|
||||
opener = find_executable("open") or "/usr/bin/open"
|
||||
subprocess.Popen([opener, target])
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
|
||||
|
||||
def launch_pdf_arranger(path: str | Path) -> None:
|
||||
executable = shutil.which("pdf-arranger") or shutil.which("pdfarranger")
|
||||
if not executable:
|
||||
raise FileNotFoundError(
|
||||
"PDF Arranger is not installed or is not available in PATH."
|
||||
)
|
||||
subprocess.Popen([executable, str(Path(path).expanduser().resolve())])
|
||||
target = str(Path(path).expanduser().resolve())
|
||||
executable = find_executable("pdf-arranger", "pdfarranger")
|
||||
if executable:
|
||||
subprocess.Popen([executable, target])
|
||||
return
|
||||
if sys.platform == "darwin":
|
||||
opener = find_executable("open") or "/usr/bin/open"
|
||||
subprocess.Popen([opener, "-b", "com.apple.Preview", target])
|
||||
return
|
||||
raise FileNotFoundError(
|
||||
"PDF Arranger is not installed or is not available in PATH."
|
||||
)
|
||||
|
||||
|
||||
def replace_with_link_or_copy(
|
||||
@@ -118,4 +163,3 @@ def replace_with_link_or_copy(
|
||||
|
||||
shutil.copy2(source_path, destination_path)
|
||||
return "copy"
|
||||
|
||||
|
||||
+142
-103
@@ -2,89 +2,102 @@ from pathlib import Path
|
||||
import io
|
||||
from . import utils
|
||||
|
||||
main_prompt = """I'm giving you an image of several written answers to an exam.
|
||||
PERSPECTIVE_GUIDANCE = (
|
||||
"Ce barème est indicatif, si une réponse est entièrement correcte mais utilise "
|
||||
"une méthode différente, elle mérite quand même tous les points."
|
||||
)
|
||||
|
||||
Each answer is separated by a black horizontal line, and underneath,
|
||||
to the left, is indicated the ID of the answer, from `01` to `50`.
|
||||
main_prompt = """Je te fournis une image contenant plusieurs réponses manuscrites à un examen.
|
||||
|
||||
I want you to score each answer, from 0 to 4, you may score half
|
||||
points, such as 2.5. Even if a result is wrong, if the reasoning is
|
||||
correct and could lead to a right answer, you should give at least
|
||||
half the points.
|
||||
Chaque réponse est séparée de la précédente par une ligne horizontale noire.
|
||||
En dessous de cette ligne, à gauche, figure l'identifiant de la réponse,
|
||||
compris entre `01` et `50`.
|
||||
|
||||
You also need to give feedback to the student, in french :
|
||||
- which part of his answer is wrong,
|
||||
- why is it wrong
|
||||
- possibly, what he should have done instead.
|
||||
Your feedback may contain LaTeX fragments written like `$a^2 + b^2 = c^2$`.
|
||||
Attribue à chaque réponse une note de 0 à 4. Les demi-points sont autorisés,
|
||||
par exemple 2.5. Même si le résultat est faux, accorde au moins la moitié
|
||||
des points si le raisonnement est correct et pourrait conduire au bon résultat.
|
||||
|
||||
If your score is not 4, you should always provide some feedback
|
||||
explaining what's missing.
|
||||
Rédige tous les commentaires destinés à l'élève en français. Indique :
|
||||
- quelle partie de sa réponse est fausse ;
|
||||
- pourquoi elle est fausse ;
|
||||
- éventuellement, ce qu'il aurait fallu faire à la place.
|
||||
Les commentaires peuvent contenir des fragments LaTeX, par exemple
|
||||
`$a^2 + b^2 = c^2$`.
|
||||
|
||||
For each piece of feedback, if it is related to a specific part of the
|
||||
answer that is wrong, you may provide a `box_2d`, to locate this
|
||||
specific part of the answer. This `box_2d` should be in the form
|
||||
[ymin, xmin, ymax, xmax] normalized to 0-1000. If you do not provide
|
||||
one, set `box_2d` to `null`.
|
||||
Si la note n'est pas 4, fournis toujours un commentaire expliquant ce qui
|
||||
manque, sauf dans le cas `empty-answer` décrit ci-dessous.
|
||||
|
||||
If the answer is correct, there is no need to provide feedback. You do
|
||||
not have to give positive feedback, but if you do, do not provide a
|
||||
`box_2d` for it.
|
||||
Lorsqu'un commentaire concerne une erreur située dans une partie précise
|
||||
de la réponse, tu peux fournir un champ `box_2d` pour la localiser.
|
||||
Ses coordonnées doivent être de la forme [ymin, xmin, ymax, xmax],
|
||||
normalisées entre 0 et 1000. Sinon, attribue la valeur `null` à `box_2d`.
|
||||
|
||||
For example, if the student says a function is continuous when it
|
||||
isn't, provide the coordinates where the word «continuous» is. If a
|
||||
calculation went wrong, gives the coordinates of the step where it
|
||||
goes wrong, and as feedback, what went wrong.
|
||||
Si la réponse est correcte, aucun commentaire n'est nécessaire. Tu n'es pas
|
||||
obligé de faire des commentaires positifs ; si tu en fais, ne leur associe
|
||||
pas de `box_2d`.
|
||||
|
||||
Avoid giving feedback about confusing letters `n` with `m`, `x` with
|
||||
`n` or `h` with `k`. If it looks wrong, assume you read it wrong,
|
||||
unless the distinction is very important.
|
||||
Par exemple, si l'élève affirme à tort qu'une fonction est continue,
|
||||
localise le mot « continue ». Si un calcul est faux, localise l'étape où
|
||||
l'erreur apparaît et explique cette erreur dans le commentaire.
|
||||
|
||||
In some case, you may find that either
|
||||
- The student didn't answer the right question. Set the score to 0.
|
||||
Since it could be a labeling error, indicate it by setting `error`
|
||||
to \"wrong-label\".
|
||||
- You can find an answer to another question of the exercice (taking
|
||||
more than a couple of lines). Score the question you are supposed
|
||||
to score, but set `error` to \"additional-answer\".
|
||||
- The answer to the question is empty, or the student has only
|
||||
rewritten the statement of the question. In this case, set `error`
|
||||
to \"empty-answer\" and do not provide any kind of feedback.
|
||||
If there's no error, set `error` to `\"\"`.
|
||||
Évite les commentaires portant sur une confusion entre les lettres `n`
|
||||
et `m`, `x` et `n`, ou `h` et `k`. En cas de doute, suppose que tu as mal
|
||||
lu, sauf si la distinction est très importante.
|
||||
|
||||
You will answer using json describing a list of dictionary with a key
|
||||
\"id\", and a key \"result\" that contains the \"score\", a list
|
||||
\"feedback\", and possibly an \"error\". Like this example :
|
||||
Certains cas nécessitent une valeur particulière du champ `error` :
|
||||
- L'élève n'a pas répondu à la bonne question : attribue la note 0 et
|
||||
indique `wrong-label`, car il peut s'agir d'une erreur de label.
|
||||
- La réponse contient aussi une réponse à une autre question de
|
||||
l'exercice, sur plus de quelques lignes : note la question demandée,
|
||||
mais indique `additional-answer`.
|
||||
- La réponse est vide, ou l'élève a seulement recopié l'énoncé : indique
|
||||
`empty-answer` et ne fournis aucun commentaire.
|
||||
S'il n'y a aucune de ces erreurs, attribue la chaîne vide `""` à `error`.
|
||||
|
||||
[{ \"id\": \"01\",
|
||||
\"result\": {\"score\" : 2.5,
|
||||
\"feedback\": [{text: \"Un retour générique. Il faut apprendre le cours.\", box_2d: null},
|
||||
{text: \"Non, la fonction n'est pas forcément continue\", pos: [145, 280, 340, 500]}],
|
||||
\"error\": \"\"}
|
||||
},
|
||||
{ \"id\": \"04\",
|
||||
\"result\": {\"score\" : 4.,
|
||||
\"feedback\" : []
|
||||
\"error\": \"\" }
|
||||
}
|
||||
Réponds uniquement en JSON, sous la forme d'une liste d'objets contenant
|
||||
les clés `id` et `result`. L'objet `result` contient `score`, la liste
|
||||
`feedback` et `error`. Chaque commentaire contient `text` et `box_2d`.
|
||||
Conserve exactement ces clés, les identifiants et les valeurs techniques
|
||||
de `error` : ne les traduis pas. Le contenu de chaque champ `text` doit
|
||||
être en français, même si certains documents fournis sont dans une autre langue.
|
||||
|
||||
Exemple :
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "01",
|
||||
"result": {
|
||||
"score": 2.5,
|
||||
"feedback": [
|
||||
{"text": "Il manque la vérification des hypothèses du théorème.", "box_2d": null},
|
||||
{"text": "Non, la fonction n'est pas forcément continue.", "box_2d": [145, 280, 340, 500]}
|
||||
],
|
||||
"error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "04",
|
||||
"result": {"score": 4.0, "feedback": [], "error": ""}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Here is the text of the exercice (or the relevant part of the problem)
|
||||
of the exam :
|
||||
Voici l'énoncé de l'exercice ou la partie pertinente du problème :
|
||||
|
||||
```
|
||||
<<text>>
|
||||
```
|
||||
|
||||
Here is a possible correct answer :
|
||||
Voici un corrigé possible :
|
||||
|
||||
```
|
||||
<<corr>>
|
||||
```
|
||||
<<persp>>
|
||||
|
||||
You are asked to score the question or exercice labeled `<<label>>`,
|
||||
do not score or give feedback to any other question."""
|
||||
Tu dois noter uniquement la question ou l'exercice portant le label
|
||||
`<<label>>`. Ne note aucune autre question et ne formule aucun commentaire
|
||||
sur les autres questions."""
|
||||
|
||||
from .utils import get_label_text_content, get_label_sol_content, get_label_persp_content
|
||||
|
||||
@@ -95,7 +108,13 @@ def make_prompt(input_dir,full_label):
|
||||
# print("Debug : l/t/c/p", full_label, text, corr, persp)
|
||||
|
||||
if persp:
|
||||
persp = "\n\nHere are additional scoring instructions : \n\n```\n" + persp +"\n```\n"
|
||||
persp = (
|
||||
"\n\nVoici des consignes de notation complémentaires :\n\n"
|
||||
+ PERSPECTIVE_GUIDANCE
|
||||
+ "\n\n```\n"
|
||||
+ persp
|
||||
+ "\n```\n"
|
||||
)
|
||||
return main_prompt.replace("<<text>>", text).replace("<<corr>>", corr).replace("<<persp>>", persp).replace("<<label>>", full_label)
|
||||
|
||||
|
||||
@@ -103,17 +122,17 @@ from pydantic import BaseModel, Field, TypeAdapter
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
class FeedbackItem(BaseModel):
|
||||
text: str = Field(description="Feedback content")
|
||||
box_2d: Optional[List[int]] = Field(None, description="box coordinates or null")
|
||||
text: str = Field(description="Commentaire destiné à l’élève, rédigé en français.")
|
||||
box_2d: Optional[List[int]] = Field(None, description="Coordonnées [ymin, xmin, ymax, xmax] normalisées entre 0 et 1000, ou null.")
|
||||
|
||||
class ResultData(BaseModel):
|
||||
score: float = Field(description="The numeric score")
|
||||
feedback: List[FeedbackItem] = Field(description="List of feedback items")
|
||||
error: str = Field(description="Indicates if an error occurred")
|
||||
score: float = Field(description="Note numérique de la réponse, sur 4 points.")
|
||||
feedback: List[FeedbackItem] = Field(description="Liste des commentaires destinés à l’élève, rédigés en français.")
|
||||
error: str = Field(description="Type d’erreur : wrong-label, additional-answer, empty-answer, ou chaîne vide.")
|
||||
|
||||
class EvaluationEntry(BaseModel):
|
||||
id: str = Field(description="Entry identifier")
|
||||
result: ResultData = Field(description="Result details")
|
||||
id: str = Field(description="Identifiant exact de la réponse.")
|
||||
result: ResultData = Field(description="Note, commentaires en français et éventuelle erreur pour cette réponse.")
|
||||
|
||||
# These nested definitions do not work with the batch api, unroll them
|
||||
UNROLLED_SCHEMA = {
|
||||
@@ -121,24 +140,24 @@ UNROLLED_SCHEMA = {
|
||||
"items": {
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"id": {"type": "STRING", "description": "Entry identifier"},
|
||||
"id": {"type": "STRING", "description": "Identifiant exact de la réponse."},
|
||||
"result": {
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"score": {"type": "NUMBER", "description": "The numeric score"},
|
||||
"error": {"type": "STRING", "description": "Indicates if an error occurred"},
|
||||
"score": {"type": "NUMBER", "description": "Note numérique de la réponse, sur 4 points."},
|
||||
"error": {"type": "STRING", "description": "Type d’erreur : wrong-label, additional-answer, empty-answer, ou chaîne vide."},
|
||||
"feedback": {
|
||||
"type": "ARRAY",
|
||||
"description": "List of feedback items",
|
||||
"description": "Liste des commentaires destinés à l’élève, rédigés en français.",
|
||||
"items": {
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"text": {"type": "STRING", "description": "Feedback content"},
|
||||
"text": {"type": "STRING", "description": "Commentaire destiné à l’élève, rédigé en français."},
|
||||
"box_2d": {
|
||||
"type": "ARRAY",
|
||||
"items": {"type": "INTEGER"},
|
||||
"nullable": True,
|
||||
"description": "box coordinates or null"
|
||||
"description": "Coordonnées [ymin, xmin, ymax, xmax] normalisées entre 0 et 1000, ou null."
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
@@ -174,6 +193,7 @@ def generate_request(input_dir, file, full_label):
|
||||
]
|
||||
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=1.0,
|
||||
top_p=0.95,
|
||||
seed=0,
|
||||
@@ -216,20 +236,27 @@ def request_for_box_correction(pdf_path, original_feedbacks):
|
||||
|
||||
localized_feedbacks = [f for f in original_feedbacks if f["box_2d"]]
|
||||
|
||||
prompt = f"""
|
||||
Here is a single student's submission to a question in a written exam. The following JSON contains feedback items with bounding boxes (box_2d) that are incorrect. Each piece of feedback is supposed to be related to a piece of the answer that is wrong.
|
||||
prompt = f"""Voici la réponse d'un élève à une question d'examen. Le JSON
|
||||
ci-dessous contient des commentaires dont les rectangles de localisation
|
||||
(`box_2d`) sont incorrects. Chaque commentaire doit correspondre à la
|
||||
partie de la réponse où se trouve l'erreur signalée.
|
||||
|
||||
For example, if the student says a function is continuous when it
|
||||
isn't, the coordinates should be where the word «continuous» is. If a
|
||||
calculation went wrong, the coordinates should be where the step where
|
||||
it goes wrong, and the feedback is what went wrong.
|
||||
Par exemple, si l'élève affirme à tort qu'une fonction est continue,
|
||||
les coordonnées doivent localiser le mot « continue ». Si un calcul est
|
||||
faux, elles doivent localiser l'étape où apparaît l'erreur expliquée dans
|
||||
le commentaire.
|
||||
|
||||
Please analyze the image and return the same feedback json content, but with ONLY the box_2d coordinates corrected for this specific image.
|
||||
Coordinates must be [ymin, xmin, ymax, xmax] scaled to 1000. If a box is invalid/not found, return null for it.
|
||||
Original feedback:
|
||||
Analyse l'image et renvoie le même contenu JSON en corrigeant UNIQUEMENT
|
||||
les coordonnées `box_2d` pour cette image. Conserve les commentaires en
|
||||
français à l'identique : ne les reformule pas et ne les traduis pas.
|
||||
Conserve les noms des clés JSON.
|
||||
Les coordonnées doivent être [ymin, xmin, ymax, xmax], normalisées entre
|
||||
0 et 1000. Si la zone est introuvable ou le rectangle invalide, renvoie
|
||||
`null` pour ce rectangle.
|
||||
|
||||
{json.dumps(localized_feedbacks, indent=2)}
|
||||
"""
|
||||
Commentaires d'origine :
|
||||
{json.dumps(localized_feedbacks, indent=2, ensure_ascii=False)}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -244,6 +271,7 @@ it goes wrong, and the feedback is what went wrong.
|
||||
]
|
||||
|
||||
config = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=1.0,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema()
|
||||
@@ -252,50 +280,61 @@ it goes wrong, and the feedback is what went wrong.
|
||||
|
||||
def request_for_wrong_label(pdf_path, label, enonce, labels_txt):
|
||||
|
||||
prompt = f"""This image is a part of the answer of a student to a written exam.
|
||||
prompt = f"""Cette image représente une partie de la réponse d'un élève à un examen.
|
||||
|
||||
It was initially labeled '{label}' but I suspect this label is wrong. Perhaps the student himself wrote the wrong label.
|
||||
Elle porte initialement le label '{label}', mais je soupçonne une erreur
|
||||
de label. L'élève a peut-être lui-même écrit le mauvais label.
|
||||
|
||||
You need to analyse this image, and find the label of the question it answers. Do not trust the label written by the student but instead check the content of its answer and the notation he uses to identify the correct label of the question the student answered.
|
||||
Analyse l'image et identifie le label de la question à laquelle cette
|
||||
réponse correspond. Ne te fie pas au label écrit par l'élève : examine
|
||||
le contenu de la réponse et les notations utilisées.
|
||||
|
||||
Return ONLY the exact label string.
|
||||
|
||||
Here is the full content of the exam :
|
||||
Renvoie UNIQUEMENT le label exact, sans le modifier ni le traduire.
|
||||
|
||||
Voici l'énoncé complet de l'examen :
|
||||
{enonce}
|
||||
|
||||
Here is a list of all possible labels. You need to answer with one of these :
|
||||
|
||||
Voici les labels possibles. Ta réponse doit être l'un d'entre eux :
|
||||
{labels_txt}
|
||||
"""
|
||||
|
||||
contents = [types.Content(role="user", parts=[
|
||||
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
|
||||
types.Part.from_text(text=prompt)])]
|
||||
config = types.GenerateContentConfig(temperature=1.0)
|
||||
config = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=1.0,
|
||||
)
|
||||
return contents, config
|
||||
|
||||
def request_for_additional_answer(pdf_path, label, enonce, labels_txt):
|
||||
prompt = f"""This image is a part of the answer of a student to a written exam.
|
||||
prompt = f"""Cette image représente une partie de la réponse d'un élève à un examen.
|
||||
|
||||
It was initially labeled '{label}' but I suspect this image also contains answers to another, or several other questions.
|
||||
Elle porte initialement le label '{label}', mais je soupçonne qu'elle
|
||||
contient aussi des réponses à une ou plusieurs autres questions.
|
||||
|
||||
You need to analyse this image, and find the list of the labels of the questions it answers. Return ONLY the list of the exact label strings.
|
||||
Analyse l'image et identifie les labels des questions auxquelles elle
|
||||
répond. Renvoie UNIQUEMENT une liste JSON contenant les labels exacts,
|
||||
sans les modifier ni les traduire.
|
||||
|
||||
If the end of the image only contains the first line of an answer to another question, ignore it.
|
||||
|
||||
Here is the full content of the exam :
|
||||
Si le bas de l'image ne contient que la première ligne d'une réponse à
|
||||
une autre question, ignore cette ligne.
|
||||
|
||||
Voici l'énoncé complet de l'examen :
|
||||
{enonce}
|
||||
|
||||
Here is a list of all possible labels. You need to answer with a list one of these :
|
||||
|
||||
Voici les labels possibles. Chaque élément de ta liste doit être l'un
|
||||
d'entre eux :
|
||||
{labels_txt}
|
||||
"""
|
||||
|
||||
contents = [types.Content(role="user", parts=[
|
||||
types.Part.from_bytes(data=get_single_image_bytes(pdf_path), mime_type="image/jpeg"),
|
||||
types.Part.from_text(text=prompt)
|
||||
])]
|
||||
config = types.GenerateContentConfig(temperature=1.0, response_mime_type="application/json")
|
||||
config = types.GenerateContentConfig(
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
|
||||
temperature=1.0,
|
||||
response_mime_type="application/json",
|
||||
)
|
||||
return contents, config
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json, configuration, read_json, utils
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.platform import safe_filename
|
||||
|
||||
RETURN_ANSWER_OPTIONS_FILE = Path(".copienator") / "return_answers.json"
|
||||
|
||||
|
||||
def configured_return_answer_options() -> dict[str, bool]:
|
||||
return {
|
||||
"context": bool(configuration.RETURN_ANSWERS_CONTEXT),
|
||||
"question": bool(configuration.RETURN_ANSWERS_QUESTION),
|
||||
"solution": bool(configuration.RETURN_ANSWERS_SOLUTION),
|
||||
}
|
||||
|
||||
|
||||
def save_return_answer_options(
|
||||
root: Path,
|
||||
*,
|
||||
context: bool,
|
||||
question: bool,
|
||||
solution: bool,
|
||||
) -> None:
|
||||
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(
|
||||
path,
|
||||
{"context": context, "question": question, "solution": solution},
|
||||
)
|
||||
|
||||
|
||||
def load_return_answer_options(root: Path) -> dict[str, bool]:
|
||||
options = configured_return_answer_options()
|
||||
path = Path(root) / RETURN_ANSWER_OPTIONS_FILE
|
||||
if not path.is_file():
|
||||
return options
|
||||
loaded = read_json(path)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"Expected a return-answer options object in {path}")
|
||||
for name in options:
|
||||
if name in loaded:
|
||||
if type(loaded[name]) is not bool:
|
||||
raise ValueError(f"Expected a boolean for {name!r} in {path}")
|
||||
options[name] = loaded[name]
|
||||
return options
|
||||
|
||||
|
||||
def publish_answer_returns(
|
||||
root: Path,
|
||||
source: Path,
|
||||
destination: Path,
|
||||
*,
|
||||
answers_only: bool = False,
|
||||
) -> None:
|
||||
"""Publish reviewed answers, optionally without touching return metadata."""
|
||||
scores = read_json(source / "score.json")
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"Expected a score object in {source}")
|
||||
info_path = source / "info.json"
|
||||
if not info_path.is_file():
|
||||
raise ValueError(f"Missing {info_path}; recompile annotations before giving-names")
|
||||
info = read_json(info_path)
|
||||
if not isinstance(info, dict) or set(info) != set(scores):
|
||||
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
|
||||
for label, entry in info.items():
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or set(entry) != {"present", "not_empty", "touched", "score"}
|
||||
or any(type(entry[key]) is not bool for key in ("present", "not_empty", "touched"))
|
||||
or (entry["not_empty"] and not entry["present"])
|
||||
or (entry["touched"] and not entry["not_empty"])
|
||||
):
|
||||
raise ValueError(f"Invalid question information in {info_path}; recompile annotations")
|
||||
# Match score.json, including manual score edits awaiting recompilation.
|
||||
entry["score"] = scores[label]
|
||||
|
||||
answers_dir = destination / "answers"
|
||||
if answers_dir.is_symlink():
|
||||
raise ValueError(f"Expected a real answer directory: {answers_dir}")
|
||||
if configuration.RETURN_ANSWERS_ENABLED:
|
||||
options = load_return_answer_options(root)
|
||||
labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]]
|
||||
|
||||
# Import the rendering backend only when individual images are requested.
|
||||
from copienator.commands.annotating import make_base_image
|
||||
from copienator.commands.reading_annotations import concatenate
|
||||
|
||||
all_labels = utils.read_all_labels(root)
|
||||
with staged_directory(answers_dir) as staging:
|
||||
for label in sorted(labels, key=utils.natural_key):
|
||||
paths = []
|
||||
if options["context"]:
|
||||
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
|
||||
if options["question"]:
|
||||
paths.append(utils.pdf_image_of_enonce(root, label))
|
||||
if options["solution"]:
|
||||
paths.append(utils.pdf_image_of_solution(root, label))
|
||||
images = []
|
||||
for path in paths:
|
||||
if path:
|
||||
supplement, _, _ = make_base_image(path)
|
||||
if supplement is None:
|
||||
raise ValueError(f"Could not render {path}")
|
||||
images.append(supplement)
|
||||
with Image.open(source / f"{label}.jpg") as answer:
|
||||
images.append(answer.convert("RGB"))
|
||||
image = concatenate(images)
|
||||
output = staging / f"{safe_filename(label)}.jpg"
|
||||
if output.exists():
|
||||
raise ValueError(
|
||||
f"Answer labels produce the same filename in {answers_dir}: {label}"
|
||||
)
|
||||
image.save(output)
|
||||
elif answers_dir.exists():
|
||||
# Replace the managed directory with an empty one to remove stale exports.
|
||||
with staged_directory(answers_dir):
|
||||
pass
|
||||
if not answers_only:
|
||||
atomic_write_json(destination / "info.json", info)
|
||||
(destination / "touched.json").unlink(missing_ok=True)
|
||||
+21
-6
@@ -160,23 +160,38 @@ def compile_to_pdf(text, output_pdf_path):
|
||||
# env['TEXINPUTS'] = f".:{current_dir}:"
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if "minted" in text:
|
||||
subprocess.run(
|
||||
result = subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', tex_filename],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_lines = [
|
||||
line.strip() for line in result.stdout.splitlines()
|
||||
if line.lstrip().startswith("!")
|
||||
]
|
||||
detail = f": {error_lines[0]}" if error_lines else ""
|
||||
print(
|
||||
f"Warning: LaTeX compilation failed for {output_pdf_path} "
|
||||
f"(exit code {result.returncode}){detail}"
|
||||
)
|
||||
|
||||
generated_pdf = os.path.join(temp_dir, pdf_filename)
|
||||
if os.path.exists(generated_pdf):
|
||||
shutil.move(generated_pdf, output_pdf_path)
|
||||
else:
|
||||
print(f"Warning: LaTeX compilation produced no PDF for {output_pdf_path}")
|
||||
except Exception as e:
|
||||
print(f"Compilation error for {output_pdf_path}: {e}")
|
||||
|
||||
+24
-1
@@ -109,6 +109,10 @@ class EvaluationWorkspace:
|
||||
def correction_progress_file(self) -> Path:
|
||||
return self.root / "correction_progress.json"
|
||||
|
||||
@property
|
||||
def correction_pending_responses_file(self) -> Path:
|
||||
return self.root / "correction_pending_responses.json"
|
||||
|
||||
@property
|
||||
def batch_jobs_file(self) -> Path:
|
||||
return self.root / "batch_jobs.json"
|
||||
@@ -165,7 +169,26 @@ class EvaluationWorkspace:
|
||||
def return_dir(self) -> Path:
|
||||
return self.root / "A Rendre"
|
||||
|
||||
@property
|
||||
def refaire_session_id(self) -> str | None:
|
||||
from .json_io import read_json
|
||||
|
||||
session = read_json(self.root / "refaire-session.json", default=None)
|
||||
if session is None:
|
||||
return None
|
||||
ident = session.get("id") if isinstance(session, dict) else None
|
||||
if not isinstance(ident, str) or not re.fullmatch(r"reprise-[0-9]{8}-[0-9]{6}-[a-f0-9]{8}", ident):
|
||||
raise ValueError("Invalid refaire-session.json")
|
||||
return ident
|
||||
|
||||
@property
|
||||
def refaire_session_dir(self) -> Path | None:
|
||||
ident = self.refaire_session_id
|
||||
return self.root / "Reprises" / ident if ident else None
|
||||
|
||||
def annotation_dir(self, mode: str) -> Path:
|
||||
if mode == "refaire" and self.refaire_session_dir is not None:
|
||||
return self.refaire_session_dir / "BRnot"
|
||||
directories = {
|
||||
"simple": "Anot",
|
||||
"checks": "Bnot",
|
||||
@@ -215,7 +238,7 @@ class EvaluationWorkspace:
|
||||
missing = [
|
||||
relative_path
|
||||
for relative_path in relative_paths
|
||||
if not (self.root / relative_path).is_dir()
|
||||
if not (self.annotation_dir("refaire") if relative_path == "BRnot" else self.root / relative_path).is_dir()
|
||||
]
|
||||
if missing:
|
||||
raise WorkspaceValidationError(self.root, missing)
|
||||
|
||||
+911
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
"""Non-blocking, cancellable batch checks while the desktop GUI is open."""
|
||||
|
||||
import queue
|
||||
|
||||
from .runner import ProcessRunner
|
||||
|
||||
CHECK_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
|
||||
class BatchMonitor:
|
||||
def __init__(self, scheduler, on_ready, on_status, on_output):
|
||||
self.scheduler = scheduler
|
||||
self.on_ready = on_ready
|
||||
self.on_status = on_status
|
||||
self.on_output = on_output
|
||||
self.active = False
|
||||
self.timer = None
|
||||
self.runner = None
|
||||
|
||||
def start(self, command, cwd, environment, log_path):
|
||||
if self.active:
|
||||
return
|
||||
self.arguments = (command, cwd, environment, log_path)
|
||||
self.active = True
|
||||
self._check()
|
||||
|
||||
def stop(self):
|
||||
self.active = False
|
||||
if self.timer is not None:
|
||||
self.scheduler.after_cancel(self.timer)
|
||||
self.timer = None
|
||||
if self.runner is not None:
|
||||
try:
|
||||
self.runner.force_stop()
|
||||
except OSError as exc:
|
||||
self.on_output(f"Arrêt de la vérification : {exc}\n")
|
||||
self.runner = None
|
||||
self.on_status("Vérification automatique arrêtée.")
|
||||
|
||||
def _later(self):
|
||||
self.timer = self.scheduler.after(CHECK_INTERVAL_MS, self._check)
|
||||
|
||||
def _check(self):
|
||||
self.timer = None
|
||||
if not self.active:
|
||||
return
|
||||
self.runner = ProcessRunner()
|
||||
self.on_status("Vérification des batchs en cours…")
|
||||
try:
|
||||
self.runner.start(*self.arguments)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
self.on_output(f"Vérification impossible : {exc}\n")
|
||||
self.runner = None
|
||||
self.on_status("Échec de la vérification. Nouvel essai dans 5 minutes.")
|
||||
self._later()
|
||||
return
|
||||
self.timer = self.scheduler.after(100, self._poll)
|
||||
|
||||
def _poll(self):
|
||||
self.timer = None
|
||||
if not self.active or self.runner is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
event, payload = self.runner.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if event in {"output", "runner_error"}:
|
||||
self.on_output(str(payload))
|
||||
elif event == "finished":
|
||||
code, interrupted = payload
|
||||
self.runner = None
|
||||
if code == 0 and not interrupted:
|
||||
self.active = False
|
||||
self.on_status("Tous les résultats batch sont prêts.")
|
||||
self.on_ready()
|
||||
else:
|
||||
self.on_status("Résultats pas encore prêts. Nouvelle vérification dans 5 minutes.")
|
||||
self._later()
|
||||
return
|
||||
self.timer = self.scheduler.after(100, self._poll)
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
import pymupdf
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from copienator.pdf_cut import cut_position
|
||||
|
||||
PAGE_GAP = 28
|
||||
SNAP_PIXELS = 12
|
||||
|
||||
|
||||
def percentage_at_y(y: float, heights: list[float], scale: float) -> float:
|
||||
"""Convert canvas y to document height, snapping across inter-page gaps."""
|
||||
top = 0.0
|
||||
cumulative = 0.0
|
||||
total = sum(heights)
|
||||
for index, height in enumerate(heights):
|
||||
bottom = top + height * scale
|
||||
if index + 1 < len(heights) and bottom - SNAP_PIXELS <= y <= bottom + PAGE_GAP + SNAP_PIXELS:
|
||||
return (cumulative + height) / total * 100
|
||||
if y <= bottom:
|
||||
return max(0.0, min(100.0, (cumulative + (y - top) / scale) / total * 100))
|
||||
cumulative += height
|
||||
top = bottom + PAGE_GAP
|
||||
return 100.0
|
||||
|
||||
|
||||
def y_at_percentage(percent: float, heights: list[float], scale: float) -> float:
|
||||
if percent <= 0:
|
||||
return 0.0
|
||||
if percent >= 100:
|
||||
return sum(heights) * scale + (len(heights) - 1) * PAGE_GAP
|
||||
index, offset = cut_position(heights, percent)
|
||||
top = sum(heights[:index]) * scale + index * PAGE_GAP
|
||||
return top - PAGE_GAP / 2 if index and offset == 0 else top + offset * scale
|
||||
|
||||
|
||||
def cut_operator(percent: float, keep: int, mode: str) -> str:
|
||||
value = f"{percent:.6f}".rstrip("0").rstrip(".")
|
||||
return f"c{{{value}}}{keep}{mode}"
|
||||
|
||||
|
||||
class CutHelper(tk.Toplevel):
|
||||
def __init__(self, parent, path: Path, on_accept, initial=None):
|
||||
# Load the source before creating a window so invalid PDFs leave no dialog.
|
||||
with pymupdf.open(path) as document:
|
||||
if not len(document):
|
||||
raise ValueError("Le PDF est vide.")
|
||||
self.heights = [page.rect.height for page in document]
|
||||
width = min(850, parent.winfo_screenwidth() - 100)
|
||||
self.scale = min(1.5, width / max(page.rect.width for page in document))
|
||||
rendered = []
|
||||
for page in document:
|
||||
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
|
||||
colorspace=pymupdf.csRGB)
|
||||
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
|
||||
super().__init__(parent)
|
||||
self.title(f"Cut — {path.name}")
|
||||
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
|
||||
self.transient(parent.winfo_toplevel())
|
||||
self.on_accept = on_accept
|
||||
self.percent = initial[0] if initial else 50.0
|
||||
self.keep = tk.IntVar(value=initial[1] if initial else 1)
|
||||
self.mode = tk.StringVar(value=initial[2] if initial else ">")
|
||||
self.caption = tk.StringVar()
|
||||
ttk.Label(self, text="Déplacez la barre rouge. Entrée : afficher la commande ; Échap : annuler.",
|
||||
wraplength=width).pack(anchor="w", padx=8, pady=5)
|
||||
controls = ttk.Frame(self)
|
||||
controls.pack(fill="x", padx=8)
|
||||
ttk.Label(controls, text="Conserver à la source :").pack(side="left")
|
||||
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left")
|
||||
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left")
|
||||
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left")
|
||||
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
|
||||
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
|
||||
viewport = ttk.Frame(self)
|
||||
viewport.pack(fill="both", expand=True)
|
||||
self.canvas = tk.Canvas(viewport, background="#555555", highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(viewport, command=self.canvas.yview)
|
||||
self.canvas.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
self.images = [ImageTk.PhotoImage(image, master=self) for image in rendered]
|
||||
top = 0.0
|
||||
self.width = width
|
||||
for index, image in enumerate(self.images):
|
||||
self.canvas.create_image(0, top, anchor="nw", image=image)
|
||||
top += self.heights[index] * self.scale
|
||||
if index + 1 < len(self.images):
|
||||
top += PAGE_GAP
|
||||
self.canvas.configure(scrollregion=(0, 0, width, top))
|
||||
self.bar = self.canvas.create_line(0, 0, width, 0, fill="#ff3030", width=4)
|
||||
self.canvas.bind("<Button-1>", self.move_bar)
|
||||
self.canvas.bind("<B1-Motion>", self.move_bar)
|
||||
self.canvas.bind("<Button-4>", lambda event: self.canvas.yview_scroll(-3, "units"))
|
||||
self.canvas.bind("<Button-5>", lambda event: self.canvas.yview_scroll(3, "units"))
|
||||
self.canvas.bind("<MouseWheel>", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units"))
|
||||
self.bind("<Return>", self.accept)
|
||||
self.bind("<Escape>", lambda event: self.destroy())
|
||||
self.keep.trace_add("write", lambda *_: self.draw_bar())
|
||||
self.mode.trace_add("write", lambda *_: self.draw_bar())
|
||||
self.draw_bar()
|
||||
self.canvas.yview_moveto(max(0, (y_at_percentage(self.percent, self.heights, self.scale) - 200) / top))
|
||||
self.focus_set()
|
||||
self.grab_set()
|
||||
|
||||
def move_bar(self, event):
|
||||
self.percent = percentage_at_y(self.canvas.canvasy(event.y), self.heights, self.scale)
|
||||
self.draw_bar()
|
||||
|
||||
def draw_bar(self):
|
||||
y = y_at_percentage(self.percent, self.heights, self.scale)
|
||||
self.canvas.coords(self.bar, 0, y, self.width, y)
|
||||
text = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
||||
if 0 < self.percent < 100:
|
||||
index, offset = cut_position(self.heights, self.percent)
|
||||
text += f" — entre les pages {index} et {index + 1}" if offset == 0 else f" — page {index + 1}"
|
||||
self.caption.set(text)
|
||||
|
||||
def accept(self, event=None):
|
||||
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
||||
rounded = float(operator.split("{")[1].split("}")[0])
|
||||
if not 0 < rounded < 100:
|
||||
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_accept(operator)
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from copienator.platform import windows_filename_problems
|
||||
from copienator.platform import find_executable, windows_filename_problems
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -33,8 +33,10 @@ def collect_diagnostics(
|
||||
checks = [
|
||||
DiagnosticCheck(
|
||||
"Système",
|
||||
os.name == "nt" or sys.platform.startswith("linux"),
|
||||
f"{sys.platform} — Linux et Windows sont pris en charge",
|
||||
os.name == "nt"
|
||||
or sys.platform.startswith("linux")
|
||||
or sys.platform == "darwin",
|
||||
f"{sys.platform} — Linux, Windows et macOS sont pris en charge",
|
||||
),
|
||||
DiagnosticCheck("Python", True, sys.executable),
|
||||
]
|
||||
@@ -49,7 +51,7 @@ def collect_diagnostics(
|
||||
"pdf2image": "pdf2image",
|
||||
"reportlab": "reportlab",
|
||||
"img2pdf": "img2pdf",
|
||||
"PyMuPDF": "fitz",
|
||||
"PyMuPDF": "pymupdf",
|
||||
"ftfy": "ftfy",
|
||||
"ezodf": "ezodf",
|
||||
"Google GenAI": "google.genai",
|
||||
@@ -65,7 +67,13 @@ def collect_diagnostics(
|
||||
("PDF Arranger", ("pdf-arranger", "pdfarranger"), False),
|
||||
)
|
||||
for label, candidates, required in programs:
|
||||
executable = next((shutil.which(candidate) for candidate in candidates if shutil.which(candidate)), None)
|
||||
executable = find_executable(*candidates)
|
||||
if label == "PDF Arranger" and not executable and sys.platform == "darwin":
|
||||
system_open = Path("/usr/bin/open")
|
||||
opener = find_executable("open") or (
|
||||
str(system_open) if system_open.is_file() else None
|
||||
)
|
||||
executable = f"{opener} (Aperçu)" if opener else None
|
||||
detail = executable or "Introuvable dans PATH"
|
||||
checks.append(DiagnosticCheck(label, executable is not None, detail, required))
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from copienator import CliError
|
||||
from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text
|
||||
from copienator.platform import open_path
|
||||
from .cut_helper import CutHelper
|
||||
|
||||
|
||||
HELP = """La correction propose x> pour un mauvais label et -> pour une réponse supplémentaire lorsque le PDF cible existe déjà. Vérifiez les deux PDF avant de choisir.
|
||||
|
||||
Format : Copie01 Label source OP Label cible|
|
||||
Conservez Copie suivi du numéro et les labels exacts (sans .pdf). Les espaces dans les labels sont acceptés ; entourez l’opérateur d’espaces.
|
||||
|
||||
-> : fusionner dans la cible, conserver la source.
|
||||
x> : fusionner dans la cible, archiver la source.
|
||||
-x : remplacer la cible par une copie de la source, conserver la source.
|
||||
xx : remplacer la cible par une copie de la source, archiver la source.
|
||||
ss : conserver les deux PDF sans fusion.
|
||||
sx : conserver la source, archiver la cible, sans fusion.
|
||||
xs : archiver la source, conserver la cible, sans fusion.
|
||||
|
||||
c{43}1> : couper la source à 43 %, conserver le début et ajouter la fin à la cible.
|
||||
c{43}2x : couper la source à 43 %, conserver la fin et remplacer la cible par le début.
|
||||
1 conserve le début, 2 conserve la fin ; > fusionne l’autre partie avec la cible, x la remplace. Le pourcentage porte sur la hauteur cumulée des pages visibles. Les décimales sont acceptées. Une seule coupe par label source est autorisée.
|
||||
Cut permet de placer la coupe, avec accrochage entre les pages. Entrée ferme l’aperçu et affiche la commande à recopier ci-dessus : aucun fichier n’est modifié. Une séparation entre pages conserve les pages entières. La source complète est archivée en _old ; les deux labels modifiés sont générés en _new et ajoutés à refaire.json.
|
||||
|
||||
Pour les fusions : « Source x> Cible| » place la cible avant la source ; « Source x> |Cible » place la source avant la cible. Même règle avec -> et c{…}1>/c{…}2> (pour la partie transférée) ; sans |, la cible vient en premier.
|
||||
Vous pouvez changer l’opérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne ne résout pas son conflit.
|
||||
|
||||
Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter. Seul le fichier enregistré est appliqué. L’archivage utilise le suffixe _old ; les PDF créés utilisent _new. Après succès, manual_resolutions.txt est supprimé et correction.json est mis à jour. Si des PDF sont créés, refaire.json est généré : relancez la correction avec --refaire."""
|
||||
|
||||
|
||||
class ManualResolutionPanel(ttk.Frame):
|
||||
def __init__(self, parent, get_evaluation):
|
||||
super().__init__(parent)
|
||||
self.get_evaluation = get_evaluation
|
||||
self.pdf_buttons = []
|
||||
self.cut_buttons = []
|
||||
actions = ttk.Frame(self)
|
||||
actions.pack(fill="x")
|
||||
self.editor_button = ttk.Button(
|
||||
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
|
||||
)
|
||||
self.editor_button.pack(side="left")
|
||||
ttk.Button(actions, text="Recharger", command=self.reload).pack(side="left", padx=6)
|
||||
self.cut_result = tk.StringVar()
|
||||
result = ttk.Frame(self)
|
||||
result.pack(fill="x", pady=4)
|
||||
ttk.Entry(result, textvariable=self.cut_result, state="readonly").pack(side="left", fill="x", expand=True)
|
||||
ttk.Button(result, text="Copier la commande Cut", command=self.copy_cut_command).pack(side="left", padx=6)
|
||||
self.status = ttk.Label(self, wraplength=650)
|
||||
self.status.pack(fill="x", pady=4)
|
||||
preview = ttk.Frame(self)
|
||||
preview.pack(fill="both", expand=True)
|
||||
self.text = tk.Text(preview, height=10, width=50, wrap="word", state="disabled")
|
||||
scroll = ttk.Scrollbar(preview, command=self.text.yview)
|
||||
self.text.configure(yscrollcommand=scroll.set)
|
||||
scroll.pack(side="right", fill="y")
|
||||
self.text.pack(fill="both", expand=True)
|
||||
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
|
||||
self.reload()
|
||||
|
||||
def manual_path(self) -> Path | None:
|
||||
evaluation = self.get_evaluation()
|
||||
return evaluation / "manual_resolutions.txt" if evaluation else None
|
||||
|
||||
def open_editor(self):
|
||||
path = self.manual_path()
|
||||
if path is None or not path.is_file():
|
||||
self.reload()
|
||||
return
|
||||
try:
|
||||
# .txt is opened in the desktop's associated text editor.
|
||||
open_path(path)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
messagebox.showerror("Ouverture impossible", str(exc))
|
||||
|
||||
def open_pdf(self, evaluation, copy_id, label):
|
||||
path = get_actual_pdf(evaluation / "Copies", copy_id, label)
|
||||
try:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"PDF introuvable : {path}")
|
||||
open_path(path)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
messagebox.showerror("Ouverture du PDF", str(exc))
|
||||
|
||||
def reload(self):
|
||||
for button in self.pdf_buttons + self.cut_buttons:
|
||||
button.destroy()
|
||||
self.pdf_buttons.clear()
|
||||
self.cut_buttons.clear()
|
||||
self.cut_result.set("")
|
||||
self.text.configure(state="normal")
|
||||
self.text.delete("1.0", "end")
|
||||
path = self.manual_path()
|
||||
self.editor_button.configure(state="disabled")
|
||||
try:
|
||||
if path is None:
|
||||
raise FileNotFoundError("Chargez une évaluation.")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
|
||||
else:
|
||||
self.editor_button.configure(state="normal")
|
||||
malformed = []
|
||||
for number, raw in enumerate(content.splitlines(keepends=True), 1):
|
||||
self.text.insert("end", raw.rstrip("\r\n"))
|
||||
try:
|
||||
instructions = parse_instruction_text(raw)
|
||||
except CliError:
|
||||
malformed.append(str(number))
|
||||
else:
|
||||
if instructions:
|
||||
instruction = instructions[0]
|
||||
for title, label in (("PDF source", instruction.old_label),
|
||||
("PDF cible", instruction.new_label)):
|
||||
button = ttk.Button(
|
||||
self.text, text=title,
|
||||
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
|
||||
)
|
||||
self.pdf_buttons.append(button)
|
||||
self.text.window_create("end", window=button, padx=8)
|
||||
button = ttk.Button(self.text, text="Cut",
|
||||
command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
|
||||
self.cut_buttons.append(button)
|
||||
self.text.window_create("end", window=button, padx=8)
|
||||
if raw.endswith("\n"):
|
||||
self.text.insert("end", "\n")
|
||||
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
|
||||
f" — {len(self.pdf_buttons) // 2} instruction(s)"
|
||||
)
|
||||
self.status.configure(text=str(path) + detail)
|
||||
finally:
|
||||
self.text.configure(state="disabled")
|
||||
|
||||
def copy_cut_command(self):
|
||||
if self.cut_result.get():
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(self.cut_result.get())
|
||||
|
||||
def open_cut(self, evaluation, instruction):
|
||||
source = get_actual_pdf(evaluation / "Copies", instruction.copy_id, instruction.old_label)
|
||||
def accepted(operator):
|
||||
target = ("|" + instruction.new_label) if instruction.pipe_first else (instruction.new_label + "|")
|
||||
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
|
||||
try:
|
||||
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
|
||||
CutHelper(self, source, accepted, initial)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
messagebox.showerror("Ouverture du PDF", str(exc))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Launch native desktop notifications without blocking Tk."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from copienator.platform import find_executable
|
||||
|
||||
|
||||
def notify_desktop(title: str, message: str) -> None:
|
||||
if sys.platform.startswith("linux"):
|
||||
executable = find_executable("notify-send")
|
||||
if not executable:
|
||||
raise RuntimeError("Installez notify-send (libnotify) pour les notifications de bureau.")
|
||||
command = [executable, "--app-name=Copienator", "--", title, message]
|
||||
elif sys.platform == "darwin":
|
||||
command = ["/usr/bin/osascript", "-e",
|
||||
f"display notification {json.dumps(message, ensure_ascii=False)} with title {json.dumps(title, ensure_ascii=False)}"]
|
||||
elif os.name == "nt":
|
||||
# Encode the script and quote strings as literals; no shell interpolation.
|
||||
quote = lambda value: "'" + value.replace("'", "''") + "'"
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"$notice = New-Object System.Windows.Forms.NotifyIcon;"
|
||||
"$notice.Icon = [System.Drawing.SystemIcons]::Information;"
|
||||
"$notice.Visible = $true;"
|
||||
f"$notice.ShowBalloonTip(10000, {quote(title)}, {quote(message)}, "
|
||||
"[System.Windows.Forms.ToolTipIcon]::Info);"
|
||||
"Start-Sleep -Seconds 12; $notice.Dispose()"
|
||||
)
|
||||
command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden",
|
||||
"-EncodedCommand", base64.b64encode(script.encode("utf-16-le")).decode("ascii")]
|
||||
else:
|
||||
raise RuntimeError("Notifications de bureau indisponibles sur ce système.")
|
||||
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Selection and command planning for the optional redo workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
|
||||
from copienator import EvaluationWorkspace, read_json
|
||||
from copienator.utils import natural_key
|
||||
|
||||
SECTION = "Refaire des copies (facultatif)"
|
||||
ALL_LABELS = "Toute la copie"
|
||||
ALL_COPIES = "Toutes les copies"
|
||||
LAYOUTS = {
|
||||
"Automatique": "auto",
|
||||
"Par question (groupé)": "grouped",
|
||||
"Par copie": "copies",
|
||||
}
|
||||
|
||||
|
||||
def resolve_layout(
|
||||
selection: list[list], labels: list[str], choice: str = "auto"
|
||||
) -> str:
|
||||
if choice in {"grouped", "copies"}:
|
||||
return choice
|
||||
seen = set()
|
||||
for _name, selected in selection:
|
||||
current = set(selected or labels)
|
||||
if seen & current:
|
||||
return "grouped"
|
||||
seen.update(current)
|
||||
return "copies"
|
||||
|
||||
|
||||
def copies_with_answer(copies: dict[str, Path], label: str) -> list[str]:
|
||||
return [
|
||||
name
|
||||
for name, path in copies.items()
|
||||
if any(
|
||||
(path.with_suffix("") / f"{label}{suffix}.pdf").is_file()
|
||||
for suffix in ("", "_new")
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def available_copies(evaluation: Path) -> dict[str, Path]:
|
||||
return {
|
||||
path.stem: path
|
||||
for path in sorted((evaluation / "Copies").glob("Copie*.pdf"), key=natural_key)
|
||||
if re.fullmatch(r"Copie\d+", path.stem)
|
||||
}
|
||||
|
||||
|
||||
def validate_selection(
|
||||
entries: object, copies: dict[str, Path], labels: list[str]
|
||||
) -> list[list]:
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ValueError("Ajoutez au moins une copie à refaire.")
|
||||
result = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, list) or len(entry) != 2:
|
||||
raise ValueError("Sélection de copies invalide.")
|
||||
name, selected = entry
|
||||
if not isinstance(name, str) or name not in copies:
|
||||
raise ValueError(f"Copie introuvable : {name}")
|
||||
if not isinstance(selected, list) or any(
|
||||
not isinstance(label, str) or label not in labels for label in selected
|
||||
):
|
||||
raise ValueError(f"Question inconnue pour {name}. Reprenez la sélection.")
|
||||
if name in result:
|
||||
raise ValueError(f"Copie sélectionnée plusieurs fois : {name}")
|
||||
result[name] = sorted(set(selected), key=natural_key)
|
||||
return [[name, result[name]] for name in sorted(result, key=natural_key)]
|
||||
|
||||
|
||||
def load_selection(evaluation: Path) -> list[list]:
|
||||
return validate_selection(
|
||||
read_json(evaluation / "refaire.json"),
|
||||
available_copies(evaluation),
|
||||
EvaluationWorkspace(evaluation).read_labels(),
|
||||
)
|
||||
|
||||
|
||||
class RefaireSelection(ttk.Frame):
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
evaluation: Path,
|
||||
draft: dict,
|
||||
directories: tuple[str, ...],
|
||||
preferred: str,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.columnconfigure(1, weight=1)
|
||||
self.copies = available_copies(evaluation)
|
||||
self.labels = (
|
||||
EvaluationWorkspace(evaluation).read_labels()
|
||||
if (evaluation / "labels").is_file()
|
||||
else []
|
||||
)
|
||||
self.entries = {}
|
||||
self.error = ""
|
||||
try:
|
||||
entries = draft.get("selection")
|
||||
if entries is None:
|
||||
entries = read_json(evaluation / "refaire.json", default=[])
|
||||
if entries:
|
||||
self.entries = dict(
|
||||
validate_selection(entries, self.copies, self.labels)
|
||||
)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
self.error = str(exc)
|
||||
self.copy_var = tk.StringVar(value=next(iter(self.copies), ""))
|
||||
self.label_var = tk.StringVar(value=ALL_LABELS)
|
||||
source = draft.get("annotation_dir", preferred)
|
||||
self.source_var = tk.StringVar(
|
||||
value=source if source in directories else preferred
|
||||
)
|
||||
ttk.Label(self, text="Copie").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.copy_var,
|
||||
values=(ALL_COPIES, *self.copies),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(self, text="Question").grid(row=1, column=0, sticky="w", pady=5)
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.label_var,
|
||||
values=(ALL_LABELS, *self.labels),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=1, column=1, sticky="ew", pady=5)
|
||||
ttk.Button(self, text="+ Ajouter", command=self.add).grid(
|
||||
row=1, column=2, padx=(8, 0)
|
||||
)
|
||||
self.table = ttk.Treeview(
|
||||
self, columns=("labels",), height=4, selectmode="browse"
|
||||
)
|
||||
self.table.heading("#0", text="Copie")
|
||||
self.table.heading("labels", text="Questions à refaire")
|
||||
self.table.column("#0", width=100, stretch=False)
|
||||
self.table.column("labels", width=330)
|
||||
self.table.grid(row=4, column=0, columnspan=3, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.table.yview)
|
||||
scrollbar.grid(row=4, column=3, sticky="ns")
|
||||
self.table.configure(yscrollcommand=scrollbar.set)
|
||||
ttk.Button(
|
||||
self, text="Retirer la copie sélectionnée", command=self.remove
|
||||
).grid(row=5, column=0, columnspan=3, sticky="w", pady=5)
|
||||
ttk.Label(self, text="Passage principal").grid(
|
||||
row=3, column=0, sticky="w", padx=(0, 8)
|
||||
)
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.source_var,
|
||||
values=directories,
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=3, column=1, sticky="ew")
|
||||
layout = draft.get("layout", "auto")
|
||||
self.layout_var = tk.StringVar(
|
||||
value=next(
|
||||
(name for name, code in LAYOUTS.items() if code == layout),
|
||||
"Automatique",
|
||||
)
|
||||
)
|
||||
ttk.Label(self, text="PDF à vérifier").grid(row=2, column=0, sticky="w")
|
||||
ttk.Combobox(
|
||||
self,
|
||||
textvariable=self.layout_var,
|
||||
values=tuple(LAYOUTS),
|
||||
state="readonly",
|
||||
width=12,
|
||||
).grid(row=2, column=1, sticky="ew")
|
||||
self.message_var = tk.StringVar(
|
||||
value=self.error
|
||||
or "Choisissez « Toutes les copies » pour refaire une question dans toute la classe."
|
||||
)
|
||||
help_label = ttk.Label(
|
||||
self,
|
||||
textvariable=self.message_var,
|
||||
wraplength=500,
|
||||
)
|
||||
help_label.grid(row=6, column=0, columnspan=3, sticky="w", pady=5)
|
||||
self.bind(
|
||||
"<Configure>",
|
||||
lambda event: help_label.configure(wraplength=max(200, event.width - 10)),
|
||||
)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
self.table.delete(*self.table.get_children())
|
||||
for name in sorted(self.entries, key=natural_key):
|
||||
self.table.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=name,
|
||||
text=name,
|
||||
values=(", ".join(self.entries[name]) or ALL_LABELS,),
|
||||
)
|
||||
|
||||
def add(self):
|
||||
name, label = self.copy_var.get(), self.label_var.get()
|
||||
if name not in (ALL_COPIES, *self.copies) or label not in (
|
||||
ALL_LABELS,
|
||||
*self.labels,
|
||||
):
|
||||
return
|
||||
names = (
|
||||
[name]
|
||||
if name != ALL_COPIES
|
||||
else list(self.copies)
|
||||
if label == ALL_LABELS
|
||||
else copies_with_answer(self.copies, label)
|
||||
)
|
||||
for copy_name in names:
|
||||
# Adding a question must not narrow a copy already selected in full.
|
||||
if label == ALL_LABELS:
|
||||
self.entries[copy_name] = []
|
||||
elif copy_name not in self.entries or self.entries[copy_name]:
|
||||
self.entries[copy_name] = sorted(
|
||||
set(self.entries.get(copy_name, [])) | {label}, key=natural_key
|
||||
)
|
||||
message = f"{len(names)} copie(s) ajoutée(s)."
|
||||
if name == ALL_COPIES and len(names) < len(self.copies):
|
||||
message += f" {len(self.copies) - len(names)} sans réponse découpée pour cette question."
|
||||
self.message_var.set(message)
|
||||
self.refresh()
|
||||
|
||||
def remove(self):
|
||||
for name in self.table.selection():
|
||||
self.entries.pop(name, None)
|
||||
self.refresh()
|
||||
|
||||
def values(self):
|
||||
return {
|
||||
"selection": [[name, labels] for name, labels in self.entries.items()],
|
||||
"annotation_dir": self.source_var.get(),
|
||||
"layout": LAYOUTS[self.layout_var.get()],
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Preserve completed redo passes and activate a fresh working directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.filesystem import staged_files
|
||||
|
||||
RESTART_WARNING = (
|
||||
"Appelez « Nouvelle reprise » seulement après avoir importé les résultats "
|
||||
"de la reprise en cours et exécuté « Mettre à jour les copies finales ». "
|
||||
"Cette consigne s’applique aussi à « Refaire la même sélection »."
|
||||
)
|
||||
|
||||
|
||||
def _identifier() -> str:
|
||||
return f"reprise-{datetime.now().astimezone():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def begin_pass(
|
||||
workspace: EvaluationWorkspace,
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
keep_selection: bool,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""Activate a pass atomically with its selection and reset GUI state.
|
||||
|
||||
Existing review files remain in place. Legacy BRnot is copied once into
|
||||
the archive; failures before activation leave the old pass active.
|
||||
"""
|
||||
selection = read_json(workspace.refaire_file, default=[])
|
||||
previous = workspace.refaire_session_dir
|
||||
if previous is None and (
|
||||
workspace.refaire_file.exists() or workspace.annotation_dir("refaire").exists()
|
||||
):
|
||||
previous = workspace.root / "Reprises" / _identifier()
|
||||
previous.mkdir(parents=True)
|
||||
legacy = workspace.annotation_dir("refaire")
|
||||
if legacy.is_dir():
|
||||
shutil.copytree(legacy, previous / "BRnot")
|
||||
if previous is not None:
|
||||
atomic_write_json(previous / "refaire.json", selection)
|
||||
atomic_write_json(
|
||||
previous / "progression.json",
|
||||
{
|
||||
name: entry
|
||||
for name, entry in state.get("steps", {}).items()
|
||||
if name.startswith("refaire_")
|
||||
},
|
||||
)
|
||||
|
||||
ident = _identifier()
|
||||
directory = workspace.root / "Reprises" / ident
|
||||
directory.mkdir(parents=True)
|
||||
new_selection = selection if keep_selection else []
|
||||
updated = copy.deepcopy(state)
|
||||
values = dict(
|
||||
updated.get("steps", {}).get("refaire_selection", {}).get("values", {})
|
||||
)
|
||||
values["selection"] = new_selection
|
||||
updated["steps"] = {
|
||||
name: entry
|
||||
for name, entry in updated.get("steps", {}).items()
|
||||
if not name.startswith("refaire_")
|
||||
}
|
||||
updated["steps"]["refaire_selection"] = {"values": values}
|
||||
updated.setdefault("history", []).append(
|
||||
{
|
||||
"step": "refaire_selection",
|
||||
"action": "repeat" if keep_selection else "new",
|
||||
"session": ident,
|
||||
"timestamp": datetime.now().astimezone().isoformat(),
|
||||
}
|
||||
)
|
||||
atomic_write_json(directory / "refaire.json", new_selection)
|
||||
atomic_write_json(directory / "session.json", {"id": ident, "values": values})
|
||||
with staged_files(workspace.root) as staging:
|
||||
atomic_write_json(staging / workspace.refaire_file.name, new_selection)
|
||||
atomic_write_json(staging / workspace.gui_state_file.name, updated)
|
||||
atomic_write_json(staging / "refaire-session.json", {"id": ident})
|
||||
return updated, ident
|
||||
@@ -27,12 +27,15 @@ class ProcessRunner:
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
environment: dict[str, str],
|
||||
log_path: Path,
|
||||
log_path: Path | None,
|
||||
) -> None:
|
||||
if self.running:
|
||||
raise RuntimeError("Un processus est déjà en cours")
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._log_file = log_path.open("wb")
|
||||
if log_path is not None:
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._log_file = log_path.open("wb")
|
||||
else:
|
||||
self._log_file = None
|
||||
self._interrupted = False
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
|
||||
+398
-68
@@ -7,6 +7,9 @@ from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import configuration
|
||||
from copienator.configuration import ALWAYS_CROP
|
||||
|
||||
EVALUATION = "${evaluation}"
|
||||
|
||||
|
||||
@@ -16,6 +19,7 @@ class ArgumentSpec:
|
||||
label: str
|
||||
kind: str = "text" # text, int, bool, choice, path
|
||||
flag: str | None = None
|
||||
false_flag: str | None = None
|
||||
default: object = ""
|
||||
choices: tuple[str, ...] = ()
|
||||
help: str = ""
|
||||
@@ -32,6 +36,8 @@ class CommandVariant:
|
||||
fixed_args: tuple[str, ...] = ()
|
||||
fixed_args_before_positionals: bool = False
|
||||
dangerous: bool = False
|
||||
danger_warning: str | None = None
|
||||
supports_verbose: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -49,31 +55,72 @@ class StepDefinition:
|
||||
auto_start_first_visit: bool = False
|
||||
skip_for_live_correction: bool = False
|
||||
skip_without_manual_conflicts: bool = False
|
||||
extra_arguments_help: str = ""
|
||||
extra_arguments_variants: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def is_manual(self) -> bool:
|
||||
return all(variant.kind == "manual" for variant in self.variants)
|
||||
|
||||
|
||||
def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec:
|
||||
def arg_target(help_text: str = "un dossier d’évaluation ou un fichier à traiter") -> ArgumentSpec:
|
||||
return ArgumentSpec(
|
||||
"target",
|
||||
"Cible",
|
||||
kind="path",
|
||||
default=EVALUATION,
|
||||
positional=True,
|
||||
help=help_text,
|
||||
help=f"La cible peut être {help_text.rstrip('.')}.",
|
||||
)
|
||||
|
||||
|
||||
def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python = lambda ident, label, script, **kwargs: CommandVariant(
|
||||
ident, label, script, "python", **kwargs
|
||||
)
|
||||
def python(ident, label, script, **kwargs):
|
||||
supports_verbose = kwargs.pop("supports_verbose", True)
|
||||
return CommandVariant(
|
||||
ident,
|
||||
label,
|
||||
script,
|
||||
"python",
|
||||
supports_verbose=supports_verbose,
|
||||
**kwargs,
|
||||
)
|
||||
manual = lambda ident, label="Étape manuelle": CommandVariant(
|
||||
ident, label, None, "manual"
|
||||
)
|
||||
|
||||
return_answer_arguments = ()
|
||||
if configuration.RETURN_ANSWERS_ENABLED:
|
||||
return_answer_arguments = (
|
||||
ArgumentSpec(
|
||||
"return_answers_context",
|
||||
"Inclure le contexte dans answers",
|
||||
"bool",
|
||||
"--return-answers-context",
|
||||
"--no-return-answers-context",
|
||||
default=configuration.RETURN_ANSWERS_CONTEXT,
|
||||
help="Inclut les pages de contexte applicables avant chaque réponse individuelle publiée dans answers.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"return_answers_question",
|
||||
"Inclure l’énoncé dans answers",
|
||||
"bool",
|
||||
"--return-answers-question",
|
||||
"--no-return-answers-question",
|
||||
default=configuration.RETURN_ANSWERS_QUESTION,
|
||||
help="Inclut l’énoncé actuel avant chaque réponse individuelle publiée dans answers.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"return_answers_solution",
|
||||
"Inclure la correction dans answers",
|
||||
"bool",
|
||||
"--return-answers-solution",
|
||||
"--no-return-answers-solution",
|
||||
default=configuration.RETURN_ANSWERS_SOLUTION,
|
||||
help="Inclut la correction actuelle avant chaque réponse individuelle publiée dans answers.",
|
||||
),
|
||||
)
|
||||
|
||||
steps = [
|
||||
StepDefinition(
|
||||
"inputs",
|
||||
@@ -89,23 +136,39 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Prétraitement de l’énoncé",
|
||||
"Analyser l’énoncé",
|
||||
"Détecte les questions, leurs groupes, le corrigé et les indications de barème.",
|
||||
(
|
||||
python("gemini", "Analyse avec Gemini", "statement"),
|
||||
python("personal", "Alternative personnelle", "statement-personal"),
|
||||
),
|
||||
((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||||
if show_personal_steps else ())
|
||||
+ (python("gemini", "Analyse avec Gemini", "statement"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"restart",
|
||||
"Ignorer le cache (--restart)",
|
||||
kind="bool",
|
||||
flag="--restart",
|
||||
help="Ignore les résultats Gemini mis en cache et recommence entièrement l’analyse de l’énoncé.",
|
||||
variants=("gemini",),
|
||||
),
|
||||
),
|
||||
requires=("enonce.pdf", "enonce.tex", "correction.tex"),
|
||||
artifacts=("labels", "Text", "Sol", "Persp"),
|
||||
),
|
||||
StepDefinition(
|
||||
"statement_groups", "Prétraitement de l’énoncé", "Regrouper les questions avec Gemini",
|
||||
"Facultatif après la génération : remplace les groupes par exercice par des groupes "
|
||||
"proposés par Gemini, en conservant les labels, les énoncés, les solutions et les barèmes.",
|
||||
(python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
"statement_persp", "Prétraitement de l’énoncé", "Remplacer les barèmes par Gemini",
|
||||
"Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. "
|
||||
"Les énoncés et les solutions personnels sont conservés.",
|
||||
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
|
||||
),
|
||||
StepDefinition(
|
||||
"review_persp",
|
||||
"Prétraitement de l’énoncé",
|
||||
@@ -128,9 +191,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"python",
|
||||
("rotate",),
|
||||
fixed_args_before_positionals=True,
|
||||
supports_verbose=True,
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -146,9 +210,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"python",
|
||||
("rename",),
|
||||
fixed_args_before_positionals=True,
|
||||
supports_verbose=True,
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
),
|
||||
StepDefinition(
|
||||
"page_splitter",
|
||||
@@ -156,18 +221,66 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Séparer et réordonner les pages",
|
||||
"Ouvre l’outil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.",
|
||||
(python("default", "Séparation des pages", "page-split"),),
|
||||
arguments=(arg_target(),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||||
),
|
||||
),
|
||||
artifacts=("Copies", "Copies Originales"),
|
||||
),
|
||||
StepDefinition(
|
||||
"crop_blank_margins",
|
||||
"Prétraitement des copies",
|
||||
"Rogner les zones vides",
|
||||
"Facultatif : détecte les zones vides en haut et en bas malgré les lignes et les perforations, "
|
||||
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
|
||||
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
|
||||
(python("default", "Rognage des zones vides", "crop-margins"),),
|
||||
arguments=(
|
||||
arg_target("le dossier de l’évaluation ou un PDF du dossier Copies"),
|
||||
ArgumentSpec(
|
||||
"workers",
|
||||
"Copies traitées en parallèle",
|
||||
"int",
|
||||
"--workers",
|
||||
default=5,
|
||||
help="Fixe le nombre maximal de copies rognées simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
requires=("Copies",),
|
||||
auto_start_first_visit=ALWAYS_CROP,
|
||||
),
|
||||
StepDefinition(
|
||||
"cutleft",
|
||||
"Prétraitement des copies",
|
||||
"Découper la marge des labels",
|
||||
"Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.",
|
||||
"Découper une partie à gauche pour détection des labels",
|
||||
"Produit les images de la partie gauche des copies. Dans la fenêtre de découpe : "
|
||||
"n décale la zone de 50 px vers la droite, N de 100 px, t de 50 px vers la gauche, "
|
||||
"l l’élargit de 50 px, 1 utilise les pages entières, Entrée valide et s signale une erreur. "
|
||||
"Une copie précise peut être ciblée.",
|
||||
(python("default", "Découpe", "crop-labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
|
||||
ArgumentSpec(
|
||||
"fullpage",
|
||||
"Toujours utiliser la page entière",
|
||||
"bool",
|
||||
"--fullpage",
|
||||
help="Désactive la découpe habituelle de la marge gauche et transmet chaque page entière à la détection des labels.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"marked",
|
||||
"Copies signalées uniquement",
|
||||
"bool",
|
||||
"--marked",
|
||||
help="Limite l’opération aux copies signalées dans l’interface au lieu de traiter toutes les copies.",
|
||||
),
|
||||
),
|
||||
requires=("Copies",),
|
||||
artifacts=("Cutleft",),
|
||||
@@ -180,16 +293,27 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
(python("default", "Détection des labels", "labels"),),
|
||||
arguments=(
|
||||
arg_target(),
|
||||
ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Régénérer les résultats",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Relance la détection même lorsqu’un fichier JSON de labels existe déjà pour la copie.",
|
||||
),
|
||||
),
|
||||
requires=("labels", "Copies", "Cutleft"),
|
||||
artifacts=("Copies/*.json",),
|
||||
extra_arguments_help=(
|
||||
"PDF de copie ou images Cutleft supplémentaires de cette évaluation, "
|
||||
"séparés par des espaces. Mettez entre guillemets les chemins contenant des espaces."
|
||||
),
|
||||
),
|
||||
StepDefinition(
|
||||
"plotting",
|
||||
"Labels et regroupement",
|
||||
"Vérifier visuellement les labels",
|
||||
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.",
|
||||
"Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie. "
|
||||
"Le raccourci p revient à l’image précédente, y compris dans la copie précédente.",
|
||||
(python("default", "Vérification", "review-labels"),),
|
||||
arguments=(arg_target(),),
|
||||
requires=("labels", "Cutleft"),
|
||||
@@ -206,32 +330,48 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
artifacts=("Copies/Copie*/*",),
|
||||
auto_start_first_visit=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"crop_exercise_bottoms",
|
||||
"Labels et regroupement",
|
||||
"Rogner le bas des réponses",
|
||||
"Facultatif après le découpage par labels : rogne uniquement les grands espaces "
|
||||
"vides au bas des réponses. Les PDF modifiés sont remplacés, les originaux sont "
|
||||
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
|
||||
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
|
||||
arguments=(
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"workers",
|
||||
"PDF traités en parallèle",
|
||||
"int",
|
||||
"--workers",
|
||||
default=5,
|
||||
help="Fixe le nombre maximal de PDF de réponse analysés simultanément. Une valeur élevée accélère le traitement si la machine possède assez de cœurs et de mémoire.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
requires=("Copies/Copie*/*.pdf",),
|
||||
auto_start_first_visit=ALWAYS_CROP,
|
||||
),
|
||||
StepDefinition(
|
||||
"grouping",
|
||||
"Labels et regroupement",
|
||||
"Regrouper les réponses",
|
||||
"Regroupe les réponses portant le même label pour préparer les requêtes.",
|
||||
(python("default", "Regroupement", "group-answers"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
requires=("Copies",),
|
||||
artifacts=("Par label",),
|
||||
auto_start_first_visit=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"verify_groups",
|
||||
"Labels et regroupement",
|
||||
"Vérifier les groupes produits",
|
||||
"Vérifie que chaque réponse PDF apparaît dans les métadonnées des groupes.",
|
||||
(python("default", "Vérification des groupes", "verify-groups"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
requires=("Copies", "Par label"),
|
||||
),
|
||||
StepDefinition(
|
||||
"correction",
|
||||
"Correction",
|
||||
"Lancer ou intégrer la correction",
|
||||
"Choisir une correction immédiate, batch, hybride, une recorrection, ou l’intégration d’un batch.",
|
||||
"Choisir une correction immédiate, batch, hybride, une recorrection, ou l’intégration d’un batch. "
|
||||
"En correction immédiate, la barre d’état affiche le nombre de groupes traités et le total. "
|
||||
"Interrompre bloque les nouveaux appels Gemini, attend ceux déjà en cours, sauvegarde leurs résultats puis arrête la commande. "
|
||||
"Au prochain lancement, les validations encore nécessaires (mauvais label ou contenu supplémentaire) reprennent sans refaire la requête principale.",
|
||||
(
|
||||
python("live", "Correction immédiate", "correct"),
|
||||
python("batch", "Préparer toutes les requêtes batch", "correct", fixed_args=("--batch",)),
|
||||
@@ -246,13 +386,39 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Évaluation ou image Group_X.jpg"),
|
||||
ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)),
|
||||
ArgumentSpec("limit", "Limite d’appels Pro", "int", "--limit", variants=("live",)),
|
||||
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
|
||||
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Écraser les corrections existantes",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Relance les corrections demandées même lorsqu’un résultat existe déjà.",
|
||||
variants=("live", "batch", "hybrid", "integrate"),
|
||||
),
|
||||
ArgumentSpec(
|
||||
"limit",
|
||||
"Limite d’appels Pro",
|
||||
"int",
|
||||
"--limit",
|
||||
help="Limite le nombre d’appels au modèle Pro pendant cette exécution. Laissez ce champ vide pour ne pas imposer de limite.",
|
||||
variants=("live", "hybrid", "refaire"),
|
||||
),
|
||||
ArgumentSpec(
|
||||
"batch_from",
|
||||
"Premier label envoyé en batch",
|
||||
"text",
|
||||
"--batch-from",
|
||||
help="Indique le premier label traité en batch ; les labels précédents sont corrigés immédiatement.",
|
||||
variants=("hybrid",),
|
||||
),
|
||||
),
|
||||
requires=("Par label", "Persp", "labels"),
|
||||
artifacts=("correction.json", "batch_requests_*.jsonl"),
|
||||
extra_arguments_help=(
|
||||
"Images Group_X.jpg supplémentaires de cette évaluation, séparées par des espaces. "
|
||||
"Mettez entre guillemets les chemins contenant des espaces."
|
||||
),
|
||||
extra_arguments_variants=("live", "batch", "hybrid", "refaire"),
|
||||
),
|
||||
StepDefinition(
|
||||
"submit_batches",
|
||||
@@ -260,7 +426,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Envoyer les batchs",
|
||||
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
|
||||
(python("default", "Envoi", "batch-submit"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
artifacts=("batch_jobs.json",),
|
||||
skip_for_live_correction=True,
|
||||
@@ -269,9 +435,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"batch_status",
|
||||
"Correction",
|
||||
"Consulter l’état des batchs",
|
||||
"Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.",
|
||||
"Vérifie les jobs enregistrés pour l’évaluation. Passe à la récupération uniquement lorsque tous ont réussi et que leurs résultats sont disponibles ; sinon, reste sur cette étape.",
|
||||
(python("default", "État des batchs", "batch-status"),),
|
||||
arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),),
|
||||
optional=True,
|
||||
skip_for_live_correction=True,
|
||||
),
|
||||
@@ -281,30 +446,31 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Récupérer les résultats batch",
|
||||
"Télécharge et rassemble les réponses des jobs terminés.",
|
||||
(python("default", "Récupération", "batch-fetch"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
skip_for_live_correction=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"post_correction",
|
||||
"Correction",
|
||||
"Nettoyer la correction",
|
||||
"Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.",
|
||||
(python("default", "Post-correction", "post-correction"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
requires=("correction.json",),
|
||||
),
|
||||
StepDefinition(
|
||||
"manual_resolution",
|
||||
"Correction",
|
||||
"Résoudre les conflits manuels",
|
||||
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.",
|
||||
"Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter. "
|
||||
"Après succès, le GUI revient à la correction avec « Recorrection depuis refaire.json » sélectionné.",
|
||||
(python("default", "Résolution", "resolve-manual"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
requires=("manual_resolutions.txt", "correction.json"),
|
||||
skip_without_manual_conflicts=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"post_correction",
|
||||
"Correction",
|
||||
"Nettoyer la correction",
|
||||
"Sauvegarde correction.json dans correction_precleanup.json, puis corrige certains problèmes d’encodage et prépare le texte pour LaTeX. La sauvegarde est remplacée à chaque nettoyage.",
|
||||
(python("default", "Post-correction", "post-correction"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
requires=("correction.json",),
|
||||
),
|
||||
StepDefinition(
|
||||
"annotation",
|
||||
"Génération des annotations",
|
||||
@@ -316,9 +482,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"overwrite",
|
||||
"Écraser les sorties",
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Remplace les annotations déjà générées dans le dossier de sortie sélectionné.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Génère uniquement les copies et questions inscrites dans refaire.json, dans le dossier réservé à la reprise.",
|
||||
variants=("checks", "grouped"),
|
||||
),
|
||||
),
|
||||
requires=("correction.json",),
|
||||
artifacts=("Anot", "Bnot", "BGnot"),
|
||||
@@ -330,16 +509,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
|
||||
(python("default", "Export", "export"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier d’annotations à exporter : groupées, avec cases, ou simples.",
|
||||
positional=True,
|
||||
),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Exporte les annotations du passage de reprise BRnot au lieu du dossier principal.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
),
|
||||
@@ -357,16 +543,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
|
||||
(python("default", "Import", "import"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
|
||||
positional=True,
|
||||
),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Importe les annotations manuscrites dans BRnot pour le passage de reprise.",
|
||||
),
|
||||
),
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -379,27 +572,60 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
python("grouped", "Lecture BGnot", "read-grouped"),
|
||||
),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
|
||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
|
||||
),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"update_score",
|
||||
"Régénérer avec les nouveaux énoncés/corrigés et les score.json",
|
||||
"bool",
|
||||
"--update-score",
|
||||
help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
"Mode refaire",
|
||||
"bool",
|
||||
"--refaire",
|
||||
help="Lit les annotations du passage de reprise BRnot et les fusionne avec les copies principales.",
|
||||
variants=("grouped",),
|
||||
),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Passage principal du mode refaire",
|
||||
"choice",
|
||||
"--annotation-dir",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||||
variants=("grouped",),
|
||||
),
|
||||
) + return_answer_arguments,
|
||||
),
|
||||
StepDefinition(
|
||||
"giving_names",
|
||||
"Finalisation",
|
||||
"Attribuer les noms et préparer A Rendre",
|
||||
"Crée le dossier A Rendre à partir du dossier d’annotations choisi.",
|
||||
"Crée le dossier A Rendre à partir du dossier d’annotations choisi, sans passer automatiquement à l’étape suivante. "
|
||||
"Après l’exécution, vous pouvez renommer chaque dossier, ainsi que son fichier .jpg et son fichier .pdf, avec un autre nom ; le suffixe (id) du dossier est conservé. "
|
||||
"Les outils affichés permettent d’identifier et corriger les noms Unknown ou attribués à plusieurs copies. Cliquez ensuite sur « Marquer terminée ».",
|
||||
(python("default", "Attribution des noms", "giving-names"),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Dossier d’annotations",
|
||||
"choice",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Choisissez le dossier d’annotations utilisé pour construire les fichiers nommés dans A Rendre.",
|
||||
positional=True,
|
||||
),
|
||||
ArgumentSpec(
|
||||
"update",
|
||||
"Mettre à jour uniquement les images de answers",
|
||||
"bool",
|
||||
"--update",
|
||||
help="Met à jour uniquement les images du dossier answers de chaque élève existant, en identifiant la copie par le numéro final entre parenthèses et sans modifier le nom du dossier ni les autres fichiers.",
|
||||
),
|
||||
),
|
||||
artifacts=("A Rendre",),
|
||||
),
|
||||
@@ -424,10 +650,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"Étapes personnelles",
|
||||
"Mettre à jour le fichier ODS",
|
||||
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
|
||||
(python("default", "Mise à jour ODS", "update-ods"),),
|
||||
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
|
||||
arguments=(
|
||||
arg_target("Dossier de l’évaluation"),
|
||||
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"sum",
|
||||
"Écrire seulement la somme",
|
||||
"bool",
|
||||
"--sum",
|
||||
help="Écrit uniquement la note totale de chaque élève dans le fichier ODS, sans détailler les scores par question.",
|
||||
),
|
||||
),
|
||||
personal=True,
|
||||
),
|
||||
@@ -451,9 +683,11 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"final_score",
|
||||
"Étapes personnelles",
|
||||
"Ajouter le score final",
|
||||
"Génère les fichiers de diffusion avec le score final.",
|
||||
(python("default", "Score final", "add-final-score"),),
|
||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
||||
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
|
||||
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
|
||||
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.",
|
||||
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
personal=True,
|
||||
),
|
||||
StepDefinition(
|
||||
@@ -473,10 +707,98 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
personal=True,
|
||||
optional=True,
|
||||
),
|
||||
StepDefinition(
|
||||
"clean",
|
||||
"Archivage",
|
||||
"Nettoyer les fichiers intermédiaires",
|
||||
"Supprime définitivement les fichiers permettant de reprendre le parcours. "
|
||||
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
|
||||
"les journaux, ainsi que les images, PDF, score.json et info.json de A Rendre.",
|
||||
(
|
||||
python(
|
||||
"default",
|
||||
"Nettoyage définitif",
|
||||
"clean",
|
||||
fixed_args=("--yes",),
|
||||
dangerous=True,
|
||||
danger_warning=(
|
||||
"Le nettoyage est irréversible.\n\n"
|
||||
"Toute la progression du GUI et les fichiers intermédiaires "
|
||||
"seront supprimés. Il ne sera plus possible de reprendre une "
|
||||
"étape sans régénérer ses données.\n\n"
|
||||
"Les PDF traités, les fichiers textuels de l’énoncé, "
|
||||
"correction.json, les journaux, ainsi que les images, PDF et "
|
||||
"score.json et info.json de A Rendre seront conservés.\n\n"
|
||||
"Continuer ?"
|
||||
),
|
||||
),
|
||||
python(
|
||||
"dry_run",
|
||||
"Prévisualiser sans supprimer",
|
||||
"clean",
|
||||
fixed_args=("--dry-run",),
|
||||
),
|
||||
),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
optional=True,
|
||||
requires=("Copies", "correction.json", "A Rendre"),
|
||||
),
|
||||
]
|
||||
steps[-1:-1] = build_refaire_workflow()
|
||||
return [step for step in steps if show_personal_steps or not step.personal]
|
||||
|
||||
|
||||
def build_refaire_workflow() -> list[StepDefinition]:
|
||||
from .refaire import SECTION
|
||||
|
||||
selection = StepDefinition(
|
||||
"refaire_selection", SECTION, "Choisir les copies et les questions",
|
||||
"Sélectionnez les copies et les questions à refaire, puis enregistrez la sélection. "
|
||||
"Le passage principal doit être terminé ; conservez ses annotations.",
|
||||
(CommandVariant("default", "Sélection", None, "manual"),),
|
||||
requires=("Copies", "labels", "correction.json"),
|
||||
)
|
||||
definitions = [
|
||||
("review", "Reprendre le découpage", "Vérifiez et ajustez les labels des copies sélectionnées. Chaque copie s’ouvre à son tour. Fermez la fenêtre pour passer à la suivante.", "review-labels", (), True),
|
||||
("split", "Redécouper les réponses", "À exécuter après une modification du découpage. Traite toutes les copies sélectionnées ; vérifiez les fichiers _new et _old en cas de résolution manuelle.", "split-answers", (), True),
|
||||
("correct", "Refaire la correction", "Relance la correction des seules questions sélectionnées. Peut être ignorée pour corriger manuellement les résultats.", "correct", ("--refaire",), True),
|
||||
("annotate", "Préparer les copies à vérifier", "Génère les questions sélectionnées avec des cases dans BRnot, par question ou par copie selon la sélection. Remplace le précédent passage dans BRnot.", "annotate-checks", ("--refaire", "--overwrite"), False),
|
||||
("export", "Exporter vers la tablette", "Exporte BRnot vers EXPORT_DIR. Retirez les anciens fichiers d’export avant le transfert.", "export", ("--refaire",), True),
|
||||
("tablet", "Vérifier sur la tablette", "Annotez les PDF exportés, puis placez les retours dans IMPORT_DIR sans changer leur nom (nom de groupe ou Copie01.pdf…). Retournez aussi les PDF sans modification. Retirez les anciens fichiers d’import.", None, (), False),
|
||||
("import", "Importer les copies vérifiées", "Importe les PDF retournés dans BRnot. Vous pouvez ignorer cette étape si les fichiers Concat_annotated.pdf y sont déjà en place.", "import", ("--refaire",), True),
|
||||
("merge", "Mettre à jour les copies finales", "Fusionne les questions refaites avec le reste de chaque copie dans le dossier du passage principal. Relancez ensuite la préparation de A Rendre, le calcul des notes et la diffusion.", "read-grouped", ("--refaire",), False),
|
||||
]
|
||||
steps = [selection]
|
||||
for suffix, title, description, program, flags, optional in definitions:
|
||||
arguments = (arg_target(),) if program else ()
|
||||
if suffix == "merge":
|
||||
arguments += (ArgumentSpec(
|
||||
"annotation_dir",
|
||||
"Passage principal",
|
||||
"choice",
|
||||
"--annotation-dir",
|
||||
default="BGnot",
|
||||
choices=("BGnot", "Bnot", "Anot"),
|
||||
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||||
),)
|
||||
requirements = ("refaire.json", "Copies", "labels", "correction.json")
|
||||
if suffix in {"export", "tablet", "import", "merge"}:
|
||||
requirements += ("BRnot",)
|
||||
steps.append(StepDefinition(
|
||||
f"refaire_{suffix}", SECTION, title, description,
|
||||
(CommandVariant(
|
||||
"default",
|
||||
title,
|
||||
program,
|
||||
"python" if program else "manual",
|
||||
flags,
|
||||
supports_verbose=program is not None,
|
||||
),),
|
||||
arguments=arguments, optional=optional, requires=requirements,
|
||||
))
|
||||
return steps
|
||||
|
||||
|
||||
def value_for_default(value: object, evaluation_arg: str) -> object:
|
||||
return evaluation_arg if value == EVALUATION else value
|
||||
|
||||
@@ -488,6 +810,7 @@ def build_command(
|
||||
values: dict[str, object],
|
||||
evaluation_arg: str,
|
||||
extra_arguments: str = "",
|
||||
verbose: bool = False,
|
||||
) -> list[str]:
|
||||
if variant.kind == "manual" or not variant.program:
|
||||
return []
|
||||
@@ -502,6 +825,9 @@ def build_command(
|
||||
|
||||
positionals: list[str] = []
|
||||
options: list[str] = []
|
||||
if step.id == "batch_status":
|
||||
# Use the loaded evaluation without introducing another input field.
|
||||
options.extend(("--evaluation", evaluation_arg))
|
||||
for spec in step.arguments:
|
||||
if spec.variants and variant.id not in spec.variants:
|
||||
continue
|
||||
@@ -509,6 +835,8 @@ def build_command(
|
||||
if spec.kind == "bool":
|
||||
if bool(value) and spec.flag:
|
||||
options.append(spec.flag)
|
||||
elif not bool(value) and spec.false_flag:
|
||||
options.append(spec.false_flag)
|
||||
continue
|
||||
if value is None or str(value).strip() == "":
|
||||
continue
|
||||
@@ -524,6 +852,8 @@ def build_command(
|
||||
if not variant.fixed_args_before_positionals:
|
||||
command.extend(variant.fixed_args)
|
||||
command.extend(options)
|
||||
if verbose and variant.supports_verbose:
|
||||
command.append("--verbose")
|
||||
if extra_arguments.strip():
|
||||
command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
|
||||
return command
|
||||
|
||||
+18
-3
@@ -6,26 +6,39 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
|
||||
EXPORT_DIR = Path("Export")
|
||||
IMPORT_DIR = Path("Import")
|
||||
|
||||
# Fichiers à inclure dans A Rendre (les sources d'annotations sont conservées).
|
||||
RETURN_JPEG_ENABLED = True
|
||||
RETURN_PDF_ENABLED = True
|
||||
RETURN_ANSWERS_ENABLED = False
|
||||
RETURN_ANSWERS_CONTEXT = False
|
||||
RETURN_ANSWERS_QUESTION = True
|
||||
RETURN_ANSWERS_SOLUTION = False
|
||||
|
||||
# Les étapes gestion_classe, ODS et publication sont masquées par défaut.
|
||||
SHOW_PERSONAL_STEPS = False
|
||||
|
||||
# Lance automatiquement les deux étapes facultatives de rognage dans le GUI.
|
||||
ALWAYS_CROP = False
|
||||
|
||||
# Chemins utilisés uniquement par les étapes personnelles.
|
||||
CURRENT_SCORE_ODS_PATH = Path("current_eval.ods")
|
||||
FINAL_SCORE_ODS_PATH = Path("simple_eval.ods")
|
||||
FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies"
|
||||
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
|
||||
FINAL_SCORE_FONT_PATH = None
|
||||
|
||||
# Modèle pour des choses très légères
|
||||
MODEL_LITE_ID = "gemini-3.5-flash-lite"
|
||||
|
||||
# Modèle pour identifier visuellement les labels
|
||||
MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite"
|
||||
# MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche moyennement, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit
|
||||
MODEL_FOR_LABEL_ID = "gemini-3.8-flash"
|
||||
|
||||
# Modèle pour des choses normales
|
||||
MODEL_FLASH_ID = "gemini-3.6-flash"
|
||||
MODEL_FLASH_ID = "gemini-3.8-flash"
|
||||
|
||||
# Modèle pour des choses dures
|
||||
MODEL_PRO_ID = "gemini-3.6-flash"
|
||||
MODEL_PRO_ID = "gemini-3.8-flash"
|
||||
# MODEL_PRO_ID = "gemini-3.1-pro-preview"
|
||||
|
||||
PAGE_SPLITTER_KB = {
|
||||
@@ -41,6 +54,7 @@ PAGE_SPLITTER_KB = {
|
||||
"next_page": "s",
|
||||
"discard_page": "z",
|
||||
"send_end": "a", # Send this page to the end
|
||||
"reverse_pages": "i", # Reverse page order and restart at the new first page
|
||||
"restart_file": "T",
|
||||
"arranger": "A", # Call `pdf arranger` software, if available
|
||||
"prev_file": "P",
|
||||
@@ -69,6 +83,7 @@ LATEX_BEFORE = r"""\documentclass[varwidth=24.8cm,margin=0.4cm]{standalone}
|
||||
\usepackage{minted}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{multicol}
|
||||
\begin{document}
|
||||
\begin{minipage}{24.8cm}
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Scanned PDF margin cropping
|
||||
|
||||
Copienator has one automatic crop detector. It finds strongly coloured or dark
|
||||
ink, recovers nearby weaker strokes, and removes substantial blank areas above
|
||||
and below the detected content. It is designed for scanned student work on
|
||||
plain, lined, or gridded paper, including mildly skewed pages and recurring
|
||||
punched-hole artifacts.
|
||||
|
||||
## Review utility
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python -m copienator.crop_blank_margins Interro01/Copies tmp/cropped-copies
|
||||
```
|
||||
|
||||
The input can be a directory or one PDF. Directory processing includes only
|
||||
PDFs directly inside that directory. The utility writes processed PDFs, an HTML
|
||||
comparison gallery, JPEG previews, and JSON/CSV reports into the output
|
||||
directory. Source PDFs are never modified.
|
||||
|
||||
Available options:
|
||||
|
||||
- `--dpi 200`: analysis resolution.
|
||||
- `--padding-mm 6`: space retained around detected content.
|
||||
- `--min-crop-mm 5`: minimum worthwhile removal at either edge.
|
||||
|
||||
The output retains filenames, page order, page count, colour, rotation, and the
|
||||
embedded scan data. Cropping changes the PDF CropBox rather than rasterizing the
|
||||
page. Red shading in `index.html` shows the removed part of each original page.
|
||||
A `review-*` status records uncertainty; one edge can still be cropped while the
|
||||
other remains unchanged.
|
||||
|
||||
## Detection
|
||||
|
||||
The detector uses colour and darkness as strong ink seeds. It recovers connected
|
||||
weak strokes within a 2 mm neighbourhood using directional contrast, which
|
||||
limits growth along paper lines. Two seed thresholds are compared so unstable
|
||||
boundaries can be flagged for review.
|
||||
|
||||
For dark neutral paper, it deskews the scan and confirms repeated horizontal or
|
||||
vertical ruling before suppressing paper-line pixels. It uses short directional
|
||||
openings to tolerate broken or bent grid lines. Very dark fraction bars and
|
||||
diagram axes remain protected. Repeated components with similar size and
|
||||
alignment in the outer 15 mm are treated as punched holes only when at least
|
||||
three span a substantial part of the page. Writing in the same side column still
|
||||
protects its margin.
|
||||
|
||||
On confirmed ruled paper, faint sparse strokes in the central 80% of the page
|
||||
use a moderately more sensitive component filter. The outermost 9% on each side
|
||||
uses a stricter filter because punched holes, torn binding edges, and page
|
||||
numbers normally appear there.
|
||||
|
||||
The large-blank refinement changes an edge only when it finds at least 30 mm of
|
||||
additional empty paper. A 2 mm recovery neighbourhood is applied before the
|
||||
normal padding. Apparently blank pages and pages without reliable ink seeds are
|
||||
kept at full height.
|
||||
|
||||
This remains a heuristic. Extremely faint isolated pencil marks, unusually
|
||||
damaged ruling, and repeated handwriting shaped like hole artifacts can be
|
||||
ambiguous. Review crops before generating answer coordinates.
|
||||
|
||||
## Optional GUI step
|
||||
|
||||
After **Séparer et réordonner les pages**, the GUI offers **Rogner les zones
|
||||
vides**. It can process the whole evaluation or a selected PDF. It runs at 200
|
||||
dpi with 6 mm padding and uses five worker processes by default. The CLI form is:
|
||||
|
||||
```sh
|
||||
python -m copienator crop-margins EVALUATION --workers 5
|
||||
```
|
||||
|
||||
The batch is fully prepared before any source is replaced. Detection failures
|
||||
and interruptions leave the working PDFs intact; replacement errors roll back.
|
||||
Each successful run saves the untrimmed PDFs and its report under
|
||||
`.copienator/runs/crop-margins-*/`. The **Archivage** step removes these backups
|
||||
and reports while keeping the cropped copies and execution logs.
|
||||
|
||||
Cropping must run before label detection. If a selected PDF already has a
|
||||
same-named JSON coordinate file, the command stops before changing any PDFs.
|
||||
When `ALWAYS_CROP` is true in `config.py`, this facultative step starts
|
||||
automatically when first reached; the default configuration keeps it manual.
|
||||
|
||||
## Performance
|
||||
|
||||
Separate worker processes isolate MuPDF and each worker uses one OpenCV thread.
|
||||
The detector uses native channel operations, vectorized component filtering,
|
||||
cached separable background filtering, and a coarser Hough voting step for skew
|
||||
candidates. Report rows remain ordered by copy and page regardless of worker
|
||||
completion order.
|
||||
|
||||
On the Ryzen 7 PRO 7840U, an end-to-end benchmark took 56.48 seconds for 48 PDFs
|
||||
of 10 pages, including rendering, detection, PDF writing, backups, and
|
||||
replacement. The fixture uses Interro01 and DS08VA scans and cycles pages in
|
||||
shorter copies, so it does not contain 480 distinct scans. Runtime depends on the
|
||||
CPU, storage, and scan content.
|
||||
|
||||
Run the focused checks with:
|
||||
|
||||
```sh
|
||||
python -m unittest tests.test_crop_blank_margins tests.test_ink_detection tests.test_crop_margins_command -v
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Bottom cropping after exercise splitting
|
||||
|
||||
This review utility processes the exercise PDFs stored directly under
|
||||
`Copies/CopieXX/`. It never changes the source files. Modified PDFs are written
|
||||
to a matching tree under the chosen output directory; unchanged PDFs are not
|
||||
copied.
|
||||
|
||||
```sh
|
||||
python -m copienator.crop_exercise_bottoms Interro01 tmp/exercise-bottom-crop
|
||||
```
|
||||
|
||||
One line is 1/36 of the uncropped full-page height recorded by the matching
|
||||
`Copies/CopieXX.pdf`. Each exercise PDF page is considered independently:
|
||||
|
||||
1. Pages shorter than 10 lines are skipped.
|
||||
2. The bottom 0.75 line is excluded from detection so a fragment of the next
|
||||
label cannot keep a large blank area.
|
||||
3. The existing scan detector locates the last ink above that strip and keeps
|
||||
6 mm of padding.
|
||||
4. The bottom CropBox changes only if the proposed removal is at least 4 lines.
|
||||
The top CropBox is always retained.
|
||||
|
||||
The ignored 0.75-line strip is therefore not removed on its own. It is included
|
||||
in the result only when the complete proposed crop passes the four-line
|
||||
threshold.
|
||||
|
||||
The output contains `index.html`, previews with removed areas shaded red, a
|
||||
plain `cropped-files.txt` list, `report.json`, and `report.csv`. The command uses
|
||||
five worker processes by default; `--workers`, `--dpi`, and `--padding-mm` are
|
||||
configurable.
|
||||
|
||||
## GUI integration
|
||||
|
||||
After **Découper les réponses par question**, the GUI offers the facultative
|
||||
step **Rogner le bas des réponses**. It runs the same thresholds at 200 dpi and
|
||||
uses five worker processes by default. Only PDFs with an accepted crop are
|
||||
replaced. Their unmodified versions and the complete report are stored under
|
||||
`.copienator/runs/crop-exercise-bottoms-*/`; a failure or interruption before
|
||||
publication leaves every exercise PDF unchanged. The **Archivage** step removes
|
||||
these retained originals and reports. When `ALWAYS_CROP` is true in `config.py`,
|
||||
this facultative step starts automatically when first reached; the default
|
||||
configuration keeps it manual.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Final output: `A Rendre`
|
||||
|
||||
This documents the current implementation, as of 2026-09-12. The return folder
|
||||
referred to as « À rendre » is named **`A Rendre`** on disk. JPEG files use the
|
||||
extension **`.jpg`**, not `.jpeg`.
|
||||
|
||||
## Files and their sources
|
||||
|
||||
After grouped correction and review:
|
||||
|
||||
```sh
|
||||
python -m copienator read-grouped Interro
|
||||
python -m copienator giving-names Interro BGnot
|
||||
```
|
||||
|
||||
The expected layout for a copy is:
|
||||
|
||||
```text
|
||||
Interro/A Rendre/
|
||||
└── Student Name (01)/
|
||||
├── Student Name.jpg
|
||||
├── Student Name.pdf
|
||||
├── score.json
|
||||
├── info.json
|
||||
└── answers/ # when individual answer export is enabled
|
||||
├── 001 - Ex 1.jpg
|
||||
└── 002 - Ex 2.jpg
|
||||
```
|
||||
|
||||
The name comes from `Copies/Copie01.json` (`name`), with filename sanitization.
|
||||
The copy ID distinguishes folders even when several copies have the same name.
|
||||
`giving-names` links the full JPEG, PDF and score file (or copies them when links
|
||||
are unavailable). It writes `info.json` and optionally composes the individual
|
||||
answer JPEGs.
|
||||
|
||||
| Return file | Source under `BGnot/Copie01/` | Contents |
|
||||
| --- | --- | --- |
|
||||
| `Student Name.jpg` | `Concat.jpg` | Full continuous image of the compiled answers and corrections. |
|
||||
| `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. |
|
||||
| `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. |
|
||||
| `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. |
|
||||
| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated non-empty answer, with optional supplementary material. |
|
||||
|
||||
## Enabling or disabling outputs
|
||||
|
||||
Set these independent options in `config.py` (the defaults also apply when
|
||||
absent from an older personal configuration):
|
||||
|
||||
```python
|
||||
RETURN_JPEG_ENABLED = True
|
||||
RETURN_PDF_ENABLED = True
|
||||
RETURN_ANSWERS_ENABLED = False
|
||||
RETURN_ANSWERS_CONTEXT = False
|
||||
RETURN_ANSWERS_QUESTION = True
|
||||
RETURN_ANSWERS_SOLUTION = False
|
||||
```
|
||||
|
||||
The personal `config.py` enables `RETURN_ANSWERS_ENABLED`; the distributed
|
||||
default is `False`. Set a full-output option to `False`, then rerun `giving-names` to omit that file from
|
||||
`A Rendre`. For each prepared copy, any existing named return file of a disabled
|
||||
type is removed, including a symlink or fallback copy. Its annotation source
|
||||
remains intact. These options control return publication, not intermediate
|
||||
rendering or scoring. `score.json` and `info.json` are always included and have
|
||||
no disabling options.
|
||||
|
||||
Cleanup allows the JPEG to be absent when disabled, still requires `score.json`,
|
||||
and preserves return PDFs when present.
|
||||
|
||||
## Individual answer JPEGs
|
||||
|
||||
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
|
||||
subdirectory inside each student's return folder. It includes **every non-empty
|
||||
compiled answer**, even a perfect answer omitted from the filtered PDF. Labels
|
||||
marked `empty-answer` and labels without an answer are excluded. Every JPEG
|
||||
contains the final annotated student answer, including retained feedback and
|
||||
extracted handwriting.
|
||||
|
||||
The three supplementary options independently prepend, in this order:
|
||||
|
||||
1. Applicable context PDFs, if `RETURN_ANSWERS_CONTEXT` is enabled.
|
||||
2. The question, if `RETURN_ANSWERS_QUESTION` is enabled (the default).
|
||||
3. The model solution, if `RETURN_ANSWERS_SOLUTION` is enabled.
|
||||
4. The annotated student answer, always.
|
||||
|
||||
These use the same `Text2`/`Sol2` sources as the filtered PDF. Missing supplements
|
||||
are skipped; an unreadable existing file fails the export. They are concatenated
|
||||
vertically on white, without PDF pagination or its black/blue borders. The
|
||||
options affect only these individual images, not the full JPEG or filtered PDF.
|
||||
Disabling all supplements produces just the annotated answer.
|
||||
|
||||
Filenames use natural label order, a three-digit minimum sequence number, and a
|
||||
sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when
|
||||
labels differ only by characters forbidden in filenames. JSON keys retain exact
|
||||
labels. The managed `answers/` directory is replaced on successful generation,
|
||||
so removed/empty answers do not leave stale images; failures preserve the previous
|
||||
directory. Disabling the option clears this directory on the next `giving-names`
|
||||
run. Separate storage keeps these images out of the personal final-mark stamping
|
||||
step, which reads only JPEGs directly inside the student's folder.
|
||||
|
||||
Recompile annotations once before exporting old evaluations with this option:
|
||||
the compiler now saves every final answer block and writes `info.json`
|
||||
next to them. For the grouped workflow, run `read-grouped`, then `giving-names`.
|
||||
This avoids reconstructing a reviewed answer from outdated correction data.
|
||||
|
||||
## `info.json`: per-question information
|
||||
|
||||
Every label in `score.json` has an object containing exactly four fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"Ex 1": {"present": true, "not_empty": true, "touched": false, "score": "4"},
|
||||
"Ex 2": {"present": true, "not_empty": true, "touched": true, "score": "2"},
|
||||
"Empty": {"present": true, "not_empty": false, "touched": false, "score": "0"},
|
||||
"Absent": {"present": false, "not_empty": false, "touched": false, "score": ""}
|
||||
}
|
||||
```
|
||||
|
||||
- `present`: an answer entry exists for this student and label in the compilation
|
||||
data. A supplied answer judged empty still has `present: true`.
|
||||
- `not_empty`: the answer was not marked `empty-answer` and was successfully
|
||||
compiled. Absent answers have `not_empty: false`. When individual export is
|
||||
enabled, a JPEG is generated if and only if both `present` and `not_empty` are
|
||||
true. These fields describe the answer regardless of export settings.
|
||||
- `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using
|
||||
the actual selection including handwriting and selective redo preservation.
|
||||
It is not a flag for human edits. Empty and absent answers have `false`.
|
||||
- `score`: the same value as `score.json` (normally a numeric string, or `""`
|
||||
for an unpopulated score). Editing scores requires recompilation to update the
|
||||
images; return publication uses current `score.json` values for this field.
|
||||
|
||||
`info.json` is always exported, even when individual JPEGs or the named PDF are
|
||||
disabled. `touched` describes the source filtered PDF. In normal `Anot` and
|
||||
`Bnot` flows, no filtered PDF is produced and all `touched` values are false.
|
||||
|
||||
This file replaces `touched.json` and the internal `answer_labels.json` manifest.
|
||||
Recompile old annotations, then run `giving-names`; successful regeneration and
|
||||
publication remove the obsolete files from their respective folders. Missing
|
||||
or malformed metadata requires recompilation rather than guessing answer presence
|
||||
from scores. Cleanup preserves `info.json` alongside `score.json`.
|
||||
|
||||
## JPEG: the full compiled correction
|
||||
|
||||
The JPEG stacks the rendered answer blocks vertically in natural label order
|
||||
(for example, Ex 2 precedes Ex 10). Each block contains the scanned answer, its
|
||||
label and score, retained global and local feedback, and detected handwritten
|
||||
review annotations. Local feedback can include red rectangles and comments in
|
||||
the left margin. Review checkboxes are applied as actions rather than reproduced
|
||||
as controls; internal error labels are hidden during recompilation.
|
||||
|
||||
The result is one RGB image of variable height, with no page breaks. It contains
|
||||
all successfully compiled answer blocks, including answers scored 4 with no
|
||||
remaining feedback. “Full” refers to those answer blocks, not the original scan
|
||||
pages or every question in the statement. Missing/unrenderable answers cannot
|
||||
be included, and the renderer normally suppresses `empty-answer` results.
|
||||
The grouped compiler refuses to publish a new set when compilation is incomplete.
|
||||
|
||||
The JPEG does not prepend the question, context or model solution PDFs.
|
||||
|
||||
## PDF: a different selection and layout
|
||||
|
||||
**The PDF is not a PDF conversion of the JPEG.** During an ordinary full grouped
|
||||
recompilation, an answer is omitted only when all three conditions hold:
|
||||
|
||||
- Its score is at least 4.
|
||||
- Every feedback item is marked `to_delete` (also true for an empty feedback list).
|
||||
- There are no significant detected handwritten annotations for that answer.
|
||||
|
||||
Thus, a 4/4 answer with retained feedback or handwriting still appears. Scores
|
||||
for omitted answers remain in `score.json`, and their answer blocks remain in
|
||||
the JPEG. Handwriting significance currently means more than 20 pixels with
|
||||
alpha greater than 50 in the extracted annotation layer.
|
||||
|
||||
For each retained answer, the PDF stacks the following available material:
|
||||
|
||||
1. Applicable context PDFs from `Text2/CTXT first_label -> last_label.pdf`.
|
||||
2. The question from `Text2/<label>.pdf`.
|
||||
3. The model solution from `Sol2/<label>.pdf`.
|
||||
4. The same compiled answer block used for the JPEG.
|
||||
|
||||
Missing supplementary PDFs are skipped. Contexts can repeat for successive
|
||||
questions. Each question's complete group stays together on one page; groups
|
||||
are packed until the next would exceed the target height. An oversized group
|
||||
gets its own taller page, rather than being split. Pages are raster images saved
|
||||
as PDF at 100 dpi, with variable heights, not fixed A4 sheets or searchable text.
|
||||
|
||||
The target height is `int(max_image_width * 1.414 * 1.25)` pixels. Small white
|
||||
margins are added on the left and above/below the page. The first image of each
|
||||
group receives a black border and the second a blue border. These borders are
|
||||
assigned by position, so they do not consistently identify question and solution
|
||||
when contexts are present or supplementary files are missing.
|
||||
|
||||
If nothing survives filtering, `read-grouped` removes old `Concat_F` outputs and
|
||||
does not create a new PDF. During a selective `--refaire` merge, saved answer
|
||||
images outside the selection are kept in the filtered output without reapplying
|
||||
the perfect-answer filter; the resulting PDF can therefore retain more answers
|
||||
than a full recompilation.
|
||||
|
||||
## JSON: per-question scores
|
||||
|
||||
`score.json` is a flat JSON object keyed by the exact question labels. For example
|
||||
(illustrative data):
|
||||
|
||||
```json
|
||||
{
|
||||
"Ex 1 : 1)": "4",
|
||||
"Ex 1 : 2)": "2.5",
|
||||
"Ex 2": ""
|
||||
}
|
||||
```
|
||||
|
||||
Values are **strings**, including numeric scores. The normal question scale is
|
||||
0 to 4. `""` means no score was populated for that label; it is distinct from
|
||||
`"0"`. The compiler initializes all labels from the evaluation's `labels` file
|
||||
to `""`, then fills processed scores. This file contains no student identity,
|
||||
feedback, annotation coordinates, grading weights, or overall final mark.
|
||||
|
||||
Scores incorporate review checkbox changes and, when requested, existing score
|
||||
overrides via `read-grouped --update-score` (or `read-annotations --update-score`
|
||||
for `Bnot`). Editing the JSON alone does not update the rendered scores. Overrides
|
||||
are read from the annotation source folder: a return symlink points there, but
|
||||
an independent fallback copy does not. After regeneration, rerun `giving-names`
|
||||
to refresh copied return files.
|
||||
|
||||
`update-ods` reads these return JSON files; empty values become `NT` in the normal
|
||||
per-question export. Its `--sum` option sums numeric values and skips nonnumeric
|
||||
ones. Weighting and the final overall mark belong to the separate grading flow.
|
||||
|
||||
## Availability and later steps
|
||||
|
||||
- `giving-names` accepts `BGnot`, `Bnot`, or `Anot`. It selects the requested
|
||||
source if `score.json` and either `Concat.jpg` or `info.json` exist,
|
||||
otherwise it tries `Anot/CopieXX`. This also permits returns for an entirely
|
||||
empty copy. It links the PDF only if `Concat_F.pdf` exists. The normal
|
||||
`Bnot` reader produces a filtered `Concat_F.jpg`, and simple annotation produces
|
||||
`Concat.jpg`; these paths do not guarantee a filtered PDF.
|
||||
- Preparing returns removes disabled named outputs, and removes older named
|
||||
outputs whose source is now absent, including broken symlinks.
|
||||
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps
|
||||
the overall mark from the configured ODS in red at the JPEG's upper right,
|
||||
rounded down to one decimal place. It copies PDFs unchanged and does not export
|
||||
either JSON file or the `answers/` directory. It does not add that mark to the
|
||||
files inside `A Rendre`.
|
||||
- The `clean` command retains return images (including individual answers), PDFs,
|
||||
`score.json` and `info.json`, materializing
|
||||
retained symlinks before deleting their sources.
|
||||
|
||||
## Implementation references
|
||||
|
||||
- [Naming and return links](../copienator/commands/giving_names.py)
|
||||
- [Individual answer publication](../copienator/return_answers.py)
|
||||
- [Grouped compilation, filtering and PDF pagination](../copienator/commands/reading_grouped_annotations.py)
|
||||
- [Answer rendering](../copienator/commands/annotating.py)
|
||||
- [Handwriting detection and per-copy compilation](../copienator/commands/reading_annotations.py)
|
||||
- [Score and feedback actions](../copienator/annotation_actions.py)
|
||||
- [Context, question and solution lookup](../copienator/utils.py)
|
||||
- [ODS export](../copienator/commands/update_ods.py)
|
||||
- [Final-mark stamping](../copienator/commands/add_final_score.py)
|
||||
- [Cleanup retention](../copienator/commands/clean.py)
|
||||
@@ -7,6 +7,22 @@ name = "copienator"
|
||||
version = "0.1.0"
|
||||
description = "Workflow assistant for preparing and correcting scanned exams"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"opencv-python-headless>=4.8",
|
||||
"pandas",
|
||||
"matplotlib",
|
||||
"Pillow",
|
||||
"pydantic",
|
||||
"pypdf",
|
||||
"pdf2image",
|
||||
"reportlab",
|
||||
"img2pdf",
|
||||
"PyMuPDF",
|
||||
"ftfy",
|
||||
"ezodf",
|
||||
"google-genai",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
copienator = "copienator.dispatcher:main"
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Applications opened from a file manager do not inherit variables exported by
|
||||
# an interactive shell (for example GEMINI_API_KEY from ~/.zshrc). Re-enter
|
||||
# through the user's shell once so its startup file can provide those values.
|
||||
if [[ -z "${GEMINI_API_KEY:-}" && -z "${COPIENATOR_GUI_SHELL_LOADED:-}" && -x "${SHELL:-}" ]]; then
|
||||
export COPIENATOR_GUI_SHELL_LOADED=1
|
||||
exec "$SHELL" -ic 'exec "$@"' copienator-gui-shell "$0" "$@"
|
||||
fi
|
||||
|
||||
# With no arguments, choose the newest visible immediate subfolder by mtime.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
newest=""
|
||||
for candidate in "$PWD"/*/; do
|
||||
[[ -d "$candidate" ]] || continue
|
||||
folder="${candidate%/}"
|
||||
folder="${folder##*/}"
|
||||
case "$folder" in
|
||||
copienator|copienator_gui|tests|OLD|__pycache__|build|dist|*.egg-info|venv|env|node_modules)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if [[ -z "$newest" || "$candidate" -nt "$newest" ]]; then
|
||||
newest="$candidate"
|
||||
fi
|
||||
done
|
||||
if [[ -n "$newest" ]]; then
|
||||
set -- "${newest%/}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Resolve the evaluation before changing to the project directory.
|
||||
if [[ $# -gt 0 && "$1" != -* && "$1" != /* ]]; then
|
||||
evaluation="$PWD/$1"
|
||||
shift
|
||||
set -- "$evaluation" "$@"
|
||||
fi
|
||||
repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd -- "$repository"
|
||||
|
||||
if [[ -x "$repository/.venv/bin/python" ]]; then
|
||||
python_command="$repository/.venv/bin/python"
|
||||
else
|
||||
python_command="python3"
|
||||
fi
|
||||
exec "$python_command" -m copienator_gui "$@"
|
||||
@@ -0,0 +1,99 @@
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
from copienator.commands import add_final_score
|
||||
|
||||
|
||||
class AddFinalScoreTests(unittest.TestCase):
|
||||
def test_creates_complete_student_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
answers = source / "answers"
|
||||
answers.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
(source / "Student.pdf").write_bytes(b"pdf")
|
||||
(source / "score.json").write_bytes(b'{"Ex 1": "4"}')
|
||||
(source / "info.json").write_bytes(b'{"Ex 1": {}}')
|
||||
(answers / "Ex 1.jpg").write_bytes(b"answer")
|
||||
|
||||
output = root / "output"
|
||||
output.mkdir()
|
||||
(output / "Student.jpg").write_bytes(b"legacy jpeg")
|
||||
(output / "Student.pdf").write_bytes(b"legacy pdf")
|
||||
stale_answers = output / "Student" / "answers"
|
||||
stale_answers.mkdir(parents=True)
|
||||
(stale_answers / "001 - Ex 1.jpg").write_bytes(b"stale")
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [12.39]})
|
||||
histogram = root / "histogramme.pdf"
|
||||
histogram.write_bytes(b"histogram")
|
||||
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
|
||||
patch.object(add_final_score, "HISTOGRAM_PATH", histogram), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
student = output / "Student"
|
||||
self.assertEqual(
|
||||
{path.name for path in student.iterdir()},
|
||||
{"Student.jpg", "Student.pdf", "score.json", "info.json", "answers"},
|
||||
)
|
||||
self.assertEqual((student / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual((student / "score.json").read_bytes(), b'{"Ex 1": "4"}')
|
||||
self.assertEqual((student / "info.json").read_bytes(), b'{"Ex 1": {}}')
|
||||
self.assertEqual(
|
||||
(student / "answers" / "Ex 1.jpg").read_bytes(), b"answer"
|
||||
)
|
||||
self.assertFalse((output / "Student.jpg").exists())
|
||||
self.assertFalse((output / "Student.pdf").exists())
|
||||
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
|
||||
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
|
||||
|
||||
def test_omits_answers_directory_when_it_was_not_generated(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
source.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
(source / "Student.pdf").write_bytes(b"pdf")
|
||||
(source / "score.json").write_text("{}")
|
||||
(source / "info.json").write_text("{}")
|
||||
|
||||
output = root / "output"
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [10]})
|
||||
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
self.assertFalse((output / "Student" / "answers").exists())
|
||||
|
||||
def test_missing_histogram_warns_without_discarding_student_outputs(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
source.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [10]})
|
||||
output = root / "output"
|
||||
messages = io.StringIO()
|
||||
|
||||
with patch.object(
|
||||
add_final_score.pd, "read_excel", return_value=scores
|
||||
), patch.object(
|
||||
add_final_score, "HISTOGRAM_PATH", root / "missing.pdf"
|
||||
), contextlib.redirect_stdout(messages):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
self.assertTrue((output / "Student" / "Student.jpg").is_file())
|
||||
self.assertFalse((output / "histogramme.pdf").exists())
|
||||
self.assertIn("Missing histogram", messages.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,343 @@
|
||||
import contextlib
|
||||
import io
|
||||
import itertools
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
|
||||
from copienator.commands import annotating, giving_names
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
from copienator.return_answers import (
|
||||
publish_answer_returns,
|
||||
save_return_answer_options,
|
||||
)
|
||||
|
||||
|
||||
class AnswerReturnTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.source = self.root / "BGnot" / "Copie01"
|
||||
self.source.mkdir(parents=True)
|
||||
self.destination = self.root / "A Rendre" / "Student (01)"
|
||||
self.destination.mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\nEmpty\n")
|
||||
atomic_write_json(self.source / "score.json", {"Ex 1": "4", "Ex 2": "2", "Empty": "0"})
|
||||
atomic_write_json(self.source / "info.json", {
|
||||
"Ex 1": {"present": True, "not_empty": True, "touched": False, "score": "4"},
|
||||
"Ex 2": {"present": True, "not_empty": True, "touched": True, "score": "2"},
|
||||
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"},
|
||||
})
|
||||
for label in ("Ex 1", "Ex 2", "Empty"):
|
||||
Image.new("RGB", (100, 30), "red").save(self.source / f"{label}.jpg")
|
||||
for folder in ("Text2", "Sol2"):
|
||||
(self.root / folder).mkdir()
|
||||
for label in ("Ex 1", "Ex 2"):
|
||||
(self.root / "Text2" / f"{label}.pdf").touch()
|
||||
(self.root / "Sol2" / f"{label}.pdf").touch()
|
||||
(self.root / "Text2" / "CTXT Ex 1 -> Ex 2.pdf").touch()
|
||||
|
||||
@staticmethod
|
||||
def supplement(path):
|
||||
path = Path(path)
|
||||
color = "blue" if path.name.startswith("CTXT") else "green" if path.parent.name == "Text2" else "yellow"
|
||||
return Image.new("RGB", (100, 20), color), 0, 0
|
||||
|
||||
def test_all_supplement_combinations_always_include_annotated_answer(self):
|
||||
for context, question, solution in itertools.product((False, True), repeat=3):
|
||||
with self.subTest(context=context, question=question, solution=solution), patch.multiple(
|
||||
configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=context, RETURN_ANSWERS_QUESTION=question,
|
||||
RETURN_ANSWERS_SOLUTION=solution,
|
||||
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
files = sorted((self.destination / "answers").glob("*.jpg"))
|
||||
self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"])
|
||||
with Image.open(files[0]) as image:
|
||||
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
|
||||
colors = []
|
||||
if context:
|
||||
colors.append((0, 0, 255))
|
||||
if question:
|
||||
colors.append((0, 128, 0))
|
||||
if solution:
|
||||
colors.append((255, 255, 0))
|
||||
colors.append((255, 0, 0))
|
||||
for index, color in enumerate(colors):
|
||||
pixel = image.getpixel((50, index * 20 + 10))
|
||||
self.assertTrue(all(abs(a - b) < 10 for a, b in zip(pixel, color)))
|
||||
self.assertEqual(read_json(self.destination / "info.json"), read_json(self.source / "info.json"))
|
||||
|
||||
def test_saved_reading_options_override_configuration_for_answers(self):
|
||||
save_return_answer_options(
|
||||
self.root,
|
||||
context=True,
|
||||
question=False,
|
||||
solution=True,
|
||||
)
|
||||
with patch.multiple(
|
||||
configuration,
|
||||
RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False,
|
||||
RETURN_ANSWERS_QUESTION=True,
|
||||
RETURN_ANSWERS_SOLUTION=False,
|
||||
), patch.object(
|
||||
annotating, "make_base_image", side_effect=self.supplement
|
||||
):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
|
||||
with Image.open(self.destination / "answers" / "Ex 1.jpg") as image:
|
||||
self.assertEqual(image.size, (100, 70))
|
||||
for y, color in (
|
||||
(10, (0, 0, 255)),
|
||||
(30, (255, 255, 0)),
|
||||
(50, (255, 0, 0)),
|
||||
):
|
||||
self.assertTrue(
|
||||
all(
|
||||
abs(actual - expected) < 10
|
||||
for actual, expected in zip(image.getpixel((50, y)), color)
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_supplement_is_optional_and_failure_preserves_old_answers(self):
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True,
|
||||
RETURN_ANSWERS_SOLUTION=False), patch.object(
|
||||
annotating, "make_base_image", side_effect=self.supplement
|
||||
):
|
||||
(self.root / "Text2" / "Ex 1.pdf").unlink()
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
path = self.destination / "answers" / "Ex 1.jpg"
|
||||
with Image.open(path) as image:
|
||||
self.assertEqual(image.size, (100, 30))
|
||||
original = path.read_bytes()
|
||||
(self.source / "Ex 2.jpg").unlink()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
self.assertTrue((self.destination / "answers" / "Ex 2.jpg").exists())
|
||||
|
||||
def test_disabling_clears_individual_images_but_retains_info(self):
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
self.assertEqual(list((self.destination / "answers").iterdir()), [])
|
||||
self.assertTrue(read_json(self.destination / "info.json")["Ex 2"]["touched"])
|
||||
|
||||
def test_missing_info_requires_recompilation_instead_of_guessing(self):
|
||||
(self.source / "info.json").unlink()
|
||||
(self.source / "Concat_F.pdf").touch()
|
||||
with self.assertRaisesRegex(ValueError, "recompile"):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
|
||||
def test_info_replaces_touched_and_tracks_manual_score_edits(self):
|
||||
atomic_write_json(self.destination / "touched.json", {"obsolete": True})
|
||||
atomic_write_json(self.source / "score.json", {"Ex 1": "3.5", "Ex 2": "2", "Empty": "0"})
|
||||
with patch.object(configuration, "RETURN_ANSWERS_ENABLED", False):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
self.assertFalse((self.destination / "touched.json").exists())
|
||||
self.assertEqual(read_json(self.destination / "info.json")["Ex 1"], {
|
||||
"present": True, "not_empty": True, "touched": False, "score": "3.5"
|
||||
})
|
||||
|
||||
def test_invalid_info_preserves_previous_return(self):
|
||||
atomic_write_json(self.destination / "info.json", {"previous": "keep"})
|
||||
info = read_json(self.source / "info.json")
|
||||
info["Empty"]["touched"] = True
|
||||
atomic_write_json(self.source / "info.json", info)
|
||||
with self.assertRaisesRegex(ValueError, "Invalid question information"):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
self.assertEqual(read_json(self.destination / "info.json"), {"previous": "keep"})
|
||||
|
||||
def test_publication_is_independent_of_full_jpeg_and_pdf_options(self):
|
||||
workspace = EvaluationWorkspace(self.root)
|
||||
workspace.copies_dir.mkdir()
|
||||
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
|
||||
(self.root / "names").write_text("Student\n")
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False, RETURN_JPEG_ENABLED=False,
|
||||
RETURN_PDF_ENABLED=False), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(giving_names.run(workspace, annotation_dir="BGnot"), 0)
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, {"answers", "score.json", "info.json"})
|
||||
|
||||
def test_update_uses_copy_id_from_renamed_folder_and_touches_only_answers(self):
|
||||
workspace = EvaluationWorkspace(self.root)
|
||||
workspace.copies_dir.mkdir()
|
||||
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
|
||||
renamed = self.destination.with_name("Nom modifié manuellement (01)")
|
||||
self.destination.rename(renamed)
|
||||
(renamed / "Nom personnalisé.jpg").write_bytes(b"keep-jpeg")
|
||||
(renamed / "Nom personnalisé.pdf").write_bytes(b"keep-pdf")
|
||||
(renamed / "score.json").write_bytes(b"keep-score")
|
||||
(renamed / "info.json").write_bytes(b"keep-info")
|
||||
answers = renamed / "answers"
|
||||
answers.mkdir()
|
||||
(answers / "obsolete.jpg").write_bytes(b"obsolete")
|
||||
preserved = {
|
||||
path.name: path.read_bytes()
|
||||
for path in renamed.iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
with patch.multiple(
|
||||
configuration,
|
||||
RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False,
|
||||
RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False,
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(
|
||||
giving_names.run(workspace, annotation_dir="BGnot", update=True),
|
||||
0,
|
||||
)
|
||||
|
||||
self.assertFalse((workspace.return_dir / "Student (01)").exists())
|
||||
self.assertEqual(
|
||||
{
|
||||
path.name: path.read_bytes()
|
||||
for path in renamed.iterdir()
|
||||
if path.is_file()
|
||||
},
|
||||
preserved,
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in answers.iterdir()),
|
||||
["Ex 1.jpg", "Ex 2.jpg"],
|
||||
)
|
||||
|
||||
|
||||
class CompiledMembershipTests(unittest.TestCase):
|
||||
def test_update_score_preserves_file_and_manual_value_wins(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = EvaluationWorkspace(Path(directory))
|
||||
output = workspace.root / "BGnot" / "Copie01"
|
||||
output.mkdir(parents=True)
|
||||
original_score = b'{\n "Ex 1": "3.5"\n}\n'
|
||||
(output / "score.json").write_bytes(original_score)
|
||||
answer = workspace.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {
|
||||
"01": {
|
||||
"Ex 1": {
|
||||
"result": {"score": 1, "feedback": []},
|
||||
"pdf_path": answer,
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
rendered_scores = []
|
||||
|
||||
def compose(base, label, result, *args, **kwargs):
|
||||
rendered_scores.append(result["score"])
|
||||
return Image.new("RGB", (100, 50), "white"), 0
|
||||
|
||||
with patch.object(
|
||||
annotating, "make_base_image", return_value=(None, 0, 0)
|
||||
), patch.object(
|
||||
annotating, "compose_label_image", side_effect=compose
|
||||
), patch.object(
|
||||
reader, "get_extra_pdfs_as_images", return_value=[]
|
||||
), patch.object(reader, "save_paginated_pdf"):
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace,
|
||||
data,
|
||||
"01",
|
||||
[{"label": "Ex 1", "type": "score", "value": "2"}],
|
||||
{},
|
||||
["Ex 1"],
|
||||
update_score=True,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(rendered_scores, ["3.5"])
|
||||
self.assertEqual((output / "score.json").read_bytes(), original_score)
|
||||
self.assertEqual(read_json(output / "info.json")["Ex 1"]["score"], "3.5")
|
||||
|
||||
def test_membership_matches_actual_groups_and_saves_every_nonempty_answer(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
workspace = EvaluationWorkspace(root)
|
||||
output = root / "BGnot" / "Copie01"
|
||||
output.mkdir(parents=True)
|
||||
answer = root / "answer.pdf"
|
||||
answer.touch()
|
||||
results = {
|
||||
"Perfect": {"score": 4, "feedback": []},
|
||||
"Low": {"score": 2, "feedback": []},
|
||||
"Feedback": {"score": 4, "feedback": [{"text": "keep"}]},
|
||||
"Deleted": {"score": 4, "feedback": [{"text": "delete", "to_delete": True}]},
|
||||
"Handwriting": {"score": 4, "feedback": []},
|
||||
"Empty": {"score": 0, "error": "empty-answer"},
|
||||
}
|
||||
data = {"01": {label: {"result": result, "pdf_path": answer, "coordinates": (0, 0)}
|
||||
for label, result in results.items()}}
|
||||
all_labels = [*results, "Absent"]
|
||||
rendered = []
|
||||
|
||||
def compose(base, label, *args, **kwargs):
|
||||
rendered.append(label)
|
||||
return Image.new("RGB", (100, 50), "white"), 0
|
||||
|
||||
notes = {"Handwriting": {"img": Image.new("RGBA", (100, 50), "red"), "old_header_h": 0}}
|
||||
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object(
|
||||
annotating, "compose_label_image", side_effect=compose
|
||||
), patch.object(reader, "get_extra_pdfs_as_images", return_value=[]), patch.object(
|
||||
reader, "save_paginated_pdf"
|
||||
) as save_pdf:
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(workspace, data, "01", [], notes, all_labels)
|
||||
self.assertEqual(status, 0)
|
||||
info = read_json(output / "info.json")
|
||||
touched = {label: entry["touched"] for label, entry in info.items()}
|
||||
self.assertEqual({label for label, value in touched.items() if value}, {"Low", "Feedback", "Handwriting"})
|
||||
self.assertEqual(len(save_pdf.call_args.args[0]), sum(touched.values()))
|
||||
self.assertEqual({label for label, entry in info.items() if entry["present"] and entry["not_empty"]}, set(results) - {"Empty"})
|
||||
self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"})
|
||||
self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""})
|
||||
self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"})
|
||||
self.assertNotIn("Empty", rendered)
|
||||
for label in rendered:
|
||||
self.assertTrue((output / f"{label}.jpg").is_file())
|
||||
# Selective redo keeps unselected saved answers in the PDF,
|
||||
# including previously perfect answers; touched must follow it.
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace, data, "01", [], {}, all_labels, selected_labels={"Low"}
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"])
|
||||
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
|
||||
|
||||
def test_all_empty_removes_stale_concat_and_records_false(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = EvaluationWorkspace(Path(directory))
|
||||
output = workspace.root / "BGnot" / "Copie01"
|
||||
output.mkdir(parents=True)
|
||||
for name in ("Concat.jpg", "Concat_F.pdf"):
|
||||
(output / name).write_bytes(b"stale")
|
||||
atomic_write_json(output / "touched.json", {"Empty": True})
|
||||
atomic_write_json(output / "answer_labels.json", ["Empty"])
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}},
|
||||
"01", [], {}, ["Empty"]
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(read_json(output / "info.json"), {
|
||||
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}
|
||||
})
|
||||
self.assertFalse((output / "Concat.jpg").exists())
|
||||
self.assertFalse((output / "Concat_F.pdf").exists())
|
||||
self.assertFalse((output / "touched.json").exists())
|
||||
self.assertFalse((output / "answer_labels.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
import queue
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from copienator_gui.batch_monitor import BatchMonitor, CHECK_INTERVAL_MS
|
||||
from copienator_gui.notifications import notify_desktop
|
||||
|
||||
|
||||
class BatchMonitorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scheduler = Mock()
|
||||
self.scheduler.after.side_effect = lambda delay, callback: (delay, callback)
|
||||
self.ready = Mock()
|
||||
self.status = Mock()
|
||||
self.monitor = BatchMonitor(self.scheduler, self.ready, self.status, Mock())
|
||||
self.runner = Mock()
|
||||
self.runner.events = queue.Queue()
|
||||
self.factory = patch("copienator_gui.batch_monitor.ProcessRunner", return_value=self.runner)
|
||||
self.factory.start()
|
||||
self.addCleanup(self.factory.stop)
|
||||
|
||||
def start(self):
|
||||
self.monitor.start(["check"], "/tmp", {}, None)
|
||||
|
||||
def test_checks_immediately_retries_in_five_minutes_and_notifies_once(self):
|
||||
self.start()
|
||||
self.runner.start.assert_called_once()
|
||||
self.start()
|
||||
self.runner.start.assert_called_once()
|
||||
self.runner.events.put(("finished", (4, False)))
|
||||
self.monitor._poll()
|
||||
self.assertEqual(self.monitor.timer[0], CHECK_INTERVAL_MS)
|
||||
self.assertEqual(CHECK_INTERVAL_MS, 300000)
|
||||
self.ready.assert_not_called()
|
||||
self.monitor.timer[1]()
|
||||
self.assertEqual(self.runner.start.call_count, 2)
|
||||
self.runner.events.put(("finished", (0, False)))
|
||||
self.monitor._poll()
|
||||
self.assertFalse(self.monitor.active)
|
||||
self.assertIsNone(self.monitor.timer)
|
||||
self.ready.assert_called_once()
|
||||
self.monitor._poll()
|
||||
self.ready.assert_called_once()
|
||||
|
||||
def test_stop_cancels_timer_and_running_check_without_notification(self):
|
||||
self.start()
|
||||
timer = self.monitor.timer
|
||||
self.monitor.stop()
|
||||
self.scheduler.after_cancel.assert_called_once_with(timer)
|
||||
self.runner.force_stop.assert_called_once()
|
||||
self.runner.events.put(("finished", (0, False)))
|
||||
self.monitor._poll()
|
||||
self.monitor._check()
|
||||
self.ready.assert_not_called()
|
||||
self.runner.start.assert_called_once()
|
||||
|
||||
def test_start_failure_retries_without_reporting_readiness(self):
|
||||
self.runner.start.side_effect = OSError("unavailable")
|
||||
self.start()
|
||||
self.assertEqual(self.monitor.timer[0], CHECK_INTERVAL_MS)
|
||||
self.ready.assert_not_called()
|
||||
|
||||
def test_linux_notification_passes_text_as_arguments(self):
|
||||
with patch("copienator_gui.notifications.sys.platform", "linux"), patch(
|
||||
"copienator_gui.notifications.find_executable", return_value="/usr/bin/notify-send"
|
||||
), patch("copienator_gui.notifications.subprocess.Popen") as launch:
|
||||
notify_desktop("Copienator", "Interro02 : résultats prêts")
|
||||
self.assertEqual(launch.call_args.args[0], ["/usr/bin/notify-send", "--app-name=Copienator",
|
||||
"--", "Copienator", "Interro02 : résultats prêts"])
|
||||
@@ -0,0 +1,54 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json
|
||||
from copienator.commands.batch_status import check_evaluation_jobs, main
|
||||
|
||||
|
||||
class BatchReadinessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.client = Mock()
|
||||
|
||||
def manifest(self, jobs):
|
||||
atomic_write_json(self.workspace.batch_jobs_file, {"jobs": jobs})
|
||||
|
||||
def test_only_recorded_jobs_are_checked_and_all_must_have_results(self):
|
||||
self.manifest({"flash": {"name": "batches/flash"}, "pro": {"name": "batches/pro"}})
|
||||
succeeded = SimpleNamespace(state="JOB_STATE_SUCCEEDED",
|
||||
dest=SimpleNamespace(file_name="files/result"))
|
||||
for state in ("JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_FAILED",
|
||||
"JOB_STATE_CANCELLED", "JOB_STATE_EXPIRED", "UNKNOWN", "JOB_STATE_SUCCEEDED"):
|
||||
with self.subTest(state=state):
|
||||
self.client.reset_mock()
|
||||
self.client.batches.get.side_effect = [succeeded, SimpleNamespace(
|
||||
state=SimpleNamespace(name=state), dest=SimpleNamespace(file_name="files/pro"))]
|
||||
result = check_evaluation_jobs(self.workspace, client=self.client)
|
||||
self.assertEqual(result, ExitCode.SUCCESS if state == "JOB_STATE_SUCCEEDED" else ExitCode.PARTIAL)
|
||||
self.assertEqual(self.client.batches.get.call_args_list,
|
||||
[call(name="batches/flash"), call(name="batches/pro")])
|
||||
self.client.batches.list.assert_not_called()
|
||||
self.client.files.download.assert_not_called()
|
||||
self.client.batches.get.side_effect = [succeeded, SimpleNamespace(
|
||||
state="JOB_STATE_SUCCEEDED", dest=None)]
|
||||
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
|
||||
|
||||
def test_missing_empty_and_invalid_manifest_cannot_report_success(self):
|
||||
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
|
||||
self.manifest({})
|
||||
self.assertEqual(check_evaluation_jobs(self.workspace, client=self.client), ExitCode.PARTIAL)
|
||||
for jobs in ([], {"flash": {}}, {"flash": {"name": ""}}, {"flash": None}):
|
||||
self.manifest(jobs)
|
||||
with self.assertRaises(CliError):
|
||||
check_evaluation_jobs(self.workspace, client=self.client)
|
||||
self.client.batches.get.assert_not_called()
|
||||
|
||||
def test_cli_returns_readiness_code_for_selected_evaluation(self):
|
||||
with patch("copienator.commands.batch_status.check_evaluation_jobs", return_value=ExitCode.PARTIAL) as check:
|
||||
self.assertEqual(main(["--evaluation", str(self.workspace.root)]), ExitCode.PARTIAL)
|
||||
self.assertEqual(check.call_args.args[0].root, self.workspace.root)
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
from copienator.crop_blank_margins import apply_bounds
|
||||
|
||||
|
||||
class CropBlankMarginsTests(unittest.TestCase):
|
||||
def test_cropbox_coordinates_with_rotation_and_existing_crop(self):
|
||||
for rotation in (0, 90, 180, 270):
|
||||
with self.subTest(rotation=rotation), pymupdf.open() as doc:
|
||||
page = doc.new_page(width=600, height=800)
|
||||
page.set_cropbox(pymupdf.Rect(30, 40, 570, 760))
|
||||
page.set_rotation(rotation)
|
||||
before = page.rect
|
||||
page.insert_text((80, 220), 'Visible content', fontsize=20)
|
||||
pix = page.get_pixmap()
|
||||
original = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
|
||||
apply_bounds(page, .2, .8)
|
||||
self.assertAlmostEqual(page.rect.width, before.width)
|
||||
self.assertAlmostEqual(page.rect.height, before.height*.6, places=3)
|
||||
self.assertEqual(page.rotation, rotation)
|
||||
pix = page.get_pixmap()
|
||||
cropped = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
|
||||
np.testing.assert_array_equal(cropped, original[int(before.height*.2):int(before.height*.8)])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator.crop_exercise_bottoms import process_exercise_pdf
|
||||
|
||||
|
||||
class CropExerciseBottomsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.review = self.root / "review"
|
||||
|
||||
def make_pdf(self, name: str, height: float, text_y: float, footer=True) -> Path:
|
||||
path = self.root / name
|
||||
with pymupdf.open() as document:
|
||||
page = document.new_page(width=600, height=height)
|
||||
page.insert_text((80, text_y), "student answer", fontsize=16)
|
||||
if footer:
|
||||
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
|
||||
document.save(path)
|
||||
return path
|
||||
|
||||
def test_short_page_is_skipped(self):
|
||||
source = self.make_pdf("short.pdf", 200, 100)
|
||||
destination = self.root / "out" / source.name
|
||||
records = process_exercise_pdf(
|
||||
source, destination, self.review, 800, dpi=150
|
||||
)
|
||||
self.assertEqual(records[0]["status"], "skipped-short")
|
||||
self.assertFalse(destination.exists())
|
||||
|
||||
def test_footer_fragment_is_ignored_for_a_large_bottom_crop(self):
|
||||
source = self.make_pdf("large.pdf", 400, 100)
|
||||
destination = self.root / "out" / source.name
|
||||
records = process_exercise_pdf(
|
||||
source, destination, self.review, 800, dpi=150
|
||||
)
|
||||
self.assertEqual(records[0]["status"], "cropped")
|
||||
self.assertGreaterEqual(records[0]["bottom_removed_lines"], 4)
|
||||
with pymupdf.open(destination) as result:
|
||||
self.assertLess(result[0].rect.height, 250)
|
||||
self.assertAlmostEqual(result[0].rect.width, 600)
|
||||
|
||||
def test_crop_smaller_than_four_lines_is_not_written(self):
|
||||
source = self.make_pdf("small.pdf", 400, 330, footer=False)
|
||||
destination = self.root / "out" / source.name
|
||||
records = process_exercise_pdf(
|
||||
source, destination, self.review, 800, dpi=150
|
||||
)
|
||||
self.assertEqual(records[0]["status"], "unchanged-small-crop")
|
||||
self.assertFalse(destination.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator.cli import CliError
|
||||
from copienator.commands import crop_exercise_bottoms
|
||||
from copienator.commands.clean import apply_cleanup, build_cleanup_plan
|
||||
from copienator.dispatcher import main
|
||||
from copienator.workspace import EvaluationWorkspace
|
||||
from copienator_gui.workflow import build_workflow
|
||||
|
||||
|
||||
class CropExerciseBottomsCommandTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name) / "Évaluation")
|
||||
self.workspace.copies_dir.mkdir(parents=True)
|
||||
self.copy_pdf = self.workspace.copies_dir / "Copie01.pdf"
|
||||
with pymupdf.open() as document:
|
||||
document.new_page(width=600, height=800)
|
||||
document.save(self.copy_pdf)
|
||||
self.answers = self.workspace.copies_dir / "Copie01"
|
||||
self.answers.mkdir()
|
||||
self.large = self.answers / "Ex 1.pdf"
|
||||
self.short = self.answers / "Ex 2.pdf"
|
||||
self._make_answer(self.large, 400, 100, footer=True)
|
||||
self._make_answer(self.short, 200, 100, footer=False)
|
||||
self.large_original = self.large.read_bytes()
|
||||
self.short_original = self.short.read_bytes()
|
||||
|
||||
@staticmethod
|
||||
def _make_answer(path: Path, height: float, text_y: float, *, footer: bool) -> None:
|
||||
with pymupdf.open() as document:
|
||||
page = document.new_page(width=600, height=height)
|
||||
page.insert_text((80, text_y), "student answer", fontsize=16)
|
||||
if footer:
|
||||
page.insert_text((20, height - 3), "Ex 2", fontsize=10)
|
||||
document.save(path)
|
||||
|
||||
def test_dispatcher_replaces_only_changed_answers_and_backs_them_up(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()) as log:
|
||||
self.assertEqual(
|
||||
main(["crop-answer-bottoms", str(self.workspace.root), "--workers", "2"]),
|
||||
0,
|
||||
)
|
||||
self.assertIn("1 PDF remplacé", log.getvalue())
|
||||
self.assertIn("1 exercice(s) rogné(s)", log.getvalue())
|
||||
self.assertIn("Rognage moyen des exercices modifiés", log.getvalue())
|
||||
with pymupdf.open(self.large) as cropped:
|
||||
self.assertLess(cropped[0].rect.height, 250)
|
||||
self.assertEqual(self.short.read_bytes(), self.short_original)
|
||||
backups = list(
|
||||
self.workspace.runs_dir.glob(
|
||||
"crop-exercise-bottoms-*/Copies/Copie01/Ex 1.pdf"
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_bytes(), self.large_original)
|
||||
self.assertFalse(
|
||||
list(
|
||||
self.workspace.runs_dir.glob(
|
||||
"crop-exercise-bottoms-*/Copies/Copie01/Ex 2.pdf"
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertTrue((backups[0].parents[2] / "report.json").is_file())
|
||||
|
||||
def test_crop_statistics_average_percentages_by_exercise(self):
|
||||
records = [
|
||||
{
|
||||
"file": "Copies/Copie01/Ex 1.pdf",
|
||||
"height_lines": 10.0,
|
||||
"bottom_removed_lines": 4.0,
|
||||
"status": "cropped",
|
||||
},
|
||||
{
|
||||
"file": "Copies/Copie01/Ex 1.pdf",
|
||||
"height_lines": 10.0,
|
||||
"bottom_removed_lines": 0.0,
|
||||
"status": "skipped-short",
|
||||
},
|
||||
{
|
||||
"file": "Copies/Copie01/Ex 2.pdf",
|
||||
"height_lines": 10.0,
|
||||
"bottom_removed_lines": 5.0,
|
||||
"status": "cropped",
|
||||
},
|
||||
{
|
||||
"file": "Copies/Copie01/Ex 3.pdf",
|
||||
"height_lines": 10.0,
|
||||
"bottom_removed_lines": 0.0,
|
||||
"status": "unchanged-small-crop",
|
||||
},
|
||||
]
|
||||
count, average = crop_exercise_bottoms.crop_statistics(records)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertAlmostEqual(average, 35.0)
|
||||
|
||||
def test_detection_failure_does_not_publish_an_earlier_result(self):
|
||||
broken = self.answers / "Ex 3.pdf"
|
||||
broken.write_bytes(b"not a PDF")
|
||||
with contextlib.redirect_stdout(io.StringIO()), self.assertRaises(Exception):
|
||||
crop_exercise_bottoms.run(
|
||||
self.workspace, self.workspace.root, workers=1
|
||||
)
|
||||
self.assertEqual(self.large.read_bytes(), self.large_original)
|
||||
self.assertEqual(self.short.read_bytes(), self.short_original)
|
||||
self.assertEqual(broken.read_bytes(), b"not a PDF")
|
||||
self.assertFalse(self.workspace.runs_dir.exists())
|
||||
|
||||
def test_invalid_target_and_worker_count_are_rejected(self):
|
||||
with self.assertRaises(CliError):
|
||||
crop_exercise_bottoms.run(self.workspace, self.large, workers=1)
|
||||
with self.assertRaises(CliError):
|
||||
crop_exercise_bottoms.run(self.workspace, self.workspace.root, workers=0)
|
||||
|
||||
def test_archiving_removes_the_retained_originals_and_report(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
crop_exercise_bottoms.run(
|
||||
self.workspace, self.workspace.root, workers=1
|
||||
)
|
||||
run_dirs = list(
|
||||
self.workspace.runs_dir.glob("crop-exercise-bottoms-*")
|
||||
)
|
||||
self.assertEqual(len(run_dirs), 1)
|
||||
self.workspace.correction_file.write_text("{}")
|
||||
student = self.workspace.return_dir / "Student"
|
||||
student.mkdir(parents=True)
|
||||
(student / "answer.jpg").write_bytes(b"return image")
|
||||
(student / "score.json").write_text("{}")
|
||||
plan = build_cleanup_plan(self.workspace)
|
||||
self.assertTrue(
|
||||
any(path.name == "report.json" for path in plan.deleted_files)
|
||||
)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
apply_cleanup(self.workspace, plan)
|
||||
self.assertFalse(self.workspace.runs_dir.exists())
|
||||
|
||||
def test_optional_step_follows_splitting_and_precedes_grouping(self):
|
||||
steps = build_workflow(False)
|
||||
index = next(
|
||||
i for i, step in enumerate(steps) if step.id == "crop_exercise_bottoms"
|
||||
)
|
||||
self.assertEqual(steps[index - 1].id, "splitting")
|
||||
self.assertEqual(steps[index + 1].id, "grouping")
|
||||
self.assertTrue(steps[index].optional)
|
||||
self.assertTrue(steps[index].auto_start_first_visit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,218 @@
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator.cli import CliError
|
||||
from copienator.commands import crop_margins
|
||||
from copienator.commands.clean import build_cleanup_plan, apply_cleanup
|
||||
from copienator.dispatcher import main
|
||||
from copienator.workspace import EvaluationWorkspace
|
||||
from copienator_gui.workflow import build_workflow
|
||||
|
||||
|
||||
class CropMarginsCommandTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name)/"Évaluation")
|
||||
self.workspace.copies_dir.mkdir(parents=True)
|
||||
self.source = self.workspace.copies_dir/"Copie01.pdf"
|
||||
with pymupdf.open() as doc:
|
||||
page = doc.new_page(width=300, height=420)
|
||||
page.insert_text((50, 180), "answer = 42", fontsize=15)
|
||||
doc.new_page(width=300, height=420)
|
||||
doc.save(self.source)
|
||||
self.original = self.source.read_bytes()
|
||||
|
||||
def test_dispatcher_replaces_copies_and_keeps_recoverable_original(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()) as log:
|
||||
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
|
||||
self.assertIn("Page 2/2", log.getvalue())
|
||||
self.assertIn("1/2 pages rognées", log.getvalue())
|
||||
self.assertIn("Rognage moyen des pages modifiées", log.getvalue())
|
||||
self.assertIn("page(s) rognée(s) de plus de 30 %", log.getvalue())
|
||||
with pymupdf.open(self.source) as result:
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertLess(result[0].rect.height, 150)
|
||||
self.assertIn("answer = 42", result[0].get_text())
|
||||
self.assertEqual(result[1].rect.height, 420)
|
||||
for page in result:
|
||||
self.assertEqual(page.rect.width, 300)
|
||||
page.get_pixmap()
|
||||
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/Copie01.pdf"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_bytes(), self.original)
|
||||
self.assertTrue((backups[0].parent.parent/"report.json").is_file())
|
||||
|
||||
def test_crop_statistics_use_only_modified_pages(self):
|
||||
records = [
|
||||
{
|
||||
"top_removed_mm": 10.0,
|
||||
"bottom_removed_mm": 20.0,
|
||||
"original_cropbox": [0, 0, 200, 300],
|
||||
"rotation": 0,
|
||||
},
|
||||
{
|
||||
"top_removed_mm": 40.0,
|
||||
"bottom_removed_mm": 0.0,
|
||||
"original_cropbox": [0, 0, 200, 300],
|
||||
"rotation": 90,
|
||||
},
|
||||
{
|
||||
"top_removed_mm": 0.0,
|
||||
"bottom_removed_mm": 0.0,
|
||||
"original_cropbox": [0, 0, 200, 300],
|
||||
"rotation": 0,
|
||||
},
|
||||
]
|
||||
count, average, over_thirty = crop_margins.crop_statistics(records)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertAlmostEqual(average, (28.3465 + 56.6929) / 2, places=3)
|
||||
self.assertEqual(over_thirty, 1)
|
||||
|
||||
def test_failure_or_interruption_never_publishes_partial_batch(self):
|
||||
second = self.workspace.copies_dir/"Copie02.pdf"
|
||||
second.write_bytes(self.original)
|
||||
real = crop_margins.process_pdf
|
||||
for error in (OSError("bad scan"), KeyboardInterrupt()):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
def process(source, *args, **kwargs):
|
||||
if source == second:
|
||||
raise error
|
||||
return real(source, *args, **kwargs)
|
||||
with patch.object(crop_margins, "process_pdf", side_effect=process):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(type(error)):
|
||||
crop_margins.run(self.workspace, self.workspace.root, workers=1)
|
||||
self.assertEqual(self.source.read_bytes(), self.original)
|
||||
self.assertEqual(second.read_bytes(), self.original)
|
||||
|
||||
def test_existing_coordinates_are_not_silently_invalidated(self):
|
||||
self.source.with_suffix(".json").write_text('{"list": []}')
|
||||
with self.assertRaisesRegex(CliError, "coordonnées"):
|
||||
crop_margins.run(self.workspace, self.workspace.root)
|
||||
self.assertEqual(self.source.read_bytes(), self.original)
|
||||
|
||||
def test_archiving_removes_crop_backups_but_keeps_processed_pdf_and_logs(self):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
crop_margins.run(self.workspace, self.workspace.root)
|
||||
processed = self.source.read_bytes()
|
||||
self.workspace.correction_file.write_text('{}')
|
||||
student = self.workspace.return_dir/"Student"
|
||||
student.mkdir(parents=True)
|
||||
(student/"answer.jpg").write_bytes(b"return image")
|
||||
(student/"score.json").write_text('{}')
|
||||
self.workspace.logs_dir.mkdir(parents=True)
|
||||
log = self.workspace.logs_dir/"crop_blank_margins.log"
|
||||
log.write_text("Completed crop")
|
||||
backups = list(self.workspace.runs_dir.glob("crop-margins-*/Copies/*.pdf"))
|
||||
reports = list(self.workspace.runs_dir.glob("crop-margins-*/report.json"))
|
||||
self.assertTrue(backups)
|
||||
self.assertTrue(reports)
|
||||
plan = build_cleanup_plan(self.workspace)
|
||||
for path in backups+reports:
|
||||
self.assertIn(path, plan.deleted_files)
|
||||
self.assertIn(self.source, plan.kept_files)
|
||||
self.assertIn(log, plan.kept_files)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
apply_cleanup(self.workspace, plan)
|
||||
self.assertFalse(self.workspace.runs_dir.exists())
|
||||
self.assertEqual(self.source.read_bytes(), processed)
|
||||
self.assertEqual(log.read_text(), "Completed crop")
|
||||
self.assertTrue((student/"answer.jpg").exists())
|
||||
self.assertTrue((student/"score.json").exists())
|
||||
|
||||
def test_single_copy_selection_cannot_target_original_scans(self):
|
||||
self.assertEqual(crop_margins.selected_files(self.workspace, self.source), [self.source])
|
||||
original = self.workspace.root/"Original.pdf"
|
||||
original.write_bytes(self.original)
|
||||
with self.assertRaises(CliError):
|
||||
crop_margins.selected_files(self.workspace, original)
|
||||
|
||||
def test_parallel_workers_match_serial_results_in_copy_and_page_order(self):
|
||||
second = self.workspace.copies_dir/"Copie02.pdf"
|
||||
second.write_bytes(self.original)
|
||||
serial, parallel = self.workspace.root/"serial", self.workspace.root/"parallel"
|
||||
serial.mkdir()
|
||||
parallel.mkdir()
|
||||
files = [self.source, second]
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
expected = crop_margins.process_copies(files, serial, 1)
|
||||
actual = crop_margins.process_copies(files, parallel, 2)
|
||||
self.assertEqual(actual, expected)
|
||||
self.assertEqual([(r['file'],r['page']) for r in actual],
|
||||
[(p.name,i) for p in files for i in (1,2)])
|
||||
for source in files:
|
||||
with pymupdf.open(serial/source.name) as a, pymupdf.open(parallel/source.name) as b:
|
||||
self.assertEqual([list(p.cropbox) for p in a], [list(p.cropbox) for p in b])
|
||||
|
||||
def test_worker_failure_does_not_replace_any_copy(self):
|
||||
broken = self.workspace.copies_dir/"Copie02.pdf"
|
||||
broken.write_bytes(b"not a PDF")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(Exception):
|
||||
crop_margins.run(self.workspace, self.workspace.root, workers=2)
|
||||
self.assertEqual(self.source.read_bytes(), self.original)
|
||||
self.assertEqual(broken.read_bytes(), b"not a PDF")
|
||||
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
|
||||
|
||||
def test_invalid_worker_count_is_rejected_before_processing(self):
|
||||
with self.assertRaises(CliError):
|
||||
crop_margins.run(self.workspace, self.workspace.root, workers=0)
|
||||
self.assertEqual(self.source.read_bytes(), self.original)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "SIGINT subprocess check uses Unix signals")
|
||||
def test_interrupt_stops_parallel_workers_before_removing_staging(self):
|
||||
import select
|
||||
with pymupdf.open() as doc:
|
||||
for _ in range(30):
|
||||
page = doc.new_page(width=595, height=842)
|
||||
page.insert_text((100,400), "answer = 42", fontsize=20)
|
||||
data = doc.tobytes()
|
||||
self.source.write_bytes(data)
|
||||
second = self.workspace.copies_dir/"Copie02.pdf"
|
||||
second.write_bytes(data)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable,"-u","-m","copienator","crop-margins",
|
||||
str(self.workspace.root),"--workers","2"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
cwd=Path(__file__).resolve().parents[1])
|
||||
try:
|
||||
while True:
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 20)
|
||||
self.assertTrue(ready, "No progress from parallel crop command")
|
||||
line = proc.stdout.readline()
|
||||
self.assertTrue(line, "Crop command exited before processing a page")
|
||||
if "Page " in line:
|
||||
break
|
||||
proc.send_signal(signal.SIGINT)
|
||||
output, _ = proc.communicate(timeout=20)
|
||||
self.assertEqual(proc.returncode, 130, output)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
self.assertEqual(self.source.read_bytes(), data)
|
||||
self.assertEqual(second.read_bytes(), data)
|
||||
self.assertFalse(list(self.workspace.root.glob(".Copies.*.files.tmp")))
|
||||
|
||||
def test_optional_step_sits_between_page_split_and_label_crop(self):
|
||||
steps = build_workflow(False)
|
||||
index = next(i for i, step in enumerate(steps) if step.id == "crop_blank_margins")
|
||||
self.assertEqual(steps[index-1].id, "page_splitter")
|
||||
self.assertEqual(steps[index+1].id, "cutleft")
|
||||
self.assertTrue(steps[index].optional)
|
||||
self.assertTrue(steps[index].auto_start_first_visit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import copy
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json
|
||||
from copienator.annotation_data import GroupCoordinates, _scaled_result
|
||||
from copienator.annotation_actions import apply_checkbox_actions
|
||||
from copienator.commands import annotating, correction
|
||||
from copienator.feedback_boxes import valid_feedback_box
|
||||
|
||||
|
||||
class FeedbackBoxTests(unittest.TestCase):
|
||||
def test_checkbox_for_promoted_feedback_deletes_the_correct_comment(self):
|
||||
feedback = [{"text": "Invalid local", "box_2d": [753, 680, 287, 946]},
|
||||
{"text": "Existing global", "box_2d": None}]
|
||||
apply_checkbox_actions({"Ex 5": {"result": {"feedback": feedback}}},
|
||||
[{"label": "Ex 5", "type": "del_global", "index": 0}], lambda _: None)
|
||||
self.assertTrue(feedback[0]["to_delete"])
|
||||
self.assertNotIn("to_delete", feedback[1])
|
||||
|
||||
def test_bad_boxes_keep_their_comment_as_global_feedback_without_mutating_data(self):
|
||||
for box in ([753, 680, 287, 946], [10, 50, 20, 30], [10, 20, 10, 30],
|
||||
[1, 2, 3], [None, 0, 10, 20], [float("nan"), 0, 10, 20]):
|
||||
with self.subTest(box=box):
|
||||
result = {"score": 2, "feedback": [{"text": "Important comment", "box_2d": box}]}
|
||||
original_box = result["feedback"][0]["box_2d"]
|
||||
scaled = _scaled_result(result, GroupCoordinates(2415, 3297, 1655, 3297))
|
||||
callback = Mock()
|
||||
render = Mock(return_value=Image.new("RGBA", (200, 40), "white"))
|
||||
with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))):
|
||||
image, _ = annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", scaled,
|
||||
2415, render_fn=render, draw_callback=callback)
|
||||
self.assertIsNotNone(image)
|
||||
render.assert_called_once_with("Important comment", unittest.mock.ANY)
|
||||
self.assertFalse(any(call.args[0] == "local_rect" for call in callback.call_args_list))
|
||||
self.assertIs(result["feedback"][0]["box_2d"], original_box)
|
||||
|
||||
def test_valid_boxes_remain_local(self):
|
||||
result = {"feedback": [{"text": "Local", "box_2d": [10, 20, 30, 40]}]}
|
||||
before = copy.deepcopy(result)
|
||||
callback = Mock()
|
||||
with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))):
|
||||
annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", result, 0,
|
||||
render_fn=Mock(return_value=Image.new("RGBA", (200, 40))),
|
||||
draw_callback=callback)
|
||||
self.assertTrue(any(call.args[0] == "local_rect" for call in callback.call_args_list))
|
||||
self.assertEqual(result, before)
|
||||
self.assertTrue(valid_feedback_box([10, 20, 30, 40]))
|
||||
|
||||
def test_invalid_auxiliary_response_loses_only_its_rectangle(self):
|
||||
returned = [{"text": "Keep this", "box_2d": [753, 680, 287, 946]}]
|
||||
with patch.object(correction.prompting, "request_for_box_correction", return_value=([], {})), patch.object(
|
||||
correction, "call_gemini_with_retries", return_value=json.dumps(returned)
|
||||
):
|
||||
feedback = correction.correct_boxes_with_gemini("26", "Ex 5", Path("unused.pdf"), [], 0, 1000, 1, 1000)
|
||||
self.assertEqual(feedback, [{"text": "Keep this", "box_2d": None}])
|
||||
|
||||
def test_correction_requests_repair_for_inverted_boxes_and_falls_back_without_losing_comments(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
group = root / "Par label" / "Ex 5" / "Group_1.jpg"
|
||||
group.parent.mkdir(parents=True)
|
||||
group.touch()
|
||||
atomic_write_json(group.with_suffix(".json"), [["26", 0, 1000, 1, "Ex 5"]])
|
||||
args = correction.build_parser().parse_args([str(root)])
|
||||
correction.configure_runtime(EvaluationWorkspace(root), [(str(group), "Ex 5")], args, api_client=Mock())
|
||||
response = [{"id": "26", "result": {"score": 2, "error": "", "feedback": [
|
||||
{"text": "First", "box_2d": [753, 680, 287, 946]},
|
||||
{"text": "Second", "box_2d": [100, 900, 200, 100]}]}}]
|
||||
with patch.object(correction.prompting, "generate_request", return_value=([], {})), patch.object(
|
||||
correction, "correct_boxes_with_gemini", side_effect=RuntimeError("repair failed")
|
||||
) as repair:
|
||||
correction.process_single_task((str(group), "Ex 5"), json.dumps(response))
|
||||
repair.assert_called_once()
|
||||
feedback = correction.results["Ex 5"][0][0]["result"]["feedback"]
|
||||
self.assertEqual([f["text"] for f in feedback], ["First", "Second"])
|
||||
self.assertTrue(all(f["box_2d"] is None for f in feedback))
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.annotation_data import AnnotationLoadResult
|
||||
from copienator.commands import annotating_by_label as grouped
|
||||
from copienator.commands import annotating_with_checks as checks
|
||||
from copienator.commands import export, import_annotations
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
|
||||
|
||||
class GroupedRedoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name) / "Exam"
|
||||
for directory in ("Copies", "Par label", "BGnot", "BRnot"):
|
||||
(self.root / directory).mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\n")
|
||||
atomic_write_json(self.root / "correction.json", {})
|
||||
atomic_write_json(
|
||||
self.root / "refaire.json", [["Copie01", ["Ex 1"]], ["Copie02", ["Ex 1"]]]
|
||||
)
|
||||
(self.root / "BRnot/previous.txt").write_text("previous redo")
|
||||
(self.root / "BGnot/main.txt").write_text("main run")
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
self.data = {"01": {"Ex 1": {}}, "02": {"Ex 1": {}}}
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def render(item):
|
||||
student_id, label, _content = item
|
||||
image = Image.new("RGB", (100, 100), "white")
|
||||
ImageDraw.Draw(image).rectangle((10, 10, 50, 50), outline="black", width=2)
|
||||
return (
|
||||
student_id,
|
||||
label,
|
||||
image,
|
||||
0,
|
||||
[
|
||||
{
|
||||
"type": "score",
|
||||
"label": label,
|
||||
"value": 3,
|
||||
"final_box": [10, 10, 50, 50],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def generate(self):
|
||||
with (
|
||||
patch.object(
|
||||
grouped,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult(self.data, []),
|
||||
) as load,
|
||||
patch.object(grouped, "render_item", side_effect=self.render),
|
||||
patch.object(grouped, "_load_label_groups") as label_groups,
|
||||
):
|
||||
self.assertEqual(
|
||||
grouped.run(self.workspace, refaire=True, overwrite=True),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
self.assertEqual(
|
||||
load.call_args.kwargs["refaire_list"],
|
||||
read_json(self.root / "refaire.json"),
|
||||
)
|
||||
label_groups.assert_not_called()
|
||||
directories = list((self.root / "BRnot").iterdir())
|
||||
self.assertEqual(len(directories), 1)
|
||||
self.assertTrue(directories[0].is_dir())
|
||||
self.assertEqual((self.root / "BGnot/main.txt").read_text(), "main run")
|
||||
return directories[0]
|
||||
|
||||
def test_grouped_redo_export_import_and_actual_annotation_detection(self):
|
||||
directory = self.generate()
|
||||
metadata = read_json(directory / "bnote.json")["images"]
|
||||
self.assertEqual(
|
||||
[(item["id"], item["label"]) for item in metadata],
|
||||
[("01", "Ex 1"), ("02", "Ex 1")],
|
||||
)
|
||||
export_root = Path(self.temp.name) / "Export"
|
||||
with patch.object(export, "EXPORT_DIR", export_root):
|
||||
self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS)
|
||||
self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1)
|
||||
imported_root = Path(self.temp.name) / "Import"
|
||||
imported_root.mkdir()
|
||||
with Image.open(directory / "Reference.jpg") as reference:
|
||||
annotated = reference.convert("RGB")
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
draw.rectangle((17, 17, 43, 43), fill="black")
|
||||
draw.rectangle((70, 170, 90, 190), fill="black")
|
||||
annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72)
|
||||
with patch.object(import_annotations, "IMPORT_DIR", imported_root):
|
||||
self.assertEqual(
|
||||
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
|
||||
)
|
||||
actions, notes, incomplete = reader._scan_redo_annotations(
|
||||
self.root / "BRnot", {"01": {"Ex 1"}, "02": {"Ex 1"}}
|
||||
)
|
||||
self.assertFalse(incomplete)
|
||||
self.assertEqual(actions["01"][0]["value"], 3)
|
||||
self.assertFalse(actions.get("02"))
|
||||
self.assertIn("Ex 1", notes["02"])
|
||||
full_data = {student: {"Ex 1": {}, "Ex 2": {}} for student in ("01", "02")}
|
||||
for mode in ("BGnot", "Bnot", "Anot"):
|
||||
(self.root / mode).mkdir(exist_ok=True)
|
||||
with (
|
||||
self.subTest(mode=mode),
|
||||
patch.object(
|
||||
reader,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult(full_data, []),
|
||||
),
|
||||
patch.object(
|
||||
reader,
|
||||
"apply_actions_and_regenerate_grouped",
|
||||
return_value=(ExitCode.SUCCESS, ""),
|
||||
) as regenerate,
|
||||
):
|
||||
self.assertEqual(
|
||||
reader.run(self.workspace, refaire=True, annotation_dir=mode),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
calls = {call.args[2]: call for call in regenerate.call_args_list}
|
||||
self.assertEqual(set(calls), {"01", "02"})
|
||||
self.assertEqual(set(calls["01"].args[1]["01"]), {"Ex 1", "Ex 2"})
|
||||
self.assertEqual(calls["01"].args[3][0]["value"], 3)
|
||||
self.assertIn("Ex 1", calls["02"].args[4])
|
||||
|
||||
def test_missing_group_leaves_affected_copy_incomplete(self):
|
||||
directory = self.generate()
|
||||
shutil.copy2(directory / "Concat.pdf", directory / "Concat_annotated.pdf")
|
||||
extra = self.root / "BRnot/Ex 2 G1"
|
||||
extra.mkdir()
|
||||
atomic_write_json(
|
||||
extra / "bnote.json", {"images": [{"id": "01", "label": "Ex 2"}]}
|
||||
)
|
||||
_actions, _notes, incomplete = reader._scan_redo_annotations(
|
||||
self.root / "BRnot", {"01": {"Ex 1", "Ex 2"}, "02": {"Ex 1"}}
|
||||
)
|
||||
self.assertEqual(incomplete, {"01"})
|
||||
|
||||
def test_failed_generation_preserves_previous_redo_for_both_layouts(self):
|
||||
for module, worker in ((grouped, "render_item"), (checks, "_render_student")):
|
||||
with (
|
||||
self.subTest(module=module),
|
||||
patch.object(
|
||||
module,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult(self.data, []),
|
||||
),
|
||||
patch.object(
|
||||
module,
|
||||
worker,
|
||||
return_value=None if module is grouped else "partial",
|
||||
),
|
||||
):
|
||||
if module is grouped:
|
||||
status = module.run(self.workspace, refaire=True, overwrite=True)
|
||||
else:
|
||||
status = module.run(
|
||||
self.workspace, self.root, refaire=True, overwrite=True
|
||||
)
|
||||
self.assertEqual(status, ExitCode.PARTIAL)
|
||||
self.assertEqual(
|
||||
(self.root / "BRnot/previous.txt").read_text(), "previous redo"
|
||||
)
|
||||
|
||||
def test_switching_to_per_copy_removes_previous_group_layout(self):
|
||||
self.generate()
|
||||
|
||||
def render(_workspace, student_id, _labels, **kwargs):
|
||||
output = kwargs["output_root"] / f"Copie{student_id}"
|
||||
output.mkdir()
|
||||
(output / "Concat.pdf").touch()
|
||||
return "success"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
checks,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult(self.data, []),
|
||||
),
|
||||
patch.object(checks, "_render_student", side_effect=render),
|
||||
):
|
||||
self.assertEqual(
|
||||
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
self.assertEqual(
|
||||
{path.name for path in (self.root / "BRnot").iterdir()},
|
||||
{"Copie01", "Copie02"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,371 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator_gui.app import CopienatorApp
|
||||
from copienator_gui.workflow import command_display
|
||||
from copienator.commands.page_splitter import _selected_inputs
|
||||
from copienator import EvaluationWorkspace
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
|
||||
class GuiConvenienceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.repository = Path(self.temp.name)
|
||||
self.evaluation = self.repository / "Évaluation avec espaces"
|
||||
self.evaluation.mkdir()
|
||||
self.app = CopienatorApp(self.repository, False, self.evaluation)
|
||||
self.app.update()
|
||||
|
||||
def tearDown(self):
|
||||
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
|
||||
self.app.after_cancel(callback)
|
||||
self.app.destroy()
|
||||
self.temp.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _label_texts(widget):
|
||||
return [
|
||||
text
|
||||
for child in widget.winfo_children()
|
||||
for text in (
|
||||
[str(child.cget("text"))]
|
||||
if isinstance(child, ttk.Label)
|
||||
else GuiConvenienceTests._label_texts(child)
|
||||
)
|
||||
]
|
||||
|
||||
def test_reload_detects_added_and_removed_files_without_advancing(self):
|
||||
for name in ("enonce.pdf", "enonce.tex", "correction.tex"):
|
||||
(self.evaluation / name).touch()
|
||||
self.app._reload_inputs()
|
||||
self.assertIn("names", self.app.info_var.get())
|
||||
(self.repository / "names").touch()
|
||||
self.app._reload_inputs()
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.state_store.step("inputs")["status"], "success")
|
||||
self.assertEqual(self.app.current_step.id, "inputs")
|
||||
(self.evaluation / "enonce.pdf").unlink()
|
||||
self.app._reload_inputs()
|
||||
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
|
||||
self.assertIn("enonce.pdf", self.app.missing_requirements_label.cget("text"))
|
||||
style = ttk.Style(self.app)
|
||||
self.assertEqual(
|
||||
style.lookup("MissingPrerequisite.TLabel", "foreground"), "#c62828"
|
||||
)
|
||||
|
||||
def test_compact_evaluation_input_and_open_folder_button(self):
|
||||
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
|
||||
self.assertEqual(
|
||||
int(self.app.open_evaluation_button.grid_info()["column"]),
|
||||
int(self.app.evaluation_entry.grid_info()["column"]) + 1,
|
||||
)
|
||||
with patch("copienator_gui.app.open_path") as opened:
|
||||
self.app.open_evaluation_button.invoke()
|
||||
opened.assert_called_once_with(self.evaluation)
|
||||
|
||||
def test_manual_resolution_panel_follows_command_target(self):
|
||||
manual = self.evaluation / "manual_resolutions.txt"
|
||||
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
||||
self.app.tree.selection_set("manual_resolution")
|
||||
self.app.update()
|
||||
self.assertEqual(len(self.app.manual_panel.pdf_buttons), 2)
|
||||
other = self.repository / "Other"
|
||||
other.mkdir()
|
||||
other_manual = other / "manual_resolutions.txt"
|
||||
other_manual.write_text("Copie02 C xs D\n", encoding="utf-8")
|
||||
self.app.arg_vars["target"].set(str(other))
|
||||
self.app.update()
|
||||
self.assertIn("Copie02", self.app.manual_panel.text.get("1.0", "end"))
|
||||
with patch("copienator_gui.manual_resolution.open_path") as opened:
|
||||
self.app.manual_panel.editor_button.invoke()
|
||||
opened.assert_called_once_with(other_manual)
|
||||
|
||||
def test_successful_manual_resolution_returns_to_refaire_correction(self):
|
||||
manual = self.evaluation / "manual_resolutions.txt"
|
||||
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
||||
self.app.state_store.update_step("manual_resolution", visited=True)
|
||||
self.app.tree.selection_set("manual_resolution")
|
||||
self.app.update()
|
||||
|
||||
self.app.active_step_id = "manual_resolution"
|
||||
self.app._finish_process(0, False)
|
||||
self.app.update()
|
||||
|
||||
self.assertEqual(self.app.current_step.id, "correction")
|
||||
self.assertEqual(self.app.variant_var.get(), "refaire")
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("correction")["variant"], "refaire"
|
||||
)
|
||||
self.assertNotIn("overwrite", self.app.arg_vars)
|
||||
self.assertIn("--refaire", self.app._make_command())
|
||||
|
||||
def test_giving_names_waits_for_explicit_manual_completion(self):
|
||||
return_dir = self.evaluation / "A Rendre" / "Student (01)"
|
||||
return_dir.mkdir(parents=True)
|
||||
(return_dir / "Student.jpg").write_bytes(b"jpg")
|
||||
(return_dir / "Student.pdf").write_bytes(b"pdf")
|
||||
self.app.tree.selection_set("giving_names")
|
||||
self.app.update()
|
||||
|
||||
self.app.active_step_id = "giving_names"
|
||||
self.app._finish_process(0, False)
|
||||
self.app.update()
|
||||
|
||||
self.assertEqual(self.app.current_step.id, "giving_names")
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("giving_names")["status"], "detected"
|
||||
)
|
||||
self.assertEqual(
|
||||
self.app.complete_giving_names_button.cget("text"),
|
||||
"Marquer terminée",
|
||||
)
|
||||
|
||||
self.app.complete_giving_names_button.invoke()
|
||||
self.app.update()
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("giving_names")["status"], "success"
|
||||
)
|
||||
self.assertNotEqual(self.app.current_step.id, "giving_names")
|
||||
|
||||
def test_batch_status_waits_until_all_jobs_are_ready(self):
|
||||
self.app.state_store.update_step("correction", variant="batch")
|
||||
self.app.state_store.update_step("batch_status", visited=True)
|
||||
self.app.tree.selection_set("batch_status")
|
||||
self.app.update()
|
||||
command = self.app._make_command()
|
||||
self.assertIn("--evaluation", command)
|
||||
self.assertFalse(self.app.arg_vars)
|
||||
self.app.active_step_id = "batch_status"
|
||||
self.app._finish_process(4, False)
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.current_step.id, "batch_status")
|
||||
self.app.active_step_id = "batch_status"
|
||||
self.app._finish_process(0, False)
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.current_step.id, "fetch_batches")
|
||||
|
||||
def test_batch_watch_buttons_use_loaded_evaluation_and_stop_on_reload(self):
|
||||
self.app.state_store.update_step("correction", variant="batch")
|
||||
self.app.tree.selection_set("batch_status")
|
||||
self.app.update()
|
||||
with patch.object(self.app.batch_monitor, "start") as start:
|
||||
self.app.watch_batches_button.invoke()
|
||||
self.assertEqual(start.call_args.args[0][-2:], ["--evaluation", str(self.evaluation)])
|
||||
self.app.batch_monitor.active = True
|
||||
self.app._update_controls()
|
||||
self.assertEqual(str(self.app.stop_watch_batches_button.cget("state")), "normal")
|
||||
self.app.stop_watch_batches_button.invoke()
|
||||
self.assertFalse(self.app.batch_monitor.active)
|
||||
self.app.batch_monitor.active = True
|
||||
self.app._load_evaluation()
|
||||
self.assertFalse(self.app.batch_monitor.active)
|
||||
|
||||
def test_background_batch_readiness_notifies_without_changing_other_step(self):
|
||||
self.app.tree.selection_set("inputs")
|
||||
self.app.update()
|
||||
with patch("copienator_gui.app.notify_desktop") as notify:
|
||||
self.app._batch_results_ready()
|
||||
notify.assert_called_once()
|
||||
self.assertIn(self.evaluation.name, notify.call_args.args[1])
|
||||
self.assertEqual(self.app.current_step.id, "inputs")
|
||||
self.assertEqual(self.app.state_store.step("batch_status")["status"], "success")
|
||||
|
||||
def test_optional_blank_crop_can_target_a_copy_or_be_skipped(self):
|
||||
copies = self.evaluation/"Copies"
|
||||
copies.mkdir()
|
||||
source = copies/"Copie01.pdf"
|
||||
source.touch()
|
||||
self.app.state_store.update_step("crop_blank_margins", visited=True)
|
||||
self.app.tree.selection_set("crop_blank_margins")
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.current_step.id, "crop_blank_margins")
|
||||
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
|
||||
self.app.copy_var.set(source.name)
|
||||
self.app._target_selected_copy()
|
||||
command = self.app._make_command()
|
||||
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
|
||||
self.app._skip_step()
|
||||
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
|
||||
|
||||
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
|
||||
answers = self.evaluation / "Copies" / "Copie01"
|
||||
answers.mkdir(parents=True)
|
||||
(answers / "Ex 1.pdf").touch()
|
||||
self.app.state_store.update_step("crop_exercise_bottoms", visited=True)
|
||||
self.app.tree.selection_set("crop_exercise_bottoms")
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.current_step.id, "crop_exercise_bottoms")
|
||||
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
|
||||
command = self.app._make_command()
|
||||
self.assertEqual(
|
||||
command[-4:],
|
||||
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
|
||||
)
|
||||
self.app._skip_step()
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
|
||||
)
|
||||
|
||||
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
|
||||
for folder in ("Copies", "Copies Originales"):
|
||||
(self.evaluation / folder).mkdir()
|
||||
for name in ("Copie01.pdf", "Copie02.pdf"):
|
||||
(self.evaluation / folder / name).touch()
|
||||
self.app.tree.selection_set("page_splitter")
|
||||
self.app.update()
|
||||
self.app.copy_var.set("Copie02.pdf")
|
||||
self.app._target_selected_copy()
|
||||
command = self.app._make_command()
|
||||
target = Path(command[-1])
|
||||
self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf")
|
||||
self.assertEqual(_selected_inputs(EvaluationWorkspace(self.evaluation), target),
|
||||
[self.evaluation / "Copies Originales" / "Copie02.pdf"])
|
||||
self.app._copy_command()
|
||||
self.assertEqual(self.app.clipboard_get(), command_display(command))
|
||||
self.app._target_all_pages()
|
||||
self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg())
|
||||
|
||||
def test_label_detection_can_target_one_copy(self):
|
||||
copies = self.evaluation / "Copies"
|
||||
copies.mkdir()
|
||||
for name in ("Copie01.pdf", "Copie02.pdf"):
|
||||
(copies / name).touch()
|
||||
|
||||
self.app.tree.selection_set("labels")
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.copy_var.get(), "Copie01.pdf")
|
||||
self.app.copy_var.set("Copie02.pdf")
|
||||
self.app._target_selected_copy()
|
||||
|
||||
command = self.app._make_command()
|
||||
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
|
||||
|
||||
def button_texts(widget):
|
||||
return [
|
||||
text
|
||||
for child in widget.winfo_children()
|
||||
for text in (
|
||||
[child.cget("text")]
|
||||
if isinstance(child, ttk.Button)
|
||||
else button_texts(child)
|
||||
)
|
||||
]
|
||||
|
||||
controls = button_texts(self.app.form)
|
||||
self.assertIn("Cibler la copie sélectionnée", controls)
|
||||
self.assertIn("Cibler tout le dossier", controls)
|
||||
|
||||
def test_label_review_can_show_and_target_one_copy(self):
|
||||
copies = self.evaluation / "Copies"
|
||||
copies.mkdir()
|
||||
for name in ("Copie01.pdf", "Copie02.pdf"):
|
||||
(copies / name).touch()
|
||||
|
||||
self.app.tree.selection_set("plotting")
|
||||
self.app.update()
|
||||
self.app.copy_var.set("Copie02.pdf")
|
||||
|
||||
with patch("copienator_gui.app.open_path") as opened:
|
||||
self.app._open_selected_copy()
|
||||
opened.assert_called_once_with(copies / "Copie02.pdf")
|
||||
|
||||
self.app._target_selected_copy()
|
||||
command = self.app._make_command()
|
||||
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
|
||||
labels = self._label_texts(self.app.form)
|
||||
self.assertTrue(
|
||||
any("Seule la copie ciblée sera vérifiée" in text for text in labels)
|
||||
)
|
||||
|
||||
def test_free_form_arguments_are_shown_only_when_documented(self):
|
||||
self.app.tree.selection_set("labels")
|
||||
self.app.update()
|
||||
labels_text = self._label_texts(self.app.form)
|
||||
extra_label = next(
|
||||
child
|
||||
for child in self.app.form.winfo_children()
|
||||
if isinstance(child, ttk.Label)
|
||||
and str(child.cget("text")).startswith("Arguments supplémentaires")
|
||||
)
|
||||
tooltip = extra_label._copienator_tooltip
|
||||
self.assertIn("Cutleft", tooltip.text)
|
||||
self.assertFalse(any("Cutleft" in text for text in labels_text))
|
||||
|
||||
tooltip.show()
|
||||
self.app.update()
|
||||
self.assertIsNotNone(tooltip.window)
|
||||
self.assertIn("Cutleft", tooltip.window.winfo_children()[0].cget("text"))
|
||||
tooltip.hide()
|
||||
|
||||
self.app.tree.selection_set("plotting")
|
||||
self.app.update()
|
||||
self.assertFalse(
|
||||
any(
|
||||
text.startswith("Arguments supplémentaires")
|
||||
for text in self._label_texts(self.app.form)
|
||||
)
|
||||
)
|
||||
|
||||
def test_each_visible_argument_label_has_a_tooltip(self):
|
||||
self.app.tree.selection_set("correction")
|
||||
self.app.update()
|
||||
argument_labels = [
|
||||
child
|
||||
for child in self.app.form.winfo_children()
|
||||
if isinstance(child, ttk.Label) and str(child.cget("text")).endswith("ⓘ")
|
||||
]
|
||||
self.assertGreaterEqual(len(argument_labels), 4)
|
||||
for label in argument_labels:
|
||||
with self.subTest(label=label.cget("text")):
|
||||
tooltip = getattr(label, "_copienator_tooltip", None)
|
||||
self.assertIsNotNone(tooltip)
|
||||
self.assertTrue(tooltip.text.strip())
|
||||
|
||||
def test_verbose_checkbox_updates_supported_commands(self):
|
||||
self.app.tree.selection_set("labels")
|
||||
self.app.update()
|
||||
self.assertNotIn("--verbose", self.app._make_command())
|
||||
self.app.verbose_var.set(True)
|
||||
self.assertIn("--verbose", self.app._make_command())
|
||||
|
||||
def test_console_selection_survives_output_and_is_read_only(self):
|
||||
self.app._append_console("Première ligne\nDeuxième ligne\n")
|
||||
self.app.console.tag_add("sel", "1.0", "1.end")
|
||||
self.app._append_console("Suite\n")
|
||||
self.app._copy_console_selection()
|
||||
self.assertEqual(self.app.clipboard_get(), "Première ligne")
|
||||
self.app._copy_console_all()
|
||||
expected = "Première ligne\nDeuxième ligne\nSuite\n"
|
||||
self.assertEqual(self.app.clipboard_get(), expected)
|
||||
self.app.console.insert("end", "unwanted edit")
|
||||
self.assertEqual(self.app.console.get("1.0", "end-1c"), expected)
|
||||
self.app._select_console_all()
|
||||
self.app._copy_console_selection()
|
||||
self.assertEqual(self.app.clipboard_get(), expected)
|
||||
|
||||
def test_validate_rename_preserves_files_and_downstream_status(self):
|
||||
pdf = self.evaluation / "Copie01.pdf"
|
||||
pdf.write_bytes(b"unchanged PDF")
|
||||
self.app.state_store.update_step("rename", status="stale")
|
||||
self.app.state_store.update_step("page_splitter", status="success")
|
||||
self.app.tree.selection_set("rename")
|
||||
self.app.update()
|
||||
with patch.object(self.app.runner, "start") as start:
|
||||
self.app.validate_rename_button.invoke()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(self.app.state_store.step("rename")["status"], "success")
|
||||
self.assertEqual(self.app.state_store.step("page_splitter")["status"], "success")
|
||||
self.assertEqual(pdf.read_bytes(), b"unchanged PDF")
|
||||
self.assertEqual(list(self.evaluation.glob("*.pdf")), [pdf])
|
||||
self.app.state_store.update_step("rename", status="stale")
|
||||
self.app.active_step_id = "page_splitter"
|
||||
self.app._update_controls()
|
||||
self.assertIn("disabled", self.app.validate_rename_button.state())
|
||||
self.app._validate_rename_step()
|
||||
self.assertEqual(self.app.state_store.step("rename")["status"], "stale")
|
||||
self.app.active_step_id = None
|
||||
+919
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator_gui.cut_helper import CutHelper, PAGE_GAP
|
||||
from copienator_gui.manual_resolution import ManualResolutionPanel
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "requires a display")
|
||||
class CutHelperTests(unittest.TestCase):
|
||||
def test_cut_button_drag_to_page_gap_and_enter_only_produces_command(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
source = directory / "Copies" / "Copie16" / "A.pdf"
|
||||
source.parent.mkdir(parents=True)
|
||||
with pymupdf.open() as document:
|
||||
for height in (200, 400):
|
||||
page = document.new_page(width=300, height=height)
|
||||
page.insert_text((20, 40), "Student answer")
|
||||
document.save(source)
|
||||
manual = directory / "manual_resolutions.txt"
|
||||
manual.write_text("Copie16 A -> B|\n")
|
||||
original = source.read_bytes()
|
||||
root = tk.Tk()
|
||||
try:
|
||||
panel = ManualResolutionPanel(root, lambda: directory)
|
||||
panel.pack()
|
||||
root.update()
|
||||
self.assertEqual(len(panel.cut_buttons), 1)
|
||||
panel.cut_buttons[0].invoke()
|
||||
helper = next(child for child in panel.winfo_children() if isinstance(child, CutHelper))
|
||||
helper.canvas.yview_moveto(0)
|
||||
root.update()
|
||||
helper.move_bar(SimpleNamespace(y=200 * helper.scale + PAGE_GAP / 2))
|
||||
self.assertIn("entre les pages 1 et 2", helper.caption.get())
|
||||
helper.keep.set(2)
|
||||
helper.mode.set("x")
|
||||
helper.focus_force()
|
||||
root.update()
|
||||
helper.event_generate("<Return>")
|
||||
root.update()
|
||||
self.assertFalse(helper.winfo_exists())
|
||||
self.assertEqual(panel.cut_result.get(), "Copie16 A c{33.333333}2x B|")
|
||||
panel.copy_cut_command()
|
||||
self.assertEqual(root.clipboard_get(), panel.cut_result.get())
|
||||
self.assertEqual(source.read_bytes(), original)
|
||||
self.assertEqual(manual.read_text(), "Copie16 A -> B|\n")
|
||||
panel.reload()
|
||||
self.assertEqual(len(panel.cut_buttons), 1)
|
||||
finally:
|
||||
root.destroy()
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from copienator_gui.manual_resolution import ManualResolutionPanel
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "requires a display")
|
||||
class ManualResolutionPanelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.evaluation = Path(self.temp.name)
|
||||
self.root = tk.Tk()
|
||||
self.addCleanup(self.root.destroy)
|
||||
self.path = self.evaluation / "manual_resolutions.txt"
|
||||
|
||||
def panel(self):
|
||||
panel = ManualResolutionPanel(self.root, lambda: self.evaluation)
|
||||
panel.pack()
|
||||
self.root.update()
|
||||
return panel
|
||||
|
||||
def test_preview_editor_and_each_pdf_pair_use_shared_resolution_rules(self):
|
||||
content = "### Instructions\nCopie01 Ex 1 x> |Ex 2\n\nCopie02 Ex 3 ss Ex 4|\n"
|
||||
self.path.write_text(content, encoding="utf-8")
|
||||
paths = []
|
||||
for copy, label in (("01", "Ex 1_new"), ("01", "Ex 2_old"),
|
||||
("02", "Ex 3"), ("02", "Ex 4")):
|
||||
path = self.evaluation / "Copies" / f"Copie{copy}" / f"{label}.pdf"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
paths.append(path)
|
||||
panel = self.panel()
|
||||
self.assertEqual(panel.text.get("1.0", "end-1c"), content)
|
||||
self.assertEqual(len(panel.pdf_buttons), 4)
|
||||
self.assertEqual([button.cget("text") for button in panel.pdf_buttons],
|
||||
["PDF source", "PDF cible"] * 2)
|
||||
with patch("copienator_gui.manual_resolution.open_path") as opened:
|
||||
panel.editor_button.invoke()
|
||||
for button in panel.pdf_buttons:
|
||||
button.invoke()
|
||||
self.assertEqual(opened.call_args_list, [call(self.path), *map(call, paths)])
|
||||
|
||||
def test_reload_keeps_invalid_lines_visible_and_removes_stale_actions(self):
|
||||
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
||||
panel = self.panel()
|
||||
self.path.write_text("bad instruction\nCopie02 C sx D\n", encoding="utf-8")
|
||||
panel.reload()
|
||||
self.assertIn("bad instruction", panel.text.get("1.0", "end"))
|
||||
self.assertIn("lignes invalides : 1", panel.status.cget("text"))
|
||||
self.assertEqual(len(panel.pdf_buttons), 2)
|
||||
self.path.unlink()
|
||||
panel.reload()
|
||||
self.assertFalse(panel.pdf_buttons)
|
||||
self.assertEqual(str(panel.editor_button.cget("state")), "disabled")
|
||||
|
||||
def test_missing_source_does_not_prevent_opening_destination(self):
|
||||
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
||||
destination = self.evaluation / "Copies" / "Copie01" / "B.pdf"
|
||||
destination.parent.mkdir(parents=True)
|
||||
destination.touch()
|
||||
panel = self.panel()
|
||||
with patch("copienator_gui.manual_resolution.open_path") as opened, patch(
|
||||
"copienator_gui.manual_resolution.messagebox.showerror"
|
||||
) as error:
|
||||
panel.pdf_buttons[0].invoke()
|
||||
opened.assert_not_called()
|
||||
panel.pdf_buttons[1].invoke()
|
||||
opened.assert_called_once_with(destination)
|
||||
self.assertIn("A.pdf", error.call_args.args[1])
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import atomic_write_json, read_json
|
||||
from copienator_gui.app import CopienatorApp
|
||||
from copienator_gui.refaire import (
|
||||
ALL_COPIES,
|
||||
SECTION,
|
||||
available_copies,
|
||||
resolve_layout,
|
||||
validate_selection,
|
||||
)
|
||||
from copienator_gui.workflow import build_command, build_workflow
|
||||
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def test_selection_validates_and_deduplicates_labels(self):
|
||||
copies = {"Copie01": Path("/tmp/Copie01.pdf")}
|
||||
self.assertEqual(
|
||||
validate_selection(
|
||||
[["Copie01", ["Ex 2", "Ex 1", "Ex 2"]]], copies, ["Ex 1", "Ex 2"]
|
||||
),
|
||||
[["Copie01", ["Ex 1", "Ex 2"]]],
|
||||
)
|
||||
for invalid in (
|
||||
[],
|
||||
[["missing", []]],
|
||||
[["Copie01", ["unknown"]]],
|
||||
[["../Copie01", []]],
|
||||
):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
|
||||
validate_selection(invalid, copies, ["Ex 1"])
|
||||
|
||||
def test_automatic_layout_groups_shared_labels_and_honors_explicit_choice(self):
|
||||
selection = [["Copie01", ["Ex 1"]], ["Copie02", ["Ex 1"]]]
|
||||
self.assertEqual(resolve_layout(selection, ["Ex 1", "Ex 2"]), "grouped")
|
||||
self.assertEqual(resolve_layout(selection, ["Ex 1"], "copies"), "copies")
|
||||
self.assertEqual(resolve_layout([["Copie01", ["Ex 1"]]], ["Ex 1"]), "copies")
|
||||
self.assertEqual(
|
||||
resolve_layout([["Copie01", ["Ex 1"]], ["Copie02", []]], ["Ex 1"]),
|
||||
"grouped",
|
||||
)
|
||||
|
||||
def test_redo_steps_are_in_both_profiles_and_never_autostart(self):
|
||||
for personal in (False, True):
|
||||
steps = [
|
||||
step for step in build_workflow(personal) if step.section == SECTION
|
||||
]
|
||||
self.assertEqual(len(steps), 9)
|
||||
self.assertEqual(steps[0].id, "refaire_selection")
|
||||
self.assertTrue(all(not step.auto_start_first_visit for step in steps))
|
||||
merge = steps[-1]
|
||||
for mode in ("BGnot", "Bnot", "Anot"):
|
||||
command = build_command(
|
||||
Path.cwd(),
|
||||
merge,
|
||||
merge.variants[0],
|
||||
{"target": "/tmp/Exam", "annotation_dir": mode},
|
||||
"/tmp/Exam",
|
||||
)
|
||||
self.assertEqual(
|
||||
command[4:],
|
||||
[
|
||||
"read-grouped",
|
||||
"/tmp/Exam",
|
||||
"--refaire",
|
||||
"--annotation-dir",
|
||||
mode,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get("DISPLAY"), "Tk tests require a display (use xvfb-run)"
|
||||
)
|
||||
class RefaireGuiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "Copies").mkdir()
|
||||
(self.root / "Anot").mkdir()
|
||||
(self.root / "Par label").mkdir()
|
||||
(self.root / "BRnot").mkdir()
|
||||
for name in ("Copie01", "Copie02"):
|
||||
(self.root / "Copies" / f"{name}.pdf").touch()
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\n", encoding="utf-8")
|
||||
atomic_write_json(self.root / "correction.json", {})
|
||||
self.app = CopienatorApp(Path.cwd(), False, self.root)
|
||||
self.app.update()
|
||||
|
||||
def tearDown(self):
|
||||
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
|
||||
self.app.after_cancel(callback)
|
||||
self.app.destroy()
|
||||
self.temp.cleanup()
|
||||
|
||||
def select(self, ident):
|
||||
self.app.tree.selection_set(ident)
|
||||
self.app.tree.see(ident)
|
||||
self.app.update()
|
||||
|
||||
def save_selection(self):
|
||||
self.select("refaire_selection")
|
||||
panel = self.app.refaire_panel
|
||||
panel.label_var.set("Ex 1")
|
||||
panel.add()
|
||||
panel.label_var.set("Ex 2")
|
||||
panel.add()
|
||||
panel.copy_var.set("Copie02")
|
||||
panel.label_var.set("Toute la copie")
|
||||
panel.add()
|
||||
self.app._run_current_step()
|
||||
self.app.update()
|
||||
|
||||
def test_collapsed_branch_is_separate_and_stays_open_when_refreshed(self):
|
||||
section = self.app.tree.parent("refaire_selection")
|
||||
self.assertFalse(self.app.tree.item(section, "open"))
|
||||
self.assertNotIn("refaire_selection", self.app._progression_ids("giving_names"))
|
||||
self.assertNotIn("clean", self.app._progression_ids("refaire_merge"))
|
||||
self.select("refaire_selection")
|
||||
self.app._populate_tree()
|
||||
self.assertTrue(self.app.tree.item(section, "open"))
|
||||
|
||||
def test_dropdown_selection_saves_json_and_drives_all_commands(self):
|
||||
self.save_selection()
|
||||
self.assertEqual(
|
||||
read_json(self.root / "refaire.json"),
|
||||
[["Copie01", ["Ex 1", "Ex 2"]], ["Copie02", []]],
|
||||
)
|
||||
self.assertEqual(self.app.current_step.id, "refaire_review")
|
||||
commands = self.app._refaire_commands()
|
||||
self.assertEqual(
|
||||
[command[5] for command in commands],
|
||||
[str(path) for path in available_copies(self.root).values()],
|
||||
)
|
||||
self.select("refaire_annotate")
|
||||
self.assertEqual(
|
||||
self.app._make_command()[4:],
|
||||
["annotate-grouped", str(self.root), "--refaire", "--overwrite"],
|
||||
)
|
||||
self.select("refaire_merge")
|
||||
self.assertEqual(
|
||||
self.app._make_command()[4:],
|
||||
["read-grouped", str(self.root), "--refaire", "--annotation-dir", "Anot"],
|
||||
)
|
||||
self.assertEqual(self.app.state_store.step("annotation").get("status"), None)
|
||||
|
||||
def test_one_label_for_all_copies_and_layout_override(self):
|
||||
for name in ("Copie01", "Copie02"):
|
||||
directory = self.root / "Copies" / name
|
||||
directory.mkdir()
|
||||
(directory / "Ex 1.pdf").touch()
|
||||
self.select("refaire_selection")
|
||||
panel = self.app.refaire_panel
|
||||
panel.copy_var.set(ALL_COPIES)
|
||||
panel.label_var.set("Ex 1")
|
||||
panel.add()
|
||||
self.assertEqual(panel.entries, {"Copie01": ["Ex 1"], "Copie02": ["Ex 1"]})
|
||||
panel.layout_var.set("Par copie")
|
||||
self.app._run_current_step()
|
||||
self.app.update()
|
||||
self.select("refaire_annotate")
|
||||
self.assertEqual(self.app._make_command()[4], "annotate-checks")
|
||||
|
||||
def test_bulk_add_skips_absent_answers_and_preserves_whole_copy_selection(self):
|
||||
directory = self.root / "Copies/Copie01"
|
||||
directory.mkdir()
|
||||
(directory / "Ex 1_new.pdf").touch()
|
||||
self.select("refaire_selection")
|
||||
panel = self.app.refaire_panel
|
||||
panel.copy_var.set("Copie01")
|
||||
panel.add()
|
||||
panel.copy_var.set(ALL_COPIES)
|
||||
panel.label_var.set("Ex 1")
|
||||
panel.add()
|
||||
self.assertEqual(panel.entries, {"Copie01": []})
|
||||
self.assertIn("1 sans réponse", panel.message_var.get())
|
||||
|
||||
def test_correction_folder_buttons_open_expected_folders(self):
|
||||
for name in ("Sol", "Persp"):
|
||||
(self.root / name).mkdir()
|
||||
self.select("refaire_selection")
|
||||
buttons = self.app.form.winfo_children()[0].winfo_children()
|
||||
with patch("copienator_gui.app.open_path") as opened:
|
||||
for button in buttons:
|
||||
button.invoke()
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in opened.call_args_list],
|
||||
[self.root / "Sol", self.root / "Persp"],
|
||||
)
|
||||
|
||||
def test_restart_actions_warn_reset_and_preserve_or_clear_selection(self):
|
||||
self.save_selection()
|
||||
self.app.state_store.update_step("refaire_merge", status="success")
|
||||
self.app.state_store.update_step("annotation", status="success")
|
||||
selection = read_json(self.root / "refaire.json")
|
||||
with patch(
|
||||
"copienator_gui.app.messagebox.askyesno", return_value=False
|
||||
) as confirm:
|
||||
self.app._start_refaire_pass(False)
|
||||
self.assertIn("importé", confirm.call_args.args[1])
|
||||
self.assertIn("Mettre à jour", confirm.call_args.args[1])
|
||||
self.assertEqual(read_json(self.root / "refaire.json"), selection)
|
||||
with patch("copienator_gui.app.messagebox.askyesno", return_value=True):
|
||||
self.app._start_refaire_pass(True)
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.current_step.id, "refaire_selection")
|
||||
self.assertEqual(self.app.refaire_panel.values()["selection"], selection)
|
||||
self.assertIsNone(self.app.state_store.step("refaire_merge").get("status"))
|
||||
self.assertEqual(self.app.state_store.step("annotation")["status"], "success")
|
||||
first = self.app.state_store.workspace.refaire_session_id
|
||||
with patch(
|
||||
"copienator_gui.app.messagebox.askyesno", return_value=True
|
||||
) as confirm:
|
||||
self.app._start_refaire_pass(False)
|
||||
self.app.update()
|
||||
self.assertIn("n’est pas marquée comme fusionnée", confirm.call_args.args[1])
|
||||
self.assertEqual(self.app.refaire_panel.entries, {})
|
||||
self.assertEqual(read_json(self.root / "refaire.json"), [])
|
||||
self.assertNotEqual(self.app.state_store.workspace.refaire_session_id, first)
|
||||
self.assertEqual(self.app.refaire_panel.source_var.get(), "Anot")
|
||||
|
||||
def test_restart_is_blocked_while_a_command_is_active(self):
|
||||
self.save_selection()
|
||||
self.app.active_step_id = "refaire_correct"
|
||||
with (
|
||||
patch("copienator_gui.app.messagebox.showwarning") as warning,
|
||||
patch("copienator_gui.app.messagebox.askyesno") as confirm,
|
||||
):
|
||||
self.app._start_refaire_pass(False)
|
||||
warning.assert_called_once()
|
||||
confirm.assert_not_called()
|
||||
self.assertIsNone(self.app.state_store.workspace.refaire_session_id)
|
||||
|
||||
def test_small_window_keeps_selection_accessible_by_scrolling(self):
|
||||
self.select("refaire_selection")
|
||||
self.app.geometry("900x640")
|
||||
self.app.update()
|
||||
self.app.form_canvas.yview_moveto(1)
|
||||
self.app.update()
|
||||
self.assertAlmostEqual(self.app.form_canvas.yview()[1], 1.0)
|
||||
self.assertLess(
|
||||
self.app.run_button.winfo_rooty() + self.app.run_button.winfo_height(),
|
||||
self.app.winfo_rooty() + self.app.winfo_height(),
|
||||
)
|
||||
|
||||
def test_queue_runs_copies_in_order_and_stops_on_failure(self):
|
||||
self.save_selection()
|
||||
self.select("refaire_split")
|
||||
with patch.object(self.app.runner, "start") as start:
|
||||
self.app._run_current_step()
|
||||
self.assertEqual(start.call_count, 1)
|
||||
self.assertEqual(len(self.app.pending_refaire_commands), 1)
|
||||
self.app._finish_process(0, False)
|
||||
self.assertEqual(start.call_count, 2)
|
||||
self.assertEqual(self.app.active_step_id, "refaire_split")
|
||||
self.app._finish_process(1, False)
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("refaire_split")["status"], "failed"
|
||||
)
|
||||
self.assertIsNone(self.app.active_step_id)
|
||||
self.assertFalse(self.app.pending_refaire_commands)
|
||||
|
||||
def test_interruption_does_not_launch_next_copy(self):
|
||||
self.save_selection()
|
||||
self.select("refaire_split")
|
||||
with patch.object(self.app.runner, "start") as start:
|
||||
self.app._run_current_step()
|
||||
self.app._finish_process(130, True)
|
||||
self.assertEqual(start.call_count, 1)
|
||||
self.assertFalse(self.app.pending_refaire_commands)
|
||||
self.assertEqual(
|
||||
self.app.state_store.step("refaire_split")["status"], "interrupted"
|
||||
)
|
||||
|
||||
def test_unsaved_or_external_changes_block_commands(self):
|
||||
self.save_selection()
|
||||
atomic_write_json(self.root / "refaire.json", [["Copie02", []]])
|
||||
with self.assertRaisesRegex(ValueError, "sélection a changé"):
|
||||
self.app._refaire_commands()
|
||||
|
||||
def test_selection_reload_and_finalization_invalidation(self):
|
||||
self.save_selection()
|
||||
self.app.state_store.update_step("giving_names", status="success")
|
||||
self.app.state_store.update_step(
|
||||
"annotation", status="success", last_run_variant="simple"
|
||||
)
|
||||
self.select("refaire_merge")
|
||||
with patch.object(self.app.runner, "start"):
|
||||
self.app._run_current_step()
|
||||
self.app._finish_process(0, False)
|
||||
self.app.update()
|
||||
self.assertEqual(self.app.state_store.step("giving_names")["status"], "stale")
|
||||
self.assertEqual(self.app.state_store.step("annotation")["status"], "success")
|
||||
self.assertEqual(self.app.current_step.id, "refaire_merge")
|
||||
self.select("refaire_selection")
|
||||
self.assertEqual(
|
||||
self.app.refaire_panel.entries, {"Copie01": ["Ex 1", "Ex 2"], "Copie02": []}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,177 @@
|
||||
import unittest
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from copienator.ink_detection import detect_bounds, _paper_blur
|
||||
|
||||
|
||||
class InkDetectionTests(unittest.TestCase):
|
||||
def test_fast_background_blur_matches_opencv_pixel_for_pixel(self):
|
||||
rng = np.random.default_rng(42)
|
||||
for shape in ((97,131), (241,319)):
|
||||
gray = rng.integers(0,256,shape,dtype=np.uint8)
|
||||
for dpi in (100,150,200,300):
|
||||
with self.subTest(shape=shape,dpi=dpi):
|
||||
expected = cv2.GaussianBlur(gray,(0,0),dpi/8)
|
||||
np.testing.assert_array_equal(_paper_blur(gray,dpi/8),expected)
|
||||
|
||||
def scan(self):
|
||||
image = np.full((1200, 850, 3), 255, np.uint8)
|
||||
for y in range(20, 1200, 20):
|
||||
cv2.line(image, (0, y), (849, y), (160, 155, 205), 1)
|
||||
for x in range(10, 850, 20):
|
||||
cv2.line(image, (x, 0), (x, 1199), (160, 155, 205), 1)
|
||||
for y in (100, 180, 400, 480, 800, 880, 1050, 1130):
|
||||
cv2.ellipse(image, (25, y), (12, 20), 0, 0, 300, (155,155,155), 2)
|
||||
cv2.putText(image, 'x + y = 2', (110, 400), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1, (20, 90, 190), 2)
|
||||
cv2.putText(image, 'answer = 42', (110, 700), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1, (20, 90, 190), 2)
|
||||
return image
|
||||
|
||||
def test_crops_past_holes_and_grid(self):
|
||||
r = detect_bounds(self.scan(), dpi=150)
|
||||
self.assertGreater(r['top_px'], 250)
|
||||
self.assertLess(r['top_px'], 370)
|
||||
self.assertGreater(r['bottom_px'], 700)
|
||||
self.assertLess(r['bottom_px'], 800)
|
||||
|
||||
def test_isolated_margin_note_survives(self):
|
||||
image = self.scan()
|
||||
cv2.putText(image, '1', (4, 1110), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.65, (20, 90, 190), 2)
|
||||
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1110)
|
||||
|
||||
def test_long_fraction_bar_on_grid_survives(self):
|
||||
for colour in ((20,90,190), (20,20,20), (190,20,20)):
|
||||
with self.subTest(colour=colour):
|
||||
image = self.scan()
|
||||
cv2.line(image, (100, 1020), (700, 1020), colour, 3)
|
||||
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1020)
|
||||
|
||||
def test_black_annotation_survives(self):
|
||||
image = self.scan()
|
||||
cv2.putText(image, 'note', (600, 1100), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.7, (30,30,30), 2)
|
||||
self.assertGreater(detect_bounds(image, dpi=150)['bottom_px'], 1100)
|
||||
|
||||
def test_blank_and_pencil_only_pages_are_retained(self):
|
||||
for pencil in (False, True):
|
||||
image = np.full((1200,850,3),255,np.uint8)
|
||||
if pencil:
|
||||
cv2.putText(image, 'pencil', (100,600), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1, (195,195,195), 2)
|
||||
r = detect_bounds(image, dpi=150)
|
||||
self.assertEqual((r['top_px'],r['bottom_px']), (0,1200))
|
||||
self.assertEqual(r['status'], 'review-no-ink-seeds')
|
||||
|
||||
def test_skewed_colour_scan(self):
|
||||
matrix = cv2.getRotationMatrix2D((425,600),2,1)
|
||||
image = cv2.warpAffine(self.scan(),matrix,(850,1200),borderValue=(255,255,255))
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertGreater(r['top_px'],250)
|
||||
self.assertLess(r['top_px'],370)
|
||||
self.assertGreater(r['bottom_px'],715)
|
||||
self.assertLess(r['bottom_px'],820)
|
||||
|
||||
def test_weak_stroke_attached_to_ink_is_recovered(self):
|
||||
image = np.full((1200,850,3),255,np.uint8)
|
||||
cv2.rectangle(image,(400,500),(410,530),(20,90,190),-1)
|
||||
cv2.rectangle(image,(400,531),(410,540),(130,170,205),-1)
|
||||
r = detect_bounds(image,dpi=150,padding_mm=0,min_crop_mm=0)
|
||||
self.assertGreaterEqual(r['bottom_px'],541)
|
||||
|
||||
def dark_grid(self):
|
||||
image = np.full((1200,850,3),255,np.uint8)
|
||||
for y in range(20,1200,20):
|
||||
cv2.line(image,(0,y),(849,y),(65,65,65),1)
|
||||
for x in range(10,850,20):
|
||||
cv2.line(image,(x,0),(x,1199),(65,65,65),1)
|
||||
cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1,(20,20,20),2)
|
||||
cv2.putText(image,'answer = 42',(100,700),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1,(20,20,20),2)
|
||||
return image
|
||||
|
||||
def test_dark_grid_does_not_seed_entire_page(self):
|
||||
r = detect_bounds(self.dark_grid(),dpi=150)
|
||||
self.assertTrue(r['paper_cleanup'])
|
||||
self.assertGreater(r['top_px'],250)
|
||||
self.assertLess(r['top_px'],370)
|
||||
self.assertGreater(r['bottom_px'],700)
|
||||
self.assertLess(r['bottom_px'],820)
|
||||
|
||||
def test_faint_isolated_note_on_dark_grid_survives(self):
|
||||
image = self.dark_grid()
|
||||
cv2.putText(image,'pencil',(100,1100),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.7,(190,190,190),2)
|
||||
self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1100)
|
||||
|
||||
def test_sparse_central_pencil_on_dark_grid_survives(self):
|
||||
image = self.dark_grid()
|
||||
# Thin, pale handwriting crossing the ruling is split into sparse
|
||||
# components. Its central position distinguishes it from page holes.
|
||||
cv2.putText(image,'result',(330,1090),cv2.FONT_HERSHEY_SCRIPT_SIMPLEX,
|
||||
.85,(155,155,155),1)
|
||||
self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1090)
|
||||
|
||||
def test_large_unruled_diagram_is_not_paper(self):
|
||||
image = np.full((1200,850,3),255,np.uint8)
|
||||
cv2.rectangle(image,(100,100),(750,1100),(20,20,20),3)
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertFalse(r['paper_cleanup'])
|
||||
self.assertLess(r['top_px'],100)
|
||||
self.assertGreater(r['bottom_px'],1100)
|
||||
|
||||
def test_dark_grid_preserves_black_fraction_bar(self):
|
||||
image = self.dark_grid()
|
||||
cv2.line(image,(150,1020),(700,1020),(0,0,0),3)
|
||||
self.assertGreater(detect_bounds(image,dpi=150)['bottom_px'],1020)
|
||||
|
||||
def test_disconnected_dark_grid_is_still_recognized(self):
|
||||
image = self.dark_grid()
|
||||
for x in range(170,850,170):
|
||||
image[:,x:x+5] = 255
|
||||
for y in range(200,1200,200):
|
||||
image[y:y+5,:] = 255
|
||||
cv2.putText(image,'x + y = 2',(100,400),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
1,(20,20,20),2)
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertTrue(r['paper_cleanup'])
|
||||
self.assertGreater(r['top_px'],250)
|
||||
self.assertLess(r['top_px'],370)
|
||||
self.assertGreater(r['bottom_px'],700)
|
||||
self.assertLess(r['bottom_px'],850)
|
||||
|
||||
def test_repeated_dark_holes_on_either_side(self):
|
||||
for right in (False, True):
|
||||
with self.subTest(right=right):
|
||||
image = self.dark_grid()
|
||||
x = 820 if right else 25
|
||||
for y in (110, 370, 630, 890, 1130):
|
||||
cv2.ellipse(image, (x,y), (12,20), 0, 0, 300, (35,35,35), 2)
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertLess(r['bottom_px'],850)
|
||||
# The same column can contain handwriting as well as holes.
|
||||
cv2.putText(image,'7',(x-5,1060),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.7,(20,20,20),2)
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertGreater(r['bottom_px'],1060)
|
||||
|
||||
def test_faint_page_number_in_outer_band_does_not_block_crop(self):
|
||||
image = self.dark_grid()
|
||||
cv2.putText(image,'4/',(3,1160),cv2.FONT_HERSHEY_SIMPLEX,
|
||||
.55,(130,130,130),1)
|
||||
self.assertLess(detect_bounds(image,dpi=150)['bottom_px'],850)
|
||||
|
||||
def test_faded_neutral_grid(self):
|
||||
image = self.dark_grid()
|
||||
image[np.all(image == 65,axis=2)] = 145
|
||||
r = detect_bounds(image,dpi=150)
|
||||
self.assertTrue(r['paper_cleanup'])
|
||||
self.assertLess(r['bottom_px'],850)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,125 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.commands.resolve_manual import parse_instruction_text, resolve_manual
|
||||
from copienator.pdf_cut import cut_position, split_pdf
|
||||
from copienator_gui.cut_helper import PAGE_GAP, cut_operator, percentage_at_y, y_at_percentage
|
||||
|
||||
|
||||
def make_pdf(path, heights=(300, 700), rotation=0, cropped=False):
|
||||
with pymupdf.open() as document:
|
||||
for index, height in enumerate(heights):
|
||||
page = document.new_page(width=200, height=height)
|
||||
page.draw_rect(pymupdf.Rect(0, 0, 200, height / 2), color=None, fill=(1, 0, 0))
|
||||
page.draw_rect(pymupdf.Rect(0, height / 2, 200, height), color=None, fill=(0, 0, 1))
|
||||
page.insert_text((30, 30), f"Page {index + 1}")
|
||||
if cropped:
|
||||
page.set_cropbox(pymupdf.Rect(10, 20, 180, height - 30))
|
||||
page.set_rotation(rotation)
|
||||
document.save(path)
|
||||
|
||||
|
||||
class ManualCutTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def test_parser_accepts_new_operators_and_rejects_invalid_cuts(self):
|
||||
for keep in (1, 2):
|
||||
for action in (">", "x"):
|
||||
item = parse_instruction_text(f"Copie16 Ex 4 : 1) c{{43.125}}{keep}{action} |Ex 4 : 2)")[0]
|
||||
self.assertEqual(item.cut, (43.125, keep))
|
||||
self.assertEqual(item.should_merge, action == ">")
|
||||
self.assertTrue(item.pipe_first)
|
||||
for operator in ("c{0}1>", "c{100}1>", "c{-1}1>", "c{101}1>", "c{43}3>",
|
||||
"c{43}1s", "c{NaN}1>"):
|
||||
with self.assertRaises(CliError):
|
||||
parse_instruction_text(f"Copie16 A {operator} B")
|
||||
with self.assertRaises(CliError):
|
||||
parse_instruction_text("Copie16 A c{43}1> A")
|
||||
|
||||
def test_page_boundary_preserves_whole_pages_without_clipping(self):
|
||||
source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")]
|
||||
make_pdf(source, heights=(100, 200), rotation=180)
|
||||
before = source.read_bytes()
|
||||
with patch("pymupdf.Page.show_pdf_page", side_effect=AssertionError("must not clip whole pages")):
|
||||
split_pdf(source, 33.333333, first, second)
|
||||
for path, height in ((first, 100), (second, 200)):
|
||||
with pymupdf.open(path) as pdf:
|
||||
self.assertEqual(len(pdf), 1)
|
||||
self.assertEqual(pdf[0].rect.height, height)
|
||||
self.assertEqual(pdf[0].rotation, 180)
|
||||
self.assertEqual(source.read_bytes(), before)
|
||||
|
||||
def test_in_page_cut_preserves_visible_pixels_for_cropped_rotated_pages(self):
|
||||
source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")]
|
||||
for rotation in (0, 90, 180, 270):
|
||||
with self.subTest(rotation=rotation):
|
||||
make_pdf(source, heights=(400,), rotation=rotation, cropped=True)
|
||||
with pymupdf.open(source) as pdf:
|
||||
pix = pdf[0].get_pixmap()
|
||||
expected = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
|
||||
split_pdf(source, 50, first, second)
|
||||
for path, pixels in ((first, expected[:len(expected)//2]), (second, expected[len(expected)//2:])):
|
||||
with pymupdf.open(path) as pdf:
|
||||
pix = pdf[0].get_pixmap()
|
||||
actual = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
|
||||
self.assertEqual(actual.shape, pixels.shape)
|
||||
self.assertLess(np.abs(actual.astype(float) - pixels).mean(), 0.1)
|
||||
|
||||
def test_cut_within_page_preserves_subsequent_pages(self):
|
||||
source, first, second = [self.root / name for name in ("source.pdf", "first.pdf", "second.pdf")]
|
||||
make_pdf(source)
|
||||
split_pdf(source, 15, first, second)
|
||||
with pymupdf.open(first) as pdf:
|
||||
self.assertEqual([page.rect.height for page in pdf], [150])
|
||||
with pymupdf.open(second) as pdf:
|
||||
self.assertEqual([page.rect.height for page in pdf], [150, 700])
|
||||
|
||||
def test_resolver_archives_source_and_recorrrects_both_labels(self):
|
||||
for keep in (1, 2):
|
||||
for mode in (">", "x"):
|
||||
for pipe_first in (False, True):
|
||||
with self.subTest(keep=keep, mode=mode, pipe_first=pipe_first):
|
||||
root = self.root / f"{keep}{mode == '>'}{pipe_first}"
|
||||
copies = root / "Copies" / "Copie16"
|
||||
copies.mkdir(parents=True)
|
||||
source, target = copies / "A.pdf", copies / "B.pdf"
|
||||
make_pdf(source)
|
||||
make_pdf(target, heights=(80,))
|
||||
original, destination = source.read_bytes(), target.read_bytes()
|
||||
atomic_write_json(root / "correction.json", {
|
||||
label: [[{"id": "16", "result": {}}]] for label in ("A", "B")
|
||||
})
|
||||
new_label = "|B" if pipe_first else "B|"
|
||||
(root / "manual_resolutions.txt").write_text(f"Copie16 A c{{30}}{keep}{mode} {new_label}\n")
|
||||
self.assertEqual(resolve_manual(EvaluationWorkspace(root)), 0)
|
||||
self.assertEqual((copies / "A_old.pdf").read_bytes(), original)
|
||||
self.assertEqual((copies / "B_old.pdf").read_bytes(), destination)
|
||||
retained, moved = (300, 700) if keep == 1 else (700, 300)
|
||||
with pymupdf.open(copies / "A_new.pdf") as pdf:
|
||||
self.assertEqual([page.rect.height for page in pdf], [retained])
|
||||
with pymupdf.open(copies / "B_new.pdf") as pdf:
|
||||
expected = ([moved, 80] if pipe_first else [80, moved]) if mode == ">" else [moved]
|
||||
self.assertEqual([page.rect.height for page in pdf], expected)
|
||||
self.assertEqual(read_json(root / "refaire.json"), [["Copie16", ["A", "B"]]])
|
||||
self.assertTrue(all(read_json(root / "correction.json")[label][0][0]["result"]["suffix"] == "_new" for label in ("A", "B")))
|
||||
self.assertFalse(list(copies.glob("temp_*.pdf")))
|
||||
|
||||
def test_helper_snaps_to_gap_and_round_trips_percentage(self):
|
||||
heights = [100, 200]
|
||||
for y in (95, 100, 100 + PAGE_GAP / 2, 100 + PAGE_GAP + 5):
|
||||
percent = percentage_at_y(y, heights, 1)
|
||||
self.assertEqual(cut_position(heights, percent), (1, 0))
|
||||
self.assertEqual(y_at_percentage(percent, heights, 1), 100 + PAGE_GAP / 2)
|
||||
self.assertEqual(cut_operator(43, 1, ">"), "c{43}1>")
|
||||
self.assertEqual(cut_operator(100/3, 2, "x"), "c{33.333333}2x")
|
||||
self.assertEqual(cut_position(heights, 33.333333), (1, 0))
|
||||
self.assertNotEqual(cut_position(heights, 33.3)[1], 0)
|
||||
@@ -0,0 +1,65 @@
|
||||
import copy
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.commands import correction, resolve_manual
|
||||
|
||||
|
||||
class ManualResolutionStateTests(unittest.TestCase):
|
||||
def test_acknowledgement_clears_only_this_target_and_copy(self):
|
||||
for error in ("wrg-lbl:B?", "wrg-lbl:B?delayed", "wrg-lbl:B?exists", "al:(->)B?(->)C?", "al:(delayed)B"):
|
||||
with self.subTest(error=error):
|
||||
source = {"id": "01", "result": {"error": error, "delayed": [
|
||||
["wrong-label", "B"], ["add-label", "B"], ["add-label", "C"]]}}
|
||||
other = {"id": "02", "result": {"error": error, "delayed": [["wrong-label", "B"]]}}
|
||||
before_other = copy.deepcopy(other)
|
||||
results = {"A": [[source, other]]}
|
||||
resolve_manual.set_suffix_and_clean_error(results, "01", "A", None, "B")
|
||||
self.assertEqual(source["result"]["delayed"], [["add-label", "C"]])
|
||||
self.assertNotIn("B?", source["result"]["error"])
|
||||
self.assertEqual(other, before_other)
|
||||
resolve_manual.set_suffix_and_clean_error(results, "01", "A", None, "C")
|
||||
self.assertNotIn("delayed", source["result"])
|
||||
|
||||
def fixture(self, root, operator):
|
||||
copies = root / "Copies" / "Copie01"
|
||||
copies.mkdir(parents=True)
|
||||
for label in ("A", "B"):
|
||||
with pymupdf.open() as doc:
|
||||
page = doc.new_page(width=200, height=200)
|
||||
page.insert_text((20, 40), label)
|
||||
doc.save(copies / f"{label}.pdf")
|
||||
atomic_write_json(root / "correction.json", {
|
||||
"A": [[{"id": "01", "result": {"error": "wrg-lbl:B?", "delayed": [["wrong-label", "B"]]}}]],
|
||||
"B": [[{"id": "01", "result": {"error": ""}}]],
|
||||
})
|
||||
(root / "manual_resolutions.txt").write_text(f"Copie01 A {operator} B|\n")
|
||||
return EvaluationWorkspace(root)
|
||||
|
||||
def test_every_resolution_acknowledges_pending_conflict(self):
|
||||
for operator in (*resolve_manual.OPERATORS, "c{50}1>", "c{50}2x"):
|
||||
with self.subTest(operator=operator), tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = self.fixture(Path(temporary), operator)
|
||||
resolve_manual.resolve_manual(workspace)
|
||||
result = read_json(workspace.correction_file)["A"][0][0]["result"]
|
||||
self.assertNotIn("delayed", result)
|
||||
self.assertFalse(workspace.manual_resolutions_file.exists())
|
||||
|
||||
def test_refaire_does_not_recreate_a_resolved_source_conflict(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = self.fixture(Path(temporary), "x>")
|
||||
resolve_manual.resolve_manual(workspace)
|
||||
(workspace.groups_dir / "B").mkdir(parents=True)
|
||||
args = correction.build_parser().parse_args([str(workspace.root), "--refaire"])
|
||||
correction.configure_runtime(workspace, [], args, api_client=Mock())
|
||||
with patch.object(correction.grouping, "get_pdf_height", return_value=400), patch.object(
|
||||
correction.grouping, "create_jpg"
|
||||
), patch.object(correction, "process_single_task", return_value=[]):
|
||||
self.assertEqual(correction.run_configured(args), 0)
|
||||
self.assertFalse(workspace.manual_resolutions_file.exists())
|
||||
self.assertNotIn("delayed", correction.results["A"][0][0]["result"])
|
||||
@@ -0,0 +1,223 @@
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, ExitCode
|
||||
from copienator.copy_errors import copy_errors, clear_copy_error, mark_copy_error, marked_copy_paths
|
||||
from copienator.commands import cutleft, page_splitter
|
||||
from copienator_gui.app import CopienatorApp
|
||||
|
||||
|
||||
class MarkedCopiesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.workspace.copies_dir.mkdir()
|
||||
self.workspace.original_copies_dir.mkdir()
|
||||
self.files = [self.workspace.copies_dir / name for name in ("Copie01.pdf", "Copie02.pdf")]
|
||||
for path in self.files:
|
||||
path.write_bytes(b"processed")
|
||||
(self.workspace.original_copies_dir / path.name).write_bytes(b"original")
|
||||
|
||||
def reviewer(self):
|
||||
review = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer)
|
||||
review.workspace = self.workspace
|
||||
review.files = self.files
|
||||
review.output_dir = self.workspace.cutleft_dir
|
||||
review.index = 0
|
||||
review.is_processing = False
|
||||
review.had_errors = False
|
||||
review.completed = False
|
||||
review.current_shift = 50
|
||||
review.current_width_offset = 0
|
||||
review.default_max_per_file = 5
|
||||
review.current_max_per_file = 1
|
||||
review.root = Mock()
|
||||
review.stop_prefetch = threading.Event()
|
||||
review.load_current_image = Mock()
|
||||
review.update_display = Mock()
|
||||
image = Image.new("RGB", (8, 8), "white")
|
||||
review.current_result = (image, [image], {"total_pages": 1, "columns_per_file": [1]})
|
||||
return review
|
||||
|
||||
def test_marks_survive_reload_and_resolve_originals_only_for_splitting(self):
|
||||
mark_copy_error(self.workspace, self.files[1], "Wrong page order")
|
||||
reloaded = EvaluationWorkspace(self.workspace.root)
|
||||
self.assertEqual(marked_copy_paths(reloaded), [self.files[1]])
|
||||
original = self.workspace.original_copies_dir / self.files[1].name
|
||||
self.assertEqual(marked_copy_paths(reloaded, originals=True), [original])
|
||||
self.files[1].unlink()
|
||||
with self.assertRaises(CliError):
|
||||
marked_copy_paths(reloaded)
|
||||
self.assertEqual(marked_copy_paths(reloaded, originals=True), [original])
|
||||
clear_copy_error(reloaded, original)
|
||||
self.assertEqual(copy_errors(self.workspace), {})
|
||||
|
||||
def test_skip_flags_and_advances_without_replacing_existing_crop(self):
|
||||
review = self.reviewer()
|
||||
review.output_dir.mkdir()
|
||||
previous = review.output_dir / "Copie01_01.jpg"
|
||||
previous.write_bytes(b"previous crop")
|
||||
review.handle_processing_result(review.current_result, self.files[0])
|
||||
review.on_skip()
|
||||
self.assertEqual(previous.read_bytes(), b"previous crop")
|
||||
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
|
||||
self.assertEqual(review.index, 1)
|
||||
self.assertEqual(review.current_max_per_file, 5)
|
||||
self.assertEqual(review.current_width_offset, 0)
|
||||
self.assertTrue(review.had_errors)
|
||||
review.load_current_image.assert_called_once_with()
|
||||
|
||||
def test_accept_saves_and_clears_only_current_flag(self):
|
||||
for path in self.files:
|
||||
mark_copy_error(self.workspace, path, "Review needed")
|
||||
review = self.reviewer()
|
||||
review.on_next(None)
|
||||
self.assertTrue((review.output_dir / "Copie01_01.jpg").is_file())
|
||||
self.assertEqual(set(copy_errors(self.workspace)), {"Copie02.pdf"})
|
||||
self.assertEqual(review.index, 1)
|
||||
|
||||
def test_failed_save_and_window_close_preserve_flag(self):
|
||||
mark_copy_error(self.workspace, self.files[0], "Review needed")
|
||||
review = self.reviewer()
|
||||
with patch.object(cutleft, "save_results", side_effect=OSError("disk full")), patch.object(
|
||||
cutleft.messagebox, "showerror"
|
||||
):
|
||||
review.on_next(None)
|
||||
self.assertEqual(review.index, 0)
|
||||
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
|
||||
review.on_close()
|
||||
self.assertFalse(review.completed)
|
||||
self.assertTrue(review.stop_prefetch.is_set())
|
||||
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
|
||||
|
||||
def test_enlarge_reprocesses_with_a_wider_selection(self):
|
||||
review = self.reviewer()
|
||||
review.trigger_processing = Mock()
|
||||
|
||||
review.on_enlarge(cutleft.CROP_WIDTH_STEP)
|
||||
|
||||
self.assertEqual(review.current_width_offset, 50)
|
||||
review.trigger_processing.assert_called_once_with(self.files[0], 50)
|
||||
|
||||
def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self):
|
||||
page = Image.new("RGB", (900, 300), "white")
|
||||
with patch.object(cutleft, "get_pdf_pages", return_value=[page]):
|
||||
regular = cutleft.process_single_pdf(self.files[0])
|
||||
enlarged = cutleft.process_single_pdf(self.files[0], width_offset=200)
|
||||
capped = cutleft.process_single_pdf(self.files[0], width_offset=1000)
|
||||
fullpage = cutleft.process_single_pdf(
|
||||
self.files[0], max_per_file=1, width_offset=200
|
||||
)
|
||||
|
||||
self.assertEqual(regular[1][0].size, (300, 300))
|
||||
self.assertEqual(enlarged[1][0].size, (500, 300))
|
||||
self.assertEqual(capped[1][0].size, (800, 300))
|
||||
self.assertEqual(fullpage[1][0].size, (900, 300))
|
||||
|
||||
def test_processing_blocks_skip_and_failed_conversion_is_flagged(self):
|
||||
review = self.reviewer()
|
||||
review.is_processing = True
|
||||
review.on_skip()
|
||||
self.assertEqual(review.index, 0)
|
||||
self.assertEqual(copy_errors(self.workspace), {})
|
||||
review.manual_queue = Queue()
|
||||
review.manual_queue.put(None)
|
||||
review.load_current_image.side_effect = lambda: setattr(review, "is_processing", True)
|
||||
review.check_manual_queue(self.files[0])
|
||||
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
|
||||
self.assertEqual(review.index, 1)
|
||||
self.assertTrue(review.is_processing) # Still loading the next copy.
|
||||
|
||||
def test_cli_splitting_preserves_flags_and_cropping_reports_partial_or_interrupted(self):
|
||||
mark_copy_error(self.workspace, self.files[1], "Wrong order")
|
||||
with patch.object(page_splitter.tk, "Tk"), patch.object(page_splitter, "PDFPreviewer") as preview:
|
||||
preview.return_value.failed = False
|
||||
self.assertEqual(page_splitter.main([str(self.workspace.root), "--marked"]), 0)
|
||||
self.assertEqual(preview.call_args.args[2], [self.workspace.original_copies_dir / "Copie02.pdf"])
|
||||
self.assertIn("Copie02.pdf", copy_errors(self.workspace))
|
||||
with patch.object(cutleft, "ImageReviewer") as reviewer:
|
||||
for completed, errors, expected in ((True, True, ExitCode.PARTIAL),
|
||||
(False, False, ExitCode.INTERRUPTED),
|
||||
(True, False, ExitCode.SUCCESS)):
|
||||
reviewer.return_value.completed = completed
|
||||
reviewer.return_value.had_errors = errors
|
||||
self.assertEqual(cutleft.main([str(self.workspace.root), "--marked"]), expected)
|
||||
self.assertEqual(reviewer.call_args.args[0], [self.files[1]])
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
|
||||
class MarkedCopiesGuiTests(unittest.TestCase):
|
||||
setUp = MarkedCopiesTests.setUp
|
||||
|
||||
def test_keyboard_skip_then_accept_and_retry_clears_flag(self):
|
||||
real_tk = cutleft.tk.Tk
|
||||
|
||||
def review_with_keys(files, keys):
|
||||
sent = []
|
||||
|
||||
def make_root():
|
||||
root = real_tk()
|
||||
|
||||
def send_when_ready():
|
||||
info = root.winfo_children()[-1].cget("text")
|
||||
index = len(sent)
|
||||
if index < len(keys) and info.startswith(f"[{index + 1}/{len(files)}]"):
|
||||
root.focus_force()
|
||||
sent.append(keys[index])
|
||||
root.event_generate(keys[index])
|
||||
if len(sent) < len(keys):
|
||||
root.after(10, send_when_ready)
|
||||
|
||||
root.after(20, send_when_ready)
|
||||
root.after(3000, root.destroy) # Bound a failed keyboard test.
|
||||
return root
|
||||
|
||||
with patch.object(cutleft.tk, "Tk", side_effect=make_root), patch.object(
|
||||
cutleft, "get_pdf_pages", return_value=[Image.new("RGB", (600, 300), "white")]
|
||||
), patch.object(cutleft, "OUTPUT_SIZE", (400, 200)):
|
||||
reviewer = cutleft.ImageReviewer(files, self.workspace.cutleft_dir)
|
||||
self.assertEqual(sent, keys)
|
||||
self.assertTrue(reviewer.completed)
|
||||
return reviewer
|
||||
|
||||
first = review_with_keys(self.files, ["<KeyPress-s>", "<Return>"])
|
||||
self.assertTrue(first.had_errors)
|
||||
self.assertEqual(set(copy_errors(self.workspace)), {"Copie01.pdf"})
|
||||
self.assertFalse((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
|
||||
self.assertTrue((self.workspace.cutleft_dir / "Copie02_01.jpg").exists())
|
||||
second = review_with_keys(marked_copy_paths(self.workspace), ["<Return>"])
|
||||
self.assertFalse(second.had_errors)
|
||||
self.assertEqual(copy_errors(self.workspace), {})
|
||||
self.assertTrue((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
|
||||
|
||||
def test_both_buttons_run_marked_and_single_copy_target_clears_filter(self):
|
||||
mark_copy_error(self.workspace, self.files[1], "Review needed")
|
||||
app = CopienatorApp(Path.cwd(), False, self.workspace.root)
|
||||
try:
|
||||
app.update()
|
||||
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels")):
|
||||
app.tree.selection_set(ident)
|
||||
app.update()
|
||||
self.assertIn("(1)", app.marked_copies_button.cget("text"))
|
||||
with patch.object(app, "_run_current_step") as run:
|
||||
app.marked_copies_button.invoke()
|
||||
run.assert_called_once_with()
|
||||
self.assertIn(command, app._make_command())
|
||||
self.assertIn("--marked", app._make_command())
|
||||
self.assertEqual(app.arg_vars["target"].get(), app._evaluation_arg())
|
||||
app.copy_var.set("Copie01.pdf")
|
||||
app._target_selected_copy()
|
||||
self.assertNotIn("--marked", app._make_command())
|
||||
self.assertIn(str(self.files[0]), app._make_command())
|
||||
finally:
|
||||
for callback in app.tk.splitlist(app.tk.call("after", "info")):
|
||||
app.after_cancel(callback)
|
||||
app.destroy()
|
||||
@@ -0,0 +1,81 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pymupdf
|
||||
|
||||
from copienator.commands.page_splitter import PDFPreviewer, PAGE_SPLITTER_KB
|
||||
|
||||
|
||||
class ReversePagesTests(unittest.TestCase):
|
||||
def test_reverse_restarts_with_fresh_settings_and_exports_reversed_pages(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source = Path(temporary) / "copy.pdf"
|
||||
with pymupdf.open() as document:
|
||||
for index in range(3):
|
||||
page = document.new_page(width=200 + 20 * index, height=300)
|
||||
page.insert_text((30, 30), f"Page {index + 1}")
|
||||
document.save(source)
|
||||
original = source.read_bytes()
|
||||
preview = PDFPreviewer.__new__(PDFPreviewer)
|
||||
preview.doc = pymupdf.open(source)
|
||||
self.addCleanup(lambda: None if preview.doc.is_closed else preview.doc.close())
|
||||
preview.current_page_index = 2
|
||||
preview.page_settings = [{"keep": "none"}, {"keep": "left"}]
|
||||
preview.current_rotation = 180
|
||||
preview.file_rotation = 180
|
||||
preview.global_rotation = 180
|
||||
preview.processing = False
|
||||
preview.load_page = Mock()
|
||||
|
||||
preview.reverse_pages()
|
||||
|
||||
self.assertEqual([page.get_text().strip() for page in preview.doc],
|
||||
["Page 3", "Page 2", "Page 1"])
|
||||
self.assertEqual(preview.current_page_index, 0)
|
||||
self.assertEqual(preview.page_settings, [])
|
||||
self.assertEqual(preview.current_line_x, 120)
|
||||
self.assertEqual(preview.current_rotation, 0)
|
||||
self.assertEqual((preview.file_rotation, preview.global_rotation), (180, 180))
|
||||
preview.load_page.assert_called_once_with()
|
||||
self.assertEqual(source.read_bytes(), original)
|
||||
|
||||
preview.reverse_pages()
|
||||
self.assertEqual([page.get_text().strip() for page in preview.doc],
|
||||
["Page 1", "Page 2", "Page 3"])
|
||||
preview.reverse_pages()
|
||||
|
||||
preview.base_name = "copy"
|
||||
preview.split_dir = Path(temporary) / "split"
|
||||
preview.reorder_dir = Path(temporary) / "reorder"
|
||||
preview.final_file = Path(temporary) / "result.pdf"
|
||||
preview.output_dir = None
|
||||
preview.page_settings = [
|
||||
{"keep": "as_is", "rotation": 0, "line_x": page.rect.width / 2}
|
||||
for page in preview.doc
|
||||
]
|
||||
preview.split_pdf()
|
||||
preview.reorder_pdfs()
|
||||
preview.concate_files()
|
||||
with pymupdf.open(preview.final_file) as result:
|
||||
self.assertEqual([page.get_text().strip() for page in result],
|
||||
["Page 3", "Page 2", "Page 1"])
|
||||
|
||||
def test_single_page_can_restart_and_processing_ignores_shortcut(self):
|
||||
preview = PDFPreviewer.__new__(PDFPreviewer)
|
||||
preview.doc = pymupdf.open()
|
||||
self.addCleanup(preview.doc.close)
|
||||
preview.doc.new_page()
|
||||
preview.processing = True
|
||||
preview.load_page = Mock()
|
||||
preview.reverse_pages()
|
||||
preview.load_page.assert_not_called()
|
||||
preview.processing = False
|
||||
preview.reverse_pages()
|
||||
self.assertEqual(preview.current_page_index, 0)
|
||||
self.assertEqual(preview.page_settings, [])
|
||||
preview.load_page.assert_called_once_with()
|
||||
|
||||
def test_shortcut_available_with_personal_configuration(self):
|
||||
self.assertEqual(PAGE_SPLITTER_KB["reverse_pages"], "i")
|
||||
@@ -0,0 +1,33 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import prompting
|
||||
|
||||
|
||||
class PromptingTests(unittest.TestCase):
|
||||
def test_perspective_is_inserted_with_alternative_method_guidance(self) -> None:
|
||||
with patch.object(
|
||||
prompting, "get_label_text_content", return_value="Question"
|
||||
), patch.object(
|
||||
prompting, "get_label_sol_content", return_value="Solution"
|
||||
), patch.object(
|
||||
prompting, "get_label_persp_content", return_value="Barème détaillé"
|
||||
):
|
||||
prompt = prompting.make_prompt("evaluation", "Ex 1")
|
||||
|
||||
self.assertIn(prompting.PERSPECTIVE_GUIDANCE, prompt)
|
||||
self.assertIn("Barème détaillé", prompt)
|
||||
|
||||
def test_alternative_method_guidance_is_omitted_without_perspective(self) -> None:
|
||||
with patch.object(
|
||||
prompting, "get_label_text_content", return_value="Question"
|
||||
), patch.object(
|
||||
prompting, "get_label_sol_content", return_value="Solution"
|
||||
), patch.object(prompting, "get_label_persp_content", return_value=None):
|
||||
prompt = prompting.make_prompt("evaluation", "Ex 1")
|
||||
|
||||
self.assertNotIn(prompting.PERSPECTIVE_GUIDANCE, prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.annotation_data import AnnotationLoadResult, _coordinate_index
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
|
||||
|
||||
class RefaireTests(unittest.TestCase):
|
||||
def workspace(self, root, mode):
|
||||
for name in ("Copies", "Par label", mode, "BRnot/Copie01"):
|
||||
(root / name).mkdir(parents=True)
|
||||
(root / "labels").write_text("Ex 1\nEx 2\n")
|
||||
atomic_write_json(root / "correction.json", {})
|
||||
atomic_write_json(root / "refaire.json", [["Copie01", ["Ex 2"]]])
|
||||
for name in (
|
||||
"bnote.json",
|
||||
"checkboxes.json",
|
||||
"Reference.jpg",
|
||||
"Concat_annotated.pdf",
|
||||
):
|
||||
(root / "BRnot/Copie01" / name).touch()
|
||||
atomic_write_json(
|
||||
root / "BRnot/Copie01/bnote.json", {"images": [{"label": "Ex 2"}]}
|
||||
)
|
||||
return EvaluationWorkspace(root)
|
||||
|
||||
def test_reader_merges_full_copy_for_every_original_mode(self):
|
||||
for mode in ("BGnot", "Bnot", "Anot"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
workspace = self.workspace(Path(tmp), mode)
|
||||
data = {"01": {"Ex 1": {}, "Ex 2": {}}, "02": {"Ex 1": {}}}
|
||||
|
||||
def load(_workspace, data=data, **kwargs):
|
||||
return AnnotationLoadResult(
|
||||
{"01": {"Ex 2": {}}} if kwargs else data, []
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(reader, "load_annotation_data", side_effect=load),
|
||||
patch.object(
|
||||
reader,
|
||||
"_scan_annotation_directory",
|
||||
return_value=(
|
||||
{"01": [{"label": "Ex 2", "type": "score", "value": 3}]},
|
||||
{},
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
reader,
|
||||
"apply_actions_and_regenerate_grouped",
|
||||
return_value=(ExitCode.SUCCESS, ""),
|
||||
) as render,
|
||||
):
|
||||
self.assertEqual(
|
||||
reader.run(workspace, refaire=True, annotation_dir=mode),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
render.assert_called_once()
|
||||
self.assertEqual(set(render.call_args.args[1]["01"]), {"Ex 1", "Ex 2"})
|
||||
self.assertEqual(render.call_args.args[2], "01")
|
||||
self.assertEqual(render.call_args.kwargs["selected_labels"], {"Ex 2"})
|
||||
self.assertEqual(render.call_args.kwargs["annotation_dir"], mode)
|
||||
|
||||
def test_whole_copy_selection_replaces_all_old_actions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = self.workspace(root, "BGnot")
|
||||
(root / "BGnot/Ex 1").mkdir()
|
||||
atomic_write_json(root / "refaire.json", [["Copie01", []]])
|
||||
atomic_write_json(
|
||||
root / "BRnot/Copie01/bnote.json",
|
||||
{"images": [{"label": label} for label in ("Ex 1", "Ex 2")]},
|
||||
)
|
||||
loaded = AnnotationLoadResult({"01": {"Ex 1": {}, "Ex 2": {}}}, [])
|
||||
|
||||
def scan(directory, *args, **kwargs):
|
||||
return (
|
||||
({"01": [{"label": "Ex 1", "type": "score", "value": 1}]}, {})
|
||||
if directory.parent.name == "BGnot"
|
||||
else ({"01": [{"label": "Ex 2", "type": "score", "value": 4}]}, {})
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(reader, "load_annotation_data", return_value=loaded),
|
||||
patch.object(reader, "_scan_annotation_directory", side_effect=scan),
|
||||
patch.object(
|
||||
reader,
|
||||
"apply_actions_and_regenerate_grouped",
|
||||
return_value=(ExitCode.SUCCESS, ""),
|
||||
) as render,
|
||||
):
|
||||
self.assertEqual(reader.run(workspace, refaire=True), ExitCode.SUCCESS)
|
||||
self.assertEqual(
|
||||
render.call_args.kwargs["selected_labels"], {"Ex 1", "Ex 2"}
|
||||
)
|
||||
self.assertEqual(
|
||||
render.call_args.args[3],
|
||||
[{"label": "Ex 2", "type": "score", "value": 4}],
|
||||
)
|
||||
|
||||
def test_stale_redo_selection_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = self.workspace(root, "Anot")
|
||||
atomic_write_json(
|
||||
root / "BRnot/Copie01/bnote.json", {"images": [{"label": "Ex 1"}]}
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
reader,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult({"01": {"Ex 2": {}}}, []),
|
||||
),
|
||||
patch.object(reader, "apply_actions_and_regenerate_grouped") as render,
|
||||
):
|
||||
self.assertEqual(
|
||||
reader.run(workspace, refaire=True, annotation_dir="Anot"),
|
||||
ExitCode.PARTIAL,
|
||||
)
|
||||
render.assert_not_called()
|
||||
|
||||
def test_missing_redo_return_leaves_copy_untouched(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = self.workspace(root, "BGnot")
|
||||
(root / "BRnot/Copie01/Concat_annotated.pdf").unlink()
|
||||
with (
|
||||
patch.object(
|
||||
reader,
|
||||
"load_annotation_data",
|
||||
return_value=AnnotationLoadResult({"01": {"Ex 2": {}}}, []),
|
||||
),
|
||||
patch.object(reader, "apply_actions_and_regenerate_grouped") as render,
|
||||
):
|
||||
self.assertEqual(reader.run(workspace, refaire=True), ExitCode.PARTIAL)
|
||||
render.assert_not_called()
|
||||
|
||||
def test_regeneration_preserves_untouched_image_and_score_and_saves_redo(self):
|
||||
for mode in ("BGnot", "Bnot", "Anot"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = self.workspace(root, mode)
|
||||
output = root / mode / "Copie01"
|
||||
output.mkdir()
|
||||
Image.new("RGB", (40, 30), "red").save(output / "Ex 1.jpg")
|
||||
before = (output / "Ex 1.jpg").read_bytes()
|
||||
atomic_write_json(output / "score.json", {"Ex 1": "3.5", "Ex 2": "0"})
|
||||
answer = root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {
|
||||
"01": {
|
||||
label: {
|
||||
"result": {"score": 2, "feedback": []},
|
||||
"pdf_path": answer,
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
for label in ("Ex 1", "Ex 2")
|
||||
}
|
||||
}
|
||||
with (
|
||||
patch.object(
|
||||
reader.annotating,
|
||||
"make_base_image",
|
||||
return_value=(Image.new("RGB", (40, 20)), 0, 0),
|
||||
),
|
||||
patch.object(
|
||||
reader.annotating,
|
||||
"compose_label_image",
|
||||
return_value=(Image.new("RGB", (40, 20), "blue"), 0),
|
||||
),
|
||||
patch.object(reader, "get_extra_pdfs_as_images", return_value=[]),
|
||||
):
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace,
|
||||
data,
|
||||
"01",
|
||||
[],
|
||||
{},
|
||||
["Ex 1", "Ex 2"],
|
||||
annotation_dir=mode,
|
||||
selected_labels={"Ex 2"},
|
||||
)
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
self.assertEqual(
|
||||
read_json(output / "score.json"), {"Ex 1": "3.5", "Ex 2": "2"}
|
||||
)
|
||||
self.assertEqual((output / "Ex 1.jpg").read_bytes(), before)
|
||||
self.assertTrue((output / "Ex 2.jpg").is_file())
|
||||
with Image.open(output / "Concat.jpg") as concat:
|
||||
self.assertEqual(concat.size, (40, 50))
|
||||
|
||||
def test_simple_import_survives_successive_redos(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
workspace = self.workspace(root, "Anot")
|
||||
output = root / "Anot/Copie01"
|
||||
output.mkdir()
|
||||
for label in ("Ex 1", "Ex 2"):
|
||||
Image.new("RGB", (40, 30), "white").save(output / f"{label}.jpg")
|
||||
Image.new("RGB", (40, 60), "red").save(output / "Concat_annotated.jpg")
|
||||
atomic_write_json(output / "score.json", {"Ex 1": "1", "Ex 2": "1"})
|
||||
answer = root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {
|
||||
"01": {
|
||||
label: {
|
||||
"result": {"score": 2, "feedback": []},
|
||||
"pdf_path": answer,
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
for label in ("Ex 1", "Ex 2")
|
||||
}
|
||||
}
|
||||
with (
|
||||
patch.object(
|
||||
reader.annotating,
|
||||
"make_base_image",
|
||||
return_value=(Image.new("RGB", (40, 20)), 0, 0),
|
||||
),
|
||||
patch.object(
|
||||
reader.annotating,
|
||||
"compose_label_image",
|
||||
return_value=(Image.new("RGB", (40, 20), "blue"), 0),
|
||||
),
|
||||
):
|
||||
for selected in ({"Ex 2"}, {"Ex 1"}):
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace,
|
||||
data,
|
||||
"01",
|
||||
[],
|
||||
{},
|
||||
["Ex 1", "Ex 2"],
|
||||
annotation_dir="Anot",
|
||||
selected_labels=selected,
|
||||
)
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
if selected == {"Ex 2"}:
|
||||
with Image.open(output / "Ex 1.jpg") as untouched:
|
||||
self.assertGreater(untouched.getpixel((10, 10))[0], 240)
|
||||
with Image.open(output / "Concat.jpg") as concat:
|
||||
self.assertEqual(concat.size, (40, 40))
|
||||
self.assertGreater(concat.getpixel((10, 30))[2], 240)
|
||||
|
||||
def test_latest_group_coordinates_win_numerically(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
group = root / "Par label" / "Ex 1"
|
||||
group.mkdir(parents=True)
|
||||
for number, height in ((2, 100), (10, 40)):
|
||||
atomic_write_json(
|
||||
group / f"Group_{number}.json", [["01", 0, height, "", "Ex 1"]]
|
||||
)
|
||||
Image.new("RGB", (20, height)).save(group / f"Group_{number}.jpg")
|
||||
index, warnings = _coordinate_index(EvaluationWorkspace(root))
|
||||
self.assertFalse(warnings)
|
||||
self.assertEqual(index[("Ex 1", "01")].height, 40)
|
||||
|
||||
def test_unreadable_redo_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
atomic_write_json(root / "bnote.json", {"images": []})
|
||||
with (
|
||||
patch.object(
|
||||
reader, "detect_checks_and_notes", return_value=([], None)
|
||||
),
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
reader._scan_annotation_directory(root, required=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.annotation_data import AnnotationLoadResult
|
||||
from copienator.commands import annotating_with_checks as checks
|
||||
from copienator.commands import export, import_annotations
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
from copienator_gui.refaire_sessions import begin_pass
|
||||
|
||||
|
||||
class RefaireSessionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name) / "Exam"
|
||||
self.root.mkdir()
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
atomic_write_json(self.workspace.refaire_file, [["Copie01", ["Ex 1"]]])
|
||||
(self.root / "BRnot/Copie01").mkdir(parents=True)
|
||||
(self.root / "BRnot/Copie01/Concat_annotated.pdf").write_bytes(b"legacy return")
|
||||
self.state = {
|
||||
"steps": {
|
||||
"annotation": {"status": "success"},
|
||||
"refaire_selection": {
|
||||
"status": "success",
|
||||
"values": {
|
||||
"selection": [["Copie01", ["Ex 1"]]],
|
||||
"annotation_dir": "Anot",
|
||||
"layout": "grouped",
|
||||
},
|
||||
},
|
||||
"refaire_merge": {"status": "success"},
|
||||
},
|
||||
"history": [],
|
||||
}
|
||||
atomic_write_json(self.workspace.gui_state_file, self.state)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_new_then_repeat_preserve_each_pass_and_reset_only_redo_progress(self):
|
||||
state, first = begin_pass(self.workspace, self.state, keep_selection=False)
|
||||
self.assertEqual(
|
||||
state["steps"]["annotation"], self.state["steps"]["annotation"]
|
||||
)
|
||||
self.assertNotIn("refaire_merge", state["steps"])
|
||||
self.assertEqual(state["steps"]["refaire_selection"]["values"]["selection"], [])
|
||||
self.assertEqual(
|
||||
state["steps"]["refaire_selection"]["values"]["layout"], "grouped"
|
||||
)
|
||||
self.assertEqual(read_json(self.workspace.refaire_file), [])
|
||||
archives = [
|
||||
path for path in (self.root / "Reprises").iterdir() if path.name != first
|
||||
]
|
||||
self.assertEqual(len(archives), 1)
|
||||
self.assertEqual(
|
||||
(archives[0] / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||
b"legacy return",
|
||||
)
|
||||
selection = [["Copie02", ["Ex 2"]]]
|
||||
atomic_write_json(self.workspace.refaire_file, selection)
|
||||
first_output = self.workspace.annotation_dir("refaire")
|
||||
first_output.mkdir(parents=True)
|
||||
(first_output / "review.pdf").write_bytes(b"first review")
|
||||
state["steps"]["refaire_merge"] = {"status": "success"}
|
||||
updated, second = begin_pass(self.workspace, state, keep_selection=True)
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertEqual(read_json(self.workspace.refaire_file), selection)
|
||||
self.assertEqual(
|
||||
updated["steps"]["refaire_selection"]["values"]["selection"], selection
|
||||
)
|
||||
self.assertEqual((first_output / "review.pdf").read_bytes(), b"first review")
|
||||
self.assertEqual(read_json(first_output.parent / "refaire.json"), selection)
|
||||
self.assertNotIn("status", updated["steps"]["refaire_selection"])
|
||||
self.assertEqual(
|
||||
self.workspace.annotation_dir("refaire"),
|
||||
self.root / "Reprises" / second / "BRnot",
|
||||
)
|
||||
reloaded = EvaluationWorkspace(self.root)
|
||||
self.assertEqual(reloaded.refaire_session_id, second)
|
||||
|
||||
def test_failed_activation_leaves_current_pass_and_state_unchanged(self):
|
||||
before = copy.deepcopy(self.state)
|
||||
with (
|
||||
patch(
|
||||
"copienator_gui.refaire_sessions.staged_files",
|
||||
side_effect=OSError("disk unavailable"),
|
||||
),
|
||||
self.assertRaises(OSError),
|
||||
):
|
||||
begin_pass(self.workspace, self.state, keep_selection=False)
|
||||
self.assertIsNone(self.workspace.refaire_session_id)
|
||||
self.assertEqual(read_json(self.workspace.gui_state_file), before)
|
||||
self.assertEqual(
|
||||
read_json(self.workspace.refaire_file), [["Copie01", ["Ex 1"]]]
|
||||
)
|
||||
self.assertEqual(self.state, before)
|
||||
self.assertEqual(
|
||||
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||
b"legacy return",
|
||||
)
|
||||
|
||||
def test_exports_are_unique_and_old_returns_cannot_enter_new_pass(self):
|
||||
export_root = Path(self.temp.name) / "Export"
|
||||
import_root = Path(self.temp.name) / "Import"
|
||||
import_root.mkdir()
|
||||
state, first = begin_pass(self.workspace, self.state, keep_selection=True)
|
||||
for expected_id in (first, None):
|
||||
if expected_id is None:
|
||||
state, second = begin_pass(self.workspace, state, keep_selection=True)
|
||||
expected_id = second
|
||||
output = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
|
||||
output.mkdir(parents=True)
|
||||
(output / "Concat.pdf").write_bytes(expected_id.encode())
|
||||
with patch.object(export, "EXPORT_DIR", export_root):
|
||||
self.assertEqual(
|
||||
export.run(self.workspace, refaire=True), ExitCode.SUCCESS
|
||||
)
|
||||
exported = (
|
||||
export_root / "Exam" / expected_id / f"{expected_id}__Ex 1 G1.pdf"
|
||||
)
|
||||
self.assertEqual(exported.read_bytes(), expected_id.encode())
|
||||
if expected_id == first:
|
||||
(import_root / exported.name).write_bytes(b"old return")
|
||||
current = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
|
||||
with patch.object(import_annotations, "IMPORT_DIR", import_root):
|
||||
self.assertEqual(
|
||||
import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL
|
||||
)
|
||||
self.assertFalse((current / "Concat_annotated.pdf").exists())
|
||||
(import_root / f"{second}__Ex 1 G1.pdf").write_bytes(b"new return")
|
||||
self.assertEqual(
|
||||
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
|
||||
)
|
||||
self.assertEqual((current / "Concat_annotated.pdf").read_bytes(), b"new return")
|
||||
self.workspace.require_directories("BRnot")
|
||||
|
||||
def test_generation_and_merge_follow_the_active_pass(self):
|
||||
for directory in ("Copies", "Par label", "Anot"):
|
||||
(self.root / directory).mkdir()
|
||||
(self.root / "labels").write_text("Ex 1\n")
|
||||
atomic_write_json(self.root / "correction.json", {})
|
||||
answer = self.root / "answer.pdf"
|
||||
answer.touch()
|
||||
loaded = AnnotationLoadResult(
|
||||
{
|
||||
"01": {
|
||||
"Ex 1": {
|
||||
"pdf_path": answer,
|
||||
"result": {"score": 2, "feedback": []},
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
begin_pass(self.workspace, self.state, keep_selection=True)
|
||||
with (
|
||||
patch.object(checks, "load_annotation_data", return_value=loaded),
|
||||
patch.object(reader, "load_annotation_data", return_value=loaded),
|
||||
patch.object(
|
||||
checks.annotating,
|
||||
"make_base_image",
|
||||
return_value=(Image.new("RGB", (100, 100), "white"), 0, 0),
|
||||
),
|
||||
patch.object(
|
||||
checks.annotating,
|
||||
"compose_label_image",
|
||||
return_value=(Image.new("RGB", (100, 100), "white"), 0),
|
||||
),
|
||||
):
|
||||
self.assertEqual(
|
||||
checks.run(self.workspace, self.root, refaire=True, overwrite=True),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
output = self.workspace.annotation_dir("refaire") / "Copie01"
|
||||
shutil.copy2(output / "Concat.pdf", output / "Concat_annotated.pdf")
|
||||
self.assertEqual(
|
||||
reader.run(self.workspace, refaire=True, annotation_dir="Anot"),
|
||||
ExitCode.SUCCESS,
|
||||
)
|
||||
self.assertEqual(
|
||||
read_json(self.root / "Anot/Copie01/score.json"), {"Ex 1": "2"}
|
||||
)
|
||||
self.assertEqual(
|
||||
(self.root / "BRnot/Copie01/Concat_annotated.pdf").read_bytes(),
|
||||
b"legacy return",
|
||||
)
|
||||
|
||||
def test_invalid_session_id_cannot_escape_workspace(self):
|
||||
atomic_write_json(self.root / "refaire-session.json", {"id": "../elsewhere"})
|
||||
with self.assertRaises(ValueError):
|
||||
self.workspace.annotation_dir("refaire")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
import contextlib
|
||||
import io
|
||||
import itertools
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration
|
||||
from copienator.commands import clean, giving_names
|
||||
|
||||
|
||||
class ReturnOutputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.workspace.copies_dir.mkdir()
|
||||
atomic_write_json(self.workspace.copies_dir / "Copie01.json", {"name": "Student"})
|
||||
(self.workspace.copies_dir / "Copie01.pdf").write_bytes(b"original")
|
||||
atomic_write_json(self.workspace.correction_file, {})
|
||||
(self.workspace.root / "names").write_text("Student\n")
|
||||
self.source = self.workspace.root / "BGnot" / "Copie01"
|
||||
self.source.mkdir(parents=True)
|
||||
(self.source / "Concat.jpg").write_bytes(b"jpeg")
|
||||
(self.source / "Concat_F.pdf").write_bytes(b"pdf")
|
||||
atomic_write_json(self.source / "score.json", {"Ex 1": "4"})
|
||||
atomic_write_json(self.source / "info.json", {
|
||||
"Ex 1": {"present": True, "not_empty": True, "touched": True, "score": "4"}
|
||||
})
|
||||
self.answer_option = patch.object(configuration, "RETURN_ANSWERS_ENABLED", False)
|
||||
self.answer_option.start()
|
||||
self.addCleanup(self.answer_option.stop)
|
||||
self.destination = self.workspace.return_dir / "Student (01)"
|
||||
|
||||
def prepare(self, jpeg=True, pdf=True):
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", jpeg), patch.object(
|
||||
configuration, "RETURN_PDF_ENABLED", pdf
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(giving_names.run(self.workspace, annotation_dir="BGnot"), 0)
|
||||
|
||||
def test_all_combinations_and_reenable_preserve_sources_and_scores(self):
|
||||
for jpeg, pdf in itertools.product((True, False), repeat=2):
|
||||
with self.subTest(jpeg=jpeg, pdf=pdf):
|
||||
self.prepare()
|
||||
self.prepare(jpeg, pdf)
|
||||
expected = {"score.json", "info.json"}
|
||||
if jpeg:
|
||||
expected.add("Student.jpg")
|
||||
if pdf:
|
||||
expected.add("Student.pdf")
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, expected)
|
||||
self.assertEqual((self.source / "Concat.jpg").read_bytes(), b"jpeg")
|
||||
self.assertEqual((self.source / "Concat_F.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual(
|
||||
(self.destination / "score.json").read_bytes(),
|
||||
(self.source / "score.json").read_bytes(),
|
||||
)
|
||||
self.prepare()
|
||||
self.assertEqual((self.destination / "Student.jpg").read_bytes(), b"jpeg")
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
|
||||
def test_disabling_removes_regular_files_and_broken_links_only(self):
|
||||
self.prepare()
|
||||
jpg = self.destination / "Student.jpg"
|
||||
jpg.unlink()
|
||||
jpg.write_bytes(b"old fallback copy")
|
||||
pdf = self.destination / "Student.pdf"
|
||||
pdf.unlink()
|
||||
pdf.symlink_to(self.source / "missing.pdf")
|
||||
unrelated = self.destination / "notes.txt"
|
||||
unrelated.write_text("keep")
|
||||
self.prepare(False, False)
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, {"score.json", "info.json", "notes.txt"})
|
||||
self.assertTrue((self.source / "Concat_F.pdf").is_file())
|
||||
|
||||
def test_fallback_source_respects_options(self):
|
||||
self.source.parent.rename(self.workspace.root / "Anot")
|
||||
(self.workspace.root / "BGnot").mkdir()
|
||||
self.prepare(False, True)
|
||||
self.assertFalse((self.destination / "Student.jpg").exists())
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertTrue((self.destination / "score.json").is_file())
|
||||
|
||||
self.assertTrue((self.destination / "info.json").is_file())
|
||||
|
||||
def test_cleanup_preserves_enabled_returns_without_jpeg(self):
|
||||
self.prepare(False, True)
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
|
||||
plan = clean.build_cleanup_plan(self.workspace)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
clean.apply_cleanup(self.workspace, plan)
|
||||
self.assertFalse(self.source.exists())
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertFalse((self.destination / "Student.pdf").is_symlink())
|
||||
self.assertTrue((self.destination / "score.json").is_file())
|
||||
self.assertTrue((self.destination / "info.json").is_file())
|
||||
|
||||
def test_cleanup_accepts_scores_only_but_still_requires_scores(self):
|
||||
self.prepare(False, False)
|
||||
with patch.object(configuration, "RETURN_JPEG_ENABLED", False):
|
||||
plan = clean.build_cleanup_plan(self.workspace)
|
||||
self.assertIn(self.destination / "score.json", plan.kept_files)
|
||||
(self.destination / "score.json").unlink()
|
||||
with self.assertRaisesRegex(clean.CliError, "score.json"):
|
||||
clean.build_cleanup_plan(self.workspace)
|
||||
|
||||
def test_older_config_defaults_to_enabled(self):
|
||||
old_config = self.workspace.root / "old_config.py"
|
||||
old_config.write_text("ALWAYS_CROP = False\n")
|
||||
with patch.dict("os.environ", {"COPIENATOR_CONFIG": str(old_config)}):
|
||||
loaded = runpy.run_path(configuration.__file__)
|
||||
self.assertIs(loaded["RETURN_JPEG_ENABLED"], True)
|
||||
self.assertIs(loaded["RETURN_PDF_ENABLED"], True)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_ENABLED"], False)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_CONTEXT"], False)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_QUESTION"], True)
|
||||
self.assertIs(loaded["RETURN_ANSWERS_SOLUTION"], False)
|
||||
self.assertNotIn("RETURN_JSON_ENABLED", loaded)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Compare extracted answers against the same Poppler view used for labels."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
from copienator.commands.splitting_int import (
|
||||
ANSWER_TOP_PADDING_POINTS,
|
||||
_render_split_outputs,
|
||||
)
|
||||
|
||||
|
||||
class SplittingGeometryTests(unittest.TestCase):
|
||||
def make_source(self, path, rotation, cropped=True):
|
||||
with pymupdf.open() as document:
|
||||
page = document.new_page(width=800, height=1000)
|
||||
# Asymmetric colours exercise both translation and orientation.
|
||||
for row in range(20):
|
||||
for column in range(8):
|
||||
colour = (row / 20, column / 8, (row + column) % 7 / 7)
|
||||
page.draw_rect(
|
||||
pymupdf.Rect(column * 100, row * 50,
|
||||
(column + 1) * 100, (row + 1) * 50),
|
||||
color=None, fill=colour,
|
||||
)
|
||||
page.insert_text((200, 500), "Middle of the answer")
|
||||
if cropped:
|
||||
page.set_cropbox(pymupdf.Rect(20, 80, 760, 920))
|
||||
page.set_rotation(rotation)
|
||||
document.save(path)
|
||||
|
||||
def assert_matches_preview(self, answer, preview, bounds):
|
||||
actual = np.asarray(convert_from_path(answer, dpi=72)[0]).astype(float)
|
||||
expected = np.asarray(preview.crop(bounds)).astype(float)
|
||||
self.assertEqual(actual.shape, expected.shape)
|
||||
self.assertLess(np.abs(actual - expected).mean(), 0.5)
|
||||
|
||||
def test_cropped_and_uncropped_pages_at_every_rotation(self):
|
||||
visible = {
|
||||
0: (20, 80, 760, 920),
|
||||
90: (80, 20, 920, 760),
|
||||
180: (40, 80, 780, 920),
|
||||
270: (80, 40, 920, 780),
|
||||
}
|
||||
for rotation in (0, 90, 180, 270):
|
||||
for cropped in (False, True):
|
||||
for full_answer in (False, True):
|
||||
with self.subTest(rotation=rotation, cropped=cropped, full=full_answer):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "copy.pdf"
|
||||
self.make_source(source, rotation, cropped)
|
||||
before = source.read_bytes()
|
||||
preview = convert_from_path(source, dpi=72, use_cropbox=False)[0]
|
||||
width, height = preview.size
|
||||
bounds = visible[rotation] if cropped else (0, 0, width, height)
|
||||
if full_answer:
|
||||
coordinates = [("A", 0, 0, 10, 0, 100)]
|
||||
else:
|
||||
coordinates = [("A", 0, 250, 260, 0, 100),
|
||||
("_", 0, 711, 721, 0, 100)]
|
||||
answer_top = int(height // 4 - ANSWER_TOP_PADDING_POINTS)
|
||||
bounds = (bounds[0], max(bounds[1], answer_top),
|
||||
bounds[2], min(bounds[3], height * 3 // 4))
|
||||
_render_split_outputs(source, coordinates, root)
|
||||
self.assert_matches_preview(root / "A.pdf", preview, bounds)
|
||||
self.assertEqual(source.read_bytes(), before)
|
||||
|
||||
def test_answer_continues_to_bottom_then_next_page(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "copy.pdf"
|
||||
first = root / "first.pdf"
|
||||
second = root / "second.pdf"
|
||||
self.make_source(first, 180)
|
||||
self.make_source(second, 0)
|
||||
with pymupdf.open() as document:
|
||||
for path in (first, second):
|
||||
with pymupdf.open(path) as part:
|
||||
document.insert_pdf(part)
|
||||
document.save(source)
|
||||
previews = convert_from_path(source, dpi=72, use_cropbox=False)
|
||||
_render_split_outputs(source, [("A", 0, 700, 720, 0, 100),
|
||||
("_", 1, 211, 230, 500, 600)], root)
|
||||
rendered = convert_from_path(root / "A.pdf", dpi=72)
|
||||
self.assertEqual(len(rendered), 2)
|
||||
for actual, preview, bounds in zip(rendered, previews,
|
||||
[(40, 688, 780, 920), (20, 80, 760, 250)]):
|
||||
expected = np.asarray(preview.crop(bounds)).astype(float)
|
||||
actual = np.asarray(actual).astype(float)
|
||||
self.assertEqual(actual.shape, expected.shape)
|
||||
self.assertLess(np.abs(actual - expected).mean(), 0.5)
|
||||
@@ -0,0 +1,221 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tkinter import ttk
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, ExitCode
|
||||
from copienator.commands import enonce_info as personal
|
||||
from copienator.commands import gemini_for_enonce as gemini
|
||||
from copienator_gui.app import CopienatorApp, get_personal_interro_files
|
||||
from copienator_gui.workflow import build_workflow
|
||||
|
||||
|
||||
class PersonalStatementTests(unittest.TestCase):
|
||||
def test_personal_generation_uses_statement_and_groups_by_exercise(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "correction.tex").write_text("Not SHEETINFO")
|
||||
blocks = [{"id": 42, "indexes": [{"indices": [1]}, {"indices": [2]}]},
|
||||
{"id": 99}]
|
||||
(root / "enonce.tex").write_text("\n".join(
|
||||
"%%SHEETINFO : " + json.dumps(block) for block in blocks))
|
||||
(root / "label_groups").write_text("obsolete groups")
|
||||
urls = []
|
||||
|
||||
def fetch(url):
|
||||
urls.append(url)
|
||||
if "/emacs/" in url:
|
||||
return io.BytesIO(b"Statement\n 1) Question\n###Solution\n 1) Answer\n###Rubric\n 1) Points")
|
||||
return io.BytesIO(b"Selected exercise content")
|
||||
|
||||
def compile_pdf(content, path):
|
||||
Path(path).write_bytes(b"test PDF")
|
||||
|
||||
with patch.object(personal.urllib.request, "urlopen", side_effect=fetch), patch.object(
|
||||
personal, "compile_to_pdf", side_effect=compile_pdf
|
||||
):
|
||||
self.assertEqual(personal.process_directory(EvaluationWorkspace(root)), ExitCode.SUCCESS)
|
||||
self.assertEqual((root / "labels").read_text(), "Ex 1 : 1)\nEx 1 : 2)\nEx 2\n")
|
||||
self.assertEqual((root / "label_groups").read_text(), "Ex 1 : 1), Ex 1 : 2)\nEx 2\n")
|
||||
for folder in ("Text2", "Sol2"):
|
||||
for label in ("Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"):
|
||||
self.assertTrue((root / folder / f"{label}.tex").is_file())
|
||||
self.assertTrue((root / folder / f"{label}.pdf").is_file())
|
||||
self.assertIn("Rubric", (root / "Persp" / "Ex 1").read_text())
|
||||
self.assertTrue(any("/exo_q_text/42/1" in url for url in urls))
|
||||
|
||||
def test_personal_choice_and_optional_steps_only_in_personal_profile(self):
|
||||
standard = {step.id: step for step in build_workflow(False)}
|
||||
enabled = {step.id: step for step in build_workflow(True)}
|
||||
self.assertEqual([variant.id for variant in standard["statement"].variants], ["gemini"])
|
||||
self.assertEqual([variant.program for variant in enabled["statement"].variants],
|
||||
["statement-personal", "statement"])
|
||||
for ident in ("statement_groups", "statement_persp"):
|
||||
self.assertNotIn(ident, standard)
|
||||
self.assertTrue(enabled[ident].optional)
|
||||
self.assertFalse(enabled[ident].auto_start_first_visit)
|
||||
|
||||
|
||||
class SelectiveGeminiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
self.labels = ["Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"]
|
||||
(self.root / "labels").write_text("\n".join(self.labels) + "\n")
|
||||
(self.root / "label_groups").write_text(", ".join(self.labels[:2]) + "\nEx 2\n")
|
||||
for folder in ("Text", "Sol", "Text2", "Sol2", "Persp"):
|
||||
(self.root / folder).mkdir()
|
||||
for label in self.labels:
|
||||
suffix = ".tex" if folder.endswith("2") else ""
|
||||
(self.root / folder / (label + suffix)).write_text(f"Personal {folder}: {label}")
|
||||
(self.root / "Persp" / "Ex 1").write_text("Old aggregate rubric")
|
||||
self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock()))
|
||||
|
||||
def snapshot(self):
|
||||
return {str(path.relative_to(self.root)): path.read_bytes()
|
||||
for path in self.root.rglob("*") if path.is_file()}
|
||||
|
||||
def response(self, value):
|
||||
return SimpleNamespace(text=json.dumps(value))
|
||||
|
||||
def test_grouping_changes_only_groups_and_keeps_exact_labels(self):
|
||||
before = self.snapshot()
|
||||
self.client.models.generate_content.return_value = self.response({"groups": [[label] for label in self.labels]})
|
||||
self.assertEqual(gemini.refine_existing(self.workspace, "groups", api_client=self.client), ExitCode.SUCCESS)
|
||||
after = self.snapshot()
|
||||
self.assertEqual(after.pop("label_groups"), ("\n".join(self.labels) + "\n").encode())
|
||||
before.pop("label_groups")
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_invalid_grouping_preserves_existing_groups(self):
|
||||
before = self.snapshot()
|
||||
for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]):
|
||||
self.client.models.generate_content.return_value = self.response({"groups": groups})
|
||||
with self.assertRaises(CliError):
|
||||
gemini.refine_existing(self.workspace, "groups", api_client=self.client)
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
def rubric_response(self, labels):
|
||||
return self.response({"rubrics": [{"label": label, "rubric_content": "Barème Gemini sur 4 points"}
|
||||
for label in labels]})
|
||||
|
||||
def test_rubrics_replace_only_persp_using_normal_prompt(self):
|
||||
before = self.snapshot()
|
||||
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]),
|
||||
self.rubric_response(self.labels[2:])]
|
||||
self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS)
|
||||
after = self.snapshot()
|
||||
self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")},
|
||||
{key: value for key, value in after.items() if not key.startswith("Persp/")})
|
||||
self.assertFalse((self.root / "Persp" / "Ex 1").exists())
|
||||
for label in self.labels:
|
||||
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
|
||||
for call in self.client.models.generate_content.call_args_list:
|
||||
self.assertEqual(call.kwargs["contents"][0].parts[0].text, gemini.PROMPT_4)
|
||||
self.assertTrue(call.kwargs["config"].automatic_function_calling.disable)
|
||||
|
||||
def test_rubric_prompt_omits_the_assumed_total_and_requires_latex(self):
|
||||
self.assertIn("total est toujours implicite", gemini.PROMPT_4)
|
||||
self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4)
|
||||
self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4)
|
||||
|
||||
def test_incomplete_or_failed_rubrics_preserve_entire_persp(self):
|
||||
before = self.snapshot()
|
||||
for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")):
|
||||
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last]
|
||||
with self.assertRaises((CliError, RuntimeError)):
|
||||
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
def test_cli_dispatches_selective_modes_without_full_extraction(self):
|
||||
with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object(
|
||||
gemini, "process_exam"
|
||||
) as full:
|
||||
for flag, mode in (("--groups-only", "groups"), ("--persp-only", "persp")):
|
||||
self.assertEqual(gemini.main([str(self.root), flag]), ExitCode.SUCCESS)
|
||||
self.assertEqual(refine.call_args.args[1], mode)
|
||||
full.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
|
||||
class PersonalStatementGuiTests(unittest.TestCase):
|
||||
def test_get_file_button_completes_inputs_and_advances(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
repository = Path(temporary)
|
||||
evaluation = repository / "Interro12"
|
||||
source = repository / "source"
|
||||
evaluation.mkdir()
|
||||
source.mkdir()
|
||||
(repository / "names").touch()
|
||||
for name, content in (
|
||||
("Interro12.pdf", b"pdf"),
|
||||
("Interro12.tex", b"statement"),
|
||||
("Interro12c.tex", b"correction"),
|
||||
):
|
||||
(source / name).write_bytes(content)
|
||||
app = CopienatorApp(repository, True, evaluation)
|
||||
try:
|
||||
app.update()
|
||||
buttons = [
|
||||
child
|
||||
for child in app.form.winfo_children()
|
||||
if isinstance(child, ttk.Button)
|
||||
]
|
||||
get_button = next(
|
||||
button for button in buttons if button.cget("text") == "Get the files"
|
||||
)
|
||||
with patch(
|
||||
"copienator_gui.app.get_personal_interro_files",
|
||||
side_effect=lambda target: get_personal_interro_files(target, source),
|
||||
):
|
||||
get_button.invoke()
|
||||
app.update()
|
||||
|
||||
self.assertEqual(app.state_store.step("inputs")["status"], "success")
|
||||
self.assertEqual(app.current_step.id, "statement")
|
||||
self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual((evaluation / "enonce.tex").read_bytes(), b"statement")
|
||||
self.assertEqual((evaluation / "correction.tex").read_bytes(), b"correction")
|
||||
finally:
|
||||
for callback in app.tk.splitlist(app.tk.call("after", "info")):
|
||||
app.after_cancel(callback)
|
||||
app.destroy()
|
||||
|
||||
def test_personal_requirements_and_optional_button_command_previews(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
evaluation = Path(temporary)
|
||||
(evaluation / "enonce.tex").touch()
|
||||
app = CopienatorApp(Path.cwd(), True, evaluation)
|
||||
try:
|
||||
app.update()
|
||||
app.tree.selection_set("statement")
|
||||
app.update()
|
||||
self.assertEqual(app.variant_var.get(), "personal")
|
||||
self.assertIn("statement-personal", app.command_var.get())
|
||||
self.assertEqual(app._missing_requirements(app.current_step), [])
|
||||
self.assertIn("SHEETINFO", app.description_label.cget("text"))
|
||||
self.assertFalse(
|
||||
any(
|
||||
isinstance(child, ttk.LabelFrame)
|
||||
and child.cget("text") == "Après génération — facultatif"
|
||||
for child in app.form.winfo_children()
|
||||
)
|
||||
)
|
||||
for ident, flag in (("statement_groups", "--groups-only"), ("statement_persp", "--persp-only")):
|
||||
app.tree.selection_set(ident)
|
||||
app.update()
|
||||
self.assertEqual(app.current_step.id, ident)
|
||||
self.assertIn(flag, app.command_var.get())
|
||||
self.assertTrue(app.current_step.optional)
|
||||
self.assertFalse(app.runner.running)
|
||||
finally:
|
||||
for callback in app.tk.splitlist(app.tk.call("after", "info")):
|
||||
app.after_cancel(callback)
|
||||
app.destroy()
|
||||
@@ -0,0 +1,48 @@
|
||||
import contextlib
|
||||
import io
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import utils
|
||||
|
||||
|
||||
class CompileToPdfTests(unittest.TestCase):
|
||||
def test_warns_when_latex_fails_even_if_partial_pdf_exists(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "result.pdf"
|
||||
|
||||
def failed_run(*_args, **kwargs):
|
||||
(Path(kwargs["cwd"]) / "text.pdf").write_bytes(b"partial")
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1,
|
||||
stdout="! LaTeX Error: Something's wrong--perhaps a missing \\item.\n",
|
||||
)
|
||||
|
||||
console = io.StringIO()
|
||||
with patch.object(utils.subprocess, "run", side_effect=failed_run), \
|
||||
contextlib.redirect_stdout(console):
|
||||
utils.compile_to_pdf("broken", output)
|
||||
|
||||
self.assertEqual(output.read_bytes(), b"partial")
|
||||
self.assertIn("Warning: LaTeX compilation failed", console.getvalue())
|
||||
self.assertIn("missing \\item", console.getvalue())
|
||||
|
||||
def test_warns_when_no_pdf_is_produced(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "result.pdf"
|
||||
result = subprocess.CompletedProcess(args=[], returncode=0, stdout="")
|
||||
|
||||
console = io.StringIO()
|
||||
with patch.object(utils.subprocess, "run", return_value=result), \
|
||||
contextlib.redirect_stdout(console):
|
||||
utils.compile_to_pdf("valid", output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
self.assertIn("produced no PDF", console.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user