Support evaluation-local configuration and names overrides

This commit is contained in:
sebastien committed 2026-11-03 18:44:56 +01:00
1 parent 0bb6f7d97c
commit d4404e4c27
36 files changed
+409 -184

No files matched your search

+21
View File
@@ -191,6 +191,27 @@ ou éventuellement, la renseigner directement dans le fichier
** Configuration ** Configuration
Copier `default_config.py` en `config.py`. Éventuellement le modifier. Copier `default_config.py` en `config.py`. Éventuellement le modifier.
Les options absentes conservent leur valeur par défaut. La variable
=COPIENATOR_CONFIG= permet de choisir un autre fichier global.
Chaque dossier d'évaluation peut aussi contenir un =config.py= avec
seulement les options à remplacer, par exemple :
#+BEGIN_SRC python
RETURN_PDF_ENABLED = False
MODEL_FLASH_ID = "gemini-3.8-flash"
#+END_SRC
Ces options prennent le dessus sur la configuration globale, y compris
celle choisie par =COPIENATOR_CONFIG=. Les autres options restent héritées.
Les chemins relatifs gardent leur interprétation depuis le dossier de
lancement. Les options explicites des commandes prennent le dessus sur
la configuration locale. L'interface recharge ces options quand on ouvre
une évaluation.
Un fichier =names= dans le dossier d'évaluation remplace entièrement celui
du dossier de lancement, sans fusion des listes. En son absence, le fichier
global =names= reste utilisé. Une liste locale vide est également prioritaire.
Les niveaux de réflexion Gemini se règlent indépendamment des modèles : Les niveaux de réflexion Gemini se règlent indépendamment des modèles :
=THINKING_LEVEL_LITE= (énoncés, ="low"=), =THINKING_LEVEL_FOR_LABEL= =THINKING_LEVEL_LITE= (énoncés, ="low"=), =THINKING_LEVEL_FOR_LABEL=
+12 -7
View File
@@ -1,15 +1,20 @@
"""Locate tablet documents, including the parts of a large checked copy.""" """Locate tablet documents, including the parts of a large checked copy."""
from pathlib import Path from pathlib import Path
from copienator.configuration import ANNOTATION_MAX_HEIGHT_PX, ANNOTATION_MAX_PIXELS from copienator import configuration
from copienator.json_io import read_json from copienator.json_io import read_json
if type(ANNOTATION_MAX_HEIGHT_PX) is not int or ANNOTATION_MAX_HEIGHT_PX <= 0:
raise ValueError("ANNOTATION_MAX_HEIGHT_PX must be a positive integer (pixels)") def annotation_limits() -> tuple[int, int]:
MAX_HEIGHT_PX = ANNOTATION_MAX_HEIGHT_PX values = (configuration.ANNOTATION_MAX_HEIGHT_PX, configuration.ANNOTATION_MAX_PIXELS)
if type(ANNOTATION_MAX_PIXELS) is not int or ANNOTATION_MAX_PIXELS <= 0: for name, value in zip(("ANNOTATION_MAX_HEIGHT_PX", "ANNOTATION_MAX_PIXELS"), values):
raise ValueError("ANNOTATION_MAX_PIXELS must be a positive integer (pixels)") if type(value) is not int or value <= 0:
MAX_PIXELS = ANNOTATION_MAX_PIXELS raise ValueError(f"{name} must be a positive integer (pixels)")
return values
# Validate global settings at import, as before.
annotation_limits()
def document_fits(width: int, height: int, max_height: int, max_pixels: int) -> bool: def document_fits(width: int, height: int, max_height: int, max_pixels: int) -> bool:
+9
View File
@@ -109,6 +109,15 @@ def execute(
) -> int: ) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
from . import configuration
evaluation = getattr(args, "evaluation", None)
if evaluation is None and getattr(args, "target", None) is not None:
try:
evaluation = EvaluationWorkspace.discover(args.target).root
except WorkspaceNotFoundError:
# The handler owns target validation and its error reporting.
evaluation = None
configuration.use_evaluation(evaluation)
from .evaluation_lock import command_lock from .evaluation_lock import command_lock
with command_lock(args): with command_lock(args):
result = handler(args) result = handler(args)
+8 -16
View File
@@ -10,21 +10,11 @@ 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 import atomic_write_json, read_json
from copienator.configuration import ( from copienator import configuration
FINAL_SCORE_FONT_PATH,
FINAL_SCORE_HISTOGRAM_PATH,
FINAL_SCORE_ODS_PATH,
FINAL_SCORE_OUTPUT_DIR,
)
# Configuration constants
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
HISTOGRAM_PATH = Path(FINAL_SCORE_HISTOGRAM_PATH).expanduser()
def score_font(size): def score_font(size):
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"] candidates = [configuration.FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
for candidate in candidates: for candidate in candidates:
if not candidate: if not candidate:
continue continue
@@ -64,9 +54,10 @@ def copy_return_artifacts(source_dir: Path, destination_dir: Path) -> None:
def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -> None: def copy_histogram(output_dir: Path, *, copy_merged_histogramme: bool = False) -> None:
"""Copy the score histogram beside the per-student output folders.""" """Copy the score histogram beside the per-student output folders."""
sources = [(HISTOGRAM_PATH, "histogramme.pdf")] histogram_path = Path(configuration.FINAL_SCORE_HISTOGRAM_PATH).expanduser()
sources = [(histogram_path, "histogramme.pdf")]
if copy_merged_histogramme: if copy_merged_histogramme:
sources.append((HISTOGRAM_PATH.with_name("merged_histogramme.pdf"), "merged_histogramme.pdf")) sources.append((histogram_path.with_name("merged_histogramme.pdf"), "merged_histogramme.pdf"))
for source, filename in sources: for source, filename in sources:
if not source.is_file(): if not source.is_file():
print(f"Warning: Missing histogram '{source}'.") print(f"Warning: Missing histogram '{source}'.")
@@ -113,7 +104,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
try: try:
# header=None assumes the file starts directly with data. # header=None assumes the file starts directly with data.
# If row 0 is a header, change to header=0 # If row 0 is a header, change to header=0
df = pd.read_excel(ODS_PATH, engine="odf", header=None) df = pd.read_excel(Path(configuration.FINAL_SCORE_ODS_PATH).expanduser(), engine="odf", header=None)
# Create a lookup dictionary: {Name: Score} # Create a lookup dictionary: {Name: Score}
score_db = dict(zip(df[0], df[1])) score_db = dict(zip(df[0], df[1]))
except Exception as e: except Exception as e:
@@ -209,7 +200,8 @@ def main(argv=None):
args = parser.parse_args(argv) args = parser.parse_args(argv)
base_dir = args.dir.expanduser().resolve() base_dir = args.dir.expanduser().resolve()
output_dir = OUTPUT_DIR / base_dir.name configuration.use_evaluation(base_dir)
output_dir = Path(configuration.FINAL_SCORE_OUTPUT_DIR).expanduser() / base_dir.name
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir, copy_merged_histogramme=args.copy_merged_histogramme) process_images(base_dir, output_dir, copy_merged_histogramme=args.copy_merged_histogramme)
return 0 return 0
+3 -3
View File
@@ -20,8 +20,8 @@ from matplotlib import pyplot as plt
from pdf2image import convert_from_path from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from copienator import configuration
from copienator import utils from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -190,10 +190,10 @@ def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_li
line_spacing = int(fontsize * 1.2) line_spacing = int(fontsize * 1.2)
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width # Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
header = LATEX_ANOT_BEFORE.substitute( header = configuration.LATEX_ANOT_BEFORE.substitute(
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
) )
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}" latex_template = f"{header}{text}{configuration.LATEX_ANOT_AFTER}"
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
tex_path = os.path.join(temp_dir, 'text.tex') tex_path = os.path.join(temp_dir, 'text.tex')
+7 -5
View File
@@ -23,7 +23,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX, MAX_PIXELS, document_fits from copienator.annotation_documents import annotation_limits, document_fits
from copienator.commands import annotating, annotating_with_checks from copienator.commands import annotating, annotating_with_checks
from copienator.commands.import_annotations import clear_import_directory from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
@@ -60,15 +60,16 @@ def render_item(item):
def save_batch(batch, prefix, group_id, output_root: Path) -> None: def save_batch(batch, prefix, group_id, output_root: Path) -> None:
max_height_px, max_pixels = annotation_limits()
output_dir = output_root / f"{prefix} G{group_id}" output_dir = output_root / f"{prefix} G{group_id}"
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)") print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width for item in batch) max_width = max(item[2].width for item in batch)
total_height = sum(item[2].height for item in batch) total_height = sum(item[2].height for item in batch)
if not document_fits(max_width, total_height, MAX_HEIGHT_PX, MAX_PIXELS): if not document_fits(max_width, total_height, max_height_px, max_pixels):
raise CliError( raise CliError(
f"{prefix} G{group_id} would be {max_width} × {total_height} px " f"{prefix} G{group_id} would be {max_width} × {total_height} px "
f"({max_width * total_height} pixels); document limits are " f"({max_width * total_height} pixels); document limits are "
f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." f"{max_height_px} px high and {max_pixels} pixels."
) )
concatenated = Image.new("RGB", (max_width, total_height), "white") concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated) draw = ImageDraw.Draw(concatenated)
@@ -280,12 +281,13 @@ def _existing_group_state(
def split_batches(rendered): def split_batches(rendered):
max_height_px, max_pixels = annotation_limits()
batches = [] batches = []
heights = [] heights = []
widths = [] widths = []
def fits(width, height): def fits(width, height):
return document_fits(width, height, MAX_HEIGHT_PX, MAX_PIXELS) return document_fits(width, height, max_height_px, max_pixels)
def add_block(items): def add_block(items):
width = max(item[2].width for item in items) width = max(item[2].width for item in items)
@@ -326,7 +328,7 @@ def split_batches(rendered):
f"Copie{student_id} ({item[1]}) is {image.width} × " f"Copie{student_id} ({item[1]}) is {image.width} × "
f"{image.height} px ({image.width * image.height} pixels); " f"{image.height} px ({image.width * image.height} pixels); "
f"a single answer cannot be split. Document limits are " f"a single answer cannot be split. Document limits are "
f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." f"{max_height_px} px high and {max_pixels} pixels."
) )
if block and not fits(max(width, image.width), height + image.height): if block and not fits(max(width, image.width), height + image.height):
add_block(block) add_block(block)
@@ -26,7 +26,7 @@ from copienator import (
workspace_from_target, workspace_from_target,
) )
from copienator.annotation_data import load_annotation_data from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX, MAX_PIXELS, copy_documents, document_fits from copienator.annotation_documents import annotation_limits, copy_documents, document_fits
from copienator.commands import annotating from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory from copienator.filesystem import staged_directory
@@ -160,6 +160,7 @@ def _render_student(
output_mode: str, output_mode: str,
output_root: Path | None = None, output_root: Path | None = None,
) -> str: ) -> str:
max_height_px, max_pixels = annotation_limits()
output_dir = ( output_dir = (
output_root output_root
if output_root is not None if output_root is not None
@@ -214,15 +215,15 @@ def _render_student(
height = 0 height = 0
width = 0 width = 0
for index, image in enumerate(label_images): for index, image in enumerate(label_images):
if not document_fits(image.width, image.height, MAX_HEIGHT_PX, MAX_PIXELS): if not document_fits(image.width, image.height, max_height_px, max_pixels):
raise CliError( raise CliError(
f"Copie{student_id} ({bnote_entries[index]['label']}) is " f"Copie{student_id} ({bnote_entries[index]['label']}) is "
f"{image.width} × {image.height} px ({image.width * image.height} pixels); " f"{image.width} × {image.height} px ({image.width * image.height} pixels); "
f"a single answer cannot be split. Document limits are " f"a single answer cannot be split. Document limits are "
f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." f"{max_height_px} px high and {max_pixels} pixels."
) )
if not document_fits(max(width, image.width), height + image.height, if not document_fits(max(width, image.width), height + image.height,
MAX_HEIGHT_PX, MAX_PIXELS): max_height_px, max_pixels):
batches.append((start, index)) batches.append((start, index))
start = index start = index
height = 0 height = 0
@@ -254,13 +255,14 @@ def _save_document(
checkbox_groups: list[list[dict[str, Any]]], checkbox_groups: list[list[dict[str, Any]]],
bnote_entries: list[dict[str, Any]], bnote_entries: list[dict[str, Any]],
) -> None: ) -> None:
max_height_px, max_pixels = annotation_limits()
max_width = max(image.width for image in label_images) max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images) total_height = sum(image.height for image in label_images)
if not document_fits(max_width, total_height, MAX_HEIGHT_PX, MAX_PIXELS): if not document_fits(max_width, total_height, max_height_px, max_pixels):
raise CliError( raise CliError(
f"Document would be {max_width} × {total_height} px " f"Document would be {max_width} × {total_height} px "
f"({max_width * total_height} pixels); document limits are " f"({max_width * total_height} pixels); document limits are "
f"{MAX_HEIGHT_PX} px high and {MAX_PIXELS} pixels." f"{max_height_px} px high and {max_pixels} pixels."
) )
concatenated = Image.new("RGB", (max_width, total_height), "white") concatenated = Image.new("RGB", (max_width, total_height), "white")
checkbox_map: list[dict[str, Any]] = [] checkbox_map: list[dict[str, Any]] = []
+16 -14
View File
@@ -41,9 +41,6 @@ from copienator.utils import enonce_total, read_all_labels
NB_THREADS = 12 NB_THREADS = 12
# PROXY_URL = "http://192.168.241.1:3128" # PROXY_URL = "http://192.168.241.1:3128"
MODEL_ID_pro = config.MODEL_PRO_ID
MODEL_ID_flash = config.MODEL_FLASH_ID
api_key = config.API_KEY
# Runtime globals retained while the processing helpers are migrated incrementally. # Runtime globals retained while the processing helpers are migrated incrementally.
INPUT_DIR = Path() INPUT_DIR = Path()
@@ -270,13 +267,18 @@ def reset_workspace(workspace: EvaluationWorkspace) -> None:
"_new PDFs from 'Par label'." "_new PDFs from 'Par label'."
) )
_DEFAULT_FALLBACK = object()
def call_gemini_with_retries(model_id, contents, config, def call_gemini_with_retries(model_id, contents, config,
fallback_model_id=MODEL_ID_flash): fallback_model_id=_DEFAULT_FALLBACK):
"""Handles requests to Gemini with a 1min and 5min retry mechanism, and quota fallback.""" """Handles requests to Gemini with a 1min and 5min retry mechanism, and quota fallback."""
global pro_quota_exhausted global pro_quota_exhausted
delays = [60, 300] delays = [60, 300]
request_seed = None request_seed = None
# Own the retries here so the SDK cannot silently resend a fixed seed. # Own the retries here so the SDK cannot silently resend a fixed seed.
if fallback_model_id is _DEFAULT_FALLBACK:
fallback_model_id = settings.MODEL_FLASH_ID
http_options = config.http_options or types.HttpOptions() http_options = config.http_options or types.HttpOptions()
config = config.model_copy(update={"http_options": http_options.model_copy( config = config.model_copy(update={"http_options": http_options.model_copy(
update={"retry_options": types.HttpRetryOptions(attempts=1)} update={"retry_options": types.HttpRetryOptions(attempts=1)}
@@ -289,7 +291,7 @@ def call_gemini_with_retries(model_id, contents, config,
config = config.model_copy(update={"seed": request_seed}) config = config.model_copy(update={"seed": request_seed})
tprint(f"\tGemini attempt {attempt + 1}/3: seed={request_seed}") tprint(f"\tGemini attempt {attempt + 1}/3: seed={request_seed}")
# Switch to fallback immediately if quota was exhausted by another thread # Switch to fallback immediately if quota was exhausted by another thread
if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id: if model_id == settings.MODEL_PRO_ID and pro_quota_exhausted and fallback_model_id:
model_id = fallback_model_id model_id = fallback_model_id
config = config.model_copy(update={"thinking_config": thinking_config( config = config.model_copy(update={"thinking_config": thinking_config(
settings.THINKING_LEVEL_FLASH settings.THINKING_LEVEL_FLASH
@@ -324,7 +326,7 @@ def call_gemini_with_retries(model_id, contents, config,
continue # Retry same model continue # Retry same model
# Immediately fallback to Flash without waiting if it's a Pro quota error # Immediately fallback to Flash without waiting if it's a Pro quota error
if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id: if is_quota_error and model_id == settings.MODEL_PRO_ID and fallback_model_id:
tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...") tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...")
model_id = fallback_model_id model_id = fallback_model_id
config = config.model_copy(update={"thinking_config": thinking_config( config = config.model_copy(update={"thinking_config": thinking_config(
@@ -347,7 +349,7 @@ def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
# pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf" # pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks) contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks)
response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config) response_text = call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config)
corrected_feedbacks = parse_correction_response(response_text) corrected_feedbacks = parse_correction_response(response_text)
global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]] global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]]
@@ -404,7 +406,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None):
if error_type == "wrong-label": if error_type == "wrong-label":
tprint(f"\tHandling wrong-label for {pid} {label}") tprint(f"\tHandling wrong-label for {pid} {label}")
contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt) contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt)
new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'') new_label = call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config).strip().strip('"\'')
if new_label not in all_labels: if new_label not in all_labels:
tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring") tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring")
res["error"] = "wrg-lbl:cldtfix" res["error"] = "wrg-lbl:cldtfix"
@@ -413,7 +415,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None):
tprint(f"\t\tLabel confirmed for Copie{pid}: regrading {label}.") tprint(f"\t\tLabel confirmed for Copie{pid}: regrading {label}.")
contents, config = prompting.request_for_confirmed_label(INPUT_DIR, pdf_path, label, pid) contents, config = prompting.request_for_confirmed_label(INPUT_DIR, pdf_path, label, pid)
response = parse_correction_response( response = parse_correction_response(
call_gemini_with_retries(MODEL_ID_flash, contents, config) call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config)
) )
if not isinstance(response, list) or len(response) != 1 or response[0].get("id") != pid: if not isinstance(response, list) or len(response) != 1 or response[0].get("id") != pid:
raise ValueError(f"Confirmed-label regrading must return exactly Copie{pid}") raise ValueError(f"Confirmed-label regrading must return exactly Copie{pid}")
@@ -469,7 +471,7 @@ def handle_label_errors(pid, label, res, pdf_path, *, group_geometry=None):
contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt) contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt)
tprint(f"\tHandling additional-answer for {pid} {label}") tprint(f"\tHandling additional-answer for {pid} {label}")
try: try:
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config)) add_labels = json.loads(call_gemini_with_retries(settings.MODEL_FLASH_ID, contents, config))
except CorrectionStopRequested: except CorrectionStopRequested:
raise raise
except Exception: # noqa: BLE001 - invalid auxiliary model response except Exception: # noqa: BLE001 - invalid auxiliary model response
@@ -555,7 +557,7 @@ def process_single_task(task_tuple, precomputed_response=None):
thinking_level=(settings.THINKING_LEVEL_FLASH if use_flash thinking_level=(settings.THINKING_LEVEL_FLASH if use_flash
else settings.THINKING_LEVEL_PRO), else settings.THINKING_LEVEL_PRO),
) )
model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro model_to_use = settings.MODEL_FLASH_ID if use_flash else settings.MODEL_PRO_ID
if precomputed_response: if precomputed_response:
tprint(f"Using saved response for: {label} {group_name}") tprint(f"Using saved response for: {label} {group_name}")
@@ -810,7 +812,7 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
global client, tasks_to_process global client, tasks_to_process
workers = request_workers(args) workers = request_workers(args)
if client is None: if client is None:
client = genai.Client(api_key=api_key) client = genai.Client(api_key=settings.API_KEY)
if args.refaire: if args.refaire:
refaire_path = INPUT_DIR / "refaire.json" refaire_path = INPUT_DIR / "refaire.json"
overwritten_path = INPUT_DIR / "overwritten_correction.json" overwritten_path = INPUT_DIR / "overwritten_correction.json"
@@ -979,8 +981,8 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
) )
print("Batch generation complete.") print("Batch generation complete.")
print(f" - {count_flash} requests saved to {batch_flash_file} (for {MODEL_ID_flash})") print(f" - {count_flash} requests saved to {batch_flash_file} (for {settings.MODEL_FLASH_ID})")
print(f" - {count_pro} requests saved to {batch_pro_file} (for {MODEL_ID_pro})") print(f" - {count_pro} requests saved to {batch_pro_file} (for {settings.MODEL_PRO_ID})")
print("Upload these files via the File API and create two separate batch jobs.") print("Upload these files via the File API and create two separate batch jobs.")
# If there's no live tasks to do, and we aren't doing a batched ingestion, exit right away # If there's no live tasks to do, and we aren't doing a batched ingestion, exit right away
+5 -5
View File
@@ -15,6 +15,7 @@ import pymupdf
from pdf2image import convert_from_path from pdf2image import convert_from_path
from PIL import Image, ImageTk from PIL import Image, ImageTk
from copienator import configuration
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -27,7 +28,6 @@ from copienator import (
) )
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.configuration import SHOW_PERSONAL_STEPS
from copienator.left_margin import analyse_margins from copienator.left_margin import analyse_margins
from copienator.name_grids import name_bottom_height_fraction from copienator.name_grids import name_bottom_height_fraction
from copienator.annotation_layout import fingerprint from copienator.annotation_layout import fingerprint
@@ -42,8 +42,8 @@ NARROW_PAGE_RATIO = 0.8
AUTO_STRIP_WIDTH_FACTOR = 1.10 AUTO_STRIP_WIDTH_FACTOR = 1.10
pdf_cache_lock = threading.Lock() pdf_cache_lock = threading.Lock()
# The requested spelling is kept as the public switch for this alternate layout. def personal_bindings_enabled() -> bool:
personnal_binding = bool(SHOW_PERSONAL_STEPS) return bool(configuration.SHOW_PERSONAL_STEPS)
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]: def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
@@ -316,7 +316,7 @@ class ImageReviewer:
self.root.bind("<Right>", lambda _event: self.on_shift(CROP_SHIFT_STEP)) self.root.bind("<Right>", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("<Up>", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) self.root.bind("<Up>", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
self.root.bind("<Down>", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP)) self.root.bind("<Down>", lambda _event: self.on_enlarge(-CROP_WIDTH_STEP))
if personnal_binding: if personal_bindings_enabled():
self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP)) self.root.bind("t", lambda _event: self.on_shift(-CROP_SHIFT_STEP))
self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP)) self.root.bind("n", lambda _event: self.on_shift(CROP_SHIFT_STEP))
self.root.bind("r", lambda _event: self.on_enlarge(CROP_WIDTH_STEP)) self.root.bind("r", lambda _event: self.on_enlarge(CROP_WIDTH_STEP))
@@ -416,7 +416,7 @@ class ImageReviewer:
self.label_img.image = tk_image self.label_img.image = tk_image
personal_instructions = ( personal_instructions = (
"\nt/s/r/n also available: left / narrower / wider / right" "\nt/s/r/n also available: left / narrower / wider / right"
if personnal_binding if personal_bindings_enabled()
else "" else ""
) )
analysis = schema.get('auto_margin') analysis = schema.get('auto_margin')
+2 -2
View File
@@ -3,6 +3,7 @@ import sys
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from copienator import configuration
from copienator import ( from copienator import (
EvaluationWorkspace, EvaluationWorkspace,
ExitCode, ExitCode,
@@ -10,7 +11,6 @@ from copienator import (
execute, execute,
workspace_from_args, workspace_from_args,
) )
from copienator.configuration import EXPORT_DIR
from copienator.annotation_documents import tablet_documents from copienator.annotation_documents import tablet_documents
from copienator.platform import replace_with_link_or_copy from copienator.platform import replace_with_link_or_copy
@@ -25,7 +25,7 @@ def export_directory(
source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name source_dir = workspace.annotation_dir("refaire") if source_dir_name == "BRnot" else workspace.root / source_dir_name
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
prefix = f"{session_id}__" if session_id else "" prefix = f"{session_id}__" if session_id else ""
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name sync_dir = Path(configuration.EXPORT_DIR).expanduser() / workspace.name
if session_id: if session_id:
sync_dir /= session_id sync_dir /= session_id
sync_dir.mkdir(parents=True, exist_ok=True) sync_dir.mkdir(parents=True, exist_ok=True)
+2 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import time import time
import webbrowser import webbrowser
from copienator import evaluation_parser from copienator import configuration, evaluation_parser
def main(argv=None): def main(argv=None):
@@ -14,6 +14,7 @@ def main(argv=None):
from copienator.finalisation_server import WebSession from copienator.finalisation_server import WebSession
session = None session = None
try: try:
configuration.use_evaluation(args.evaluation)
session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir) session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir)
print(session.url, flush=True) print(session.url, flush=True)
if not args.no_browser: if not args.no_browser:
+10 -13
View File
@@ -48,9 +48,6 @@ def get_lcp(s1: str, s2: str) -> str:
return lcp return lcp
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
# --- Modèles pour la Requête 1 --- # --- Modèles pour la Requête 1 ---
class StatementContent(BaseModel): class StatementContent(BaseModel):
@field_validator("question_content", "solution_content", "context_content", @field_validator("question_content", "solution_content", "context_content",
@@ -246,7 +243,7 @@ def find_file(folder: Path, base_name: str) -> Path | None:
def _request_rubrics(client, group_context_text: str): def _request_rubrics(client, group_context_text: str):
response = client.models.generate_content( response = client.models.generate_content(
model=MODEL_ID, model=config.MODEL_LITE_ID,
contents=[types.Content(role="user", parts=[ contents=[types.Content(role="user", parts=[
types.Part.from_text(text=PROMPT_4), types.Part.from_text(text=PROMPT_4),
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
@@ -378,13 +375,13 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
questions[label] = "\n".join(parts) questions[label] = "\n".join(parts)
context = utils.enonce_total(workspace.root) context = utils.enonce_total(workspace.root)
if api_client is None: if api_client is None:
if not api_key: if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured") raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key) api_client = genai.Client(api_key=config.API_KEY)
if mode == "groups": if mode == "groups":
response = api_client.models.generate_content( response = api_client.models.generate_content(
model=MODEL_ID, model=config.MODEL_LITE_ID,
contents=[types.Content(role="user", parts=[types.Part.from_text(text=( contents=[types.Content(role="user", parts=[types.Part.from_text(text=(
"Regroupe ces questions d’examen en groupes cohérents pour la correction " "Regroupe ces questions d’examen en groupes cohérents pour la correction "
"et l’annotation, selon leurs dépendances et leur contexte commun. " "et l’annotation, selon leurs dépendances et leur contexte commun. "
@@ -515,9 +512,9 @@ def process_exam(
correction_text = correction_path.read_text(encoding="utf-8") correction_text = correction_path.read_text(encoding="utf-8")
if api_client is None: if api_client is None:
if not api_key: if not config.API_KEY:
raise CliError("GEMINI_API_KEY is not configured") raise CliError("GEMINI_API_KEY is not configured")
api_client = genai.Client(api_key=api_key) api_client = genai.Client(api_key=config.API_KEY)
client = api_client client = api_client
# ========================================== # ==========================================
@@ -543,7 +540,7 @@ def process_exam(
questions_data = ExamQuestions(questions=extract_stage( questions_data = ExamQuestions(questions=extract_stage(
folder, 'questions', QuestionOnlyItem, 'question_content', folder, 'questions', QuestionOnlyItem, 'question_content',
lambda _pending: client.models.generate_content(model=MODEL_ID, contents=contents_1, config=config_1), lambda _pending: client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents_1, config=config_1),
)) ))
# ========================================== # ==========================================
@@ -567,7 +564,7 @@ def process_exam(
types.Part.from_text(text='--- QUESTIONS EXTRAITES ---\n' + selected.model_dump_json(indent=2)), types.Part.from_text(text='--- QUESTIONS EXTRAITES ---\n' + selected.model_dump_json(indent=2)),
types.Part.from_text(text=f'--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}'), types.Part.from_text(text=f'--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}'),
])] ])]
return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_2) return client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents, config=config_2)
solutions_data = ExamSolutions(solutions=extract_stage( solutions_data = ExamSolutions(solutions=extract_stage(
folder, 'solutions', SolutionOnlyItem, 'solution_content', request_solutions, labels=question_labels, folder, 'solutions', SolutionOnlyItem, 'solution_content', request_solutions, labels=question_labels,
)) ))
@@ -599,7 +596,7 @@ def process_exam(
contents = [types.Content(role='user', parts=[*contents_3[0].parts, contents = [types.Content(role='user', parts=[*contents_3[0].parts,
types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : ' types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : '
+ ', '.join(pending))])] + ', '.join(pending))])]
return client.models.generate_content(model=MODEL_ID, contents=contents, config=config_3) return client.models.generate_content(model=config.MODEL_LITE_ID, contents=contents, config=config_3)
context_data = ExamContext(contexts=extract_stage( context_data = ExamContext(contexts=extract_stage(
folder, 'contexts', ExtractedContext, 'context_content', request_contexts, labels=question_labels, folder, 'contexts', ExtractedContext, 'context_content', request_contexts, labels=question_labels,
)) ))
@@ -1083,7 +1080,7 @@ def compile_existing_pdfs(workspace: EvaluationWorkspace) -> ExitCode:
if not source_path.is_file(): if not source_path.is_file():
source_path = None source_path = None
fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path) fingerprint = hashlib.sha256((utils.fragment_latex_header(source_path)
+ utils.LATEX_AFTER + str(source_path)).encode()).hexdigest() + config.LATEX_AFTER + str(source_path)).encode()).hexdigest()
status = json.loads(status_path.read_text()) if status_path.exists() else {} status = json.loads(status_path.read_text()) if status_path.exists() else {}
failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint
if (pdf_path.is_file() and pdf_path.stat().st_size > 0 if (pdf_path.is_file() and pdf_path.stat().st_size > 0
+3 -5
View File
@@ -31,8 +31,6 @@ from copienator import (
) )
from copienator.utils import natural_key, read_all_labels from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
REQUEST_TIMEOUT_SECONDS = 80 REQUEST_TIMEOUT_SECONDS = 80
my_prompt = """I'm giving you an image of the left columns of a written exam. my_prompt = """I'm giving you an image of the left columns of a written exam.
@@ -583,7 +581,7 @@ def process_copy_group(
known_name=known_name, known_name=known_name,
) )
response = client.models.generate_content( response = client.models.generate_content(
model=MODEL_ID, model=config.MODEL_FOR_LABEL_ID,
contents=contents, contents=contents,
config=request_config, config=request_config,
) )
@@ -720,7 +718,7 @@ def run(
line.strip() for line in names_text.splitlines() if line.strip() line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"} } | {"Unknown", "Continued"}
if client is None: if client is None:
client = genai.Client(api_key=api_key) client = genai.Client(api_key=config.API_KEY)
groups = group_images(images) groups = group_images(images)
pending_groups = {key: files for key, files in groups.items() pending_groups = {key: files for key, files in groups.items()
@@ -828,7 +826,7 @@ def main(argv: Sequence[str] | None = None) -> int:
def handle(args: argparse.Namespace) -> ExitCode: def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target( workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2] args, repository=Path.cwd()
) )
targets = [target] targets = [target]
for additional in args.additional_targets: for additional in args.additional_targets:
+4 -4
View File
@@ -16,15 +16,15 @@ from copienator import configuration
from copienator.annotation_documents import tablet_documents from copienator.annotation_documents import tablet_documents
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot") ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
def clear_import_directory(workspace: EvaluationWorkspace) -> None: def clear_import_directory(workspace: EvaluationWorkspace) -> None:
"""Empty the configured import folder while preserving the folder itself.""" """Empty the configured import folder while preserving the folder itself."""
if IMPORT_DIR is None or not str(IMPORT_DIR).strip(): import_dir = configuration.IMPORT_DIR
if import_dir is None or not str(import_dir).strip():
print("Aucun dossier d’import configuré ; aucun fichier supprimé.") print("Aucun dossier d’import configuré ; aucun fichier supprimé.")
return return
directory = Path(IMPORT_DIR).expanduser().resolve() directory = Path(import_dir).expanduser().resolve()
if workspace.root.resolve().is_relative_to(directory) or Path.cwd().resolve().is_relative_to(directory): if workspace.root.resolve().is_relative_to(directory) or Path.cwd().resolve().is_relative_to(directory):
raise CliError(f"Le dossier d’import ne peut pas contenir l’évaluation ou le projet : {directory}", raise CliError(f"Le dossier d’import ne peut pas contenir l’évaluation ou le projet : {directory}",
ExitCode.INVALID_ARGUMENTS) ExitCode.INVALID_ARGUMENTS)
@@ -112,7 +112,7 @@ def run(
return sync_annotated( return sync_annotated(
workspace, workspace,
annotation_dir_name="BRnot" if refaire else annotation_dir, annotation_dir_name="BRnot" if refaire else annotation_dir,
import_dir=Path(IMPORT_DIR), import_dir=Path(configuration.IMPORT_DIR),
) )
+3 -11
View File
@@ -16,7 +16,7 @@ import pymupdf # PyMuPDF
from PIL import Image, ImageDraw, ImageTk from PIL import Image, ImageDraw, ImageTk
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from copienator.configuration import PAGE_SPLITTER_KB from copienator import configuration
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -29,14 +29,6 @@ from copienator.platform import launch_pdf_arranger
from copienator.copy_errors import marked_copy_paths, clear_copy_error from copienator.copy_errors import marked_copy_paths, clear_copy_error
from copienator.orientation import save_provenance, report_path from copienator.orientation import save_provenance, report_path
# Keep new shortcuts available with older personal configuration files.
PAGE_SPLITTER_KB = {
"reverse_pages": "i",
"add_secondary_splitter": "<Insert>",
"delete_secondary_splitter": "<Delete>",
**PAGE_SPLITTER_KB,
}
# --- Constants --- # --- Constants ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch) # Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72 CM_TO_POINTS = (1 / 2.54) * 72
@@ -172,7 +164,7 @@ class PDFPreviewer:
self.master.geometry("800x1000") self.master.geometry("800x1000")
def fmt(action): def fmt(action):
k = PAGE_SPLITTER_KB.get(action, "") k = configuration.PAGE_SPLITTER_KB.get(action, "")
return k return k
# Dynamic instructions text # Dynamic instructions text
@@ -216,7 +208,7 @@ class PDFPreviewer:
"prev_file": self.go_to_previous_file, "prev_file": self.go_to_previous_file,
} }
for action, key in PAGE_SPLITTER_KB.items(): for action, key in configuration.PAGE_SPLITTER_KB.items():
if action in action_map: if action in action_map:
self.master.bind(key, action_map[action]) self.master.bind(key, action_map[action])
+3 -3
View File
@@ -420,19 +420,19 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--return-answers-context", "--return-answers-context",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT, default=None,
help="Include applicable context pages in individual answer exports", help="Include applicable context pages in individual answer exports",
) )
parser.add_argument( parser.add_argument(
"--return-answers-question", "--return-answers-question",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION, default=None,
help="Include the current question PDF in individual answer exports", help="Include the current question PDF in individual answer exports",
) )
parser.add_argument( parser.add_argument(
"--return-answers-solution", "--return-answers-solution",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION, default=None,
help="Include the current solution PDF in individual answer exports", help="Include the current solution PDF in individual answer exports",
) )
return parser return parser
@@ -750,19 +750,19 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--return-answers-context", "--return-answers-context",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT, default=None,
help="Include applicable context pages in individual answer exports", help="Include applicable context pages in individual answer exports",
) )
parser.add_argument( parser.add_argument(
"--return-answers-question", "--return-answers-question",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION, default=None,
help="Include the current question PDF in individual answer exports", help="Include the current question PDF in individual answer exports",
) )
parser.add_argument( parser.add_argument(
"--return-answers-solution", "--return-answers-solution",
action=argparse.BooleanOptionalAction, action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION, default=None,
help="Include the current solution PDF in individual answer exports", help="Include the current solution PDF in individual answer exports",
) )
return parser return parser
+7 -6
View File
@@ -6,11 +6,10 @@ from pathlib import Path
import ezodf import ezodf
from copienator.configuration import CURRENT_SCORE_ODS_PATH from copienator import configuration
from copienator.utils import read_all_labels from copienator.utils import read_all_labels
# Configuration # Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre" TARGET_DIR_NAME = "A Rendre"
def main(argv=None): def main(argv=None):
@@ -20,6 +19,8 @@ def main(argv=None):
args = parser.parse_args(argv) args = parser.parse_args(argv)
work_dir = os.path.abspath(args.work_dir) work_dir = os.path.abspath(args.work_dir)
configuration.use_evaluation(work_dir)
ods_path = Path(configuration.CURRENT_SCORE_ODS_PATH).expanduser()
all_labels = read_all_labels(Path(work_dir)) all_labels = read_all_labels(Path(work_dir))
@@ -29,13 +30,13 @@ def main(argv=None):
print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}") print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}")
sys.exit(1) sys.exit(1)
if not os.path.exists(ODS_PATH): if not os.path.exists(ods_path):
print(f"Error: ODS file not found at {ODS_PATH}") print(f"Error: ODS file not found at {ods_path}")
sys.exit(1) sys.exit(1)
print(f"Opening ODS file: {ODS_PATH}...") print(f"Opening ODS file: {ods_path}...")
try: try:
doc = ezodf.opendoc(str(ODS_PATH)) doc = ezodf.opendoc(str(ods_path))
except Exception as e: except Exception as e:
print(f"Failed to open ODS: {e}") print(f"Failed to open ODS: {e}")
sys.exit(1) sys.exit(1)
+64 -28
View File
@@ -25,32 +25,68 @@ def _load_user_config(path: Path) -> ModuleType:
return module return module
CONFIG_PATH = _user_config_path() def _settings(path: Path | None) -> dict[str, object]:
if CONFIG_PATH is None: import default_config
import default_config as _configuration
else:
_configuration = _load_user_config(CONFIG_PATH)
# Keep new optional settings compatible with older personal configuration files. values = {
THINKING_LEVEL_LITE = "low" name: getattr(default_config, name)
THINKING_LEVEL_FOR_LABEL = "medium" for name in dir(default_config)
MODEL_FOR_NAME_ID = getattr(_configuration, "MODEL_FOR_LABEL_ID", "gemini-3.8-flash") if not name.startswith("_")
THINKING_LEVEL_FOR_NAME = "low" }
THINKING_LEVEL_FLASH = "medium" if path is not None:
THINKING_LEVEL_PRO = "high" # Preserve compatibility defaults for older personal configurations.
ALWAYS_CROP = False values.update(THINKING_LEVEL_FOR_LABEL="medium", THINKING_LEVEL_PRO="high")
ANNOTATION_MARGIN_LEFT = 300 module = _load_user_config(path)
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)] values.update({
ANNOTATION_MAX_HEIGHT_PX = 33000 name: getattr(module, name)
ANNOTATION_MAX_PIXELS = 67_108_864 for name in dir(module)
SHOW_PERSONAL_STEPS = False if not name.startswith("_")
RETURN_JPEG_ENABLED = True })
RETURN_PDF_ENABLED = True # Historically the name recognizer inherited the label model when omitted.
RETURN_ANSWERS_ENABLED = False if not hasattr(module, "MODEL_FOR_NAME_ID"):
RETURN_ANSWERS_CONTEXT = False values["MODEL_FOR_NAME_ID"] = values["MODEL_FOR_LABEL_ID"]
RETURN_ANSWERS_QUESTION = True values["PAGE_SPLITTER_KB"] = {
RETURN_ANSWERS_SOLUTION = False "reverse_pages": "i",
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf") "add_secondary_splitter": "<Insert>",
for _name in dir(_configuration): "delete_secondary_splitter": "<Delete>",
if not _name.startswith("_"): **values["PAGE_SPLITTER_KB"],
globals()[_name] = getattr(_configuration, _name) }
return values
CONFIG_PATH = _user_config_path()
_global_settings = _settings(CONFIG_PATH)
globals().update(_global_settings)
LOCAL_CONFIG_PATH: Path | None = None
_evaluation_overrides: set[str] = set()
def use_evaluation(root: str | Path | None) -> None:
"""Apply selected evaluation settings, resetting any previous overrides."""
global LOCAL_CONFIG_PATH, _evaluation_overrides
path = Path(root).expanduser().resolve() / "config.py" if root is not None else None
values = dict(_global_settings)
overrides = {}
if path is not None and path.is_file() and path != CONFIG_PATH:
module = _load_user_config(path)
overrides = {
name: getattr(module, name)
for name in dir(module)
if name.isupper() and name in _global_settings
}
values.update(overrides)
else:
path = None
for name in ("ANNOTATION_MAX_HEIGHT_PX", "ANNOTATION_MAX_PIXELS"):
if type(values[name]) is not int or values[name] <= 0:
raise ValueError(f"{name} must be a positive integer (pixels)")
values["PAGE_SPLITTER_KB"] = {
"reverse_pages": "i",
"add_secondary_splitter": "<Insert>",
"delete_secondary_splitter": "<Delete>",
**values["PAGE_SPLITTER_KB"],
}
for name in _evaluation_overrides | overrides.keys():
globals()[name] = values[name]
_evaluation_overrides = set(overrides)
LOCAL_CONFIG_PATH = path
+8 -8
View File
@@ -1,6 +1,7 @@
import re import re
from pathlib import Path from pathlib import Path
from copienator import configuration
from copienator import EvaluationWorkspace from copienator import EvaluationWorkspace
from .platform import validate_windows_labels from .platform import validate_windows_labels
@@ -177,7 +178,6 @@ import tempfile
import shutil import shutil
import subprocess import subprocess
from .configuration import LATEX_AFTER, LATEX_BEFORE
LATEX_WARNING_MARKER = "[Copienator:latex-warning] " LATEX_WARNING_MARKER = "[Copienator:latex-warning] "
@@ -190,19 +190,19 @@ def report_latex_warning(output_pdf_path) -> None:
def fragment_latex_header(source_path=None): def fragment_latex_header(source_path=None):
"""Keep source packages/macros while using the configured fragment layout.""" """Keep source packages/macros while using the configured fragment layout."""
if source_path is None: if source_path is None:
return LATEX_BEFORE return configuration.LATEX_BEFORE
source = Path(source_path).read_text(encoding="utf-8") source = Path(source_path).read_text(encoding="utf-8")
# Ignore commented document commands when locating the preamble. # Ignore commented document commands when locating the preamble.
source = re.sub(r"(?m)(?<!\\)%[^\n]*", "", source) source = re.sub(r"(?m)(?<!\\)%[^\n]*", "", source)
begin = re.search(r"\\begin\s*\{document\}", source) begin = re.search(r"\\begin\s*\{document\}", source)
document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}" document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}"
configured_class = re.search(document_class, LATEX_BEFORE) configured_class = re.search(document_class, configuration.LATEX_BEFORE)
configured_begin = re.search(r"\\begin\s*\{document\}", LATEX_BEFORE) configured_begin = re.search(r"\\begin\s*\{document\}", configuration.LATEX_BEFORE)
if begin is None or configured_class is None or configured_begin is None: if begin is None or configured_class is None or configured_begin is None:
return LATEX_BEFORE return configuration.LATEX_BEFORE
preamble = source[:begin.start()] preamble = source[:begin.start()]
if not re.search(document_class, preamble): if not re.search(document_class, preamble):
return LATEX_BEFORE return configuration.LATEX_BEFORE
preamble = re.sub(document_class, lambda _: configured_class.group(), preamble, count=1) preamble = re.sub(document_class, lambda _: configured_class.group(), preamble, count=1)
# geometry overrides standalone's crop coordinates and can produce blank or # geometry overrides standalone's crop coordinates and can produce blank or
# clipped PDFs even when LaTeX succeeds. Keep other source packages intact. # clipped PDFs even when LaTeX succeeds. Keep other source packages intact.
@@ -214,12 +214,12 @@ def fragment_latex_header(source_path=None):
preamble = re.sub(r'\\usepackage\s*(\[[^\]]*\])?\s*\{([^}]+)\}', preamble = re.sub(r'\\usepackage\s*(\[[^\]]*\])?\s*\{([^}]+)\}',
without_geometry, preamble) without_geometry, preamble)
preamble = re.sub(r'\\(?:geometry|newgeometry)\s*\{[^}]*\}', '', preamble) preamble = re.sub(r'\\(?:geometry|newgeometry)\s*\{[^}]*\}', '', preamble)
return preamble + LATEX_BEFORE[configured_begin.start():] return preamble + configuration.LATEX_BEFORE[configured_begin.start():]
def compile_to_pdf(text, output_pdf_path, *, strict=False, source_path=None): def compile_to_pdf(text, output_pdf_path, *, strict=False, source_path=None):
"""Wraps text in standalone header/footer templates and compiles it to PDF.""" """Wraps text in standalone header/footer templates and compiles it to PDF."""
latex_template = f"{fragment_latex_header(source_path)}{text}{LATEX_AFTER}" latex_template = f"{fragment_latex_header(source_path)}{text}{configuration.LATEX_AFTER}"
env = os.environ.copy() env = os.environ.copy()
if source_path is not None: if source_path is not None:
source_dir = str(Path(source_path).resolve().parent) source_dir = str(Path(source_path).resolve().parent)
+3 -2
View File
@@ -260,9 +260,10 @@ class EvaluationWorkspace:
def names_file(self) -> Path: def names_file(self) -> Path:
local = self.root / "names" local = self.root / "names"
if local.exists() or self.repository is None: if local.is_file():
return local return local
return self.repository / "names" repository = self.repository if self.repository is not None else Path.cwd()
return repository / "names"
def read_labels(self) -> list[str]: def read_labels(self) -> list[str]:
return [ return [
+14 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Any from typing import Any
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json from copienator import configuration, CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.commands.resolve_manual import pending_manual_conflicts from copienator.commands.resolve_manual import pending_manual_conflicts
from copienator.copy_errors import copy_errors from copienator.copy_errors import copy_errors
from copienator.orientation import reports as orientation_reports from copienator.orientation import reports as orientation_reports
@@ -275,6 +275,7 @@ class CopienatorApp(tk.Tk):
super().__init__() super().__init__()
self.repository = repository.resolve() self.repository = repository.resolve()
self.show_personal_steps = show_personal_steps self.show_personal_steps = show_personal_steps
self._global_personal_steps = show_personal_steps
self.steps = build_workflow(show_personal_steps) self.steps = build_workflow(show_personal_steps)
self.step_by_id = {step.id: step for step in self.steps} self.step_by_id = {step.id: step for step in self.steps}
self.state_store = StateStore() self.state_store = StateStore()
@@ -739,10 +740,22 @@ class CopienatorApp(tk.Tk):
if not evaluation or not evaluation.is_dir(): if not evaluation or not evaluation.is_dir():
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
return return
try:
configuration.use_evaluation(evaluation)
except Exception as exc:
messagebox.showerror("Configuration invalide", str(exc))
return
self.batch_monitor.stop() self.batch_monitor.stop()
self._reset_latex_warnings() self._reset_latex_warnings()
self._save_current_form() self._save_current_form()
self.state_store.load(evaluation) self.state_store.load(evaluation)
self.show_personal_steps = (
bool(configuration.SHOW_PERSONAL_STEPS)
if configuration.LOCAL_CONFIG_PATH is not None
else self._global_personal_steps
)
self.steps = build_workflow(self.show_personal_steps)
self.step_by_id = {step.id: step for step in self.steps}
for step in self.steps: for step in self.steps:
if self.state_store.step(step.id).get("status") == "running": if self.state_store.step(step.id).get("status") == "running":
self.state_store.update_step(step.id, status="interrupted") self.state_store.update_step(step.id, status="interrupted")
+2 -3
View File
@@ -8,7 +8,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from copienator import configuration from copienator import configuration
from copienator.configuration import ALWAYS_CROP
EVALUATION = "${evaluation}" EVALUATION = "${evaluation}"
EVALUATION_NAME = "${evaluation_name}" EVALUATION_NAME = "${evaluation_name}"
@@ -295,7 +294,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
), ),
optional=True, optional=True,
requires=("Copies",), requires=("Copies",),
auto_start_first_visit=ALWAYS_CROP, auto_start_first_visit=configuration.ALWAYS_CROP,
), ),
StepDefinition( StepDefinition(
"cutleft", "cutleft",
@@ -435,7 +434,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
), ),
optional=True, optional=True,
requires=("Copies/Copie*/*.pdf",), requires=("Copies/Copie*/*.pdf",),
auto_start_first_visit=ALWAYS_CROP, auto_start_first_visit=configuration.ALWAYS_CROP,
), ),
StepDefinition( StepDefinition(
"grouping", "grouping",
+4 -4
View File
@@ -44,7 +44,7 @@ class AddFinalScoreTests(unittest.TestCase):
merged.write_bytes(b'merged') merged.write_bytes(b'merged')
output = root / 'output' output = root / 'output'
output.mkdir() output.mkdir()
with patch.object(add_final_score, 'HISTOGRAM_PATH', histogram): with patch.object(add_final_score.configuration, 'FINAL_SCORE_HISTOGRAM_PATH', histogram):
add_final_score.copy_histogram(output) add_final_score.copy_histogram(output)
self.assertFalse((output / merged.name).exists()) self.assertFalse((output / merged.name).exists())
add_final_score.copy_histogram(output, copy_merged_histogramme=True) add_final_score.copy_histogram(output, copy_merged_histogramme=True)
@@ -54,7 +54,7 @@ class AddFinalScoreTests(unittest.TestCase):
def test_cli_forwards_merged_histogram_option(self): def test_cli_forwards_merged_histogram_option(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
with patch.object(add_final_score, 'OUTPUT_DIR', root / 'output'), patch.object( with patch.object(add_final_score.configuration, 'FINAL_SCORE_OUTPUT_DIR', root / 'output'), patch.object(
add_final_score, 'process_images' add_final_score, 'process_images'
) as process: ) as process:
self.assertEqual(add_final_score.main([str(root), '--copy-merged-histogramme']), 0) self.assertEqual(add_final_score.main([str(root), '--copy-merged-histogramme']), 0)
@@ -83,7 +83,7 @@ class AddFinalScoreTests(unittest.TestCase):
histogram = root / "histogramme.pdf" histogram = root / "histogramme.pdf"
histogram.write_bytes(b"histogram") histogram.write_bytes(b"histogram")
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \ with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
patch.object(add_final_score, "HISTOGRAM_PATH", histogram), \ patch.object(add_final_score.configuration, "FINAL_SCORE_HISTOGRAM_PATH", histogram), \
contextlib.redirect_stdout(io.StringIO()): contextlib.redirect_stdout(io.StringIO()):
add_final_score.process_images(root, output) add_final_score.process_images(root, output)
@@ -137,7 +137,7 @@ class AddFinalScoreTests(unittest.TestCase):
with patch.object( with patch.object(
add_final_score.pd, "read_excel", return_value=scores add_final_score.pd, "read_excel", return_value=scores
), patch.object( ), patch.object(
add_final_score, "HISTOGRAM_PATH", root / "missing.pdf" add_final_score.configuration, "FINAL_SCORE_HISTOGRAM_PATH", root / "missing.pdf"
), contextlib.redirect_stdout(messages): ), contextlib.redirect_stdout(messages):
add_final_score.process_images(root, output) add_final_score.process_images(root, output)
+1 -1
View File
@@ -16,7 +16,7 @@ class AnnotationHeightConfigTests(unittest.TestCase):
[sys.executable, "-c", [sys.executable, "-c",
"from copienator.commands import annotating_by_label as grouped; " "from copienator.commands import annotating_by_label as grouped; "
"from copienator.commands import annotating_with_checks as checks; " "from copienator.commands import annotating_with_checks as checks; "
f"print(grouped.{attribute}, checks.{attribute})"], f"print(grouped.annotation_limits()[{0 if attribute == 'MAX_HEIGHT_PX' else 1}], checks.annotation_limits()[{0 if attribute == 'MAX_HEIGHT_PX' else 1}])"],
cwd=Path(__file__).resolve().parents[1], cwd=Path(__file__).resolve().parents[1],
env={**os.environ, "COPIENATOR_CONFIG": str(config)}, env={**os.environ, "COPIENATOR_CONFIG": str(config)},
capture_output=True, text=True, check=False, capture_output=True, text=True, check=False,
+3 -3
View File
@@ -24,7 +24,7 @@ class AnnotationImportCleanupTests(unittest.TestCase):
(self.workspace.root / "correction.json").write_text("{}") (self.workspace.root / "correction.json").write_text("{}")
self.import_dir = self.root / "Annotées" self.import_dir = self.root / "Annotées"
self.import_dir.mkdir() self.import_dir.mkdir()
self.patch = patch.object(import_annotations, "IMPORT_DIR", self.import_dir) self.patch = patch.object(import_annotations.configuration, "IMPORT_DIR", self.import_dir)
self.patch.start() self.patch.start()
self.addCleanup(self.patch.stop) self.addCleanup(self.patch.stop)
@@ -45,12 +45,12 @@ class AnnotationImportCleanupTests(unittest.TestCase):
def test_cleanup_handles_unconfigured_or_missing_folder(self): def test_cleanup_handles_unconfigured_or_missing_folder(self):
for configured in (None, "", self.root / "absent"): for configured in (None, "", self.root / "absent"):
with self.subTest(configured=configured), \ with self.subTest(configured=configured), \
patch.object(import_annotations, "IMPORT_DIR", configured): patch.object(import_annotations.configuration, "IMPORT_DIR", configured):
import_annotations.clear_import_directory(self.workspace) import_annotations.clear_import_directory(self.workspace)
self.assertFalse((self.root / "absent").exists()) self.assertFalse((self.root / "absent").exists())
def test_cleanup_rejects_an_ancestor_of_evaluation(self): def test_cleanup_rejects_an_ancestor_of_evaluation(self):
with patch.object(import_annotations, "IMPORT_DIR", self.root), \ with patch.object(import_annotations.configuration, "IMPORT_DIR", self.root), \
self.assertRaises(CliError): self.assertRaises(CliError):
import_annotations.clear_import_directory(self.workspace) import_annotations.clear_import_directory(self.workspace)
self.assertTrue(self.workspace.root.is_dir()) self.assertTrue(self.workspace.root.is_dir())
+8 -7
View File
@@ -6,9 +6,10 @@ from unittest.mock import patch
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from copienator import configuration
from copienator import CliError, EvaluationWorkspace, ExitCode, read_json from copienator import CliError, EvaluationWorkspace, ExitCode, read_json
from copienator.annotation_data import AnnotationLoadResult from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents from copienator.annotation_documents import annotation_limits, copy_documents
from copienator.commands import annotating_with_checks as checks from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations, reading_annotations as reader from copienator.commands import export, import_annotations, reading_annotations as reader
from copienator.commands import reading_grouped_annotations as grouped_reader from copienator.commands import reading_grouped_annotations as grouped_reader
@@ -46,7 +47,7 @@ class CheckedDocumentTests(unittest.TestCase):
def generate(self, overwrite=False): def generate(self, overwrite=False):
with ( with (
patch.object(checks, 'MAX_HEIGHT_PX', 100), patch.object(configuration, 'ANNOTATION_MAX_HEIGHT_PX', 100),
patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)), patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)),
patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose), patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose),
): ):
@@ -54,7 +55,7 @@ class CheckedDocumentTests(unittest.TestCase):
overwrite=overwrite, output_mode='checks') overwrite=overwrite, output_mode='checks')
def test_splits_on_labels_and_resets_geometry_at_each_part(self): def test_splits_on_labels_and_resets_geometry_at_each_part(self):
self.assertEqual(MAX_HEIGHT_PX, 33000) self.assertEqual(annotation_limits()[0], 33000)
self.assertEqual(self.generate(), 'success') self.assertEqual(self.generate(), 'success')
parts = copy_documents(self.output) parts = copy_documents(self.output)
self.assertEqual([part.name for part in parts], ['Part1', 'Part2']) self.assertEqual([part.name for part in parts], ['Part1', 'Part2'])
@@ -76,7 +77,7 @@ class CheckedDocumentTests(unittest.TestCase):
self.assertFalse((self.output / 'Part1').exists()) self.assertFalse((self.output / 'Part1').exists())
def test_pixel_limit_splits_documents_at_exact_boundary(self): def test_pixel_limit_splits_documents_at_exact_boundary(self):
with patch.object(checks, 'MAX_PIXELS', 6000): with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 6000):
self.assertEqual(self.generate(), 'success') self.assertEqual(self.generate(), 'success')
parts = copy_documents(self.output) parts = copy_documents(self.output)
self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [60, 40, 60]) self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [60, 40, 60])
@@ -88,13 +89,13 @@ class CheckedDocumentTests(unittest.TestCase):
if label == 'Ex 2': if label == 'Ex 2':
image = image.resize((200, image.height)) image = image.resize((200, image.height))
return image, header return image, header
with patch.object(self, 'compose', side_effect=wider), patch.object(checks, 'MAX_PIXELS', 12000): with patch.object(self, 'compose', side_effect=wider), patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 12000):
self.assertEqual(self.generate(), 'success') self.assertEqual(self.generate(), 'success')
parts = copy_documents(self.output) parts = copy_documents(self.output)
self.assertEqual([read_json(p / 'bnote.json')['width'] for p in parts], [100, 200, 100]) self.assertEqual([read_json(p / 'bnote.json')['width'] for p in parts], [100, 200, 100])
sentinel = self.output / 'sentinel' sentinel = self.output / 'sentinel'
sentinel.write_text('previous') sentinel.write_text('previous')
with patch.object(checks, 'MAX_PIXELS', 5999): with patch.object(configuration, 'ANNOTATION_MAX_PIXELS', 5999):
with self.assertRaisesRegex(CliError, 'Copie01.*Ex 1'): with self.assertRaisesRegex(CliError, 'Copie01.*Ex 1'):
self.generate(overwrite=True) self.generate(overwrite=True)
self.assertEqual(sentinel.read_text(), 'previous') self.assertEqual(sentinel.read_text(), 'previous')
@@ -121,7 +122,7 @@ class CheckedDocumentTests(unittest.TestCase):
def returned_parts(self): def returned_parts(self):
self.generate() self.generate()
export_root = self.root / 'Export' export_root = self.root / 'Export'
with patch.object(export, 'EXPORT_DIR', export_root): with patch.object(export.configuration, 'EXPORT_DIR', export_root):
self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS) self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS)
self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()), self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()),
['Copie01 Part1.pdf', 'Copie01 Part2.pdf']) ['Copie01 Part1.pdf', 'Copie01 Part2.pdf'])
+1 -1
View File
@@ -99,7 +99,7 @@ class GeminiConfigTests(unittest.TestCase):
] ]
config = types.GenerateContentConfig(thinking_config=thinking_config("high")) config = types.GenerateContentConfig(thinking_config=thinking_config("high"))
with patch.object(correction, "client", client), patch.object( with patch.object(correction, "client", client), patch.object(
correction, "MODEL_ID_pro", "pro-model" configuration, "MODEL_PRO_ID", "pro-model"
), patch.object(correction, "pro_quota_exhausted", False), patch.object( ), patch.object(correction, "pro_quota_exhausted", False), patch.object(
configuration, "THINKING_LEVEL_FLASH", "low" configuration, "THINKING_LEVEL_FLASH", "low"
), patch.object(correction, "tprint"), patch.object(correction, "stop_requested") as stop: ), patch.object(correction, "tprint"), patch.object(correction, "stop_requested") as stop:
+2 -2
View File
@@ -88,7 +88,7 @@ class GroupedRedoTests(unittest.TestCase):
[("01", "Ex 1"), ("02", "Ex 1")], [("01", "Ex 1"), ("02", "Ex 1")],
) )
export_root = Path(self.temp.name) / "Export" export_root = Path(self.temp.name) / "Export"
with patch.object(export, "EXPORT_DIR", export_root): with patch.object(export.configuration, "EXPORT_DIR", export_root):
self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS) self.assertEqual(export.run(self.workspace, refaire=True), ExitCode.SUCCESS)
self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1) self.assertEqual(len(list((export_root / "Exam").glob("*.pdf"))), 1)
imported_root = Path(self.temp.name) / "Import" imported_root = Path(self.temp.name) / "Import"
@@ -99,7 +99,7 @@ class GroupedRedoTests(unittest.TestCase):
draw.rectangle((17, 17, 43, 43), fill="black") draw.rectangle((17, 17, 43, 43), fill="black")
draw.rectangle((70, 170, 90, 190), fill="black") draw.rectangle((70, 170, 90, 190), fill="black")
annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72) annotated.save(imported_root / f"{directory.name}.pdf", "PDF", resolution=72)
with patch.object(import_annotations, "IMPORT_DIR", imported_root): with patch.object(import_annotations.configuration, "IMPORT_DIR", imported_root):
self.assertEqual( self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS import_annotations.run(self.workspace, refaire=True), ExitCode.SUCCESS
) )
+15 -14
View File
@@ -18,6 +18,7 @@ from unittest.mock import Mock, patch
from PIL import Image from PIL import Image
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from copienator import configuration
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -531,7 +532,7 @@ class StandardCliTests(unittest.TestCase):
evaluation = base / "Exam" evaluation = base / "Exam"
evaluation.mkdir() evaluation.mkdir()
export_dir = base / "Export" export_dir = base / "Export"
with patch.object(module, "EXPORT_DIR", export_dir), redirect_stderr( with patch.object(module.configuration, "EXPORT_DIR", export_dir), redirect_stderr(
io.StringIO() io.StringIO()
): ):
self.assertEqual(module.main([str(evaluation)]), 3) self.assertEqual(module.main([str(evaluation)]), 3)
@@ -706,7 +707,7 @@ class StandardCliTests(unittest.TestCase):
source = evaluation / "BGnot" / "Ex 1" source = evaluation / "BGnot" / "Ex 1"
source.mkdir(parents=True) source.mkdir(parents=True)
(source / "Concat.pdf").write_bytes(b"annotated") (source / "Concat.pdf").write_bytes(b"annotated")
with patch.object(module, "EXPORT_DIR", base / "Export"): with patch.object(module.configuration, "EXPORT_DIR", base / "Export"):
self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual(module.main([str(evaluation)]), 0)
exported = base / "Export" / "Exam" / "Ex 1.pdf" exported = base / "Export" / "Exam" / "Ex 1.pdf"
self.assertEqual(exported.read_bytes(), b"annotated") self.assertEqual(exported.read_bytes(), b"annotated")
@@ -721,7 +722,7 @@ class StandardCliTests(unittest.TestCase):
source.mkdir(parents=True) source.mkdir(parents=True)
suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" suffix = ".jpg" if annotation_dir == "Anot" else ".pdf"
(source / f"Concat{suffix}").write_bytes(annotation_dir.encode()) (source / f"Concat{suffix}").write_bytes(annotation_dir.encode())
with patch.object(module, "EXPORT_DIR", base / "Export"): with patch.object(module.configuration, "EXPORT_DIR", base / "Export"):
self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual(module.main([str(evaluation), annotation_dir]), 0)
self.assertEqual( self.assertEqual(
(base / "Export" / "Exam" / f"Copie01{suffix}").read_bytes(), (base / "Export" / "Exam" / f"Copie01{suffix}").read_bytes(),
@@ -738,7 +739,7 @@ class StandardCliTests(unittest.TestCase):
import_dir = base / "Import" import_dir = base / "Import"
import_dir.mkdir() import_dir.mkdir()
(import_dir / "Ex 1.pdf").write_bytes(b"handwritten") (import_dir / "Ex 1.pdf").write_bytes(b"handwritten")
with patch.object(module, "IMPORT_DIR", import_dir): with patch.object(module.configuration, "IMPORT_DIR", import_dir):
self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual(module.main([str(evaluation)]), 0)
self.assertEqual( self.assertEqual(
(target / "Concat_annotated.pdf").read_bytes(), b"handwritten" (target / "Concat_annotated.pdf").read_bytes(), b"handwritten"
@@ -756,7 +757,7 @@ class StandardCliTests(unittest.TestCase):
import_dir.mkdir() import_dir.mkdir()
suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" suffix = ".jpg" if annotation_dir == "Anot" else ".pdf"
(import_dir / f"Copie01{suffix}").write_bytes(b"handwritten") (import_dir / f"Copie01{suffix}").write_bytes(b"handwritten")
with patch.object(module, "IMPORT_DIR", import_dir): with patch.object(module.configuration, "IMPORT_DIR", import_dir):
self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual(module.main([str(evaluation), annotation_dir]), 0)
self.assertEqual( self.assertEqual(
(target / f"Concat_annotated{suffix}").read_bytes(), b"handwritten" (target / f"Concat_annotated{suffix}").read_bytes(), b"handwritten"
@@ -2323,7 +2324,7 @@ class StandardCliTests(unittest.TestCase):
("02", "Ex 1", small, 0, []), ("02", "Ex 1", small, 0, []),
("02", "Ex 2", small, 0, []), ("02", "Ex 2", small, 0, []),
] ]
with patch.object(module, "MAX_HEIGHT_PX", 100): with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered) batches = module.split_batches(rendered)
self.assertEqual([len(batch) for batch in batches], [1, 2]) self.assertEqual([len(batch) for batch in batches], [1, 2])
self.assertTrue( self.assertTrue(
@@ -2338,14 +2339,14 @@ class StandardCliTests(unittest.TestCase):
("01", "Ex 2", image, 0, []), ("01", "Ex 2", image, 0, []),
("02", "Ex 1", image, 0, []), ("02", "Ex 1", image, 0, []),
] ]
with patch.object(module, "MAX_HEIGHT_PX", 100): with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered) batches = module.split_batches(rendered)
self.assertEqual(batches, [[rendered[0]], [rendered[1]], [rendered[2]]]) self.assertEqual(batches, [[rendered[0]], [rendered[1]], [rendered[2]]])
def test_grouped_oversized_answer_is_rejected(self) -> None: def test_grouped_oversized_answer_is_rejected(self) -> None:
module = self.modules["annotating_by_label"] module = self.modules["annotating_by_label"]
rendered = [("02", "Ex 1", Image.new("RGB", (10, 120)), 0, [])] rendered = [("02", "Ex 1", Image.new("RGB", (10, 120)), 0, [])]
with tempfile.TemporaryDirectory() as directory, patch.object(module, "MAX_HEIGHT_PX", 100): with tempfile.TemporaryDirectory() as directory, patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100):
with self.assertRaisesRegex(CliError, "Copie02.*Ex 1"): with self.assertRaisesRegex(CliError, "Copie02.*Ex 1"):
module.split_batches(rendered) module.split_batches(rendered)
with self.assertRaises(CliError): with self.assertRaises(CliError):
@@ -2359,7 +2360,7 @@ class StandardCliTests(unittest.TestCase):
("02", "Ex 1", Image.new("RGB", (30, 20)), 0, []), ("02", "Ex 1", Image.new("RGB", (30, 20)), 0, []),
("03", "Ex 1", Image.new("RGB", (10, 40)), 0, []), ("03", "Ex 1", Image.new("RGB", (10, 40)), 0, []),
] ]
with patch.object(module, "MAX_HEIGHT_PX", 100), patch.object(module, "MAX_PIXELS", 2000): with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100), patch.object(configuration, "ANNOTATION_MAX_PIXELS", 2000):
batches = module.split_batches(rendered) batches = module.split_batches(rendered)
self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]]) self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]])
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -2372,9 +2373,9 @@ class StandardCliTests(unittest.TestCase):
module = self.modules["annotating_by_label"] module = self.modules["annotating_by_label"]
image = Image.new("RGB", (30, 40)) image = Image.new("RGB", (30, 40))
rendered = [("01", label, image, 0, []) for label in ("Ex 1", "Ex 2")] rendered = [("01", label, image, 0, []) for label in ("Ex 1", "Ex 2")]
with patch.object(module, "MAX_HEIGHT_PX", 100), patch.object(module, "MAX_PIXELS", 1200): with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100), patch.object(configuration, "ANNOTATION_MAX_PIXELS", 1200):
self.assertEqual(module.split_batches(rendered), [[rendered[0]], [rendered[1]]]) self.assertEqual(module.split_batches(rendered), [[rendered[0]], [rendered[1]]])
with patch.object(module, "MAX_PIXELS", 1199): with patch.object(configuration, "ANNOTATION_MAX_PIXELS", 1199):
with self.assertRaisesRegex(CliError, "single answer cannot be split"): with self.assertRaisesRegex(CliError, "single answer cannot be split"):
module.split_batches(rendered) module.split_batches(rendered)
@@ -2387,7 +2388,7 @@ class StandardCliTests(unittest.TestCase):
("04", "Ex 1", Image.new("RGB", (10, 15)), 0, []), ("04", "Ex 1", Image.new("RGB", (10, 15)), 0, []),
("04", "Ex 2", Image.new("RGB", (10, 15)), 0, []), ("04", "Ex 2", Image.new("RGB", (10, 15)), 0, []),
] ]
with patch.object(module, "MAX_HEIGHT_PX", 100): with patch.object(configuration, "ANNOTATION_MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered) batches = module.split_batches(rendered)
self.assertEqual([[item[0] for item in batch] for batch in batches], self.assertEqual([[item[0] for item in batch] for batch in batches],
[["01", "04", "04"], ["02", "03"]]) [["01", "04", "04"], ["02", "03"]])
@@ -2397,7 +2398,7 @@ class StandardCliTests(unittest.TestCase):
def test_grouped_annotation_height_limit_is_33000_pixels(self) -> None: def test_grouped_annotation_height_limit_is_33000_pixels(self) -> None:
module = self.modules["annotating_by_label"] module = self.modules["annotating_by_label"]
self.assertEqual(module.MAX_HEIGHT_PX, 33000) self.assertEqual(module.annotation_limits()[0], 33000)
class WorkflowTests(unittest.TestCase): class WorkflowTests(unittest.TestCase):
@@ -2511,7 +2512,7 @@ class WorkflowTests(unittest.TestCase):
def test_crop_auto_start_follows_always_crop_configuration(self) -> None: def test_crop_auto_start_follows_always_crop_configuration(self) -> None:
for enabled in (False, True): for enabled in (False, True):
with self.subTest(enabled=enabled), patch( with self.subTest(enabled=enabled), patch(
"copienator_gui.workflow.ALWAYS_CROP", enabled "copienator_gui.workflow.configuration.ALWAYS_CROP", enabled
): ):
steps = {step.id: step for step in build_workflow(False)} steps = {step.id: step for step in build_workflow(False)}
self.assertEqual( self.assertEqual(
+151
View File
@@ -0,0 +1,151 @@
"""Evaluation files override shared settings without leaking across evaluations."""
import contextlib
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from copienator import EvaluationWorkspace
REPOSITORY = Path(__file__).resolve().parents[1]
class LocalConfigurationTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.evaluation = self.root / "Exam"
self.evaluation.mkdir()
(self.evaluation / "labels").write_text("Ex 1\n")
self.global_config = self.root / "config.py"
self.global_config.write_text("MODEL_FLASH_ID = 'global-model'\nRETURN_PDF_ENABLED = False\n")
def run_python(self, source, *, explicit=True):
env = dict(os.environ, PYTHONPATH=str(REPOSITORY))
env.pop("COPIENATOR_CONFIG", None)
if explicit:
env["COPIENATOR_CONFIG"] = str(self.global_config)
result = subprocess.run([sys.executable, "-c", source], cwd=self.root, env=env,
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result.stdout
def test_partial_local_config_inherits_global_and_defaults(self):
(self.evaluation / "config.py").write_text("MODEL_FLASH_ID = 'local-model'\n")
for explicit in (False, True):
with self.subTest(explicit=explicit):
self.run_python("""
from copienator import configuration as c
c.use_evaluation('Exam')
assert c.MODEL_FLASH_ID == 'local-model'
assert c.RETURN_PDF_ENABLED is False
assert c.RETURN_JPEG_ENABLED is True
assert c.CONFIG_PATH.name == 'config.py'
assert c.LOCAL_CONFIG_PATH.parent.name == 'Exam'
c.use_evaluation(None)
assert c.MODEL_FLASH_ID == 'global-model'
""", explicit=explicit)
def test_switching_evaluations_removes_previous_overrides(self):
(self.evaluation / "config.py").write_text("RETURN_JPEG_ENABLED = False\n")
(self.root / "Other").mkdir()
self.run_python("""
from copienator import configuration as c
c.use_evaluation('Exam')
assert c.RETURN_JPEG_ENABLED is False
c.use_evaluation('Other')
assert c.RETURN_JPEG_ENABLED is True
assert c.RETURN_PDF_ENABLED is False
assert c.LOCAL_CONFIG_PATH is None
""")
def test_command_loads_config_for_directory_and_nested_target(self):
(self.evaluation / "config.py").write_text("MODEL_FLASH_ID = 'local-model'\n")
self.run_python("""
from copienator import configuration as c
from copienator.cli import execute, evaluation_parser, target_parser
from copienator.commands import correction
def check(args):
assert correction.settings.MODEL_FLASH_ID == 'local-model'
return 0
assert execute(evaluation_parser('test'), ['Exam'], check) == 0
c.use_evaluation(None)
assert execute(target_parser('test'), ['Exam/labels'], check) == 0
""")
def test_annotation_limits_and_gui_defaults_follow_active_config(self):
(self.evaluation / "config.py").write_text(
"ANNOTATION_MAX_HEIGHT_PX = 12345\nALWAYS_CROP = True\n")
self.run_python("""
from copienator import configuration as c
from copienator.commands import annotating_by_label, annotating_with_checks
from copienator_gui.workflow import build_workflow
c.use_evaluation('Exam')
assert annotating_by_label.annotation_limits()[0] == 12345
assert annotating_with_checks.annotation_limits()[0] == 12345
steps = {step.id: step for step in build_workflow(False)}
assert steps['crop_blank_margins'].auto_start_first_visit
""")
@unittest.skipUnless(os.environ.get("DISPLAY"), "GUI display unavailable")
def test_gui_switches_evaluation_settings(self):
(self.evaluation / "config.py").write_text("ALWAYS_CROP = True\n")
(self.root / "Other").mkdir()
self.run_python("""
from pathlib import Path
from copienator import configuration as c
from copienator_gui.app import CopienatorApp
app = CopienatorApp(Path.cwd(), False, Path('Exam').resolve())
try:
assert app.step_by_id['crop_blank_margins'].auto_start_first_visit
app.evaluation_var.set(str(Path('Other').resolve()))
app._load_evaluation()
assert not app.step_by_id['crop_blank_margins'].auto_start_first_visit
assert c.LOCAL_CONFIG_PATH is None
finally:
app.destroy()
""")
def test_explicit_command_option_overrides_local_config(self):
(self.evaluation / "config.py").write_text("RETURN_ANSWERS_CONTEXT = True\n")
self.run_python("""
from unittest.mock import patch
from copienator.commands import reading_annotations as command
from copienator import configuration as c
def check(workspace, **options):
assert c.RETURN_ANSWERS_CONTEXT is True
assert options['return_answers_context'] is False
return 0
with patch.object(command, 'run', check):
assert command.main(['Exam', '--no-return-answers-context']) == 0
""")
def test_invalid_local_config_reports_error_before_handler(self):
(self.evaluation / "config.py").write_text("ANNOTATION_MAX_HEIGHT_PX = 0\n")
self.run_python("""
from unittest.mock import Mock
from copienator.cli import execute, evaluation_parser
handler = Mock()
assert execute(evaluation_parser('test'), ['Exam'], handler) == 1
handler.assert_not_called()
""")
def test_local_names_replace_global_including_an_empty_roster(self):
(self.root / "names").write_text("Global\n")
workspace = EvaluationWorkspace(self.evaluation, self.root)
self.assertEqual(workspace.names_file(), self.root / "names")
local = self.evaluation / "names"
local.write_text("Local\n")
self.assertEqual(workspace.names_file(), local)
local.write_text("")
self.assertEqual(workspace.names_file().read_text(), "")
with contextlib.chdir(self.root):
self.assertEqual(EvaluationWorkspace(self.evaluation).names_file(), local)
local.unlink()
self.assertEqual(EvaluationWorkspace(self.evaluation).names_file(), self.root / "names")
+1 -1
View File
@@ -116,7 +116,7 @@ class MarkedCopiesTests(unittest.TestCase):
def test_personal_crop_binding_follows_personal_workflow_option(self): def test_personal_crop_binding_follows_personal_workflow_option(self):
from copienator.configuration import SHOW_PERSONAL_STEPS from copienator.configuration import SHOW_PERSONAL_STEPS
self.assertEqual(cutleft.personnal_binding, bool(SHOW_PERSONAL_STEPS)) self.assertEqual(cutleft.personal_bindings_enabled(), bool(SHOW_PERSONAL_STEPS))
def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self): def test_process_single_pdf_enlarges_crop_and_caps_it_at_page_edge(self):
page = Image.new("RGB", (900, 300), "white") page = Image.new("RGB", (900, 300), "white")
+1 -1
View File
@@ -153,7 +153,7 @@ class NameGridTests(unittest.TestCase):
self.assertEqual(len(requests), 4) self.assertEqual(len(requests), 4)
self.assertEqual(requests[0].kwargs['model'], 'custom-name-model') self.assertEqual(requests[0].kwargs['model'], 'custom-name-model')
self.assertEqual(requests[0].kwargs['config'].thinking_config.thinking_level.value, 'MEDIUM') self.assertEqual(requests[0].kwargs['config'].thinking_config.thinking_level.value, 'MEDIUM')
self.assertEqual(requests[1].kwargs['model'], labels.MODEL_ID) self.assertEqual(requests[1].kwargs['model'], labels.config.MODEL_FOR_LABEL_ID)
for request in requests[1:3]: for request in requests[1:3]:
text = request.kwargs['contents'][0].parts[1].text text = request.kwargs['contents'][0].parts[1].text
self.assertNotIn('Alice', text) self.assertNotIn('Alice', text)
+2 -1
View File
@@ -5,7 +5,8 @@ from unittest.mock import Mock
import pymupdf import pymupdf
from copienator.commands.page_splitter import PDFPreviewer, PAGE_SPLITTER_KB from copienator.commands.page_splitter import PDFPreviewer
from copienator.configuration import PAGE_SPLITTER_KB
class ReversePagesTests(unittest.TestCase): class ReversePagesTests(unittest.TestCase):
+2 -2
View File
@@ -120,7 +120,7 @@ class RefaireSessionTests(unittest.TestCase):
output = self.workspace.annotation_dir("refaire") / "Ex 1 G1" output = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
output.mkdir(parents=True) output.mkdir(parents=True)
(output / "Concat.pdf").write_bytes(expected_id.encode()) (output / "Concat.pdf").write_bytes(expected_id.encode())
with patch.object(export, "EXPORT_DIR", export_root): with patch.object(export.configuration, "EXPORT_DIR", export_root):
self.assertEqual( self.assertEqual(
export.run(self.workspace, refaire=True), ExitCode.SUCCESS export.run(self.workspace, refaire=True), ExitCode.SUCCESS
) )
@@ -131,7 +131,7 @@ class RefaireSessionTests(unittest.TestCase):
if expected_id == first: if expected_id == first:
(import_root / exported.name).write_bytes(b"old return") (import_root / exported.name).write_bytes(b"old return")
current = self.workspace.annotation_dir("refaire") / "Ex 1 G1" current = self.workspace.annotation_dir("refaire") / "Ex 1 G1"
with patch.object(import_annotations, "IMPORT_DIR", import_root): with patch.object(import_annotations.configuration, "IMPORT_DIR", import_root):
self.assertEqual( self.assertEqual(
import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL import_annotations.run(self.workspace, refaire=True), ExitCode.PARTIAL
) )
+1 -1
View File
@@ -325,7 +325,7 @@ class SelectiveGeminiTests(unittest.TestCase):
for label in self.labels: for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric') (self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
before = self.snapshot() before = self.snapshot()
with patch.object(gemini, 'api_key', ''), patch.object(gemini.genai, 'Client') as client: with patch.object(gemini.config, 'API_KEY', ''), patch.object(gemini.genai, 'Client') as client:
self.assertEqual(gemini.refine_existing(self.workspace, 'persp'), ExitCode.SUCCESS) self.assertEqual(gemini.refine_existing(self.workspace, 'persp'), ExitCode.SUCCESS)
client.assert_not_called() client.assert_not_called()
self.assertEqual(self.snapshot(), before) self.assertEqual(self.snapshot(), before)