Files
Copies/copienator/commands/gemini_for_labels.py
T
2026-11-02 21:03:01 +01:00

857 lines
34 KiB
Python

from __future__ import annotations
import argparse
import re
import time
import typing
from threading import Lock
from collections import defaultdict
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor, wait
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, ConfigDict, Field, model_validator
from copienator import configuration as config
from copienator.gemini_config import fresh_seed, thinking_config
from copienator.name_grids import identify_names, MISSING
from copienator.copy_errors import marked_copy_paths
from copienator.orientation import record_detection, reports as orientation_reports
from copienator import (
CliError,
EvaluationWorkspace,
ExitCode,
atomic_write_json,
execute,
read_json,
target_parser,
workspace_from_target,
)
from copienator.utils import natural_key, read_all_labels
MODEL_ID = config.MODEL_FOR_LABEL_ID
api_key = config.API_KEY
REQUEST_TIMEOUT_SECONDS = 80
my_prompt = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
Carry the current exercise number across column and page boundaries. A
bare question number such as `2)` is most likely to belong to the most
recent exercise heading in reading order unless a new heading changes
the exercise. For example, if a column ends with `Ex 11 : 1)` and the
next starts with `2)` without a new exercise heading, label it `Ex 11
: 2)`.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts. If they are
horizontally far apart (example : if the `1)` is to the left and the
`Exercice i` is either to the right, or in the middle) then give only
the bounding box of the question label `1)` part. You should still
label it as `Exercice i : 1)` though.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels. Order the
box_2d by their position in the page, column by column : first column
(top to bottom), then second column, etc.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
Here's a list of the names of the students, pick the one that matches
the best or `\"Unknown\"` if you cannot read the name
##names##"""
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
Students answer several exercises, which can have several questions.
The image consists of several columns, separated by vertical black
lines. The image should be read top to bottom and then left to right,
meaning first column, then second column, etc.
Carry the current exercise number across column and page boundaries. A
bare question number such as `2)` is likely to belong to the most
recent exercise heading in reading order unless a new heading changes
the exercise. For example, if a column ends with `Ex 11 : 1)` and the
next starts with `2)` without a new exercise heading, label it `Ex 11
: 2)`.
In their sheet, students delimit exercises and questions using
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
to give me the bounding boxes of each delimiter.
When giving the bounding box of the first question of an exercise, the
box should be large enough to contain both the exercice label
(`Exercice i`) and the question label (`1)`) parts.
You also need to give me the student name. It should appear on the top
left of the image. Disregard any mention of `MPSI 3`, it is their
class. A list of possible student names will be given below.
You will answer with a JSON object, containing a `name` field with the
name, and a `list` field, with the list of the bounding boxes and
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
to 0-1000.
Here is an example :
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
Do not provide a box_2d for the name. Only for the labels.
You may find the same label present several times, as a student either
recall the current label on a new page, or adds content to its answer
later on. Give the position of each instance of each label.
This image is one part of a sequence (e.g., part 2 of 3) for a single
student. Here is the list of labels found in the *previous* parts of
this copy:
[
##prev_context##
]
If the first column starts with a number like =3)= or =c)=, look at
the labels in the list above. If the last relevant label was =Ex 4 :
2)=, you should label the new box =Ex 4 : 3)=.
For this exam you should look for the labels given below, separated by
newlines. A student need not have answered every question, so some may
be missing.
##labels##
##wrong_labels##
##wrong_label_text_context##
Since this copy isn't the first part of a sequence, simply set the
name to `\"Continued\"`."""
def labels_only_prompt(template: str) -> str:
"""Keep label instructions/context, removing every name-related instruction."""
start = template.index("You also need to give me the student name.")
end = template.index("You may find the same label", start)
template = template[:start] + """Answer with a JSON object containing a `list` of bounding boxes and labels.
The box_2d is [ymin, xmin, ymax, xmax] normalized to 0-1000.
Example: {"list": [{"box_2d": [10, 20, 30, 40], "label": "Ex 1 : 1)"}]}
Order boxes column by column, top to bottom within each column.
""" + template[end:]
for ending in ("Here's a list of the names", "Since this copy isn't the first part"):
template = template.split(ending)[0]
return template
class BoxItem(BaseModel):
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
label: str = Field(description="The label associated with the specific box")
def _require_success_fields(schema: dict) -> None:
"""Mirror the conditional payload validators in the schema sent to Gemini."""
required = ["list"]
if "name" in schema["properties"]:
required.append("name")
schema["anyOf"] = [
{"required": required},
{"required": ["error"],
"properties": {"error": {"type": "string", "enum": ["insufficient_crop"]}}},
{"required": ["upside_down_pages"],
"properties": {"upside_down_pages": {"type": "array", "minItems": 1}}},
]
class LabelData(BaseModel):
model_config = ConfigDict(json_schema_extra=_require_success_fields)
upside_down_pages: typing.List[typing.Annotated[int, Field(strict=True, ge=1)]] = Field(
default_factory=list,
description='1-based column numbers in this image that appear upside down (180 degrees)',
)
error: typing.Literal["insufficient_crop"] | None = Field(
default=None, description="Report insufficient_crop when the crop hides information needed to identify labels"
)
error_reason: str | None = Field(default=None, description="Explanation of the crop error")
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
default_factory=list, description="List of bounding box items"
)
@model_validator(mode="after")
def require_labels(self):
if self.error is None and not self.upside_down_pages and "list" not in self.model_fields_set:
raise ValueError("Successful detection must include list")
return self
class AnnotationData(LabelData):
name: str = Field(default="Unknown", description="The name identifier")
@model_validator(mode="after")
def require_success_payload(self):
if self.error is None and not self.upside_down_pages and not {"name", "list"} <= self.model_fields_set:
raise ValueError("Successful detection must include name and list")
return self
TEXT_CONTEXT_MAX_CHARS = 4000
def _label_filename(path: Path) -> str:
return path.stem if path.suffix.casefold() in {".tex", ".txt"} else path.name
def _common_prefix_length(left: str, right: str) -> int:
left_folded = left.casefold()
right_folded = right.casefold()
limit = min(len(left_folded), len(right_folded))
for index in range(limit):
if left_folded[index] != right_folded[index]:
return index
return limit
def closest_text_context(
workspace: EvaluationWorkspace, wrong_labels: list[str]
) -> tuple[Path | None, str]:
"""Return a bounded excerpt from the Text file closest to an invalid label."""
text_dir = workspace.root / "Text"
if not wrong_labels or not text_dir.is_dir():
return None, ""
ranked: list[tuple[int, str, Path]] = []
for path in text_dir.iterdir():
if not path.is_file() or path.suffix.casefold() == ".pdf":
continue
filename = _label_filename(path)
prefix_length = max(
_common_prefix_length(filename, wrong_label)
for wrong_label in wrong_labels
)
if prefix_length:
ranked.append((prefix_length, filename.casefold(), path))
for _prefix_length, _filename, path in sorted(
ranked, key=lambda item: (-item[0], item[1])
):
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
if len(content) > TEXT_CONTEXT_MAX_CHARS:
content = content[:TEXT_CONTEXT_MAX_CHARS] + "\n[excerpt truncated]"
return path, content
return None, ""
def generate_request(
file,
labels,
names,
context_labels,
wrong_labels,
wrong_label_text_context="",
wrong_label_text_file: Path | None = None,
seed: int | None = None,
*,
retry: bool = False,
full_pages: bool = False,
known_name: str | None = None,
):
"""Generates request for Gemini with context."""
image_path = Path(file)
# Format context list as a string
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
if context_labels == []:
text = (labels_only_prompt(my_prompt) if known_name else my_prompt).replace("##labels##", labels)\
.replace("##names##", names)
else:
text = (labels_only_prompt(my_prompt2) if known_name else my_prompt2).replace("##labels##", labels)\
.replace("##prev_context##", context_str)
if wrong_labels:
formatted_wrong_labels = "\n".join(f'- "{label}"' for label in wrong_labels)
text = text.replace(
"##wrong_labels##",
"On the previous request for this image, you answered with these "
"invalid labels:\n"
f"{formatted_wrong_labels}\n"
"They are wrong because they do not exactly match any label in the "
"valid list above.\n\n"
"CRITICAL RETRY CONSTRAINT: NEVER return any of the invalid labels "
"listed above again. Your answer must use only exact labels copied "
"verbatim from the valid list. If the handwriting resembles an "
"invalid label, choose the closest exact valid label instead.",
)
else:
text = text.replace("##wrong_labels##", "")
if wrong_label_text_context and wrong_label_text_file:
text = text.replace(
"##wrong_label_text_context##",
"Here is an excerpt from the exam text file whose name has the "
"longest prefix in common with the invalid label(s), "
f"`{wrong_label_text_file.name}`:\n\n"
"<exam_text_excerpt>\n"
f"{wrong_label_text_context}\n"
"</exam_text_excerpt>\n\n"
"Use this excerpt as extra context for identifying the handwritten "
"label, but return only an exact label from the valid list above.",
)
else:
text = text.replace("##wrong_label_text_context##", "")
text += """
If the cropped margins hide labels or exercise headings needed to identify
questions reliably, return error="insufficient_crop", a short error_reason,
and an empty list. Do not guess labels from incomplete cropped information.
Missing answers or an empty page alone are not evidence of insufficient crop:
students may skip questions. On success, return error=null.
"""
if full_pages:
text = text.replace("image of the left columns", "image of the full pages")
text += "\nThis image now contains full pages, in the same reading order. Identify the labels using this expanded view."
text += ('\nReport upside-down handwriting in upside_down_pages, using 1-based '
'column numbers in THIS image, left to right. Only report clear 180-degree '
'orientation errors; blank pages are not evidence. If any are reported, '
'you may return name="Unknown", list=[] and error=null. Orientation '
'reporting takes priority over insufficient_crop.')
if known_name:
text = text.replace('name="Unknown", list=[]', 'list=[]')
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
data=image_path.read_bytes(),
mime_type="image/jpeg"
),
types.Part.from_text(text=text),
],
)
]
generate_content_config = types.GenerateContentConfig(
http_options=types.HttpOptions(
timeout=REQUEST_TIMEOUT_SECONDS * 1000,
retry_options=types.HttpRetryOptions(attempts=1),
),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
thinking_config=thinking_config("low" if retry else config.THINKING_LEVEL_FOR_LABEL),
seed=fresh_seed() if seed is None else seed,
max_output_tokens=65535,
response_mime_type= "application/json",
response_json_schema=(LabelData if known_name else AnnotationData).model_json_schema(),
)
return (contents, generate_content_config)
TARGET_INTERVAL = 3.5
MAX_ATTEMPTS = 3
PROGRESS_INTERVAL = 30
Sleep = Callable[[float], None]
class IncompleteCopyError(Exception):
def __init__(self, images: list[Path]):
self.images = images
super().__init__(f"Label detection incomplete for {images[0].name}")
def selected_images(
workspace: EvaluationWorkspace,
targets: list[Path],
) -> tuple[list[Path], list[str]]:
"""Resolve evaluation, copy-PDF, or Cutleft-image targets."""
workspace.require_directories("Copies", "Cutleft")
images: list[Path] = []
warnings: list[str] = []
for target in targets:
if target.is_dir():
copy_pdfs = sorted(
workspace.copies_dir.glob("Copie*.pdf"), key=natural_key
)
if not copy_pdfs:
warnings.append(f"No Copie*.pdf files found in {workspace.copies_dir}")
stems = [path.stem for path in copy_pdfs]
elif target.suffix.casefold() in {".jpg", ".jpeg"}:
if target.parent != workspace.cutleft_dir:
raise CliError(
f"Image target is not in {workspace.cutleft_dir}: {target}",
ExitCode.INVALID_ARGUMENTS,
)
images.append(target)
continue
elif target.suffix.casefold() == ".pdf":
stems = [target.stem]
else:
raise CliError(
f"Unsupported target for label detection: {target}",
ExitCode.INVALID_ARGUMENTS,
)
for stem in stems:
found = sorted(
workspace.cutleft_dir.glob(f"{stem}_*.jpg"), key=natural_key
)
if found:
images.extend(found)
else:
warnings.append(
f"No Cutleft image variants found for {stem} in "
f"{workspace.cutleft_dir}"
)
return list(dict.fromkeys(images)), warnings
def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
groups: defaultdict[str, list[Path]] = defaultdict(list)
for image in image_files:
match = re.match(r"(.+)_(\d+)$", image.stem)
groups[match.group(1) if match else image.stem].append(image)
for files in groups.values():
files.sort(key=natural_key)
return dict(groups)
def sort_boxes_for_image(
workspace: EvaluationWorkspace,
image_file: Path,
boxes: list[BoxItem],
) -> list[BoxItem]:
"""Sort boxes in the image's page-column reading order when schema exists."""
match = re.match(r"(.+)_(\d+)$", image_file.stem)
if not match:
return boxes
schema_path = workspace.cutleft_dir / f"{match.group(1)}_schema.json"
try:
schema = read_json(schema_path)
columns_per_file = schema["columns_per_file"]
column_count = int(columns_per_file[int(match.group(2)) - 1])
if column_count < 1:
return boxes
except (OSError, KeyError, IndexError, TypeError, ValueError):
return boxes
def position(item: BoxItem) -> tuple[int, int, int]:
ymin, xmin, _ymax, xmax = item.box_2d
center_x = (xmin + xmax) // 2
column = min(column_count - 1, max(0, center_x * column_count // 1000))
return column, ymin, xmin
return sorted(boxes, key=position)
def _existing_context(output_json: Path) -> list[str]:
try:
loaded = read_json(output_json)
if not isinstance(loaded, dict):
return []
return [
str(item["label"])
for item in loaded.get("list", [])
if isinstance(item, dict) and "label" in item
]
except (OSError, TypeError, ValueError):
return []
def image_contains_full_pages(workspace: EvaluationWorkspace, image_file: Path) -> bool:
"""Recognize expanded images on subsequent detection runs."""
match = re.fullmatch(r"(.+)_(\d+)", image_file.stem)
if not match:
return False
try:
schema = read_json(workspace.cutleft_dir / f"{match.group(1)}_schema.json")
counts = schema["columns_per_file"]
part = int(match.group(2)) - 1
if not 0 <= part < len(counts) or any(type(n) is not int or n < 1 for n in counts):
return False
first = sum(counts[:part]) + 1
return set(range(first, first + counts[part])) <= set(schema.get("full_width_pages", []))
except (OSError, KeyError, TypeError, ValueError):
return False
def process_copy_group(
workspace: EvaluationWorkspace,
group_key: str,
files: list[Path],
*,
client,
labels_text: str,
names_text: str,
valid_labels: set[str],
valid_names: set[str],
overwrite: bool,
known_name: str | None = None,
sleep: Sleep = time.sleep,
target_interval: float = TARGET_INTERVAL,
progress: Callable[[str, str, float], None] | None = None,
) -> int:
"""Process one student's image parts sequentially to preserve context."""
known_name = known_name if known_name in valid_names and known_name not in {"Missing", "Unknown", "Continued"} else None
accumulated_labels: list[str] = []
generated = 0
for image_index, image_file in enumerate(files):
started = time.monotonic()
output_json = workspace.copies_dir / f"{image_file.stem}.json"
if output_json.exists() and not overwrite:
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
accumulated_labels.extend(_existing_context(output_json))
continue
print(
f"[{group_key}] Processing {image_file.name} with "
f"{len(accumulated_labels)} accumulated labels..."
)
attempt = 0
label_retry_count = 0
wrong_labels: list[str] = []
request_seed = None
full_page_fallback = image_contains_full_pages(workspace, image_file)
while attempt < MAX_ATTEMPTS:
if progress:
progress(group_key, f"{image_file.name}, attempt {attempt + 1}/{MAX_ATTEMPTS}", started)
if attempt > 0:
print(f"[{group_key}] Retrying {image_file.name} in {10 * attempt}s...")
sleep(10 * attempt)
level = "low" if attempt > 0 or full_page_fallback else config.THINKING_LEVEL_FOR_LABEL
request_seed = fresh_seed(request_seed)
print(
f"[{group_key}] {image_file.name}: attempt {attempt + 1}/{MAX_ATTEMPTS} "
f"({REQUEST_TIMEOUT_SECONDS}s timeout, thinking={level or 'default'}, "
f"seed={request_seed})."
)
try:
text_context_file, text_context = closest_text_context(
workspace, wrong_labels
)
if text_context_file:
print(
f"[{group_key}] Retry context for {image_file.name}: "
f"{text_context_file.relative_to(workspace.root)}"
)
contents, request_config = generate_request(
image_file,
labels_text,
names_text,
accumulated_labels,
wrong_labels,
text_context,
text_context_file,
seed=request_seed,
retry=attempt > 0 or full_page_fallback,
full_pages=full_page_fallback,
known_name=known_name,
)
response = client.models.generate_content(
model=MODEL_ID,
contents=contents,
config=request_config,
)
if known_name:
detected = LabelData.model_validate_json(response.text)
# Preserve the established downstream first-part/continuation contract.
part = re.search(r"_(\d+)$", image_file.stem)
saved_name = "Continued" if part and int(part.group(1)) > 1 else known_name
annotation = AnnotationData(**detected.model_dump(), name=saved_name)
else:
annotation = AnnotationData.model_validate_json(response.text)
if record_detection(workspace, image_file, annotation.upside_down_pages):
output_json.unlink(missing_ok=True)
print(f'[{group_key}] Orientation à vérifier avant de valider les labels.')
break
if annotation.error == "insufficient_crop":
print(f"[{group_key}] {image_file.name}: insufficient crop: "
f"{annotation.error_reason or 'labels require a wider view'}")
if full_page_fallback:
raise IncompleteCopyError(files[image_index:])
# Keep numbering and page grouping stable for downstream consumers.
from copienator.commands.cutleft import regenerate_full_page_image
try:
regenerate_full_page_image(workspace, image_file)
except Exception as exc:
print(f"[{group_key}] Full-page regeneration failed: {exc}")
raise IncompleteCopyError(files[image_index:]) from exc
output_json.unlink(missing_ok=True)
full_page_fallback = True
attempt = 0
label_retry_count = 0
wrong_labels = []
print(f"[{group_key}] Regenerated {image_file.name} with full pages; resending to Gemini.")
continue
unknown = [
item.label
for item in annotation.list
if item.label not in valid_labels
]
if unknown:
print(
f"Error: {image_file.name} contained unknown labels: "
f"{unknown}"
)
unique_unknown = list(dict.fromkeys(unknown))
if (
label_retry_count >= 2
and set(unique_unknown) == set(wrong_labels)
):
for item in annotation.list:
if item.label in unique_unknown:
item.label = f"??{item.label}"
print(
f"Warning: {image_file.name} repeated the same unknown "
"label(s) on the third try; keeping them with a ?? prefix."
)
else:
wrong_labels = unique_unknown
label_retry_count += 1
attempt += 1
continue
if not known_name and annotation.name not in valid_names:
print(
f"Error: {image_file.name} returned unknown name: "
f"{annotation.name}"
)
if attempt == 0:
attempt += 1
continue
annotation.name = "Unknown"
annotation.list = sort_boxes_for_image(
workspace, image_file, annotation.list
)
atomic_write_json(output_json, annotation.model_dump(exclude={"error", "error_reason", "upside_down_pages"}))
accumulated_labels.extend(box.label for box in annotation.list)
generated += 1
print(f"[{group_key}] Saved {image_file.name} after {time.monotonic() - started:.0f}s.")
break
except IncompleteCopyError:
raise
except KeyboardInterrupt:
raise
except Exception as exc: # noqa: BLE001 - remote API retry boundary
print(
f"Error processing {image_file.name} "
f"(attempt {attempt + 1}/{MAX_ATTEMPTS}): {type(exc).__name__}: {exc}"
)
attempt += 1
else:
# Later pages need the context of this page; defer them until resume.
incomplete = [path for path in files[image_index:]
if overwrite or not (workspace.copies_dir / f"{path.stem}.json").exists()]
print(f"[{group_key}] Failed after {MAX_ATTEMPTS} attempts; "
"deferring remaining pages of this copy until the next run.")
raise IncompleteCopyError(incomplete)
sleep(max(0.0, target_interval - (time.monotonic() - started)))
return generated
def run(
workspace: EvaluationWorkspace,
targets: list[Path],
*,
overwrite: bool = False,
marked: bool = False,
client=None,
sleep: Sleep = time.sleep,
max_workers: int = 12,
) -> ExitCode:
if max_workers < 1:
raise CliError("Le nombre de requêtes simultanées doit être positif.",
ExitCode.INVALID_ARGUMENTS)
workspace.require_files("labels")
if marked:
targets = marked_copy_paths(workspace)
if not targets:
print("No flagged copies to detect labels for.")
return ExitCode.SUCCESS
overwrite = True
images, warnings = selected_images(workspace, targets)
for warning in warnings:
print(f"Warning: {warning}")
if not images:
return ExitCode.PARTIAL
all_labels = read_all_labels(workspace.root)
labels_text = "\n".join(all_labels) + "\n"
names_path = workspace.names_file()
if not names_path.is_file():
raise CliError(f"Names file not found: {names_path}", ExitCode.INVALID_WORKSPACE)
names_text = names_path.read_text(encoding="utf-8")
valid_names = {
line.strip() for line in names_text.splitlines() if line.strip()
} | {"Unknown", "Continued"}
if client is None:
client = genai.Client(api_key=api_key)
groups = group_images(images)
pending_groups = {key: files for key, files in groups.items()
if overwrite or any(not (workspace.copies_dir / f"{image.stem}.json").exists()
for image in files)}
identified_names = identify_names(
workspace, pending_groups, client=client, model=config.MODEL_FOR_NAME_ID,
names_text=names_text, overwrite=overwrite,
thinking_level=config.THINKING_LEVEL_FOR_NAME,
)
total_groups = len(groups)
active: dict[str, tuple[str, float]] = {}
progress_lock = Lock()
incomplete: list[Path] = []
run_started = time.monotonic()
def print_group_progress(completed: int) -> None:
print(
f"Label detection progress: {completed}/{total_groups} copy groups completed.",
flush=True,
)
def report_progress(group: str, description: str, started: float) -> None:
with progress_lock:
active[group] = (description, started)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
process_copy_group,
workspace,
group_key,
files,
client=client,
labels_text=labels_text,
names_text=names_text,
valid_labels=set(all_labels),
valid_names=valid_names,
overwrite=overwrite,
known_name=(identified_names.get(group_key) if identified_names.get(group_key) != MISSING else None),
sleep=sleep,
progress=report_progress,
): group_key
for group_key, files in groups.items()
}
pending = set(futures)
print_group_progress(0)
while pending:
done, pending = wait(pending, timeout=PROGRESS_INTERVAL)
for future in done:
with progress_lock:
active.pop(futures[future], None)
try:
future.result()
except IncompleteCopyError as exc:
incomplete.extend(exc.images)
print_group_progress(total_groups - len(pending))
if pending:
now = time.monotonic()
with progress_lock:
snapshot = list(active.items())
print(f"Label detection: {now - run_started:.0f}s elapsed; "
f"{len(pending)} copies outstanding ({len(pending) - len(snapshot)} queued).")
for group, (description, started) in snapshot:
print(f" [{group}] {description}: {now - started:.0f}s elapsed for image.")
if incomplete:
print("Incomplete images (rerun label detection to resume):")
for path in sorted(incomplete):
print(f" {path.name}")
orientation = {name: data for name, data in orientation_reports(workspace).items()
if Path(name).stem in groups}
for name, data in orientation.items():
pages = sorted({p for values in data.get('images', {}).values() for p in values})
if pages:
print(f'[Orientation] {name}: pages {pages}. Signalez la copie ou utilisez la correction automatique.')
return ExitCode.PARTIAL if warnings or incomplete or orientation else ExitCode.SUCCESS
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Detect handwritten question labels with Gemini")
parser.add_argument(
"additional_targets",
nargs="*",
type=Path,
help="Additional copy PDFs or Cutleft images from the same evaluation",
)
parser.add_argument("--marked", action="store_true",
help="Regenerate labels only for copies with remaining flags")
parser.add_argument(
"--overwrite",
action="store_true",
help="Regenerate JSON outputs that already exist",
)
parser.add_argument(
"--workers",
type=int,
default=12,
help="Maximum simultaneous Gemini requests (positive integer; default: 12)",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
def handle(args: argparse.Namespace) -> ExitCode:
workspace, target = workspace_from_target(
args, repository=Path(__file__).resolve().parents[2]
)
targets = [target]
for additional in args.additional_targets:
resolved = additional.expanduser().resolve()
if not resolved.exists():
raise CliError(
f"Target does not exist: {resolved}",
ExitCode.INVALID_WORKSPACE,
)
additional_workspace = EvaluationWorkspace.discover(
resolved, repository=workspace.repository
)
if additional_workspace.root != workspace.root:
raise CliError(
"All targets must belong to the same evaluation",
ExitCode.INVALID_ARGUMENTS,
)
targets.append(resolved)
return run(workspace, targets, overwrite=args.overwrite, marked=args.marked, max_workers=args.workers)
return execute(parser, argv, handle)
if __name__ == "__main__":
raise SystemExit(main())