From e8b8a11c5b47a00055b752754de748897a250723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Sat, 12 Sep 2026 23:56:07 +0200 Subject: [PATCH] Configuration de l'output final --- Readme.org | 2 + Script.org | 31 ++- copienator/answer_info.py | 22 ++ copienator/commands/annotating.py | 6 + copienator/commands/clean.py | 10 +- copienator/commands/giving_names.py | 36 ++- copienator/commands/reading_annotations.py | 19 +- .../commands/reading_grouped_annotations.py | 26 +- copienator/configuration.py | 6 + copienator/return_answers.py | 72 +++++ copienator_gui/workflow.py | 6 +- default_config.py | 8 + docs/final_output.md | 258 ++++++++++++++++++ tests/test_answer_returns.py | 217 +++++++++++++++ tests/test_gui_core.py | 9 +- tests/test_return_outputs.py | 124 +++++++++ 16 files changed, 814 insertions(+), 38 deletions(-) create mode 100644 copienator/answer_info.py create mode 100644 copienator/return_answers.py create mode 100644 docs/final_output.md create mode 100644 tests/test_answer_returns.py create mode 100644 tests/test_return_outputs.py diff --git a/Readme.org b/Readme.org index 9bf30cf..ac3c67c 100644 --- a/Readme.org +++ b/Readme.org @@ -255,6 +255,8 @@ Les chemins des étapes personnelles peuvent être adaptés avec * Documentation complémentaire +- [[file:docs/final_output.md][Fichiers finaux dans A Rendre]] : contenu du JPEG, sélection et + pagination du PDF, JPEG par réponse, =score.json=, =info.json= et diffusion. - [[file:Script.org][Référence des étapes et des scripts]] : commandes, arguments, prérequis, fichiers produits et parcours alternatifs. - [[file:Architecture.org][Architecture et conventions de développement]] : API commune, diff --git a/Script.org b/Script.org index 3a1f552..3a02ba0 100644 --- a/Script.org +++ b/Script.org @@ -290,8 +290,31 @@ OU 3. =python -m copienator giving-names Interro BGnot= Crée un dossier =A Rendre= avec des liens symboliques vers - + La copie à rendre + + =.jpg= : la correction complète concaténée (=Concat.jpg=) + + =.pdf=, si disponible : la correction filtrée, avec contexte, + énoncés et solutions dans le parcours =BGnot= (=Concat_F.pdf=) + un fichier =score.json= qui contient les notes par question + + =info.json= : pour chaque label, =present= (réponse fournie), + =not_empty= (réponse non vide compilée), =touched= (présente dans le + PDF filtré) et =score= (même valeur que dans =score.json=) + + =answers/*.jpg=, si =RETURN_ANSWERS_ENABLED= est activé : un JPEG + par réponse non vide, contenant toujours la réponse annotée + + Le PDF n'est donc pas une conversion du JPEG. Voir la + [[file:docs/final_output.md][documentation des fichiers finaux]] pour les règles de sélection, + la pagination et les différences entre parcours. + + =RETURN_JPEG_ENABLED= et =RETURN_PDF_ENABLED= dans =config.py= + permettent de désactiver ces sorties séparément (activées par défaut). + Relancer =giving-names= retire les fichiers nommés désactivés en + conservant leurs sources. =score.json= et =info.json= sont toujours inclus. + + =RETURN_ANSWERS_ENABLED= est désactivé par défaut, activé dans la + configuration personnelle. =RETURN_ANSWERS_CONTEXT=, + =RETURN_ANSWERS_QUESTION= et =RETURN_ANSWERS_SOLUTION= choisissent les + documents ajoutés avant chaque réponse (seule la question est activée + par défaut). Recompiler les anciennes annotations une fois avant cet + export pour produire les images finales et leurs métadonnées. Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans. 4. Éventuellement, faire des modifications manuelles aux =score.json=. @@ -328,12 +351,14 @@ conserve que : + le résultat final =correction.json= ; + les journaux de =.copienator/logs= et les journaux placés à la 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 véritables fichiers avant la suppression de leurs cibles. La commande refuse de démarrer si les copies traitées, =correction.json= ou une -image/un score d'élève sont absents. 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. Dans le GUI, cette commande apparaît comme dernière étape facultative diff --git a/copienator/answer_info.py b/copienator/answer_info.py new file mode 100644 index 0000000..4e5e304 --- /dev/null +++ b/copienator/answer_info.py @@ -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() + } diff --git a/copienator/commands/annotating.py b/copienator/commands/annotating.py index 649fa49..e102ec1 100644 --- a/copienator/commands/annotating.py +++ b/copienator/commands/annotating.py @@ -30,6 +30,7 @@ from copienator import ( workspace_from_args, ) from copienator.annotation_data import load_annotation_data +from copienator.answer_info import build_answer_info from copienator.filesystem import staged_directory from copienator.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: d_notes = dict.fromkeys(all_labels, "") label_images = [] + answer_labels = [] sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0])) for label, content in sorted_labels: @@ -475,8 +477,12 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite): final_img.save(staging / f"{label}.jpg") if result.get('error', "") != "empty-answer": label_images.append(final_img) + answer_labels.append(label) atomic_write_json(staging / "score.json", d_notes) + atomic_write_json(staging / "info.json", build_answer_info( + d_notes, labels_data, answer_labels + )) if label_images: max_w = max(image.width for image in label_images) total_h = sum(image.height for image in label_images) diff --git a/copienator/commands/clean.py b/copienator/commands/clean.py index 161ba79..303329b 100644 --- a/copienator/commands/clean.py +++ b/copienator/commands/clean.py @@ -13,6 +13,7 @@ from copienator import ( CliError, EvaluationWorkspace, ExitCode, + configuration, evaluation_parser, execute, 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 ] 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 = [] - if not images: + if configuration.RETURN_JPEG_ENABLED and not images: missing.append("image") if not scores: missing.append("score.json") incomplete.append(f"{directory.name} ({', '.join(missing)})") artifacts.extend(images) artifacts.extend(scores) + artifacts.extend(pdfs) + artifacts.extend(path for path in files if path.name.casefold() == "info.json") if incomplete: details = "\n".join(f" - {item}" for item in incomplete) @@ -219,7 +223,7 @@ def print_plan(workspace: EvaluationWorkspace, plan: CleanupPlan, *, verbose: bo print(f" - {statement_count} textual statement files") print(" - correction.json") 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( f"Will delete {len(plan.deleted_files)} files and " f"{len(plan.deleted_directories)} directories " diff --git a/copienator/commands/giving_names.py b/copienator/commands/giving_names.py index 532b846..6211134 100644 --- a/copienator/commands/giving_names.py +++ b/copienator/commands/giving_names.py @@ -10,12 +10,14 @@ from pathlib import Path from copienator import ( EvaluationWorkspace, ExitCode, + configuration, evaluation_parser, execute, read_json, workspace_from_args, ) from copienator.platform import replace_with_link_or_copy, safe_filename +from copienator.return_answers import publish_answer_returns ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot") @@ -94,9 +96,10 @@ def prepare_named_returns( fallback = fallback_annotations / f"Copie{copy_id}" source_folder = None for candidate in (selected, fallback): - if (candidate / "Concat.jpg").exists() and ( - candidate / "score.json" - ).exists(): + if (candidate / "score.json").is_file() and ( + (candidate / "Concat.jpg").is_file() + or (candidate / "info.json").is_file() + ): source_folder = candidate break if source_folder is None: @@ -105,19 +108,31 @@ def prepare_named_returns( assigned_names.add(name) destination = workspace.return_dir / f"{safe_name} ({copy_id})" destination.mkdir(parents=True, exist_ok=True) + try: + publish_answer_returns(workspace.root, source_folder, destination) + except (OSError, TypeError, ValueError) as exc: + print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr) + had_errors = True + continue links = ( - ("Concat.jpg", f"{safe_name}.jpg"), - ("Concat_F.pdf", f"{safe_name}.pdf"), - ("score.json", "score.json"), + ("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED), + ("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED), + ("score.json", "score.json", True), ) - for source_name, destination_name in links: + for source_name, destination_name, enabled in links: source = source_folder / source_name - if not source.exists(): - continue + target = destination / destination_name try: + if not enabled: + # Remove only the named return entry, never its link target. + target.unlink(missing_ok=True) + continue + if not source.exists(): + target.unlink(missing_ok=True) + continue method = replace_with_link_or_copy( source, - destination / destination_name, + target, prefer="symlink", ) if method == "copy": @@ -163,4 +178,3 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 8497fea..1e35f6e 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -21,6 +21,7 @@ from copienator import ( workspace_from_args, ) from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides +from copienator.answer_info import build_answer_info from copienator.annotation_data import AnnotationData, load_annotation_data from copienator.filesystem import staged_files @@ -148,11 +149,12 @@ def apply_actions_and_regenerate( raise TypeError(f"Expected a JSON object in {bnote_path}") labels_data = data[student_id] - dirty_labels = apply_checkbox_actions(labels_data, actions, print) + apply_checkbox_actions(labels_data, actions, print) 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, "") + answer_labels: list[str] = [] dirty_images: dict[str, Image.Image] = {} concatenated: list[Image.Image] = [] filtered: list[Image.Image] = [] @@ -169,6 +171,8 @@ def apply_actions_and_regenerate( content = labels_data[label] result = content["result"] scores[label] = str(result.get("score", 0)) + if result.get("error") == "empty-answer": + continue sub_note = None if notes_layer is not None: @@ -204,18 +208,22 @@ def apply_actions_and_regenerate( body = sub_note.crop((0, old_header_height, width, height)) final_image.paste(body, (0, new_header_height), mask=body) - if label in dirty_labels or has_notes: - dirty_images[label] = final_image + dirty_images[label] = final_image + answer_labels.append(label) concatenated.append(final_image) if float(scores[label]) != 4.0 or result.get("feedback", []): filtered.append(final_image) concat_image = concatenate(concatenated) filtered_image = concatenate(filtered) - with staged_files(output_dir) as staging: + with staged_files(output_dir, remove=("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", + "touched.json", "answer_labels.json")) as staging: for label, image in dirty_images.items(): image.save(staging / f"{label}.jpg") atomic_write_json(staging / "score.json", scores) + atomic_write_json(staging / "info.json", build_answer_info( + scores, labels_data, answer_labels + )) if concat_image is not None: concat_image.save(staging / "Concat.jpg") if filtered_image is not None: @@ -284,4 +292,3 @@ def main(argv: Sequence[str] | None = None) -> int: if __name__ == "__main__": raise SystemExit(main()) - diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index a66ffd2..c4f688c 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -20,6 +20,7 @@ from copienator import ( workspace_from_args, ) from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides +from copienator.answer_info import build_answer_info from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data from copienator.commands import annotating from copienator.commands.reading_annotations import ( @@ -189,14 +190,13 @@ def apply_actions_and_regenerate_grouped( logs = [f"\nProcessing compilation for: Copie{student_id}"] output_dir = workspace.root / annotation_dir / f"Copie{student_id}" labels_data = data.get(student_id, {}) - dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append) + apply_checkbox_actions(labels_data, actions, logs.append) if update_score: - dirty_labels |= apply_score_overrides( + apply_score_overrides( labels_data, output_dir / "score.json", logs.append ) selected_labels = selected_labels if selected_labels is not None else set() - dirty_labels |= selected_labels simple_layout = None simple_annotated = None if selected_labels and annotation_dir == "Anot": @@ -239,6 +239,8 @@ def apply_actions_and_regenerate_grouped( else {} ) scores = dict.fromkeys(all_labels, "") + touched = dict.fromkeys(all_labels, False) + answer_labels: list[str] = [] dirty_images: dict[str, Image.Image] = {} concat_images: list[Image.Image] = [] filtered_groups: list[list[Image.Image]] = [] @@ -255,6 +257,9 @@ def apply_actions_and_regenerate_grouped( ): result["score"] = old_scores[label] scores[label] = str(result.get("score", 0)) + touched[label] = False + if result.get("error") == "empty-answer": + continue saved_image = output_dir / f"{label}.jpg" if selected_labels and label not in selected_labels and saved_image.is_file(): with Image.open(saved_image) as saved: @@ -271,12 +276,14 @@ def apply_actions_and_regenerate_grouped( dirty_images[label] = final_image scores[label] = str(old_scores.get(label, scores[label])) concat_images.append(final_image) + answer_labels.append(label) # Keep previously reviewed content, including handwriting. if annotation_dir == "BGnot": extras = get_extra_pdfs_as_images( workspace.root, label, annotating, all_labels ) filtered_groups.append([*extras, final_image]) + touched[label] = True else: filtered_groups.append([final_image]) continue @@ -313,9 +320,9 @@ def apply_actions_and_regenerate_grouped( body = sub_note.crop((0, old_header_height, width, height)) final_image.paste(body, (0, new_header_height), mask=body) - if label in dirty_labels or has_notes or selected_labels: - dirty_images[label] = final_image - logs.append(f" Saved dirty image: {label}.jpg") + # Persist every final block, including unchanged answers, for returns. + dirty_images[label] = final_image + answer_labels.append(label) concat_images.append(final_image) feedbacks = result.get("feedback", []) @@ -329,11 +336,13 @@ def apply_actions_and_regenerate_grouped( else [] ) filtered_groups.append([*extras, final_image]) + touched[label] = annotation_dir == "BGnot" concat_image = concatenate(concat_images) if incomplete: return ExitCode.PARTIAL, "\n".join(logs) - with staged_files(output_dir, remove=("Concat_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: simple_layout["replaced"] = sorted( set(simple_layout["replaced"]) | selected_labels @@ -342,6 +351,9 @@ def apply_actions_and_regenerate_grouped( for label, image in dirty_images.items(): image.save(staging / f"{label}.jpg") atomic_write_json(staging / "score.json", scores) + atomic_write_json(staging / "info.json", build_answer_info( + scores, labels_data, answer_labels, touched + )) if concat_image is not None: concat_image.save(staging / "Concat.jpg") if filtered_groups: diff --git a/copienator/configuration.py b/copienator/configuration.py index 04ba22f..0ea6731 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -33,6 +33,12 @@ else: # Keep new optional settings compatible with older personal configuration files. ALWAYS_CROP = False +RETURN_JPEG_ENABLED = True +RETURN_PDF_ENABLED = True +RETURN_ANSWERS_ENABLED = False +RETURN_ANSWERS_CONTEXT = False +RETURN_ANSWERS_QUESTION = True +RETURN_ANSWERS_SOLUTION = False for _name in dir(_configuration): if not _name.startswith("_"): globals()[_name] = getattr(_configuration, _name) diff --git a/copienator/return_answers.py b/copienator/return_answers.py new file mode 100644 index 0000000..89af872 --- /dev/null +++ b/copienator/return_answers.py @@ -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) diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index b0391ab..bdc2c68 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -677,7 +677,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Nettoyer les fichiers intermédiaires", "Supprime définitivement les fichiers permettant de reprendre le parcours. " "Conserve les PDF traités, les fichiers textuels de l’énoncé, correction.json, " - "les journaux, ainsi que les images et score.json de A Rendre.", + "les journaux, ainsi que les images, PDF, score.json et info.json de A Rendre.", ( python( "default", @@ -691,8 +691,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "seront supprimés. Il ne sera plus possible de reprendre une " "étape sans régénérer ses données.\n\n" "Les PDF traités, les fichiers textuels de l’énoncé, " - "correction.json, les journaux, ainsi que les images et " - "score.json de A Rendre seront conservés.\n\n" + "correction.json, les journaux, ainsi que les images, PDF et " + "score.json et info.json de A Rendre seront conservés.\n\n" "Continuer ?" ), ), diff --git a/default_config.py b/default_config.py index 02e21ac..5248be6 100644 --- a/default_config.py +++ b/default_config.py @@ -6,6 +6,14 @@ API_KEY = os.environ.get("GEMINI_API_KEY") EXPORT_DIR = Path("Export") IMPORT_DIR = Path("Import") +# Fichiers à inclure dans A Rendre (les sources d'annotations sont conservées). +RETURN_JPEG_ENABLED = True +RETURN_PDF_ENABLED = True +RETURN_ANSWERS_ENABLED = False +RETURN_ANSWERS_CONTEXT = False +RETURN_ANSWERS_QUESTION = True +RETURN_ANSWERS_SOLUTION = False + # Les étapes gestion_classe, ODS et publication sont masquées par défaut. SHOW_PERSONAL_STEPS = False diff --git a/docs/final_output.md b/docs/final_output.md new file mode 100644 index 0000000..90501ed --- /dev/null +++ b/docs/final_output.md @@ -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/