Add per label remark, post correction
This commit is contained in:
1 parent
3abd7873e6
commit
8918d64452
13 files changed
+342
-22
No files matched your search
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from copienator import atomic_write_json, configuration, read_json
|
||||
from copienator.annotation_documents import copy_documents
|
||||
from copienator.return_answers import load_return_answer_options
|
||||
from copienator.custom_remarks import load_remarks, copy_key
|
||||
|
||||
STATE_FILE = ".annotation-read-state.json"
|
||||
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
|
||||
@@ -36,7 +37,9 @@ class AnnotationReadCache:
|
||||
shared.extend(directory / name for name in DOCUMENT_INPUTS)
|
||||
common = (_files_digest(shared), load_return_answer_options(root),
|
||||
configuration.RETURN_ANSWERS_ENABLED,
|
||||
configuration.RETURN_JPEG_ENABLED, configuration.RETURN_PDF_ENABLED,
|
||||
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
|
||||
remarks = load_remarks(root)
|
||||
for copy_id, labels in data.items():
|
||||
directory = root / mode / f"Copie{copy_id}"
|
||||
paths = [directory / "score.json", directory / "bnote.json"]
|
||||
@@ -45,7 +48,7 @@ class AnnotationReadCache:
|
||||
for content in labels.values():
|
||||
pdf = Path(content["pdf_path"])
|
||||
paths.extend((pdf, pdf.with_suffix(".layout.json")))
|
||||
payload = json.dumps((1, common, labels, _files_digest(paths)),
|
||||
payload = json.dumps((2, common, labels, remarks.get(copy_key(copy_id), {}), _files_digest(paths)),
|
||||
sort_keys=True, default=str).encode()
|
||||
self.signatures[copy_id] = sha256(payload).hexdigest()
|
||||
|
||||
@@ -71,6 +74,9 @@ class AnnotationReadCache:
|
||||
outputs.extend(directory.glob("annotation_baseline.json"))
|
||||
for destination in self.workspace.return_dir.glob(f"* ({copy_id})"):
|
||||
outputs.extend((destination / "answers").glob("*.jpg"))
|
||||
stem = destination.name.rsplit(" (", 1)[0]
|
||||
outputs.extend(path for suffix in (".jpg", ".pdf")
|
||||
if (path := destination / (stem + suffix)).is_file())
|
||||
atomic_write_json(directory / STATE_FILE, {
|
||||
"signature": self.signatures[copy_id],
|
||||
"outputs": [str(path) for path in outputs],
|
||||
|
||||
@@ -79,8 +79,9 @@ def update_named_return_answers(
|
||||
annotation_dir_name: str,
|
||||
*,
|
||||
copy_ids: Collection[str] | None = None,
|
||||
refresh_named: bool = False,
|
||||
) -> ExitCode:
|
||||
"""Refresh answers/ in existing returns, optionally only successful rereads."""
|
||||
"""Refresh answers/; readers also refresh named outputs after successful rereads."""
|
||||
if copy_ids is not None and (not copy_ids or not workspace.return_dir.is_dir()):
|
||||
return ExitCode.SUCCESS
|
||||
workspace.require_directories(annotation_dir_name, "A Rendre")
|
||||
@@ -118,6 +119,18 @@ def update_named_return_answers(
|
||||
destination,
|
||||
answers_only=True,
|
||||
)
|
||||
if refresh_named:
|
||||
stem = destination.name[:match.start()].rstrip()
|
||||
for source_name, suffix, enabled in (
|
||||
("Concat.jpg", ".jpg", configuration.RETURN_JPEG_ENABLED),
|
||||
("Concat_F.pdf", ".pdf", configuration.RETURN_PDF_ENABLED),
|
||||
):
|
||||
source = source_folder / source_name
|
||||
target = destination / (stem + suffix)
|
||||
if enabled and source.is_file():
|
||||
replace_with_link_or_copy(source, target, prefer="symlink")
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
print(f"Updated answers for {destination.name} from Copie{copy_id}")
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -25,6 +25,7 @@ from copienator.annotation_data import AnnotationData, load_annotation_data
|
||||
from copienator.annotation_documents import copy_documents
|
||||
from copienator.annotation_layout import legacy_layout
|
||||
from copienator.annotation_read_cache import AnnotationReadCache
|
||||
from copienator.custom_remarks import apply_custom_remarks
|
||||
from copienator.annotation_modifications import (
|
||||
BASELINE,
|
||||
output_removals,
|
||||
@@ -175,6 +176,7 @@ def apply_actions_and_regenerate(
|
||||
|
||||
labels_data = data[student_id]
|
||||
apply_checkbox_actions(labels_data, actions, print)
|
||||
apply_custom_remarks(workspace.root, student_id, labels_data)
|
||||
score_path = output_dir / "score.json"
|
||||
preserve_score_file = update_score and score_path.is_file()
|
||||
if update_score:
|
||||
@@ -381,7 +383,7 @@ def run(
|
||||
status = ExitCode.PARTIAL
|
||||
else:
|
||||
regenerated.add(student_id)
|
||||
if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS:
|
||||
if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated, refresh_named=True) != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
elif cache is not None:
|
||||
cache.remember(regenerated)
|
||||
|
||||
@@ -28,6 +28,7 @@ from copienator.annotation_data import AnnotationData, RefaireList, load_annotat
|
||||
from copienator.annotation_documents import copy_documents, tablet_documents
|
||||
from copienator.annotation_layout import legacy_layout
|
||||
from copienator.annotation_read_cache import AnnotationReadCache
|
||||
from copienator.custom_remarks import apply_custom_remarks
|
||||
from copienator.annotation_modifications import (
|
||||
BASELINE,
|
||||
baseline,
|
||||
@@ -235,6 +236,7 @@ def apply_actions_and_regenerate_grouped(
|
||||
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
||||
labels_data = data.get(student_id, {})
|
||||
apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
apply_custom_remarks(workspace.root, student_id, labels_data)
|
||||
score_path = output_dir / "score.json"
|
||||
preserve_score_file = update_score and score_path.is_file()
|
||||
if update_score:
|
||||
@@ -700,7 +702,7 @@ def run(
|
||||
status = ExitCode.PARTIAL
|
||||
else:
|
||||
regenerated.add(futures[future])
|
||||
if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS:
|
||||
if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated, refresh_named=True) != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
elif cache is not None:
|
||||
cache.remember(regenerated)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Teacher-authored feedback, independent of the generated correction data."""
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import atomic_update_json, read_json
|
||||
|
||||
FILENAME = "custom_remarks.json"
|
||||
|
||||
|
||||
def copy_key(copy_id: str) -> str:
|
||||
if not copy_id.strip().isascii() or not copy_id.strip().isdecimal():
|
||||
raise ValueError("Numéro de copie invalide.")
|
||||
return str(int(copy_id))
|
||||
|
||||
|
||||
def load_remarks(root: Path) -> dict[str, dict[str, str]]:
|
||||
data = read_json(root / FILENAME, default={})
|
||||
validate(data)
|
||||
return data
|
||||
|
||||
|
||||
def validate(data):
|
||||
if not isinstance(data, dict) or any(
|
||||
not isinstance(key, str) or copy_key(key) != key
|
||||
or not isinstance(labels, dict)
|
||||
or any(not isinstance(label, str) or not isinstance(text, str)
|
||||
for label, text in labels.items())
|
||||
for key, labels in data.items()
|
||||
):
|
||||
raise ValueError(f"Format invalide dans {FILENAME}.")
|
||||
|
||||
|
||||
def remarks_for_copy(root: Path, copy_id: str) -> dict[str, str]:
|
||||
return load_remarks(root).get(copy_key(copy_id), {})
|
||||
|
||||
|
||||
def set_remark(root: Path, copy_id: str, label: str, text: str) -> str:
|
||||
key = copy_key(copy_id)
|
||||
text = text.strip()
|
||||
previous = ""
|
||||
|
||||
def update(data):
|
||||
nonlocal previous
|
||||
validate(data)
|
||||
labels = data.setdefault(key, {})
|
||||
previous = labels.get(label, "")
|
||||
if text:
|
||||
labels[label] = text
|
||||
else:
|
||||
labels.pop(label, None)
|
||||
if not labels:
|
||||
data.pop(key, None)
|
||||
|
||||
atomic_update_json(root / FILENAME, update, default_factory=dict)
|
||||
return previous
|
||||
|
||||
|
||||
def apply_custom_remarks(root: Path, copy_id: str, labels_data: dict) -> None:
|
||||
"""Append after checkbox actions; replace our own entries on repeated calls."""
|
||||
remarks = remarks_for_copy(root, copy_id)
|
||||
for label, content in labels_data.items():
|
||||
result = content["result"]
|
||||
feedback = [item for item in result.get("feedback", [])
|
||||
if not item.get("custom_remark")]
|
||||
if remarks.get(label):
|
||||
feedback.append({"text": remarks[label], "box_2d": None, "custom_remark": True})
|
||||
result["feedback"] = feedback
|
||||
+18
-3
@@ -1086,7 +1086,7 @@ class CopienatorApp(tk.Tk):
|
||||
return row
|
||||
if step.id == "edit_scores" and self.evaluation:
|
||||
self.score_editor_panel = ScoreEditorPanel(
|
||||
self.form, self.evaluation, self._score_changed
|
||||
self.form, self.evaluation, self._score_changed, self._remark_changed
|
||||
)
|
||||
self.score_editor_panel.grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
|
||||
row += 1
|
||||
@@ -1644,6 +1644,15 @@ class CopienatorApp(tk.Tk):
|
||||
if (step.is_manual or step.id == "personal_create"
|
||||
or (step.id == "personal_read" and review_command is None)):
|
||||
self._mark_step("success")
|
||||
if step.id == "edit_scores":
|
||||
entry = self.state_store.step("read_annotations")
|
||||
values = dict(entry.get("values", {}))
|
||||
values["update_score"] = True
|
||||
self.state_store.update_step("read_annotations", status="stale", values=values)
|
||||
self._populate_tree()
|
||||
self.tree.selection_set("read_annotations")
|
||||
self.tree.see("read_annotations")
|
||||
else:
|
||||
self._move_selection_from(step.id, 1)
|
||||
return
|
||||
if os.name == "nt" and step.id != "statement":
|
||||
@@ -1799,13 +1808,19 @@ class CopienatorApp(tk.Tk):
|
||||
self._populate_tree()
|
||||
self.info_var.set("Renommage validé — aucun fichier modifié.")
|
||||
|
||||
def _score_changed(self, copy_id: str, label: str, previous: str, current: str) -> None:
|
||||
def _remark_changed(self, copy_id: str, label: str, previous: str, current: str) -> None:
|
||||
self._score_changed(copy_id, label, previous, current, remark=True)
|
||||
|
||||
def _score_changed(self, copy_id: str, label: str, previous: str, current: str,
|
||||
*, remark: bool = False) -> None:
|
||||
step = self.step_by_id["edit_scores"]
|
||||
self._invalidate_after_step(step, "default")
|
||||
self.state_store.update_step(step.id, status="detected")
|
||||
self.state_store.update_step("read_annotations", status="stale")
|
||||
self.state_store.add_history({
|
||||
"step": step.id, "copy_id": copy_id, "label": label,
|
||||
"previous_score": previous, "score": current, "manual": True,
|
||||
("previous_remark" if remark else "previous_score"): previous,
|
||||
("remark" if remark else "score"): current, "manual": True,
|
||||
})
|
||||
# Refresh statuses without rebuilding the form between score edits.
|
||||
for item in self.steps:
|
||||
|
||||
@@ -8,6 +8,7 @@ from tkinter import messagebox, ttk
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_update_json, read_json
|
||||
from copienator.utils import natural_key
|
||||
from copienator.custom_remarks import remarks_for_copy, set_remark
|
||||
from .giving_names import RETURN_FOLDER_PATTERN
|
||||
|
||||
|
||||
@@ -61,10 +62,11 @@ def set_score(path: Path, label: str, value: str) -> str:
|
||||
|
||||
|
||||
class ScoreEditorPanel(ttk.Frame):
|
||||
def __init__(self, parent, evaluation: Path, on_change):
|
||||
def __init__(self, parent, evaluation: Path, on_change, on_remark_change=None):
|
||||
super().__init__(parent)
|
||||
self.evaluation = evaluation
|
||||
self.on_change = on_change
|
||||
self.on_remark_change = on_remark_change
|
||||
self.current_path = None
|
||||
self.busy = False
|
||||
self.id_var = tk.StringVar()
|
||||
@@ -81,10 +83,17 @@ class ScoreEditorPanel(ttk.Frame):
|
||||
ttk.Label(self, text="Score").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=4)
|
||||
self.score_entry = ttk.Entry(self, textvariable=self.score_var)
|
||||
self.score_entry.grid(row=2, column=1, sticky="ew", pady=4)
|
||||
self.set_button = ttk.Button(self, text="Set", command=self.save_score)
|
||||
self.set_button = ttk.Button(self, text="Enregistrer le score", command=self.save_score)
|
||||
self.set_button.grid(row=2, column=2, padx=(8, 0), pady=4)
|
||||
ttk.Label(self, text="Remarque personnelle").grid(row=3, column=0, sticky="nw", pady=4)
|
||||
self.remark_entry = tk.Text(self, height=4, width=45, wrap="word", undo=True)
|
||||
self.remark_entry.grid(row=3, column=1, sticky="ew", pady=4)
|
||||
self.remark_button = ttk.Button(self, text="Enregistrer la remarque", command=self.save_remark)
|
||||
self.remark_button.grid(row=3, column=2, padx=(8, 0), pady=4)
|
||||
ttk.Label(self, text="Videz le champ puis enregistrez pour supprimer votre remarque.").grid(
|
||||
row=4, column=1, columnspan=2, sticky="w")
|
||||
ttk.Label(self, textvariable=self.status_var, wraplength=650).grid(
|
||||
row=3, column=0, columnspan=3, sticky="w", pady=8
|
||||
row=5, column=0, columnspan=3, sticky="w", pady=8
|
||||
)
|
||||
self.id_var.trace_add("write", self.load_score)
|
||||
self.label_var.trace_add("write", self.load_score)
|
||||
@@ -94,6 +103,8 @@ class ScoreEditorPanel(ttk.Frame):
|
||||
def load_score(self, *_args):
|
||||
self.current_path = None
|
||||
self.score_var.set("")
|
||||
self.remark_entry.configure(state="normal")
|
||||
self.remark_entry.delete("1.0", "end")
|
||||
self.label_entry.configure(values=())
|
||||
try:
|
||||
path = score_path_for_id(self.evaluation, self.id_var.get())
|
||||
@@ -105,11 +116,14 @@ class ScoreEditorPanel(ttk.Frame):
|
||||
elif label not in scores:
|
||||
self.status_var.set(f"Label absent de score.json : {label}")
|
||||
else:
|
||||
remark = remarks_for_copy(self.evaluation, self.id_var.get()).get(label, "")
|
||||
self.remark_entry.insert("1.0", remark)
|
||||
self.current_path = path
|
||||
self.score_var.set(str(scores[label]))
|
||||
self.status_var.set(path.parent.name)
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_var.set(str(exc))
|
||||
self.remark_entry.edit_reset()
|
||||
self.set_busy(self.busy)
|
||||
|
||||
def set_busy(self, busy: bool):
|
||||
@@ -119,6 +133,8 @@ class ScoreEditorPanel(ttk.Frame):
|
||||
state = "normal" if self.current_path is not None and not busy else "disabled"
|
||||
self.score_entry.configure(state=state)
|
||||
self.set_button.configure(state=state)
|
||||
self.remark_entry.configure(state=state)
|
||||
self.remark_button.configure(state=state)
|
||||
|
||||
def save_score(self):
|
||||
if self.current_path is None or self.busy:
|
||||
@@ -130,6 +146,21 @@ class ScoreEditorPanel(ttk.Frame):
|
||||
self.score_var.set(current)
|
||||
if previous != current:
|
||||
self.on_change(self.id_var.get().strip(), label, previous, current)
|
||||
self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}")
|
||||
self.status_var.set(f"Score enregistré : {self.current_path.parent.name} — {label} : {current}. Régénération nécessaire.")
|
||||
except (OSError, ValueError) as exc:
|
||||
messagebox.showerror("Modification du score", str(exc))
|
||||
|
||||
def save_remark(self):
|
||||
if self.current_path is None or self.busy:
|
||||
return
|
||||
label = self.label_var.get().strip()
|
||||
try:
|
||||
if label not in read_scores(self.current_path):
|
||||
raise ValueError(f"Label absent de score.json : {label}")
|
||||
current = self.remark_entry.get("1.0", "end-1c").strip()
|
||||
previous = set_remark(self.evaluation, self.id_var.get(), label, current)
|
||||
if previous != current and self.on_remark_change:
|
||||
self.on_remark_change(self.id_var.get().strip(), label, previous, current)
|
||||
self.status_var.set("Remarque enregistrée — régénération nécessaire dans « Lire les annotations manuscrites ».")
|
||||
except (OSError, ValueError) as exc:
|
||||
messagebox.showerror("Modification de la remarque", str(exc))
|
||||
@@ -682,10 +682,10 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
arg_target("le dossier de l’évaluation"),
|
||||
ArgumentSpec(
|
||||
"update_score",
|
||||
"Régénérer avec les nouveaux énoncés/corrigés et les score.json",
|
||||
"Régénérer avec les nouveaux énoncés/corrigés, scores et remarques",
|
||||
"bool",
|
||||
"--update-score",
|
||||
help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite.",
|
||||
help="Régénère les images avec les PDF actuels d’énoncé et de correction ; pour chaque label, le score.json existant prévaut sur le score relu dans l’annotation manuscrite. Les remarques personnelles sont ajoutées aux commentaires conservés.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"refaire",
|
||||
@@ -756,10 +756,11 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
StepDefinition(
|
||||
"edit_scores",
|
||||
"Finalisation",
|
||||
"Modifier des scores",
|
||||
"Modifier des scores et remarques",
|
||||
"Saisissez l’id de copie, puis choisissez ou saisissez un label. "
|
||||
"Le score actuel est chargé automatiquement. Modifiez-le puis cliquez sur Set "
|
||||
"pour l’enregistrer dans score.json. Cliquez sur « Marquer terminée » quand vous avez fini.",
|
||||
"Le score et la remarque personnelle sont chargés automatiquement. Enregistrez chacun "
|
||||
"séparément, puis relancez « Lire les annotations manuscrites » avec « Régénérer ». "
|
||||
"Videz la remarque et enregistrez pour la supprimer. Cliquez sur « Marquer terminée » quand vous avez fini.",
|
||||
(manual("default"),),
|
||||
optional=True,
|
||||
requires=("A Rendre",),
|
||||
|
||||
@@ -455,22 +455,50 @@ ne reste à y inclure. Contrôlez donc aussi les scores et l’image
|
||||
complète, sans assimiler automatiquement une omission à une perte de
|
||||
réponse.
|
||||
|
||||
Le mode **Régénérer avec les nouveaux énoncés/corrigés et les
|
||||
score.json** sert à mettre à jour les scores sur les images finales à
|
||||
partir d'une modification manuelle des fichiers `score.json`.
|
||||
Le mode **Régénérer avec les nouveaux énoncés/corrigés, scores et remarques**
|
||||
met à jour les scores sur les images finales à partir des fichiers `score.json`
|
||||
et ajoute les remarques personnelles enregistrées pour chaque copie et label.
|
||||
Après une lecture réussie, les images `answers` des dossiers déjà présents dans
|
||||
**A Rendre** sont également régénérées, avec les compléments sélectionnés.
|
||||
Les dossiers sont identifiés par leur numéro de copie final entre parenthèses ;
|
||||
leurs noms personnalisés et les autres fichiers sont conservés.
|
||||
leurs noms personnalisés sont conservés. Lors de la lecture des annotations,
|
||||
les PDF/JPEG portant le nom du dossier (sans le suffixe de copie) sont également
|
||||
actualisés, même lorsque ce sont des copies de fichiers plutôt que des liens.
|
||||
Un PDF filtré devenu inutile est supprimé. Les autres fichiers sont conservés.
|
||||
Le mode « Mettre à jour uniquement les images de answers » de l’attribution
|
||||
des noms conserve, lui, son comportement limité aux images individuelles.
|
||||
Dans ce mode, les copies dont les fichiers et options sont inchangés sont
|
||||
ignorées avant la lecture des PDF et la génération des images. Le premier
|
||||
passage établit une empreinte par copie ; modifier un seul `score.json` ne
|
||||
régénère ensuite que cette copie. Une modification des documents partagés
|
||||
régénère ensuite que cette copie. Ajouter, modifier ou supprimer une remarque
|
||||
personnelle ne régénère également que la copie concernée. Une modification des documents partagés
|
||||
(énoncés, corrigés, annotations groupées) peut relancer plusieurs copies.
|
||||
La lecture sans cette option force une régénération ; le mode refaire conserve
|
||||
son fonctionnement habituel.
|
||||
|
||||
|
||||
### Modifier des scores et remarques
|
||||
|
||||
Après la préparation de **A Rendre**, saisissez l’id de copie et choisissez le
|
||||
label. Le score actuel et votre remarque personnelle sont chargés automatiquement.
|
||||
Les boutons **Enregistrer le score** et **Enregistrer la remarque** sont indépendants.
|
||||
La remarque accepte plusieurs lignes et le même texte LaTeX que les commentaires
|
||||
de correction. Pour la supprimer, videz le champ puis enregistrez.
|
||||
|
||||
Les remarques sont enregistrées dans `custom_remarks.json`, à la racine de
|
||||
l’évaluation, par numéro de copie et label. Elles restent distinctes de
|
||||
`correction.json` et s’ajoutent aux commentaires Gemini conservés, sous la forme
|
||||
de remarques non localisées. Une nouvelle saisie remplace votre remarque précédente.
|
||||
|
||||
Après les modifications, cliquez sur **Marquer terminée** : l’interface ouvre
|
||||
automatiquement **Lire les annotations manuscrites**, marquée **À revalider**,
|
||||
avec **Régénérer** déjà cochée. Le mode et les autres options sont conservés ;
|
||||
vérifiez le mode **Bnot** ou **BGnot**, puis cliquez sur **Exécuter**. Aucun nouvel appel à Gemini
|
||||
n’est nécessaire. La remarque apparaît dans l’en-tête et fait conserver dans le PDF
|
||||
filtré une réponse non vide qui aurait autrement été omise pour son score parfait.
|
||||
Cette régénération actualise aussi les images individuelles et les PDF/JPEG nommés
|
||||
décrits ci-dessus. Les remarques sont également conservées lors d’une lecture normale.
|
||||
|
||||
### Modifier les réponses annotées
|
||||
|
||||
Cette étape facultative se trouve après la lecture des annotations, pour **Bnot**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json
|
||||
from copienator.custom_remarks import apply_custom_remarks, set_remark, remarks_for_copy
|
||||
from copienator.commands import annotating, reading_annotations as standard
|
||||
from copienator.commands import reading_grouped_annotations as grouped
|
||||
|
||||
|
||||
class CustomRemarkTests(unittest.TestCase):
|
||||
def test_storage_preserves_other_students_and_feedback_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
set_remark(root, "01", "Ex 1", "First")
|
||||
set_remark(root, "2", "Ex 1", "Other student")
|
||||
set_remark(root, "1", "Ex 2", "Other label")
|
||||
labels = {"Ex 1": {"result": {"feedback": [{"text": "Gemini"}]}}}
|
||||
apply_custom_remarks(root, "1", labels)
|
||||
apply_custom_remarks(root, "01", labels)
|
||||
self.assertEqual([f["text"] for f in labels["Ex 1"]["result"]["feedback"]], ["Gemini", "First"])
|
||||
set_remark(root, "01", "Ex 1", " ")
|
||||
apply_custom_remarks(root, "1", labels)
|
||||
self.assertEqual(labels["Ex 1"]["result"]["feedback"], [{"text": "Gemini"}])
|
||||
self.assertEqual(remarks_for_copy(root, "1"), {"Ex 2": "Other label"})
|
||||
self.assertEqual(remarks_for_copy(root, "02"), {"Ex 1": "Other student"})
|
||||
|
||||
def test_readers_render_remark_after_checkbox_deletion_and_preserve_ink(self):
|
||||
for mode in ("Bnot", "BGnot"):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
workspace = EvaluationWorkspace(root)
|
||||
output = root / mode / "Copie01"
|
||||
output.mkdir(parents=True)
|
||||
answer = root / "answer.pdf"
|
||||
answer.touch()
|
||||
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
|
||||
atomic_write_json(output / "score.json", {"Ex 1": "4"})
|
||||
original = {"01": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0),
|
||||
"result": {"score": 4, "feedback": [{"text": "Gemini"}]}}}}
|
||||
set_remark(root, "1", "Ex 1", "Teacher")
|
||||
ink = Image.new("RGBA", (1500, 110), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(ink)
|
||||
draw.rectangle((100, 1, 120, 8), fill="red")
|
||||
draw.rectangle((100, 30, 120, 50), fill="blue")
|
||||
real_compose = annotating.compose_label_image
|
||||
def compose(*args, **kwargs):
|
||||
return real_compose(*args, **kwargs,
|
||||
render_fn=lambda text, *a, **k: Image.new("RGB", (1500, 20), "green"))
|
||||
with patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (1500, 100), "white"), 0, 0)), \
|
||||
patch.object(annotating, "render_score_text", return_value=Image.new("RGB", (1500, 10), "white")), \
|
||||
patch.object(annotating, "compose_label_image", side_effect=compose), \
|
||||
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
|
||||
def run(with_ink=True):
|
||||
data = deepcopy(original)
|
||||
actions = [{"label": "Ex 1", "type": "clear_all"}]
|
||||
if mode == "Bnot":
|
||||
# The tablet header was ten pixels tall.
|
||||
atomic_write_json(output / "bnote.json", {"images": [
|
||||
{"label": "Ex 1", "header_height": 10}]})
|
||||
return standard.apply_actions_and_regenerate(workspace, data, "01", actions,
|
||||
None, ["Ex 1"], update_score=True, label_notes={"Ex 1": ink} if with_ink else {})
|
||||
status, _ = grouped.apply_actions_and_regenerate_grouped(workspace, data, "01",
|
||||
actions, {"Ex 1": {"img": ink, "old_header_h": 10}} if with_ink else {}, ["Ex 1"], update_score=True)
|
||||
return status
|
||||
self.assertEqual(run(), ExitCode.SUCCESS)
|
||||
self.assertTrue((output / "Concat_F.pdf").is_file())
|
||||
with Image.open(output / "Ex 1.jpg") as image:
|
||||
self.assertEqual(image.height, 130)
|
||||
self.assertGreater(image.getpixel((110, 4))[0], 180)
|
||||
self.assertGreater(image.getpixel((110, 65))[2], 180)
|
||||
# Even with no handwritten ink, a personal remark includes a perfect answer.
|
||||
original["01"]["Ex 1"]["result"]["feedback"] = []
|
||||
self.assertEqual(run(with_ink=False), ExitCode.SUCCESS)
|
||||
self.assertTrue((output / "Concat_F.pdf").is_file())
|
||||
set_remark(root, "1", "Ex 1", "")
|
||||
self.assertEqual(run(), ExitCode.SUCCESS)
|
||||
with Image.open(output / "Ex 1.jpg") as image:
|
||||
self.assertEqual(image.height, 110)
|
||||
self.assertEqual(run(with_ink=False), ExitCode.SUCCESS)
|
||||
self.assertFalse((output / "Concat_F.pdf").exists())
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import read_json
|
||||
from copienator.custom_remarks import remarks_for_copy
|
||||
from copienator_gui.app import CopienatorApp
|
||||
from copienator_gui.scores import score_path_for_id, set_score
|
||||
from copienator_gui.workflow import build_workflow
|
||||
@@ -111,3 +112,39 @@ class ScoreEditorGuiTests(ScoreEditorTests):
|
||||
self.app._skip_step()
|
||||
self.assertEqual(self.app.state_store.step("edit_scores")["status"], "skipped")
|
||||
self.assertEqual(self.app.state_store.step("final_score")["status"], "success")
|
||||
|
||||
def test_remark_saves_independently_reloads_and_clears(self):
|
||||
self.panel.id_var.set("1")
|
||||
self.panel.label_var.set("Ex 1")
|
||||
self.panel.score_var.set("bad unsaved score")
|
||||
self.panel.remark_entry.insert("1.0", "Justifier.\nDétailler le calcul.")
|
||||
self.panel.remark_button.invoke()
|
||||
self.assertEqual(read_json(self.path)["Ex 1"], "2")
|
||||
self.assertEqual(remarks_for_copy(self.evaluation, "01")["Ex 1"], "Justifier.\nDétailler le calcul.")
|
||||
self.assertEqual(self.app.state_store.step("read_annotations")["status"], "stale")
|
||||
self.panel.label_var.set("Ex 2")
|
||||
self.assertEqual(self.panel.remark_entry.get("1.0", "end-1c"), "")
|
||||
self.panel.label_var.set("Ex 1")
|
||||
self.assertIn("Justifier.", self.panel.remark_entry.get("1.0", "end-1c"))
|
||||
self.panel.remark_entry.delete("1.0", "end")
|
||||
self.panel.remark_button.invoke()
|
||||
self.assertEqual(remarks_for_copy(self.evaluation, "1"), {})
|
||||
self.panel.set_busy(True)
|
||||
self.assertEqual(str(self.panel.remark_button.cget("state")), "disabled")
|
||||
|
||||
def test_completing_editor_selects_regeneration_and_preserves_options(self):
|
||||
self.app.state_store.update_step("read_annotations", status="success", variant="grouped",
|
||||
values={"update_score": False, "return_answers_solution": False, "annotation_dir": "BGnot"})
|
||||
with patch.object(self.app.runner, "start") as start:
|
||||
self.app.run_button.invoke()
|
||||
self.app.update()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(self.app.state_store.step("edit_scores")["status"], "success")
|
||||
self.assertEqual(self.app.current_step.id, "read_annotations")
|
||||
self.assertEqual(self.app.tree.selection(), ("read_annotations",))
|
||||
self.assertEqual(self.app.tree.item("read_annotations", "values"), ("À revalider",))
|
||||
self.assertTrue(self.app.arg_vars["update_score"].get())
|
||||
self.assertEqual(self.app.variant_var.get(), "grouped")
|
||||
self.assertFalse(self.app.arg_vars["return_answers_solution"].get())
|
||||
self.assertTrue(self.app.state_store.step("read_annotations")["values"]["update_score"])
|
||||
self.assertIn("--update-score", self.app._make_command())
|
||||
@@ -11,6 +11,7 @@ from PIL import Image
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration
|
||||
from copienator.annotation_data import AnnotationLoadResult
|
||||
from copienator.annotation_read_cache import STATE_FILE
|
||||
from copienator.custom_remarks import set_remark
|
||||
from copienator.commands import annotating, reading_annotations as standard
|
||||
from copienator.commands import reading_grouped_annotations as grouped
|
||||
|
||||
@@ -89,6 +90,19 @@ class IncrementalAnnotationReadsTests(unittest.TestCase):
|
||||
self.assertEqual(scan.call_args.args[1], {"01"})
|
||||
run(0)
|
||||
|
||||
# Remarks are per-copy inputs, including their removal.
|
||||
set_remark(root, "1", "Ex 1", "Justifier cette étape.")
|
||||
run(1)
|
||||
self.assertEqual(compose.call_args.args[2]["feedback"][-1]["text"], "Justifier cette étape.")
|
||||
run(0)
|
||||
set_remark(root, "01", "Ex 1", "Détailler le calcul.")
|
||||
run(1)
|
||||
self.assertEqual(len(compose.call_args.args[2]["feedback"]), 1)
|
||||
set_remark(root, "1", "Ex 1", "")
|
||||
run(1)
|
||||
self.assertEqual(compose.call_args.args[2]["feedback"], [])
|
||||
run(0)
|
||||
|
||||
# Per-copy sources and correction data invalidate only that copy.
|
||||
(root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF")
|
||||
run(1)
|
||||
|
||||
@@ -7,7 +7,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration
|
||||
from copienator import ExitCode, EvaluationWorkspace, atomic_write_json, configuration
|
||||
from copienator.commands import clean, giving_names
|
||||
|
||||
|
||||
@@ -40,6 +40,26 @@ class ReturnOutputTests(unittest.TestCase):
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(giving_names.run(self.workspace, annotation_dir="BGnot"), 0)
|
||||
|
||||
def test_regeneration_refreshes_copied_named_files_and_removes_obsolete_pdf(self):
|
||||
self.prepare()
|
||||
for suffix in (".jpg", ".pdf"):
|
||||
target = self.destination / ("Student" + suffix)
|
||||
target.unlink()
|
||||
target.write_bytes(b"old copied output")
|
||||
(self.destination / "custom.pdf").write_bytes(b"keep")
|
||||
with patch.multiple(configuration, RETURN_JPEG_ENABLED=True, RETURN_PDF_ENABLED=True):
|
||||
status = giving_names.update_named_return_answers(
|
||||
self.workspace, "BGnot", copy_ids={"01"}, refresh_named=True)
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
self.assertEqual((self.destination / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual((self.destination / "Student.jpg").read_bytes(), b"jpeg")
|
||||
(self.source / "Concat_F.pdf").unlink()
|
||||
giving_names.update_named_return_answers(
|
||||
self.workspace, "BGnot", copy_ids={"01"}, refresh_named=True)
|
||||
self.assertFalse((self.destination / "Student.pdf").is_symlink())
|
||||
self.assertFalse((self.destination / "Student.pdf").exists())
|
||||
self.assertEqual((self.destination / "custom.pdf").read_bytes(), b"keep")
|
||||
|
||||
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):
|
||||
|
||||
Reference in new issue
Block a user