Allow Gemini to report insufficient crop, and deal with it

This commit is contained in:
sebastien committed 2026-10-12 19:01:51 +02:00
1 parent 247b124754
commit ad7813ea8f
4 files changed
+215 -32

No files matched your search

+70 -7
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from copienator import configuration as config
from copienator.gemini_config import fresh_seed, thinking_config
@@ -149,10 +149,20 @@ class BoxItem(BaseModel):
label: str = Field(description="The label associated with the specific box")
class AnnotationData(BaseModel):
name: str = Field(description="The name identifier")
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
description="List of bounding box items"
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")
name: str = Field(default="Unknown", description="The name identifier")
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_success_payload(self):
if self.error is None and not {"name", "list"} <= self.model_fields_set:
raise ValueError("Successful detection must include name and list")
return self
TEXT_CONTEXT_MAX_CHARS = 4000
@@ -216,6 +226,7 @@ def generate_request(
seed: int | None = None,
*,
retry: bool = False,
full_pages: bool = False,
):
"""Generates request for Gemini with context."""
@@ -263,6 +274,18 @@ def generate_request(
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."
contents = [
types.Content(
role="user",
@@ -399,6 +422,23 @@ def _existing_context(output_json: Path) -> list[str]:
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,
@@ -433,13 +473,14 @@ def process_copy_group(
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 else config.THINKING_LEVEL_FOR_LABEL
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} "
@@ -464,7 +505,8 @@ def process_copy_group(
text_context,
text_context_file,
seed=request_seed,
retry=attempt > 0,
retry=attempt > 0 or full_page_fallback,
full_pages=full_page_fallback,
)
response = client.models.generate_content(
model=MODEL_ID,
@@ -472,6 +514,25 @@ def process_copy_group(
config=request_config,
)
annotation = AnnotationData.model_validate_json(response.text)
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
@@ -512,11 +573,13 @@ def process_copy_group(
annotation.list = sort_boxes_for_image(
workspace, image_file, annotation.list
)
atomic_write_json(output_json, annotation.model_dump())
atomic_write_json(output_json, annotation.model_dump(exclude={"error", "error_reason"}))
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