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

+12 -7
View File
@@ -1,15 +1,20 @@
"""Locate tablet documents, including the parts of a large checked copy."""
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
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)")
MAX_HEIGHT_PX = ANNOTATION_MAX_HEIGHT_PX
if type(ANNOTATION_MAX_PIXELS) is not int or ANNOTATION_MAX_PIXELS <= 0:
raise ValueError("ANNOTATION_MAX_PIXELS must be a positive integer (pixels)")
MAX_PIXELS = ANNOTATION_MAX_PIXELS
def annotation_limits() -> tuple[int, int]:
values = (configuration.ANNOTATION_MAX_HEIGHT_PX, configuration.ANNOTATION_MAX_PIXELS)
for name, value in zip(("ANNOTATION_MAX_HEIGHT_PX", "ANNOTATION_MAX_PIXELS"), values):
if type(value) is not int or value <= 0:
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:
+9
View File
@@ -109,6 +109,15 @@ def execute(
) -> int:
args = parser.parse_args(argv)
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
with command_lock(args):
result = handler(args)
+8 -16
View File
@@ -10,21 +10,11 @@ import pandas as pd
from PIL import Image, ImageDraw, ImageFont
from copienator import atomic_write_json, read_json
from copienator.configuration import (
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()
from copienator import configuration
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:
if not candidate:
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:
"""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:
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:
if not source.is_file():
print(f"Warning: Missing histogram '{source}'.")
@@ -113,7 +104,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
try:
# header=None assumes the file starts directly with data.
# 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}
score_db = dict(zip(df[0], df[1]))
except Exception as e:
@@ -209,7 +200,8 @@ def main(argv=None):
args = parser.parse_args(argv)
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)
process_images(base_dir, output_dir, copy_merged_histogramme=args.copy_merged_histogramme)
return 0
+3 -3
View File
@@ -20,8 +20,8 @@ from matplotlib import pyplot as plt
from pdf2image import convert_from_path
from PIL import Image, ImageDraw, ImageFont
from copienator import configuration
from copienator import utils
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
from copienator import (
EvaluationWorkspace,
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)
# 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
)
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
latex_template = f"{header}{text}{configuration.LATEX_ANOT_AFTER}"
with tempfile.TemporaryDirectory() as temp_dir:
tex_path = os.path.join(temp_dir, 'text.tex')
+7 -5
View File
@@ -23,7 +23,7 @@ from copienator import (
workspace_from_args,
)
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_annotations import clear_import_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:
max_height_px, max_pixels = annotation_limits()
output_dir = output_root / f"{prefix} G{group_id}"
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width 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(
f"{prefix} G{group_id} would be {max_width} × {total_height} px "
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")
draw = ImageDraw.Draw(concatenated)
@@ -280,12 +281,13 @@ def _existing_group_state(
def split_batches(rendered):
max_height_px, max_pixels = annotation_limits()
batches = []
heights = []
widths = []
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):
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"{image.height} px ({image.width * image.height} pixels); "
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):
add_block(block)
@@ -26,7 +26,7 @@ from copienator import (
workspace_from_target,
)
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_annotations import clear_import_directory
from copienator.filesystem import staged_directory
@@ -160,6 +160,7 @@ def _render_student(
output_mode: str,
output_root: Path | None = None,
) -> str:
max_height_px, max_pixels = annotation_limits()
output_dir = (
output_root
if output_root is not None
@@ -214,15 +215,15 @@ def _render_student(
height = 0
width = 0
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(
f"Copie{student_id} ({bnote_entries[index]['label']}) is "
f"{image.width} × {image.height} px ({image.width * image.height} pixels); "
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,
MAX_HEIGHT_PX, MAX_PIXELS):
max_height_px, max_pixels):
batches.append((start, index))
start = index
height = 0
@@ -254,13 +255,14 @@ def _save_document(
checkbox_groups: list[list[dict[str, Any]]],
bnote_entries: list[dict[str, Any]],
) -> None:
max_height_px, max_pixels = annotation_limits()
max_width = max(image.width 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(
f"Document would be {max_width} × {total_height} px "
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")
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
# 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.
INPUT_DIR = Path()
@@ -270,13 +267,18 @@ def reset_workspace(workspace: EvaluationWorkspace) -> None:
"_new PDFs from 'Par label'."
)
_DEFAULT_FALLBACK = object()
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."""
global pro_quota_exhausted
delays = [60, 300]
request_seed = None
# 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()
config = config.model_copy(update={"http_options": http_options.model_copy(
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})
tprint(f"\tGemini attempt {attempt + 1}/3: seed={request_seed}")
# 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
config = config.model_copy(update={"thinking_config": thinking_config(
settings.THINKING_LEVEL_FLASH
@@ -324,7 +326,7 @@ def call_gemini_with_retries(model_id, contents, config,
continue # Retry same model
# 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...")
model_id = fallback_model_id
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"
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)
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":
tprint(f"\tHandling wrong-label for {pid} {label}")
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:
tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring")
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}.")
contents, config = prompting.request_for_confirmed_label(INPUT_DIR, pdf_path, label, pid)
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:
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)
tprint(f"\tHandling additional-answer for {pid} {label}")
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:
raise
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
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:
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
workers = request_workers(args)
if client is None:
client = genai.Client(api_key=api_key)
client = genai.Client(api_key=settings.API_KEY)
if args.refaire:
refaire_path = INPUT_DIR / "refaire.json"
overwritten_path = INPUT_DIR / "overwritten_correction.json"
@@ -979,8 +981,8 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
)
print("Batch generation complete.")
print(f" - {count_flash} requests saved to {batch_flash_file} (for {MODEL_ID_flash})")
print(f" - {count_pro} requests saved to {batch_pro_file} (for {MODEL_ID_pro})")
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 {settings.MODEL_PRO_ID})")
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
+5 -5
View File
@@ -15,6 +15,7 @@ import pymupdf
from pdf2image import convert_from_path
from PIL import Image, ImageTk
from copienator import configuration
from copienator import (
CliError,
EvaluationWorkspace,
@@ -27,7 +28,6 @@ from copienator import (
)
from copienator.filesystem import staged_files
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.name_grids import name_bottom_height_fraction
from copienator.annotation_layout import fingerprint
@@ -42,8 +42,8 @@ NARROW_PAGE_RATIO = 0.8
AUTO_STRIP_WIDTH_FACTOR = 1.10
pdf_cache_lock = threading.Lock()
# The requested spelling is kept as the public switch for this alternate layout.
personnal_binding = bool(SHOW_PERSONAL_STEPS)
def personal_bindings_enabled() -> bool:
return bool(configuration.SHOW_PERSONAL_STEPS)
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("<Up>", 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("n", lambda _event: self.on_shift(CROP_SHIFT_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
personal_instructions = (
"\nt/s/r/n also available: left / narrower / wider / right"
if personnal_binding
if personal_bindings_enabled()
else ""
)
analysis = schema.get('auto_margin')
+2 -2
View File
@@ -3,6 +3,7 @@ import sys
from collections.abc import Sequence
from pathlib import Path
from copienator import configuration
from copienator import (
EvaluationWorkspace,
ExitCode,
@@ -10,7 +11,6 @@ from copienator import (
execute,
workspace_from_args,
)
from copienator.configuration import EXPORT_DIR
from copienator.annotation_documents import tablet_documents
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
session_id = workspace.refaire_session_id if source_dir_name == "BRnot" else None
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:
sync_dir /= session_id
sync_dir.mkdir(parents=True, exist_ok=True)
+2 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import time
import webbrowser
from copienator import evaluation_parser
from copienator import configuration, evaluation_parser
def main(argv=None):
@@ -14,6 +14,7 @@ def main(argv=None):
from copienator.finalisation_server import WebSession
session = None
try:
configuration.use_evaluation(args.evaluation)
session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir)
print(session.url, flush=True)
if not args.no_browser:
+10 -13
View File
@@ -48,9 +48,6 @@ def get_lcp(s1: str, s2: str) -> str:
return lcp
MODEL_ID = config.MODEL_LITE_ID
api_key = config.API_KEY
# --- Modèles pour la Requête 1 ---
class StatementContent(BaseModel):
@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):
response = client.models.generate_content(
model=MODEL_ID,
model=config.MODEL_LITE_ID,
contents=[types.Content(role="user", parts=[
types.Part.from_text(text=PROMPT_4),
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)
context = utils.enonce_total(workspace.root)
if api_client is None:
if not api_key:
if not config.API_KEY:
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":
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=(
"Regroupe ces questions d’examen en groupes cohérents pour la correction "
"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")
if api_client is None:
if not api_key:
if not config.API_KEY:
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
# ==========================================
@@ -543,7 +540,7 @@ def process_exam(
questions_data = ExamQuestions(questions=extract_stage(
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=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(
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,
types.Part.from_text(text='Produis des contextes UNIQUEMENT pour les labels cibles suivants : '
+ ', '.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(
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():
source_path = None
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 {}
failed_before = not status.get('complete') or status.get('preamble_hash') != fingerprint
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
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
REQUEST_TIMEOUT_SECONDS = 80
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,
)
response = client.models.generate_content(
model=MODEL_ID,
model=config.MODEL_FOR_LABEL_ID,
contents=contents,
config=request_config,
)
@@ -720,7 +718,7 @@ def run(
line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"}
if client is None:
client = genai.Client(api_key=api_key)
client = genai.Client(api_key=config.API_KEY)
groups = group_images(images)
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:
workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2]
args, repository=Path.cwd()
)
targets = [target]
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
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
def clear_import_directory(workspace: EvaluationWorkspace) -> None:
"""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é.")
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):
raise CliError(f"Le dossier d’import ne peut pas contenir l’évaluation ou le projet : {directory}",
ExitCode.INVALID_ARGUMENTS)
@@ -112,7 +112,7 @@ def run(
return sync_annotated(
workspace,
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 pypdf import PdfReader, PdfWriter
from copienator.configuration import PAGE_SPLITTER_KB
from copienator import configuration
from copienator import (
CliError,
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.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 ---
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
CM_TO_POINTS = (1 / 2.54) * 72
@@ -172,7 +164,7 @@ class PDFPreviewer:
self.master.geometry("800x1000")
def fmt(action):
k = PAGE_SPLITTER_KB.get(action, "")
k = configuration.PAGE_SPLITTER_KB.get(action, "")
return k
# Dynamic instructions text
@@ -216,7 +208,7 @@ class PDFPreviewer:
"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:
self.master.bind(key, action_map[action])
+3 -3
View File
@@ -420,19 +420,19 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
default=None,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
default=None,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
default=None,
help="Include the current solution PDF in individual answer exports",
)
return parser
@@ -750,19 +750,19 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--return-answers-context",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_CONTEXT,
default=None,
help="Include applicable context pages in individual answer exports",
)
parser.add_argument(
"--return-answers-question",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_QUESTION,
default=None,
help="Include the current question PDF in individual answer exports",
)
parser.add_argument(
"--return-answers-solution",
action=argparse.BooleanOptionalAction,
default=configuration.RETURN_ANSWERS_SOLUTION,
default=None,
help="Include the current solution PDF in individual answer exports",
)
return parser
+7 -6
View File
@@ -6,11 +6,10 @@ from pathlib import Path
import ezodf
from copienator.configuration import CURRENT_SCORE_ODS_PATH
from copienator import configuration
from copienator.utils import read_all_labels
# Configuration
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
TARGET_DIR_NAME = "A Rendre"
def main(argv=None):
@@ -20,6 +19,8 @@ def main(argv=None):
args = parser.parse_args(argv)
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))
@@ -29,13 +30,13 @@ def main(argv=None):
print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}")
sys.exit(1)
if not os.path.exists(ODS_PATH):
print(f"Error: ODS file not found at {ODS_PATH}")
if not os.path.exists(ods_path):
print(f"Error: ODS file not found at {ods_path}")
sys.exit(1)
print(f"Opening ODS file: {ODS_PATH}...")
print(f"Opening ODS file: {ods_path}...")
try:
doc = ezodf.opendoc(str(ODS_PATH))
doc = ezodf.opendoc(str(ods_path))
except Exception as e:
print(f"Failed to open ODS: {e}")
sys.exit(1)
+64 -28
View File
@@ -25,32 +25,68 @@ def _load_user_config(path: Path) -> ModuleType:
return module
CONFIG_PATH = _user_config_path()
if CONFIG_PATH is None:
import default_config as _configuration
else:
_configuration = _load_user_config(CONFIG_PATH)
def _settings(path: Path | None) -> dict[str, object]:
import default_config
# Keep new optional settings compatible with older personal configuration files.
THINKING_LEVEL_LITE = "low"
THINKING_LEVEL_FOR_LABEL = "medium"
MODEL_FOR_NAME_ID = getattr(_configuration, "MODEL_FOR_LABEL_ID", "gemini-3.8-flash")
THINKING_LEVEL_FOR_NAME = "low"
THINKING_LEVEL_FLASH = "medium"
THINKING_LEVEL_PRO = "high"
ALWAYS_CROP = False
ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
ANNOTATION_MAX_HEIGHT_PX = 33000
ANNOTATION_MAX_PIXELS = 67_108_864
SHOW_PERSONAL_STEPS = False
RETURN_JPEG_ENABLED = True
RETURN_PDF_ENABLED = True
RETURN_ANSWERS_ENABLED = False
RETURN_ANSWERS_CONTEXT = False
RETURN_ANSWERS_QUESTION = True
RETURN_ANSWERS_SOLUTION = False
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
for _name in dir(_configuration):
if not _name.startswith("_"):
globals()[_name] = getattr(_configuration, _name)
values = {
name: getattr(default_config, name)
for name in dir(default_config)
if not name.startswith("_")
}
if path is not None:
# Preserve compatibility defaults for older personal configurations.
values.update(THINKING_LEVEL_FOR_LABEL="medium", THINKING_LEVEL_PRO="high")
module = _load_user_config(path)
values.update({
name: getattr(module, name)
for name in dir(module)
if not name.startswith("_")
})
# Historically the name recognizer inherited the label model when omitted.
if not hasattr(module, "MODEL_FOR_NAME_ID"):
values["MODEL_FOR_NAME_ID"] = values["MODEL_FOR_LABEL_ID"]
values["PAGE_SPLITTER_KB"] = {
"reverse_pages": "i",
"add_secondary_splitter": "<Insert>",
"delete_secondary_splitter": "<Delete>",
**values["PAGE_SPLITTER_KB"],
}
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
from pathlib import Path
from copienator import configuration
from copienator import EvaluationWorkspace
from .platform import validate_windows_labels
@@ -177,7 +178,6 @@ import tempfile
import shutil
import subprocess
from .configuration import LATEX_AFTER, LATEX_BEFORE
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):
"""Keep source packages/macros while using the configured fragment layout."""
if source_path is None:
return LATEX_BEFORE
return configuration.LATEX_BEFORE
source = Path(source_path).read_text(encoding="utf-8")
# Ignore commented document commands when locating the preamble.
source = re.sub(r"(?m)(?<!\\)%[^\n]*", "", source)
begin = re.search(r"\\begin\s*\{document\}", source)
document_class = r"\\documentclass(?:\s*\[[^\]]*\])?\s*\{[^}]+\}"
configured_class = re.search(document_class, LATEX_BEFORE)
configured_begin = re.search(r"\\begin\s*\{document\}", LATEX_BEFORE)
configured_class = re.search(document_class, configuration.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:
return LATEX_BEFORE
return configuration.LATEX_BEFORE
preamble = source[:begin.start()]
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)
# geometry overrides standalone's crop coordinates and can produce blank or
# 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*\{([^}]+)\}',
without_geometry, 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):
"""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()
if source_path is not None:
source_dir = str(Path(source_path).resolve().parent)
+3 -2
View File
@@ -260,9 +260,10 @@ class EvaluationWorkspace:
def names_file(self) -> Path:
local = self.root / "names"
if local.exists() or self.repository is None:
if local.is_file():
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]:
return [