Cache for reading annotation
This commit is contained in:
1 parent
55d5cf16c9
commit
ff7496b113
12 files changed
+466
-13
No files matched your search
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
from copienator import CliError, atomic_write_json, read_json, utils
|
from copienator import CliError, atomic_write_json, read_json, utils
|
||||||
|
from copienator.annotation_read_cache import STATE_FILE
|
||||||
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
|
from copienator.commands.resolve_manual import ManualInstruction, parse_instruction_text
|
||||||
|
|
||||||
DRAFT = "annotation_modifications.txt"
|
DRAFT = "annotation_modifications.txt"
|
||||||
@@ -153,7 +154,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct
|
|||||||
|
|
||||||
def output_removals(directory: Path) -> tuple[str, ...]:
|
def output_removals(directory: Path) -> tuple[str, ...]:
|
||||||
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
||||||
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||||
*(f"{label}.jpg" for label, entry in info.items()
|
*(f"{label}.jpg" for label, entry in info.items()
|
||||||
if Path(label).name == label))
|
if Path(label).name == label))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Small content fingerprints for incremental --update-score reads."""
|
||||||
|
from hashlib import file_digest, sha256
|
||||||
|
import json
|
||||||
|
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
|
||||||
|
|
||||||
|
STATE_FILE = ".annotation-read-state.json"
|
||||||
|
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
def _files_digest(paths) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
for path in sorted(set(paths)):
|
||||||
|
digest.update(str(path).encode())
|
||||||
|
if path.is_file():
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
digest.update(file_digest(stream, "sha256").digest())
|
||||||
|
else:
|
||||||
|
digest.update(b"missing")
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationReadCache:
|
||||||
|
def __init__(self, workspace, mode: str, data: dict, group_dirs=()):
|
||||||
|
self.workspace = workspace
|
||||||
|
self.mode = mode
|
||||||
|
self.signatures = {}
|
||||||
|
root = workspace.root
|
||||||
|
shared = [root / "labels", root / mode / "annotation_modifications_applied.txt"]
|
||||||
|
for folder in ("Text2", "Sol2"):
|
||||||
|
shared.extend((root / folder).glob("*.pdf"))
|
||||||
|
for directory in group_dirs:
|
||||||
|
shared.extend(directory / name for name in DOCUMENT_INPUTS)
|
||||||
|
common = (_files_digest(shared), load_return_answer_options(root),
|
||||||
|
configuration.RETURN_ANSWERS_ENABLED,
|
||||||
|
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
|
||||||
|
for copy_id, labels in data.items():
|
||||||
|
directory = root / mode / f"Copie{copy_id}"
|
||||||
|
paths = [directory / "score.json", directory / "bnote.json"]
|
||||||
|
for document in copy_documents(directory):
|
||||||
|
paths.extend(document / name for name in DOCUMENT_INPUTS)
|
||||||
|
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)),
|
||||||
|
sort_keys=True, default=str).encode()
|
||||||
|
self.signatures[copy_id] = sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
def unchanged(self, copy_id: str) -> bool:
|
||||||
|
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
|
||||||
|
try:
|
||||||
|
state = read_json(directory / STATE_FILE)
|
||||||
|
return (state["signature"] == self.signatures[copy_id]
|
||||||
|
and bool(state["outputs"])
|
||||||
|
and all(Path(path).is_file() for path in state["outputs"]))
|
||||||
|
except (OSError, ValueError, TypeError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remember(self, copy_ids) -> None:
|
||||||
|
for copy_id in copy_ids:
|
||||||
|
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
|
||||||
|
outputs = [directory / "info.json"]
|
||||||
|
info = read_json(directory / "info.json")
|
||||||
|
outputs.extend(directory / f"{label}.jpg" for label, entry in info.items()
|
||||||
|
if entry["present"])
|
||||||
|
outputs.extend(directory.glob("Concat*.*"))
|
||||||
|
outputs.extend(directory.glob(".annotation-base-*.png"))
|
||||||
|
outputs.extend(directory.glob("annotation_baseline.json"))
|
||||||
|
for destination in self.workspace.return_dir.glob(f"* ({copy_id})"):
|
||||||
|
outputs.extend((destination / "answers").glob("*.jpg"))
|
||||||
|
atomic_write_json(directory / STATE_FILE, {
|
||||||
|
"signature": self.signatures[copy_id],
|
||||||
|
"outputs": [str(path) for path in outputs],
|
||||||
|
})
|
||||||
@@ -2,11 +2,13 @@ import argparse
|
|||||||
import math
|
import math
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_CEILING
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from copienator import atomic_write_json, read_json
|
||||||
from copienator.configuration import (
|
from copienator.configuration import (
|
||||||
FINAL_SCORE_FONT_PATH,
|
FINAL_SCORE_FONT_PATH,
|
||||||
FINAL_SCORE_HISTOGRAM_PATH,
|
FINAL_SCORE_HISTOGRAM_PATH,
|
||||||
@@ -72,6 +74,39 @@ def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -
|
|||||||
shutil.copy2(source, destination)
|
shutil.copy2(source, destination)
|
||||||
print(f"Copied histogram: {destination}")
|
print(f"Copied histogram: {destination}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_score_statistics(search_path: Path, output_dir: Path) -> None:
|
||||||
|
"""Average available per-question scores, rounding upward to one decimal."""
|
||||||
|
scores_by_label = {}
|
||||||
|
for path in sorted(search_path.glob("*/score.json")):
|
||||||
|
try:
|
||||||
|
scores = read_json(path)
|
||||||
|
if not isinstance(scores, dict):
|
||||||
|
raise ValueError("expected an object of per-label scores")
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"Warning: Could not read scores from '{path}': {exc}")
|
||||||
|
continue
|
||||||
|
for label, value in scores.items():
|
||||||
|
values = scores_by_label.setdefault(label, [])
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
if not number.is_finite():
|
||||||
|
raise InvalidOperation
|
||||||
|
except InvalidOperation:
|
||||||
|
print(f"Warning: Invalid score '{value}' for '{label}' in '{path}'.")
|
||||||
|
continue
|
||||||
|
values.append(number)
|
||||||
|
statistics = {
|
||||||
|
label: str((sum(values) / len(values)).quantize(Decimal("0.1"), rounding=ROUND_CEILING))
|
||||||
|
if values else ""
|
||||||
|
for label, values in scores_by_label.items()
|
||||||
|
}
|
||||||
|
atomic_write_json(output_dir / "stat.json", statistics)
|
||||||
|
print(f"Wrote per-label averages: {output_dir / 'stat.json'}")
|
||||||
|
|
||||||
|
|
||||||
def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
|
def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
|
||||||
# 1. Load Data
|
# 1. Load Data
|
||||||
try:
|
try:
|
||||||
@@ -152,6 +187,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
|
|||||||
for pdf_path in pdf_paths:
|
for pdf_path in pdf_paths:
|
||||||
shutil.copy2(pdf_path, student_output / pdf_path.name)
|
shutil.copy2(pdf_path, student_output / pdf_path.name)
|
||||||
|
|
||||||
|
write_score_statistics(search_path, output_dir)
|
||||||
copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
|
copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import argparse
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Sequence
|
from collections.abc import Collection, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
@@ -77,8 +77,12 @@ def _annotation_source(
|
|||||||
def update_named_return_answers(
|
def update_named_return_answers(
|
||||||
workspace: EvaluationWorkspace,
|
workspace: EvaluationWorkspace,
|
||||||
annotation_dir_name: str,
|
annotation_dir_name: str,
|
||||||
|
*,
|
||||||
|
copy_ids: Collection[str] | None = None,
|
||||||
) -> ExitCode:
|
) -> ExitCode:
|
||||||
"""Refresh only answers/ in existing returns, preserving manual names."""
|
"""Refresh answers/ in existing returns, optionally only 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")
|
workspace.require_directories(annotation_dir_name, "A Rendre")
|
||||||
had_errors = False
|
had_errors = False
|
||||||
found = False
|
found = False
|
||||||
@@ -86,6 +90,8 @@ def update_named_return_answers(
|
|||||||
if not destination.is_dir():
|
if not destination.is_dir():
|
||||||
continue
|
continue
|
||||||
match = RETURN_COPY_ID.search(destination.name)
|
match = RETURN_COPY_ID.search(destination.name)
|
||||||
|
if copy_ids is not None and (match is None or match.group(1) not in copy_ids):
|
||||||
|
continue
|
||||||
if match is None:
|
if match is None:
|
||||||
print(
|
print(
|
||||||
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
|
f"Warning: cannot identify a copy ID in {destination.name!r}; skipped",
|
||||||
@@ -116,7 +122,7 @@ def update_named_return_answers(
|
|||||||
except (OSError, TypeError, ValueError) as exc:
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
|
print(f"Error updating answers for {destination.name}: {exc}", file=sys.stderr)
|
||||||
had_errors = True
|
had_errors = True
|
||||||
if not found:
|
if not found and copy_ids is None:
|
||||||
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
|
print("Warning: no identifiable student folders found in A Rendre", file=sys.stderr)
|
||||||
had_errors = True
|
had_errors = True
|
||||||
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
|
|||||||
from copienator.annotation_data import AnnotationData, load_annotation_data
|
from copienator.annotation_data import AnnotationData, load_annotation_data
|
||||||
from copienator.annotation_documents import copy_documents
|
from copienator.annotation_documents import copy_documents
|
||||||
from copienator.annotation_layout import legacy_layout
|
from copienator.annotation_layout import legacy_layout
|
||||||
|
from copienator.annotation_read_cache import AnnotationReadCache
|
||||||
from copienator.annotation_modifications import (
|
from copienator.annotation_modifications import (
|
||||||
BASELINE,
|
BASELINE,
|
||||||
output_removals,
|
output_removals,
|
||||||
@@ -31,6 +32,7 @@ from copienator.annotation_modifications import (
|
|||||||
)
|
)
|
||||||
from copienator.answer_info import build_answer_info
|
from copienator.answer_info import build_answer_info
|
||||||
from copienator.commands import annotating
|
from copienator.commands import annotating
|
||||||
|
from copienator.commands.giving_names import update_named_return_answers
|
||||||
from copienator.filesystem import staged_files
|
from copienator.filesystem import staged_files
|
||||||
from copienator.return_answers import save_return_answer_options
|
from copienator.return_answers import save_return_answer_options
|
||||||
|
|
||||||
@@ -324,7 +326,12 @@ def run(
|
|||||||
return ExitCode.PARTIAL
|
return ExitCode.PARTIAL
|
||||||
|
|
||||||
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||||
|
cache = AnnotationReadCache(workspace, "Bnot", loaded.data) if update_score else None
|
||||||
|
regenerated: set[str] = set()
|
||||||
for student_id in sorted(loaded.data, key=utils.natural_key):
|
for student_id in sorted(loaded.data, key=utils.natural_key):
|
||||||
|
if cache is not None and cache.unchanged(student_id):
|
||||||
|
print(f"Skipping unchanged Copie{student_id}")
|
||||||
|
continue
|
||||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||||
only_empty = all(content["result"].get("error") == "empty-answer"
|
only_empty = all(content["result"].get("error") == "empty-answer"
|
||||||
for content in loaded.data[student_id].values())
|
for content in loaded.data[student_id].values())
|
||||||
@@ -372,6 +379,12 @@ def run(
|
|||||||
)
|
)
|
||||||
if result != ExitCode.SUCCESS:
|
if result != ExitCode.SUCCESS:
|
||||||
status = ExitCode.PARTIAL
|
status = ExitCode.PARTIAL
|
||||||
|
else:
|
||||||
|
regenerated.add(student_id)
|
||||||
|
if update_named_return_answers(workspace, "Bnot", copy_ids=regenerated) != ExitCode.SUCCESS:
|
||||||
|
status = ExitCode.PARTIAL
|
||||||
|
elif cache is not None:
|
||||||
|
cache.remember(regenerated)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from copienator.annotation_actions import apply_checkbox_actions, apply_score_ov
|
|||||||
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
|
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
|
||||||
from copienator.annotation_documents import copy_documents, tablet_documents
|
from copienator.annotation_documents import copy_documents, tablet_documents
|
||||||
from copienator.annotation_layout import legacy_layout
|
from copienator.annotation_layout import legacy_layout
|
||||||
|
from copienator.annotation_read_cache import AnnotationReadCache
|
||||||
from copienator.annotation_modifications import (
|
from copienator.annotation_modifications import (
|
||||||
BASELINE,
|
BASELINE,
|
||||||
baseline,
|
baseline,
|
||||||
@@ -36,6 +37,7 @@ from copienator.annotation_modifications import (
|
|||||||
)
|
)
|
||||||
from copienator.answer_info import build_answer_info
|
from copienator.answer_info import build_answer_info
|
||||||
from copienator.commands import annotating
|
from copienator.commands import annotating
|
||||||
|
from copienator.commands.giving_names import update_named_return_answers
|
||||||
from copienator.commands.reading_annotations import (
|
from copienator.commands.reading_annotations import (
|
||||||
concatenate,
|
concatenate,
|
||||||
detect_checks_and_notes,
|
detect_checks_and_notes,
|
||||||
@@ -594,6 +596,18 @@ def run(
|
|||||||
and path.is_dir()
|
and path.is_dir()
|
||||||
and not path.name.startswith("Copie")
|
and not path.name.startswith("Copie")
|
||||||
]
|
]
|
||||||
|
# Redo reads deliberately remain full reads: their selected labels and
|
||||||
|
# preserved baseline images have different dependencies.
|
||||||
|
cache = (AnnotationReadCache(workspace, annotation_dir, loaded.data, group_dirs)
|
||||||
|
if update_score and not refaire else None)
|
||||||
|
pending_ids = set(loaded.data)
|
||||||
|
if cache is not None:
|
||||||
|
pending_ids = {copy_id for copy_id in loaded.data if not cache.unchanged(copy_id)}
|
||||||
|
for copy_id in sorted(set(loaded.data) - pending_ids, key=utils.natural_key):
|
||||||
|
print(f"Skipping unchanged Copie{copy_id}")
|
||||||
|
if not pending_ids:
|
||||||
|
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||||
|
only_ids = pending_ids
|
||||||
# Each worker decodes a full-height returned group and its reference image.
|
# Each worker decodes a full-height returned group and its reference image.
|
||||||
# Keep this stage deliberately narrow; answer regeneration below has its own
|
# Keep this stage deliberately narrow; answer regeneration below has its own
|
||||||
# parallel executor and a much smaller per-task memory footprint.
|
# parallel executor and a much smaller per-task memory footprint.
|
||||||
@@ -657,6 +671,7 @@ def run(
|
|||||||
if refaire
|
if refaire
|
||||||
else sorted(loaded.data, key=utils.natural_key)
|
else sorted(loaded.data, key=utils.natural_key)
|
||||||
)
|
)
|
||||||
|
regenerated: set[str] = set()
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
futures = {
|
futures = {
|
||||||
executor.submit(
|
executor.submit(
|
||||||
@@ -676,13 +691,19 @@ def run(
|
|||||||
),
|
),
|
||||||
): student_id
|
): student_id
|
||||||
for student_id in student_ids
|
for student_id in student_ids
|
||||||
if student_id in loaded.data and student_id not in skipped_students
|
if student_id in pending_ids and student_id not in skipped_students
|
||||||
}
|
}
|
||||||
for future in concurrent.futures.as_completed(futures):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
result, output = future.result()
|
result, output = future.result()
|
||||||
print(output)
|
print(output)
|
||||||
if result != ExitCode.SUCCESS:
|
if result != ExitCode.SUCCESS:
|
||||||
status = ExitCode.PARTIAL
|
status = ExitCode.PARTIAL
|
||||||
|
else:
|
||||||
|
regenerated.add(futures[future])
|
||||||
|
if update_named_return_answers(workspace, annotation_dir, copy_ids=regenerated) != ExitCode.SUCCESS:
|
||||||
|
status = ExitCode.PARTIAL
|
||||||
|
elif cache is not None:
|
||||||
|
cache.remember(regenerated)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -778,7 +778,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Étapes personnelles",
|
"Étapes personnelles",
|
||||||
"Renseigner le barème",
|
"Renseigner le barème",
|
||||||
"Exécute gestion_classe we.",
|
"Exécute gestion_classe we.",
|
||||||
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),),
|
(CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",), fixed_args_before_positionals=True),),
|
||||||
|
arguments=(arg_evaluation_name(),),
|
||||||
personal=True,
|
personal=True,
|
||||||
),
|
),
|
||||||
StepDefinition(
|
StepDefinition(
|
||||||
@@ -823,7 +824,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Ajouter le score final",
|
"Ajouter le score final",
|
||||||
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
|
"Prérequis immédiat : gestion_classe wse doit avoir été exécuté juste avant. "
|
||||||
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
|
"Génère ensuite les dossiers de diffusion avec le score final, puis copie "
|
||||||
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation.",
|
"gestion_classe/Staging/histogramme.pdf dans le dossier Server/copies de l’évaluation. "
|
||||||
|
"Écrit aussi stat.json avec les moyennes par label, arrondies par excès au dixième.",
|
||||||
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
(python("default", "Score final", "add-final-score", supports_verbose=False),),
|
||||||
arguments=(
|
arguments=(
|
||||||
arg_target("le dossier de l’évaluation"),
|
arg_target("le dossier de l’évaluation"),
|
||||||
|
|||||||
+22
-5
@@ -261,8 +261,21 @@ Scores incorporate review checkbox changes and, when requested, existing score
|
|||||||
overrides via `read-grouped --update-score` (or `read-annotations --update-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
|
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
|
are read from the annotation source folder: a return symlink points there, but
|
||||||
an independent fallback copy does not. After regeneration, rerun `giving-names`
|
an independent fallback copy does not. Successful annotation rereads automatically
|
||||||
to refresh copied return files.
|
refresh individual `answers/` images in existing return folders, matching their
|
||||||
|
trailing copy ID and preserving manual names and other return files. This also
|
||||||
|
applies the selected supplementary-material options. Copies whose regeneration
|
||||||
|
fails retain their existing exports. Rerun `giving-names` to refresh other copied
|
||||||
|
return files.
|
||||||
|
|
||||||
|
With `--update-score`, both readers save a small content fingerprint per copy
|
||||||
|
after successful regeneration and answer publication. Subsequent runs skip
|
||||||
|
unchanged copies before scanning or rendering PDFs. A single edited `score.json`
|
||||||
|
therefore rebuilds only its copy. Changed shared documents or grouped annotation
|
||||||
|
inputs conservatively invalidate all copies; changed export options also trigger
|
||||||
|
regeneration. Missing generated files and failed runs are retried. The first run
|
||||||
|
establishes the fingerprints. Reads without `--update-score` always regenerate;
|
||||||
|
selective `--refaire` reads do not use this optimization.
|
||||||
|
|
||||||
`update-ods` reads these return JSON files; empty values become `NT` in the normal
|
`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
|
per-question export. Its `--sum` option sums numeric values and skips nonnumeric
|
||||||
@@ -280,9 +293,13 @@ ones. Weighting and the final overall mark belong to the separate grading flow.
|
|||||||
outputs whose source is now absent, including broken symlinks.
|
outputs whose source is now absent, including broken symlinks.
|
||||||
- `add-final-score` writes to `FINAL_SCORE_OUTPUT_DIR/<evaluation>/`. It stamps
|
- `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,
|
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
|
rounded down to one decimal place. It copies PDFs unchanged, together with
|
||||||
either JSON file or the `answers/` directory. It does not add that mark to the
|
`score.json`, `info.json` and the optional `answers/` directory. Alongside the
|
||||||
files inside `A Rendre`.
|
student folders it writes `stat.json`, averaging each label across the current
|
||||||
|
`A Rendre/*/score.json` files and rounding upward to one decimal place, stored
|
||||||
|
as strings like the original scores (for example `"2.7"`). Blank
|
||||||
|
and missing scores are excluded; labels without numeric scores retain `""`.
|
||||||
|
It does not add the overall mark to the files inside `A Rendre`.
|
||||||
- The `clean` command retains return images (including individual answers), PDFs,
|
- The `clean` command retains return images (including individual answers), PDFs,
|
||||||
`score.json` and `info.json`, materializing
|
`score.json` and `info.json`, materializing
|
||||||
retained symlinks before deleting their sources.
|
retained symlinks before deleting their sources.
|
||||||
|
|||||||
@@ -439,6 +439,17 @@ réponse.
|
|||||||
Le mode **Régénérer avec les nouveaux énoncés/corrigés et les
|
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 à
|
score.json** sert à mettre à jour les scores sur les images finales à
|
||||||
partir d'une modification manuelle des fichiers `score.json`.
|
partir d'une modification manuelle des fichiers `score.json`.
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
(é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 les réponses annotées
|
### Modifier les réponses annotées
|
||||||
|
|||||||
@@ -9,9 +9,31 @@ import pandas as pd
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from copienator.commands import add_final_score
|
from copienator.commands import add_final_score
|
||||||
|
from copienator import atomic_write_json, read_json
|
||||||
|
|
||||||
|
|
||||||
class AddFinalScoreTests(unittest.TestCase):
|
class AddFinalScoreTests(unittest.TestCase):
|
||||||
|
def test_statistics_average_all_score_files_and_round_up_without_float_drift(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
returns = root / 'A Rendre'
|
||||||
|
atomic_write_json(returns / 'Alice/score.json', {
|
||||||
|
'Ex 1': '2.0', 'Ex 2': '0.1', 'Ex 3': '', 'Empty': '', 'Zero': 0,
|
||||||
|
})
|
||||||
|
atomic_write_json(returns / 'Bob/score.json', {
|
||||||
|
'Ex 1': 2.01, 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': None,
|
||||||
|
})
|
||||||
|
output = root / 'output'
|
||||||
|
output.mkdir()
|
||||||
|
add_final_score.write_score_statistics(returns, output)
|
||||||
|
self.assertEqual(read_json(output / 'stat.json'), {
|
||||||
|
'Ex 1': '2.1', 'Ex 2': '0.1', 'Ex 3': '3.5', 'Empty': '', 'Zero': '0.0',
|
||||||
|
})
|
||||||
|
# Rebuilding uses current returns, without accumulating old averages.
|
||||||
|
atomic_write_json(returns / 'Bob/score.json', {'Ex 1': 4})
|
||||||
|
add_final_score.write_score_statistics(returns, output)
|
||||||
|
self.assertEqual(read_json(output / 'stat.json')['Ex 1'], '3.0')
|
||||||
|
|
||||||
def test_merged_histogram_is_copied_only_when_selected(self):
|
def test_merged_histogram_is_copied_only_when_selected(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
@@ -80,6 +102,7 @@ class AddFinalScoreTests(unittest.TestCase):
|
|||||||
self.assertFalse((output / "Student.pdf").exists())
|
self.assertFalse((output / "Student.pdf").exists())
|
||||||
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
|
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
|
||||||
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
|
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
|
||||||
|
self.assertEqual(read_json(output / "stat.json"), {"Ex 1": "4.0"})
|
||||||
|
|
||||||
def test_omits_answers_directory_when_it_was_not_generated(self):
|
def test_omits_answers_directory_when_it_was_not_generated(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
|
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration, read_json
|
||||||
|
from copienator.annotation_data import AnnotationLoadResult
|
||||||
from copienator.commands import annotating, giving_names
|
from copienator.commands import annotating, giving_names
|
||||||
|
from copienator.commands import reading_annotations as standard
|
||||||
from copienator.commands import reading_grouped_annotations as reader
|
from copienator.commands import reading_grouped_annotations as reader
|
||||||
from copienator.return_answers import (
|
from copienator.return_answers import (
|
||||||
publish_answer_returns,
|
publish_answer_returns,
|
||||||
@@ -216,6 +218,116 @@ class AnswerReturnTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RereadAnswerReturnTests(unittest.TestCase):
|
||||||
|
def check_reread(self, module, mode, *, existing_returns=True, fail_publication=False,
|
||||||
|
failed_copy=False):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
root = Path(temporary.name)
|
||||||
|
workspace = EvaluationWorkspace(root)
|
||||||
|
for folder in ("Copies", "Par label", mode, "Text2"):
|
||||||
|
(root / folder).mkdir()
|
||||||
|
(root / "labels").write_text("Ex 1\n")
|
||||||
|
atomic_write_json(root / "correction.json", {})
|
||||||
|
question = root / "Text2" / "Ex 1.pdf"
|
||||||
|
question.touch()
|
||||||
|
data = {}
|
||||||
|
original_score = b'{\n "Ex 1": "3.5"\n}\n'
|
||||||
|
destinations = {}
|
||||||
|
for copy_id in ("01", "02"):
|
||||||
|
output = root / mode / f"Copie{copy_id}"
|
||||||
|
output.mkdir()
|
||||||
|
(output / "score.json").write_bytes(original_score)
|
||||||
|
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
|
||||||
|
pdf = root / "Copies" / f"{copy_id}.pdf"
|
||||||
|
if copy_id == "01":
|
||||||
|
pdf.touch()
|
||||||
|
data[copy_id] = {"Ex 1": {
|
||||||
|
"result": {"score": 1, "feedback": []},
|
||||||
|
"pdf_path": pdf,
|
||||||
|
"coordinates": (0, 0),
|
||||||
|
}}
|
||||||
|
destination = workspace.return_dir / f"Renamed student ({copy_id})"
|
||||||
|
destinations[copy_id] = destination
|
||||||
|
if existing_returns:
|
||||||
|
(destination / "answers").mkdir(parents=True)
|
||||||
|
(destination / "answers" / "old.jpg").write_bytes(b"previous answers")
|
||||||
|
(destination / "custom.pdf").write_bytes(b"keep PDF")
|
||||||
|
(destination / "info.json").write_bytes(b"keep info")
|
||||||
|
|
||||||
|
if not failed_copy:
|
||||||
|
del data["02"]
|
||||||
|
|
||||||
|
def compose(base, label, result, *args, **kwargs):
|
||||||
|
# Make the rendered score observable in the published answer pixels.
|
||||||
|
color = "red" if str(result["score"]) == "3.5" else "black"
|
||||||
|
return Image.new("RGB", (100, 50), color), 10
|
||||||
|
|
||||||
|
def render(path):
|
||||||
|
if Path(path) == question and fail_publication:
|
||||||
|
raise ValueError("cannot render supplement")
|
||||||
|
return Image.new("RGB", (100, 20), "blue"), 0, 0
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True),
|
||||||
|
patch.object(module, "load_annotation_data", return_value=AnnotationLoadResult(data, [])),
|
||||||
|
patch.object(standard, "detect_checks_and_notes", return_value=([], None)),
|
||||||
|
patch.object(annotating, "make_base_image", side_effect=render),
|
||||||
|
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"),
|
||||||
|
patch.object(reader, "save_checked_pdf"),
|
||||||
|
contextlib.redirect_stdout(io.StringIO()),
|
||||||
|
contextlib.redirect_stderr(io.StringIO()),
|
||||||
|
):
|
||||||
|
status = module.run(
|
||||||
|
workspace, update_score=True,
|
||||||
|
return_answers_context=False, return_answers_question=True,
|
||||||
|
return_answers_solution=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = ExitCode.PARTIAL if failed_copy or fail_publication else ExitCode.SUCCESS
|
||||||
|
self.assertEqual(status, expected)
|
||||||
|
self.assertEqual((root / mode / "Copie01" / "score.json").read_bytes(), original_score)
|
||||||
|
if not existing_returns:
|
||||||
|
self.assertFalse(workspace.return_dir.exists())
|
||||||
|
return
|
||||||
|
for destination in destinations.values():
|
||||||
|
self.assertEqual((destination / "custom.pdf").read_bytes(), b"keep PDF")
|
||||||
|
self.assertEqual((destination / "info.json").read_bytes(), b"keep info")
|
||||||
|
self.assertEqual((destinations["02"] / "answers" / "old.jpg").read_bytes(), b"previous answers")
|
||||||
|
answers = destinations["01"] / "answers"
|
||||||
|
if fail_publication:
|
||||||
|
self.assertEqual((answers / "old.jpg").read_bytes(), b"previous answers")
|
||||||
|
return
|
||||||
|
self.assertEqual([p.name for p in answers.iterdir()], ["Ex 1.jpg"])
|
||||||
|
with Image.open(answers / "Ex 1.jpg") as image:
|
||||||
|
self.assertEqual(image.size, (100, 70))
|
||||||
|
self.assertGreater(image.getpixel((50, 10))[2], 240)
|
||||||
|
self.assertGreater(image.getpixel((50, 45))[0], 240)
|
||||||
|
self.assertLess(image.getpixel((50, 45))[2], 10)
|
||||||
|
|
||||||
|
def test_reread_refreshes_answers_from_new_scores_and_supplements(self):
|
||||||
|
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.check_reread(module, mode)
|
||||||
|
|
||||||
|
def test_reread_does_not_create_named_returns(self):
|
||||||
|
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.check_reread(module, mode, existing_returns=False)
|
||||||
|
|
||||||
|
def test_failed_copy_does_not_republish_stale_annotations(self):
|
||||||
|
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.check_reread(module, mode, failed_copy=True)
|
||||||
|
|
||||||
|
def test_failed_publication_preserves_previous_answers(self):
|
||||||
|
for module, mode in ((standard, "Bnot"), (reader, "BGnot")):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
self.check_reread(module, mode, fail_publication=True)
|
||||||
|
|
||||||
|
|
||||||
class CompiledMembershipTests(unittest.TestCase):
|
class CompiledMembershipTests(unittest.TestCase):
|
||||||
def test_update_score_preserves_file_and_manual_value_wins(self):
|
def test_update_score_preserves_file_and_manual_value_wins(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import contextlib
|
||||||
|
from copy import deepcopy
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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.commands import annotating, reading_annotations as standard
|
||||||
|
from copienator.commands import reading_grouped_annotations as grouped
|
||||||
|
|
||||||
|
|
||||||
|
class IncrementalAnnotationReadsTests(unittest.TestCase):
|
||||||
|
def exercise(self, module, mode):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary, contextlib.ExitStack() as stack:
|
||||||
|
root = Path(temporary)
|
||||||
|
workspace = EvaluationWorkspace(root)
|
||||||
|
for folder in ("Copies", "Par label", mode, "Text2"):
|
||||||
|
(root / folder).mkdir()
|
||||||
|
(root / "labels").write_text("Ex 1\n")
|
||||||
|
atomic_write_json(root / "correction.json", {})
|
||||||
|
question = root / "Text2" / "Ex 1.pdf"
|
||||||
|
question.write_bytes(b"original statement")
|
||||||
|
data = {}
|
||||||
|
for copy_id in ("01", "02"):
|
||||||
|
output = root / mode / f"Copie{copy_id}"
|
||||||
|
output.mkdir()
|
||||||
|
atomic_write_json(output / "score.json", {"Ex 1": "2"})
|
||||||
|
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
|
||||||
|
pdf = root / "Copies" / f"{copy_id}.pdf"
|
||||||
|
pdf.write_bytes(b"student answer")
|
||||||
|
data[copy_id] = {"Ex 1": {"pdf_path": pdf,
|
||||||
|
"result": {"score": 1, "feedback": []}, "coordinates": (0, 0)}}
|
||||||
|
destination = workspace.return_dir / f"Renamed ({copy_id})" / "answers"
|
||||||
|
destination.mkdir(parents=True)
|
||||||
|
group = root / mode / "Ex 1 G1"
|
||||||
|
if mode == "BGnot":
|
||||||
|
group.mkdir()
|
||||||
|
atomic_write_json(group / "bnote.json", {"images": [
|
||||||
|
{"id": "01", "label": "Ex 1"}, {"id": "02", "label": "Ex 1"}]})
|
||||||
|
|
||||||
|
stack.enter_context(patch.object(configuration, "RETURN_ANSWERS_ENABLED", True))
|
||||||
|
stack.enter_context(patch.object(module, "load_annotation_data",
|
||||||
|
side_effect=lambda _: AnnotationLoadResult(deepcopy(data), [])))
|
||||||
|
detect = stack.enter_context(patch.object(standard, "detect_checks_and_notes",
|
||||||
|
return_value=([], None)))
|
||||||
|
scan = stack.enter_context(patch.object(grouped, "_scan_annotation_directory",
|
||||||
|
return_value=({}, {})))
|
||||||
|
stack.enter_context(patch.object(annotating, "make_base_image",
|
||||||
|
return_value=(Image.new("RGB", (100, 20), "white"), 0, 0)))
|
||||||
|
compose = stack.enter_context(patch.object(annotating, "compose_label_image",
|
||||||
|
return_value=(Image.new("RGB", (100, 50), "white"), 10)))
|
||||||
|
stack.enter_context(patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]))
|
||||||
|
stack.enter_context(patch.object(grouped, "save_paginated_pdf"))
|
||||||
|
stack.enter_context(patch.object(grouped, "save_checked_pdf"))
|
||||||
|
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||||
|
stack.enter_context(contextlib.redirect_stderr(io.StringIO()))
|
||||||
|
|
||||||
|
def run(expected_count, **options):
|
||||||
|
compose.reset_mock()
|
||||||
|
detect.reset_mock()
|
||||||
|
scan.reset_mock()
|
||||||
|
status = module.run(workspace, update_score=True,
|
||||||
|
return_answers_context=False, return_answers_question=options.get("question", False),
|
||||||
|
return_answers_solution=False)
|
||||||
|
self.assertEqual(status, ExitCode.SUCCESS)
|
||||||
|
self.assertEqual(compose.call_count, expected_count)
|
||||||
|
if expected_count == 0:
|
||||||
|
detect.assert_not_called()
|
||||||
|
scan.assert_not_called()
|
||||||
|
|
||||||
|
run(2) # First run records fingerprints.
|
||||||
|
untouched = root / mode / "Copie02" / "Ex 1.jpg"
|
||||||
|
untouched_time = untouched.stat().st_mtime_ns
|
||||||
|
run(0)
|
||||||
|
score = root / mode / "Copie01" / "score.json"
|
||||||
|
score.touch() # Timestamp-only changes do not cause rendering.
|
||||||
|
run(0)
|
||||||
|
score.write_text('{"Ex 1": "3.5"}')
|
||||||
|
run(1)
|
||||||
|
self.assertEqual(compose.call_args.args[2]["score"], "3.5")
|
||||||
|
self.assertEqual(untouched.stat().st_mtime_ns, untouched_time)
|
||||||
|
if mode == "BGnot":
|
||||||
|
self.assertEqual(scan.call_args.args[1], {"01"})
|
||||||
|
run(0)
|
||||||
|
|
||||||
|
# Per-copy sources and correction data invalidate only that copy.
|
||||||
|
(root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF")
|
||||||
|
run(1)
|
||||||
|
data["02"]["Ex 1"]["result"]["feedback"] = [{"text": "new feedback"}]
|
||||||
|
run(1)
|
||||||
|
# Changed tablet input cannot be mistaken for a score-only update.
|
||||||
|
annotation = (group if mode == "BGnot" else score.parent) / "Concat_annotated.pdf"
|
||||||
|
annotation.write_bytes(b"new tablet annotations")
|
||||||
|
run(2 if mode == "BGnot" else 1)
|
||||||
|
run(0)
|
||||||
|
|
||||||
|
# New shared documents and changed export options must not be skipped.
|
||||||
|
question.write_bytes(b"updated statement")
|
||||||
|
run(2)
|
||||||
|
run(2, question=True)
|
||||||
|
run(0, question=True)
|
||||||
|
# Missing generated files are rebuilt even when inputs match.
|
||||||
|
(root / mode / "Copie01" / "Ex 1.jpg").unlink()
|
||||||
|
run(1, question=True)
|
||||||
|
(workspace.return_dir / "Renamed (02)" / "answers" / "Ex 1.jpg").unlink()
|
||||||
|
run(1, question=True)
|
||||||
|
|
||||||
|
# A failed publication must be retried on the next run.
|
||||||
|
score.write_text('{"Ex 1": "3"}')
|
||||||
|
with patch.object(module, "update_named_return_answers", return_value=ExitCode.PARTIAL):
|
||||||
|
self.assertEqual(module.run(workspace, update_score=True,
|
||||||
|
return_answers_context=False, return_answers_question=True,
|
||||||
|
return_answers_solution=False), ExitCode.PARTIAL)
|
||||||
|
self.assertFalse((score.parent / STATE_FILE).exists())
|
||||||
|
run(1, question=True)
|
||||||
|
run(0, question=True)
|
||||||
|
|
||||||
|
# Normal reads still force regeneration and invalidate the snapshot.
|
||||||
|
if mode == "Bnot":
|
||||||
|
detect.return_value = ([{"label": "Ex 1", "type": "score", "value": 2}], None)
|
||||||
|
self.assertEqual(module.run(workspace), ExitCode.SUCCESS)
|
||||||
|
self.assertFalse((score.parent / STATE_FILE).exists())
|
||||||
|
|
||||||
|
def test_standard(self):
|
||||||
|
self.exercise(standard, "Bnot")
|
||||||
|
|
||||||
|
def test_grouped(self):
|
||||||
|
self.exercise(grouped, "BGnot")
|
||||||
Reference in new issue
Block a user