Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b8a11c5b | ||
|
|
060859ddef | ||
|
|
dfce79f240 |
@@ -255,6 +255,8 @@ Les chemins des étapes personnelles peuvent être adaptés avec
|
|||||||
|
|
||||||
* Documentation complémentaire
|
* 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,
|
- [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments,
|
||||||
prérequis, fichiers produits et parcours alternatifs.
|
prérequis, fichiers produits et parcours alternatifs.
|
||||||
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
|
- [[file:Architecture.org][Architecture et conventions de développement]] : API commune,
|
||||||
|
|||||||
+28
-3
@@ -290,8 +290,31 @@ OU
|
|||||||
3. =python -m copienator giving-names Interro BGnot=
|
3. =python -m copienator giving-names Interro BGnot=
|
||||||
|
|
||||||
Crée un dossier =A Rendre= avec des liens symboliques vers
|
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
|
+ 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.
|
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans.
|
||||||
4. Éventuellement, faire des modifications manuelles aux =score.json=.
|
4. Éventuellement, faire des modifications manuelles aux =score.json=.
|
||||||
@@ -328,12 +351,14 @@ conserve que :
|
|||||||
+ le résultat final =correction.json= ;
|
+ le résultat final =correction.json= ;
|
||||||
+ les journaux de =.copienator/logs= et les journaux placés à la
|
+ les journaux de =.copienator/logs= et les journaux placés à la
|
||||||
racine, comme =correction_log= ;
|
racine, comme =correction_log= ;
|
||||||
+ les images et fichiers =score.json= présents dans =A Rendre=.
|
+ 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
|
Les liens symboliques conservés dans =A Rendre= sont remplacés par de
|
||||||
véritables fichiers avant la suppression de leurs cibles. La commande
|
véritables fichiers avant la suppression de leurs cibles. La commande
|
||||||
refuse de démarrer si les copies traitées, =correction.json= ou une
|
refuse de démarrer si les copies traitées, =correction.json= ou une
|
||||||
image/un score d'élève sont absents. Elle affiche d'abord un résumé et
|
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.
|
demande de saisir le nom de l'évaluation pour confirmer.
|
||||||
|
|
||||||
Dans le GUI, cette commande apparaît comme dernière étape facultative
|
Dans le GUI, cette commande apparaît comme dernière étape facultative
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ from copienator import (
|
|||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.annotation_data import load_annotation_data
|
from copienator.annotation_data import load_annotation_data
|
||||||
|
from copienator.answer_info import build_answer_info
|
||||||
from copienator.filesystem import staged_directory
|
from copienator.filesystem import staged_directory
|
||||||
from copienator.utils import natural_key
|
from copienator.utils import natural_key
|
||||||
|
|
||||||
@@ -446,6 +447,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
|||||||
with staged_directory(output_dir) as staging:
|
with staged_directory(output_dir) as staging:
|
||||||
d_notes = dict.fromkeys(all_labels, "")
|
d_notes = dict.fromkeys(all_labels, "")
|
||||||
label_images = []
|
label_images = []
|
||||||
|
answer_labels = []
|
||||||
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||||
|
|
||||||
for label, content in sorted_labels:
|
for label, content in sorted_labels:
|
||||||
@@ -475,8 +477,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
|||||||
final_img.save(staging / f"{label}.jpg")
|
final_img.save(staging / f"{label}.jpg")
|
||||||
if result.get('error', "") != "empty-answer":
|
if result.get('error', "") != "empty-answer":
|
||||||
label_images.append(final_img)
|
label_images.append(final_img)
|
||||||
|
answer_labels.append(label)
|
||||||
|
|
||||||
atomic_write_json(staging / "score.json", d_notes)
|
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:
|
if label_images:
|
||||||
max_w = max(image.width for image in label_images)
|
max_w = max(image.width for image in label_images)
|
||||||
total_h = sum(image.height for image in label_images)
|
total_h = sum(image.height for image in label_images)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from copienator import (
|
|||||||
CliError,
|
CliError,
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
|
configuration,
|
||||||
evaluation_parser,
|
evaluation_parser,
|
||||||
execute,
|
execute,
|
||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
@@ -83,15 +84,18 @@ def _return_artifacts(workspace: EvaluationWorkspace) -> list[Path]:
|
|||||||
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
|
path for path in files if path.suffix.casefold() in IMAGE_SUFFIXES
|
||||||
]
|
]
|
||||||
scores = [path for path in files if path.name.casefold() == "score.json"]
|
scores = [path for path in files if path.name.casefold() == "score.json"]
|
||||||
if not images or not scores:
|
pdfs = [path for path in files if path.suffix.casefold() == ".pdf"]
|
||||||
|
if (configuration.RETURN_JPEG_ENABLED and not images) or not scores:
|
||||||
missing = []
|
missing = []
|
||||||
if not images:
|
if configuration.RETURN_JPEG_ENABLED and not images:
|
||||||
missing.append("image")
|
missing.append("image")
|
||||||
if not scores:
|
if not scores:
|
||||||
missing.append("score.json")
|
missing.append("score.json")
|
||||||
incomplete.append(f"{directory.name} ({', '.join(missing)})")
|
incomplete.append(f"{directory.name} ({', '.join(missing)})")
|
||||||
artifacts.extend(images)
|
artifacts.extend(images)
|
||||||
artifacts.extend(scores)
|
artifacts.extend(scores)
|
||||||
|
artifacts.extend(pdfs)
|
||||||
|
artifacts.extend(path for path in files if path.name.casefold() == "info.json")
|
||||||
|
|
||||||
if incomplete:
|
if incomplete:
|
||||||
details = "\n".join(f" - {item}" for item in incomplete)
|
details = "\n".join(f" - {item}" for item in incomplete)
|
||||||
@@ -219,7 +223,7 @@ def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bo
|
|||||||
print(f" - {statement_count} textual statement files")
|
print(f" - {statement_count} textual statement files")
|
||||||
print(" - correction.json")
|
print(" - correction.json")
|
||||||
print(f" - {log_count} log files")
|
print(f" - {log_count} log files")
|
||||||
print(f" - {return_count} image/score artifacts in A Rendre")
|
print(f" - {return_count} image/PDF/score artifacts in A Rendre")
|
||||||
print(
|
print(
|
||||||
f"Will delete {len(plan.deleted_files)} files and "
|
f"Will delete {len(plan.deleted_files)} files and "
|
||||||
f"{len(plan.deleted_directories)} directories "
|
f"{len(plan.deleted_directories)} directories "
|
||||||
|
|||||||
@@ -134,6 +134,27 @@ def _publish(
|
|||||||
return backup_root
|
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:
|
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
|
||||||
if workers < 1:
|
if workers < 1:
|
||||||
raise CliError(
|
raise CliError(
|
||||||
@@ -161,15 +182,23 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex
|
|||||||
raise CliError(
|
raise CliError(
|
||||||
f"{source} a changé pendant l’analyse. Aucun PDF remplacé."
|
f"{source} a changé pendant l’analyse. Aucun PDF remplacé."
|
||||||
)
|
)
|
||||||
|
cropped_exercises, mean_percentage = crop_statistics(records)
|
||||||
if not changed_files:
|
if not changed_files:
|
||||||
print("Terminé : aucun PDF ne remplit les critères de rognage.", flush=True)
|
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
|
return ExitCode.SUCCESS
|
||||||
backup = _publish(workspace, changed_files, staging, records)
|
backup = _publish(workspace, changed_files, staging, records)
|
||||||
cropped_pages = sum(record["status"] == "cropped" for record in records)
|
cropped_pages = sum(record["status"] == "cropped" for record in records)
|
||||||
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
|
print(f"Sauvegarde des PDF non rognés : {backup}", flush=True)
|
||||||
print(
|
print(
|
||||||
f"Terminé : {cropped_pages} page(s) rognée(s) dans "
|
f"Terminé : {cropped_exercises} exercice(s) rogné(s), soit "
|
||||||
f"{len(changed_files)} PDF remplacé(s).",
|
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,
|
flush=True,
|
||||||
)
|
)
|
||||||
return ExitCode.SUCCESS
|
return ExitCode.SUCCESS
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ def process_copies(files: list[Path], staging: Path, workers: int) -> list[dict]
|
|||||||
key=lambda row: (order[row["file"]], row["page"]))
|
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:
|
def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> ExitCode:
|
||||||
if workers < 1:
|
if workers < 1:
|
||||||
raise CliError("Le nombre de traitements parallèles doit être positif.",
|
raise CliError("Le nombre de traitements parallèles doit être positif.",
|
||||||
@@ -109,9 +129,14 @@ def run(workspace: EvaluationWorkspace, target: Path, *, workers: int = 5) -> Ex
|
|||||||
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
|
(backup/"report.json").write_text(json.dumps(records, ensure_ascii=False, indent=2),
|
||||||
encoding="utf-8")
|
encoding="utf-8")
|
||||||
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
|
print(f"Sauvegarde des PDF non rognés : {originals}", flush=True)
|
||||||
cropped = sum(row["top_removed_mm"]+row["bottom_removed_mm"] > 0 for row in records)
|
cropped, mean_percentage, over_thirty = crop_statistics(records)
|
||||||
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
|
print(f"Terminé : {cropped}/{len(records)} pages rognées ; "
|
||||||
f"{len(files)} PDF remplacés dans Copies.", flush=True)
|
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
|
return ExitCode.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ from pathlib import Path
|
|||||||
from copienator import (
|
from copienator import (
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
ExitCode,
|
ExitCode,
|
||||||
|
configuration,
|
||||||
evaluation_parser,
|
evaluation_parser,
|
||||||
execute,
|
execute,
|
||||||
read_json,
|
read_json,
|
||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.platform import replace_with_link_or_copy, safe_filename
|
from copienator.platform import replace_with_link_or_copy, safe_filename
|
||||||
|
from copienator.return_answers import publish_answer_returns
|
||||||
|
|
||||||
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
|
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
|
||||||
|
|
||||||
@@ -94,9 +96,10 @@ def prepare_named_returns(
|
|||||||
fallback = fallback_annotations / f"Copie{copy_id}"
|
fallback = fallback_annotations / f"Copie{copy_id}"
|
||||||
source_folder = None
|
source_folder = None
|
||||||
for candidate in (selected, fallback):
|
for candidate in (selected, fallback):
|
||||||
if (candidate / "Concat.jpg").exists() and (
|
if (candidate / "score.json").is_file() and (
|
||||||
candidate / "score.json"
|
(candidate / "Concat.jpg").is_file()
|
||||||
).exists():
|
or (candidate / "info.json").is_file()
|
||||||
|
):
|
||||||
source_folder = candidate
|
source_folder = candidate
|
||||||
break
|
break
|
||||||
if source_folder is None:
|
if source_folder is None:
|
||||||
@@ -105,19 +108,31 @@ def prepare_named_returns(
|
|||||||
assigned_names.add(name)
|
assigned_names.add(name)
|
||||||
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
|
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
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 = (
|
links = (
|
||||||
("Concat.jpg", f"{safe_name}.jpg"),
|
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
|
||||||
("Concat_F.pdf", f"{safe_name}.pdf"),
|
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
|
||||||
("score.json", "score.json"),
|
("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
|
source = source_folder / source_name
|
||||||
if not source.exists():
|
target = destination / destination_name
|
||||||
continue
|
|
||||||
try:
|
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(
|
method = replace_with_link_or_copy(
|
||||||
source,
|
source,
|
||||||
destination / destination_name,
|
target,
|
||||||
prefer="symlink",
|
prefer="symlink",
|
||||||
)
|
)
|
||||||
if method == "copy":
|
if method == "copy":
|
||||||
@@ -163,4 +178,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from copienator import (
|
|||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
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.annotation_data import AnnotationData, load_annotation_data
|
||||||
from copienator.filesystem import staged_files
|
from copienator.filesystem import staged_files
|
||||||
|
|
||||||
@@ -148,11 +149,12 @@ def apply_actions_and_regenerate(
|
|||||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||||
|
|
||||||
labels_data = data[student_id]
|
labels_data = data[student_id]
|
||||||
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
|
apply_checkbox_actions(labels_data, actions, print)
|
||||||
if update_score:
|
if update_score:
|
||||||
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
|
apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||||
|
|
||||||
scores = dict.fromkeys(all_labels, "")
|
scores = dict.fromkeys(all_labels, "")
|
||||||
|
answer_labels: list[str] = []
|
||||||
dirty_images: dict[str, Image.Image] = {}
|
dirty_images: dict[str, Image.Image] = {}
|
||||||
concatenated: list[Image.Image] = []
|
concatenated: list[Image.Image] = []
|
||||||
filtered: list[Image.Image] = []
|
filtered: list[Image.Image] = []
|
||||||
@@ -169,6 +171,8 @@ def apply_actions_and_regenerate(
|
|||||||
content = labels_data[label]
|
content = labels_data[label]
|
||||||
result = content["result"]
|
result = content["result"]
|
||||||
scores[label] = str(result.get("score", 0))
|
scores[label] = str(result.get("score", 0))
|
||||||
|
if result.get("error") == "empty-answer":
|
||||||
|
continue
|
||||||
|
|
||||||
sub_note = None
|
sub_note = None
|
||||||
if notes_layer is not None:
|
if notes_layer is not None:
|
||||||
@@ -204,18 +208,22 @@ def apply_actions_and_regenerate(
|
|||||||
body = sub_note.crop((0, old_header_height, width, height))
|
body = sub_note.crop((0, old_header_height, width, height))
|
||||||
final_image.paste(body, (0, new_header_height), mask=body)
|
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)
|
concatenated.append(final_image)
|
||||||
if float(scores[label]) != 4.0 or result.get("feedback", []):
|
if float(scores[label]) != 4.0 or result.get("feedback", []):
|
||||||
filtered.append(final_image)
|
filtered.append(final_image)
|
||||||
|
|
||||||
concat_image = concatenate(concatenated)
|
concat_image = concatenate(concatenated)
|
||||||
filtered_image = concatenate(filtered)
|
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():
|
for label, image in dirty_images.items():
|
||||||
image.save(staging / f"{label}.jpg")
|
image.save(staging / f"{label}.jpg")
|
||||||
atomic_write_json(staging / "score.json", scores)
|
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:
|
if concat_image is not None:
|
||||||
concat_image.save(staging / "Concat.jpg")
|
concat_image.save(staging / "Concat.jpg")
|
||||||
if filtered_image is not None:
|
if filtered_image is not None:
|
||||||
@@ -284,4 +292,3 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from copienator import (
|
|||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
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.annotation_data import AnnotationData, RefaireList, load_annotation_data
|
||||||
from copienator.commands import annotating
|
from copienator.commands import annotating
|
||||||
from copienator.commands.reading_annotations import (
|
from copienator.commands.reading_annotations import (
|
||||||
@@ -189,14 +190,13 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
||||||
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
||||||
labels_data = data.get(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)
|
||||||
if update_score:
|
if update_score:
|
||||||
dirty_labels |= apply_score_overrides(
|
apply_score_overrides(
|
||||||
labels_data, output_dir / "score.json", logs.append
|
labels_data, output_dir / "score.json", logs.append
|
||||||
)
|
)
|
||||||
|
|
||||||
selected_labels = selected_labels if selected_labels is not None else set()
|
selected_labels = selected_labels if selected_labels is not None else set()
|
||||||
dirty_labels |= selected_labels
|
|
||||||
simple_layout = None
|
simple_layout = None
|
||||||
simple_annotated = None
|
simple_annotated = None
|
||||||
if selected_labels and annotation_dir == "Anot":
|
if selected_labels and annotation_dir == "Anot":
|
||||||
@@ -239,6 +239,8 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
scores = dict.fromkeys(all_labels, "")
|
scores = dict.fromkeys(all_labels, "")
|
||||||
|
touched = dict.fromkeys(all_labels, False)
|
||||||
|
answer_labels: list[str] = []
|
||||||
dirty_images: dict[str, Image.Image] = {}
|
dirty_images: dict[str, Image.Image] = {}
|
||||||
concat_images: list[Image.Image] = []
|
concat_images: list[Image.Image] = []
|
||||||
filtered_groups: list[list[Image.Image]] = []
|
filtered_groups: list[list[Image.Image]] = []
|
||||||
@@ -255,6 +257,9 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
):
|
):
|
||||||
result["score"] = old_scores[label]
|
result["score"] = old_scores[label]
|
||||||
scores[label] = str(result.get("score", 0))
|
scores[label] = str(result.get("score", 0))
|
||||||
|
touched[label] = False
|
||||||
|
if result.get("error") == "empty-answer":
|
||||||
|
continue
|
||||||
saved_image = output_dir / f"{label}.jpg"
|
saved_image = output_dir / f"{label}.jpg"
|
||||||
if selected_labels and label not in selected_labels and saved_image.is_file():
|
if selected_labels and label not in selected_labels and saved_image.is_file():
|
||||||
with Image.open(saved_image) as saved:
|
with Image.open(saved_image) as saved:
|
||||||
@@ -271,12 +276,14 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
scores[label] = str(old_scores.get(label, scores[label]))
|
scores[label] = str(old_scores.get(label, scores[label]))
|
||||||
concat_images.append(final_image)
|
concat_images.append(final_image)
|
||||||
|
answer_labels.append(label)
|
||||||
# Keep previously reviewed content, including handwriting.
|
# Keep previously reviewed content, including handwriting.
|
||||||
if annotation_dir == "BGnot":
|
if annotation_dir == "BGnot":
|
||||||
extras = get_extra_pdfs_as_images(
|
extras = get_extra_pdfs_as_images(
|
||||||
workspace.root, label, annotating, all_labels
|
workspace.root, label, annotating, all_labels
|
||||||
)
|
)
|
||||||
filtered_groups.append([*extras, final_image])
|
filtered_groups.append([*extras, final_image])
|
||||||
|
touched[label] = True
|
||||||
else:
|
else:
|
||||||
filtered_groups.append([final_image])
|
filtered_groups.append([final_image])
|
||||||
continue
|
continue
|
||||||
@@ -313,9 +320,9 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
body = sub_note.crop((0, old_header_height, width, height))
|
body = sub_note.crop((0, old_header_height, width, height))
|
||||||
final_image.paste(body, (0, new_header_height), mask=body)
|
final_image.paste(body, (0, new_header_height), mask=body)
|
||||||
|
|
||||||
if label in dirty_labels or has_notes or selected_labels:
|
# Persist every final block, including unchanged answers, for returns.
|
||||||
dirty_images[label] = final_image
|
dirty_images[label] = final_image
|
||||||
logs.append(f" Saved dirty image: {label}.jpg")
|
answer_labels.append(label)
|
||||||
concat_images.append(final_image)
|
concat_images.append(final_image)
|
||||||
|
|
||||||
feedbacks = result.get("feedback", [])
|
feedbacks = result.get("feedback", [])
|
||||||
@@ -329,11 +336,13 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
filtered_groups.append([*extras, final_image])
|
filtered_groups.append([*extras, final_image])
|
||||||
|
touched[label] = annotation_dir == "BGnot"
|
||||||
|
|
||||||
concat_image = concatenate(concat_images)
|
concat_image = concatenate(concat_images)
|
||||||
if incomplete:
|
if incomplete:
|
||||||
return ExitCode.PARTIAL, "\n".join(logs)
|
return ExitCode.PARTIAL, "\n".join(logs)
|
||||||
with staged_files(output_dir, remove=("Concat_F.pdf", "Concat_F.jpg")) as staging:
|
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:
|
if simple_layout is not None:
|
||||||
simple_layout["replaced"] = sorted(
|
simple_layout["replaced"] = sorted(
|
||||||
set(simple_layout["replaced"]) | selected_labels
|
set(simple_layout["replaced"]) | selected_labels
|
||||||
@@ -342,6 +351,9 @@ def apply_actions_and_regenerate_grouped(
|
|||||||
for label, image in dirty_images.items():
|
for label, image in dirty_images.items():
|
||||||
image.save(staging / f"{label}.jpg")
|
image.save(staging / f"{label}.jpg")
|
||||||
atomic_write_json(staging / "score.json", scores)
|
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:
|
if concat_image is not None:
|
||||||
concat_image.save(staging / "Concat.jpg")
|
concat_image.save(staging / "Concat.jpg")
|
||||||
if filtered_groups:
|
if filtered_groups:
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ else:
|
|||||||
|
|
||||||
# Keep new optional settings compatible with older personal configuration files.
|
# Keep new optional settings compatible with older personal configuration files.
|
||||||
ALWAYS_CROP = False
|
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
|
||||||
for _name in dir(_configuration):
|
for _name in dir(_configuration):
|
||||||
if not _name.startswith("_"):
|
if not _name.startswith("_"):
|
||||||
globals()[_name] = getattr(_configuration, _name)
|
globals()[_name] = getattr(_configuration, _name)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def publish_answer_returns(root: Path, source: Path, destination: Path) -> None:
|
||||||
|
"""Publish individual reviewed answers and per-question information."""
|
||||||
|
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:
|
||||||
|
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 index, label in enumerate(sorted(labels, key=utils.natural_key), 1):
|
||||||
|
paths = []
|
||||||
|
if configuration.RETURN_ANSWERS_CONTEXT:
|
||||||
|
paths.extend(utils.pdf_images_of_contexts(root, label, all_labels))
|
||||||
|
if configuration.RETURN_ANSWERS_QUESTION:
|
||||||
|
paths.append(utils.pdf_image_of_enonce(root, label))
|
||||||
|
if configuration.RETURN_ANSWERS_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)
|
||||||
|
# Numbering avoids collisions between sanitized label filenames.
|
||||||
|
image.save(staging / f"{index:03d} - {safe_filename(label)}.jpg")
|
||||||
|
elif answers_dir.exists():
|
||||||
|
# Replace the managed directory with an empty one to remove stale exports.
|
||||||
|
with staged_directory(answers_dir):
|
||||||
|
pass
|
||||||
|
atomic_write_json(destination / "info.json", info)
|
||||||
|
(destination / "touched.json").unlink(missing_ok=True)
|
||||||
+88
-21
@@ -60,6 +60,65 @@ ANNOTATION_VARIANT_DIRECTORIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Tooltip:
|
||||||
|
"""Small delayed tooltip for Tk and ttk widgets."""
|
||||||
|
|
||||||
|
def __init__(self, widget: tk.Widget, text: str, delay_ms: int = 450) -> None:
|
||||||
|
self.widget = widget
|
||||||
|
self.text = text
|
||||||
|
self.delay_ms = delay_ms
|
||||||
|
self._after_id: str | None = None
|
||||||
|
self.window: tk.Toplevel | None = None
|
||||||
|
widget.bind("<Enter>", self._schedule, add="+")
|
||||||
|
widget.bind("<Leave>", self.hide, add="+")
|
||||||
|
widget.bind("<ButtonPress>", self.hide, add="+")
|
||||||
|
|
||||||
|
def _schedule(self, _event: tk.Event | None = None) -> None:
|
||||||
|
self.hide()
|
||||||
|
self._after_id = self.widget.after(self.delay_ms, self.show)
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
self._after_id = None
|
||||||
|
if self.window is not None or not self.widget.winfo_exists():
|
||||||
|
return
|
||||||
|
self.window = tk.Toplevel(self.widget)
|
||||||
|
self.window.wm_overrideredirect(True)
|
||||||
|
self.window.attributes("-topmost", True)
|
||||||
|
x = self.widget.winfo_rootx() + 12
|
||||||
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
|
||||||
|
self.window.wm_geometry(f"+{x}+{y}")
|
||||||
|
tk.Label(
|
||||||
|
self.window,
|
||||||
|
text=self.text,
|
||||||
|
justify="left",
|
||||||
|
wraplength=430,
|
||||||
|
background="#fffbd8",
|
||||||
|
foreground="#202020",
|
||||||
|
relief="solid",
|
||||||
|
borderwidth=1,
|
||||||
|
padx=8,
|
||||||
|
pady=5,
|
||||||
|
).pack()
|
||||||
|
|
||||||
|
def hide(self, _event: tk.Event | None = None) -> None:
|
||||||
|
if self._after_id is not None:
|
||||||
|
try:
|
||||||
|
self.widget.after_cancel(self._after_id)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
self._after_id = None
|
||||||
|
if self.window is not None:
|
||||||
|
self.window.destroy()
|
||||||
|
self.window = None
|
||||||
|
|
||||||
|
|
||||||
|
def attach_tooltip(widget: tk.Widget, text: str) -> Tooltip:
|
||||||
|
tooltip = Tooltip(widget, text)
|
||||||
|
# Keep the tooltip easy to inspect and alive for as long as its widget.
|
||||||
|
widget._copienator_tooltip = tooltip # type: ignore[attr-defined]
|
||||||
|
return tooltip
|
||||||
|
|
||||||
|
|
||||||
def copy_pdf_paths(evaluation: Path) -> list[Path]:
|
def copy_pdf_paths(evaluation: Path) -> list[Path]:
|
||||||
"""List the most relevant version of each scanned copy."""
|
"""List the most relevant version of each scanned copy."""
|
||||||
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
|
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
|
||||||
@@ -231,19 +290,33 @@ class CopienatorApp(tk.Tk):
|
|||||||
)
|
)
|
||||||
environment = ttk.Frame(top)
|
environment = ttk.Frame(top)
|
||||||
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
|
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
|
||||||
ttk.Checkbutton(
|
proxy_toggle = ttk.Checkbutton(
|
||||||
environment,
|
environment,
|
||||||
text="Utiliser le proxy HTTPS",
|
text="Utiliser le proxy HTTPS",
|
||||||
variable=self.use_proxy_var,
|
variable=self.use_proxy_var,
|
||||||
command=self._toggle_proxy,
|
command=self._toggle_proxy,
|
||||||
).pack(side="left", padx=(0, 6))
|
)
|
||||||
|
proxy_toggle.pack(side="left", padx=(0, 6))
|
||||||
|
attach_tooltip(
|
||||||
|
proxy_toggle,
|
||||||
|
"Active le proxy HTTPS configuré dans le champ voisin pour les commandes lancées par le GUI.",
|
||||||
|
)
|
||||||
self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
|
self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
|
||||||
self.proxy_entry.pack(side="left")
|
self.proxy_entry.pack(side="left")
|
||||||
ttk.Checkbutton(
|
attach_tooltip(
|
||||||
|
self.proxy_entry,
|
||||||
|
"Adresse du proxy HTTPS transmise aux commandes lorsque l’option de proxy est activée.",
|
||||||
|
)
|
||||||
|
verbose_toggle = ttk.Checkbutton(
|
||||||
environment,
|
environment,
|
||||||
text="Afficher les détails en cas d’erreur (--verbose)",
|
text="Détails d’erreur ⓘ",
|
||||||
variable=self.verbose_var,
|
variable=self.verbose_var,
|
||||||
).pack(side="left", padx=(10, 0))
|
)
|
||||||
|
verbose_toggle.pack(side="left", padx=(10, 0))
|
||||||
|
attach_tooltip(
|
||||||
|
verbose_toggle,
|
||||||
|
"Ajoute --verbose aux commandes compatibles afin d’afficher la trace complète lorsqu’une erreur inattendue survient.",
|
||||||
|
)
|
||||||
profile = "standard + personnel" if show_personal_steps else "standard"
|
profile = "standard + personnel" if show_personal_steps else "standard"
|
||||||
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
||||||
|
|
||||||
@@ -647,7 +720,9 @@ class CopienatorApp(tk.Tk):
|
|||||||
choices = spec.choices
|
choices = spec.choices
|
||||||
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
|
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
|
||||||
choices, value = self._annotation_directory_choices(step.id)
|
choices, value = self._annotation_directory_choices(step.id)
|
||||||
ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
|
argument_label = ttk.Label(self.form, text=f"{spec.label} ⓘ")
|
||||||
|
argument_label.grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
|
||||||
|
attach_tooltip(argument_label, spec.help)
|
||||||
if spec.kind == "bool":
|
if spec.kind == "bool":
|
||||||
variable: tk.Variable = tk.BooleanVar(value=bool(value))
|
variable: tk.Variable = tk.BooleanVar(value=bool(value))
|
||||||
widget = ttk.Checkbutton(self.form, variable=variable)
|
widget = ttk.Checkbutton(self.form, variable=variable)
|
||||||
@@ -672,11 +747,7 @@ class CopienatorApp(tk.Tk):
|
|||||||
)
|
)
|
||||||
self.arg_vars[spec.name] = variable
|
self.arg_vars[spec.name] = variable
|
||||||
variable.trace_add("write", lambda *_args: self._update_command_preview())
|
variable.trace_add("write", lambda *_args: self._update_command_preview())
|
||||||
if spec.help:
|
attach_tooltip(widget, spec.help)
|
||||||
ttk.Label(self.form, text=spec.help, foreground="#666666", wraplength=540).grid(
|
|
||||||
row=row + 1, column=1, sticky="w"
|
|
||||||
)
|
|
||||||
row += 1
|
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
show_extra = bool(step.extra_arguments_help) and (
|
show_extra = bool(step.extra_arguments_help) and (
|
||||||
@@ -684,18 +755,14 @@ class CopienatorApp(tk.Tk):
|
|||||||
or variant.id in step.extra_arguments_variants
|
or variant.id in step.extra_arguments_variants
|
||||||
)
|
)
|
||||||
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION:
|
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION:
|
||||||
ttk.Label(self.form, text="Arguments supplémentaires").grid(
|
extra_label = ttk.Label(self.form, text="Arguments supplémentaires ⓘ")
|
||||||
|
extra_label.grid(
|
||||||
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
|
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
|
||||||
)
|
)
|
||||||
ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
extra_entry = ttk.Entry(self.form, textvariable=self.extra_var)
|
||||||
row += 1
|
extra_entry.grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
||||||
ttk.Label(
|
attach_tooltip(extra_label, step.extra_arguments_help)
|
||||||
self.form,
|
attach_tooltip(extra_entry, step.extra_arguments_help)
|
||||||
text=step.extra_arguments_help,
|
|
||||||
foreground="#666666",
|
|
||||||
wraplength=540,
|
|
||||||
justify="left",
|
|
||||||
).grid(row=row, column=1, sticky="w")
|
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
if self.show_personal_steps and step.id == "statement":
|
if self.show_personal_steps and step.id == "statement":
|
||||||
|
|||||||
+151
-42
@@ -61,14 +61,14 @@ class StepDefinition:
|
|||||||
return all(variant.kind == "manual" for variant in self.variants)
|
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(
|
return ArgumentSpec(
|
||||||
"target",
|
"target",
|
||||||
"Cible",
|
"Cible",
|
||||||
kind="path",
|
kind="path",
|
||||||
default=EVALUATION,
|
default=EVALUATION,
|
||||||
positional=True,
|
positional=True,
|
||||||
help=help_text,
|
help=f"La cible peut être {help_text.rstrip('.')}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,12 +107,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
) + ((python("personal", "Énoncés et solutions personnels (SHEETINFO)", "statement-personal"),)
|
||||||
if show_personal_steps else ()),
|
if show_personal_steps else ()),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"restart",
|
"restart",
|
||||||
"Ignorer le cache (--restart)",
|
"Ignorer le cache (--restart)",
|
||||||
kind="bool",
|
kind="bool",
|
||||||
flag="--restart",
|
flag="--restart",
|
||||||
|
help="Ignore les résultats Gemini mis en cache et recommence entièrement l’analyse de l’énoncé.",
|
||||||
variants=("gemini",),
|
variants=("gemini",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -124,7 +125,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Facultatif après la génération : remplace les groupes par exercice par des groupes "
|
"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.",
|
"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",)),),
|
(python("default", "Groupes Gemini", "statement", fixed_args=("--groups-only",)),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
|
optional=True, personal=True, requires=("labels", "Text2", "Sol2"),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -132,7 +133,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Facultatif : remplace Persp par des barèmes Gemini sur 4 points, pour les groupes actuels. "
|
"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.",
|
"Les énoncés et les solutions personnels sont conservés.",
|
||||||
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
|
(python("default", "Barèmes Gemini", "statement", fixed_args=("--persp-only",)),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
|
optional=True, personal=True, requires=("labels", "label_groups", "Text2", "Sol2"),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -160,7 +161,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
supports_verbose=True,
|
supports_verbose=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -179,7 +180,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
supports_verbose=True,
|
supports_verbose=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
"page_splitter",
|
"page_splitter",
|
||||||
@@ -187,7 +188,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Séparer et réordonner les pages",
|
"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.",
|
"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"),),
|
(python("default", "Séparation des pages", "page-split"),),
|
||||||
arguments=(arg_target(), ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked")),
|
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"),
|
artifacts=("Copies", "Copies Originales"),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -198,8 +208,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"puis remplace les PDF dans Copies. Les versions non rognées sont sauvegardées. "
|
"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.",
|
"À effectuer avant la détection des labels. Traite plusieurs copies en parallèle.",
|
||||||
(python("default", "Rognage des zones vides", "crop-margins"),),
|
(python("default", "Rognage des zones vides", "crop-margins"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation ou PDF dans Copies"),
|
arguments=(
|
||||||
ArgumentSpec("workers", "Copies traitées en parallèle", "int", "--workers", default=5)),
|
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,
|
optional=True,
|
||||||
requires=("Copies",),
|
requires=("Copies",),
|
||||||
auto_start_first_visit=ALWAYS_CROP,
|
auto_start_first_visit=ALWAYS_CROP,
|
||||||
@@ -212,8 +231,20 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
(python("default", "Découpe", "crop-labels"),),
|
(python("default", "Découpe", "crop-labels"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target(),
|
arg_target(),
|
||||||
ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"),
|
ArgumentSpec(
|
||||||
ArgumentSpec("marked", "Copies signalées uniquement", "bool", "--marked"),
|
"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",),
|
requires=("Copies",),
|
||||||
artifacts=("Cutleft",),
|
artifacts=("Cutleft",),
|
||||||
@@ -226,7 +257,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
(python("default", "Détection des labels", "labels"),),
|
(python("default", "Détection des labels", "labels"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target(),
|
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"),
|
requires=("labels", "Copies", "Cutleft"),
|
||||||
artifacts=("Copies/*.json",),
|
artifacts=("Copies/*.json",),
|
||||||
@@ -265,13 +302,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
|
"sauvegardés et plusieurs fichiers sont analysés en parallèle.",
|
||||||
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
|
(python("default", "Rognage du bas", "crop-answer-bottoms"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"workers",
|
"workers",
|
||||||
"PDF traités en parallèle",
|
"PDF traités en parallèle",
|
||||||
"int",
|
"int",
|
||||||
"--workers",
|
"--workers",
|
||||||
default=5,
|
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,
|
optional=True,
|
||||||
@@ -284,7 +322,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Regrouper les réponses",
|
"Regrouper les réponses",
|
||||||
"Regroupe les réponses portant le même label pour préparer les requêtes.",
|
"Regroupe les réponses portant le même label pour préparer les requêtes.",
|
||||||
(python("default", "Regroupement", "group-answers"),),
|
(python("default", "Regroupement", "group-answers"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
requires=("Copies",),
|
requires=("Copies",),
|
||||||
artifacts=("Par label",),
|
artifacts=("Par label",),
|
||||||
auto_start_first_visit=True,
|
auto_start_first_visit=True,
|
||||||
@@ -308,12 +346,13 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
|
python("reset", "Réinitialiser les corrections", "correct", fixed_args=("--reset",), dangerous=True),
|
||||||
),
|
),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Évaluation ou image Group_X.jpg"),
|
arg_target("le dossier de l’évaluation ou une image Group_X.jpg"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"overwrite",
|
"overwrite",
|
||||||
"Écraser les corrections existantes",
|
"Écraser les corrections existantes",
|
||||||
"bool",
|
"bool",
|
||||||
"--overwrite",
|
"--overwrite",
|
||||||
|
help="Relance les corrections demandées même lorsqu’un résultat existe déjà.",
|
||||||
variants=("live", "batch", "hybrid", "refaire"),
|
variants=("live", "batch", "hybrid", "refaire"),
|
||||||
),
|
),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
@@ -321,9 +360,17 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Limite d’appels Pro",
|
"Limite d’appels Pro",
|
||||||
"int",
|
"int",
|
||||||
"--limit",
|
"--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"),
|
variants=("live", "hybrid", "refaire"),
|
||||||
),
|
),
|
||||||
ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)),
|
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"),
|
requires=("Par label", "Persp", "labels"),
|
||||||
artifacts=("correction.json", "batch_requests_*.jsonl"),
|
artifacts=("correction.json", "batch_requests_*.jsonl"),
|
||||||
@@ -339,7 +386,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Envoyer les batchs",
|
"Envoyer les batchs",
|
||||||
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
|
"Envoie à Gemini les fichiers JSONL produits par le mode batch.",
|
||||||
(python("default", "Envoi", "batch-submit"),),
|
(python("default", "Envoi", "batch-submit"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
artifacts=("batch_jobs.json",),
|
artifacts=("batch_jobs.json",),
|
||||||
skip_for_live_correction=True,
|
skip_for_live_correction=True,
|
||||||
@@ -351,13 +398,19 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.",
|
"Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.",
|
||||||
(python("default", "État des batchs", "batch-status"),),
|
(python("default", "État des batchs", "batch-status"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
ArgumentSpec("download", "Télécharger le job", "text", "--download"),
|
ArgumentSpec(
|
||||||
|
"download",
|
||||||
|
"Télécharger le job",
|
||||||
|
"text",
|
||||||
|
"--download",
|
||||||
|
help="Saisissez l’identifiant complet d’un job Gemini terminé pour télécharger son fichier de résultats.",
|
||||||
|
),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"output",
|
"output",
|
||||||
"Fichier JSONL de destination",
|
"Fichier JSONL de destination",
|
||||||
"path",
|
"path",
|
||||||
"--output",
|
"--output",
|
||||||
help="Utilisé avec un identifiant de téléchargement.",
|
help="Choisissez le fichier JSONL dans lequel enregistrer le job téléchargé. Ce champ n’est utilisé que si un identifiant de job est fourni.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
optional=True,
|
optional=True,
|
||||||
@@ -369,7 +422,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Récupérer les résultats batch",
|
"Récupérer les résultats batch",
|
||||||
"Télécharge et rassemble les réponses des jobs terminés.",
|
"Télécharge et rassemble les réponses des jobs terminés.",
|
||||||
(python("default", "Récupération", "batch-fetch"),),
|
(python("default", "Récupération", "batch-fetch"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
skip_for_live_correction=True,
|
skip_for_live_correction=True,
|
||||||
),
|
),
|
||||||
@@ -379,7 +432,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Nettoyer la correction",
|
"Nettoyer la correction",
|
||||||
"Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.",
|
"Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.",
|
||||||
(python("default", "Post-correction", "post-correction"),),
|
(python("default", "Post-correction", "post-correction"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
requires=("correction.json",),
|
requires=("correction.json",),
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -388,7 +441,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Résoudre les conflits manuels",
|
"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.",
|
||||||
(python("default", "Résolution", "resolve-manual"),),
|
(python("default", "Résolution", "resolve-manual"),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
requires=("manual_resolutions.txt", "correction.json"),
|
requires=("manual_resolutions.txt", "correction.json"),
|
||||||
skip_without_manual_conflicts=True,
|
skip_without_manual_conflicts=True,
|
||||||
@@ -404,9 +457,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
|
python("grouped", "Annotations groupées (BGnot)", "annotate-grouped"),
|
||||||
),
|
),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"),
|
ArgumentSpec(
|
||||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks", "grouped")),
|
"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",),
|
requires=("correction.json",),
|
||||||
artifacts=("Anot", "Bnot", "BGnot"),
|
artifacts=("Anot", "Bnot", "BGnot"),
|
||||||
@@ -418,16 +484,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
|
"Exporte les annotations vers le dossier EXPORT_DIR défini dans config.py.",
|
||||||
(python("default", "Export", "export"),),
|
(python("default", "Export", "export"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"annotation_dir",
|
"annotation_dir",
|
||||||
"Dossier d’annotations",
|
"Dossier d’annotations",
|
||||||
"choice",
|
"choice",
|
||||||
default="BGnot",
|
default="BGnot",
|
||||||
choices=("BGnot", "Bnot", "Anot"),
|
choices=("BGnot", "Bnot", "Anot"),
|
||||||
|
help="Choisissez le dossier d’annotations à exporter : groupées, avec cases, ou simples.",
|
||||||
positional=True,
|
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,
|
optional=True,
|
||||||
),
|
),
|
||||||
@@ -445,16 +518,23 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
|
"Copie les PDF présents dans IMPORT_DIR vers l’évaluation.",
|
||||||
(python("default", "Import", "import"),),
|
(python("default", "Import", "import"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"annotation_dir",
|
"annotation_dir",
|
||||||
"Dossier d’annotations",
|
"Dossier d’annotations",
|
||||||
"choice",
|
"choice",
|
||||||
default="BGnot",
|
default="BGnot",
|
||||||
choices=("BGnot", "Bnot", "Anot"),
|
choices=("BGnot", "Bnot", "Anot"),
|
||||||
|
help="Choisissez le dossier principal dans lequel importer les annotations manuscrites.",
|
||||||
positional=True,
|
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(
|
StepDefinition(
|
||||||
@@ -467,9 +547,22 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
python("grouped", "Lecture BGnot", "read-grouped"),
|
python("grouped", "Lecture BGnot", "read-grouped"),
|
||||||
),
|
),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"),
|
ArgumentSpec(
|
||||||
ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)),
|
"update_score",
|
||||||
|
"Réappliquer les score.json",
|
||||||
|
"bool",
|
||||||
|
"--update-score",
|
||||||
|
help="Réutilise les valeurs présentes dans les fichiers score.json pour remplacer les scores lus dans les annotations.",
|
||||||
|
),
|
||||||
|
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(
|
ArgumentSpec(
|
||||||
"annotation_dir",
|
"annotation_dir",
|
||||||
"Passage principal du mode refaire",
|
"Passage principal du mode refaire",
|
||||||
@@ -477,6 +570,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"--annotation-dir",
|
"--annotation-dir",
|
||||||
default="BGnot",
|
default="BGnot",
|
||||||
choices=("BGnot", "Bnot", "Anot"),
|
choices=("BGnot", "Bnot", "Anot"),
|
||||||
|
help="Indique le dossier d’annotations du passage principal dans lequel intégrer les questions refaites.",
|
||||||
variants=("grouped",),
|
variants=("grouped",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -488,13 +582,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Crée le dossier A Rendre à partir du dossier d’annotations choisi.",
|
"Crée le dossier A Rendre à partir du dossier d’annotations choisi.",
|
||||||
(python("default", "Attribution des noms", "giving-names"),),
|
(python("default", "Attribution des noms", "giving-names"),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec(
|
ArgumentSpec(
|
||||||
"annotation_dir",
|
"annotation_dir",
|
||||||
"Dossier d’annotations",
|
"Dossier d’annotations",
|
||||||
"choice",
|
"choice",
|
||||||
default="BGnot",
|
default="BGnot",
|
||||||
choices=("BGnot", "Bnot", "Anot"),
|
choices=("BGnot", "Bnot", "Anot"),
|
||||||
|
help="Choisissez le dossier d’annotations utilisé pour construire les fichiers nommés dans A Rendre.",
|
||||||
positional=True,
|
positional=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -523,8 +618,14 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
|
"Transfère les scores, avec la possibilité de n’écrire que leur somme.",
|
||||||
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
|
(python("default", "Mise à jour ODS", "update-ods", supports_verbose=False),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("Dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"),
|
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,
|
personal=True,
|
||||||
),
|
),
|
||||||
@@ -550,7 +651,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Ajouter le score final",
|
"Ajouter le score final",
|
||||||
"Génère les fichiers de diffusion avec le score final.",
|
"Génère les fichiers de diffusion avec le score final.",
|
||||||
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
personal=True,
|
personal=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -576,7 +677,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Nettoyer les fichiers intermédiaires",
|
"Nettoyer les fichiers intermédiaires",
|
||||||
"Supprime définitivement les fichiers permettant de reprendre le parcours. "
|
"Supprime définitivement les fichiers permettant de reprendre le parcours. "
|
||||||
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
|
"Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, "
|
||||||
"les journaux, ainsi que les images et score.json de A Rendre.",
|
"les journaux, ainsi que les images, PDF, score.json et info.json de A Rendre.",
|
||||||
(
|
(
|
||||||
python(
|
python(
|
||||||
"default",
|
"default",
|
||||||
@@ -590,8 +691,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"seront supprimés. Il ne sera plus possible de reprendre une "
|
"seront supprimés. Il ne sera plus possible de reprendre une "
|
||||||
"étape sans régénérer ses données.\n\n"
|
"étape sans régénérer ses données.\n\n"
|
||||||
"Les PDF traités, les fichiers textuels de l’énoncé, "
|
"Les PDF traités, les fichiers textuels de l’énoncé, "
|
||||||
"correction.json, les journaux, ainsi que les images et "
|
"correction.json, les journaux, ainsi que les images, PDF et "
|
||||||
"score.json de A Rendre seront conservés.\n\n"
|
"score.json et info.json de A Rendre seront conservés.\n\n"
|
||||||
"Continuer ?"
|
"Continuer ?"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -602,7 +703,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
fixed_args=("--dry-run",),
|
fixed_args=("--dry-run",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
arguments=(arg_target("Dossier de l’évaluation"),),
|
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||||
optional=True,
|
optional=True,
|
||||||
requires=("Copies", "correction.json", "A Rendre"),
|
requires=("Copies", "correction.json", "A Rendre"),
|
||||||
),
|
),
|
||||||
@@ -635,7 +736,15 @@ def build_refaire_workflow() -> list[StepDefinition]:
|
|||||||
for suffix, title, description, program, flags, optional in definitions:
|
for suffix, title, description, program, flags, optional in definitions:
|
||||||
arguments = (arg_target(),) if program else ()
|
arguments = (arg_target(),) if program else ()
|
||||||
if suffix == "merge":
|
if suffix == "merge":
|
||||||
arguments += (ArgumentSpec("annotation_dir", "Passage principal", "choice", "--annotation-dir", default="BGnot", choices=("BGnot", "Bnot", "Anot")),)
|
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")
|
requirements = ("refaire.json", "Copies", "labels", "correction.json")
|
||||||
if suffix in {"export", "tablet", "import", "merge"}:
|
if suffix in {"export", "tablet", "import", "merge"}:
|
||||||
requirements += ("BRnot",)
|
requirements += ("BRnot",)
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
|
|||||||
EXPORT_DIR = Path("Export")
|
EXPORT_DIR = Path("Export")
|
||||||
IMPORT_DIR = Path("Import")
|
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.
|
# Les étapes gestion_classe, ODS et publication sont masquées par défaut.
|
||||||
SHOW_PERSONAL_STEPS = False
|
SHOW_PERSONAL_STEPS = False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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], ["001 - Ex 1.jpg", "002 - 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_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" / "001 - 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" / "002 - 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"})
|
||||||
|
|
||||||
|
|
||||||
|
class CompiledMembershipTests(unittest.TestCase):
|
||||||
|
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()
|
||||||
@@ -49,6 +49,8 @@ class CropExerciseBottomsCommandTests(unittest.TestCase):
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
self.assertIn("1 PDF remplacé", log.getvalue())
|
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:
|
with pymupdf.open(self.large) as cropped:
|
||||||
self.assertLess(cropped[0].rect.height, 250)
|
self.assertLess(cropped[0].rect.height, 250)
|
||||||
self.assertEqual(self.short.read_bytes(), self.short_original)
|
self.assertEqual(self.short.read_bytes(), self.short_original)
|
||||||
@@ -68,6 +70,37 @@ class CropExerciseBottomsCommandTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue((backups[0].parents[2] / "report.json").is_file())
|
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):
|
def test_detection_failure_does_not_publish_an_earlier_result(self):
|
||||||
broken = self.answers / "Ex 3.pdf"
|
broken = self.answers / "Ex 3.pdf"
|
||||||
broken.write_bytes(b"not a PDF")
|
broken.write_bytes(b"not a PDF")
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class CropMarginsCommandTests(unittest.TestCase):
|
|||||||
with contextlib.redirect_stdout(io.StringIO()) as log:
|
with contextlib.redirect_stdout(io.StringIO()) as log:
|
||||||
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
|
self.assertEqual(main(["crop-margins", str(self.workspace.root)]), 0)
|
||||||
self.assertIn("Page 2/2", log.getvalue())
|
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:
|
with pymupdf.open(self.source) as result:
|
||||||
self.assertEqual(len(result), 2)
|
self.assertEqual(len(result), 2)
|
||||||
self.assertLess(result[0].rect.height, 150)
|
self.assertLess(result[0].rect.height, 150)
|
||||||
@@ -50,6 +53,32 @@ class CropMarginsCommandTests(unittest.TestCase):
|
|||||||
self.assertEqual(backups[0].read_bytes(), self.original)
|
self.assertEqual(backups[0].read_bytes(), self.original)
|
||||||
self.assertTrue((backups[0].parent.parent/"report.json").is_file())
|
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):
|
def test_failure_or_interruption_never_publishes_partial_batch(self):
|
||||||
second = self.workspace.copies_dir/"Copie02.pdf"
|
second = self.workspace.copies_dir/"Copie02.pdf"
|
||||||
second.write_bytes(self.original)
|
second.write_bytes(self.original)
|
||||||
|
|||||||
@@ -153,12 +153,45 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
self.app.tree.selection_set("labels")
|
self.app.tree.selection_set("labels")
|
||||||
self.app.update()
|
self.app.update()
|
||||||
labels_text = self._label_texts(self.app.form)
|
labels_text = self._label_texts(self.app.form)
|
||||||
self.assertIn("Arguments supplémentaires", labels_text)
|
extra_label = next(
|
||||||
self.assertTrue(any("Cutleft" in text for text in labels_text))
|
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.tree.selection_set("plotting")
|
||||||
self.app.update()
|
self.app.update()
|
||||||
self.assertNotIn("Arguments supplémentaires", self._label_texts(self.app.form))
|
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):
|
def test_verbose_checkbox_updates_supported_commands(self):
|
||||||
self.app.tree.selection_set("labels")
|
self.app.tree.selection_set("labels")
|
||||||
|
|||||||
+11
-5
@@ -502,11 +502,6 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
"default",
|
"default",
|
||||||
{"target": evaluation},
|
{"target": evaluation},
|
||||||
),
|
),
|
||||||
"verify_groups": (
|
|
||||||
"verify_groups",
|
|
||||||
"default",
|
|
||||||
{"target": evaluation},
|
|
||||||
),
|
|
||||||
"annotating": (
|
"annotating": (
|
||||||
"annotation",
|
"annotation",
|
||||||
"simple",
|
"simple",
|
||||||
@@ -690,6 +685,10 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
annotations.mkdir(parents=True)
|
annotations.mkdir(parents=True)
|
||||||
atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"})
|
atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"})
|
||||||
atomic_write_json(annotations / "score.json", {"total": 10})
|
atomic_write_json(annotations / "score.json", {"total": 10})
|
||||||
|
atomic_write_json(annotations / "info.json", {
|
||||||
|
"total": {"present": False, "not_empty": False, "touched": False, "score": 10}
|
||||||
|
})
|
||||||
|
(evaluation / "labels").write_text("total\n")
|
||||||
(annotations / "Concat.jpg").write_bytes(b"image")
|
(annotations / "Concat.jpg").write_bytes(b"image")
|
||||||
(evaluation / "names").write_text("Élève Test\n", encoding="utf-8")
|
(evaluation / "names").write_text("Élève Test\n", encoding="utf-8")
|
||||||
|
|
||||||
@@ -1829,6 +1828,13 @@ class WorkflowTests(unittest.TestCase):
|
|||||||
("live", "batch", "hybrid", "refaire"),
|
("live", "batch", "hybrid", "refaire"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_every_graphical_argument_has_tooltip_documentation(self) -> None:
|
||||||
|
for step in self.steps.values():
|
||||||
|
for spec in step.arguments:
|
||||||
|
with self.subTest(step=step.id, argument=spec.name):
|
||||||
|
self.assertTrue(spec.help.strip())
|
||||||
|
self.assertTrue(spec.help.rstrip().endswith("."))
|
||||||
|
|
||||||
def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None:
|
def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None:
|
||||||
status = self.command(
|
status = self.command(
|
||||||
"batch_status",
|
"batch_status",
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user