Support evaluation-local configuration and names overrides
This commit is contained in:
1 parent
0bb6f7d97c
commit
d4404e4c27
36 files changed
+409
-184
No files matched your search
+21
@@ -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=
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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]] = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user