kennethzychew's picture
feat(gemini): Gemini backend + vision-direct image acquire (T2)
7a6af4c
Raw
History Blame Contribute Delete
8.19 kB
"""Gemini extraction backend using the Google GenAI SDK.
Calls the Gemini API with multimodal (image) or text input, requests
schema-constrained JSON output (CLAUDE.md rule 4), and applies bounded retries
with exponential backoff before surfacing a ``RuntimeError`` that the core
catches and routes to review (rule 6 -- exhausted retries never crash the loop).
Architecture rules honoured here:
- Rule 2: no direct SDK import at module load; ``google.genai`` is imported
inside ``__init__`` (lazy, so this module stays a dependency leaf until
the Gemini backend is actually selected).
- Rule 3: the model identifier comes from ``Settings.gemini_model`` (config),
never hardcoded.
- Rule 4: schema-constrained JSON output via ``response_schema``; no regex.
"""
from __future__ import annotations
import logging
import time
from typing import Any
from pydantic import BaseModel, Field
from doc_agent.backends.base import BackendResult, DocumentPayload
from doc_agent.config import Settings
logger = logging.getLogger(__name__)
_EXTRACT_PROMPT: str = """\
You are a document-extraction assistant. Extract every available field from \
this document and return them as JSON.
Rules:
- Set any absent or illegible field to null.
- Dates must be ISO 8601 strings (YYYY-MM-DD) or null.
- Monetary amounts must be plain numbers with no currency symbols.
- doc_type must be exactly "receipt", "invoice", or "other".
- currency must be an ISO 4217 code (e.g. "USD", "SGD") or null.
"""
_MAX_RETRIES: int = 3
_BASE_BACKOFF_S: float = 1.0
_TIMEOUT_MS: int = 60_000 # 60 s in milliseconds (HttpOptions.timeout unit)
class _LineItem(BaseModel):
"""Gemini-serializable line item (all primitives so the JSON schema is clean)."""
description: str | None = None
quantity: float | None = None
unit_price: float | None = None
amount: float | None = None
class _ExtractionSchema(BaseModel):
"""Schema sent to Gemini for constrained JSON output.
Uses ``str`` for date fields so Gemini's JSON schema remains simple;
``Document``'s validators downstream coerce them to ``datetime.date``
(CLAUDE.md rule 4 -- structured output is enforced at validation time,
not by regex).
"""
doc_type: str = "other"
vendor_name: str | None = None
vendor_address: str | None = None
invoice_number: str | None = None
document_date: str | None = None
due_date: str | None = None
currency: str | None = None
line_items: list[_LineItem] = Field(default_factory=list)
subtotal: float | None = None
tax: float | None = None
total: float | None = None
class GeminiBackend:
"""Extraction backend that calls the Gemini multimodal API.
Accepts image bytes (``vision_direct`` mode) or plain text (native-PDF /
OCR path), enforces schema-constrained JSON output, and retries up to
``_MAX_RETRIES`` times with exponential backoff on transient failures.
Attributes:
name: Backend identifier used in logs and the factory registry.
"""
name = "gemini"
def __init__(self, settings: Settings) -> None:
"""Build the Gemini client from validated settings.
Imports ``google.genai`` lazily here so the module stays a dependency
leaf until this backend is actually selected (architecture rule 2).
Args:
settings: Validated runtime configuration supplying the API key,
model identifier, and timeout.
"""
from google import genai
from google.genai import types as _t
self._model: str = settings.gemini_model
self._types = _t
self._client = genai.Client(
api_key=settings.gemini_api_key,
http_options=_t.HttpOptions(timeout=_TIMEOUT_MS),
)
def extract(self, payload: DocumentPayload, schema: type[BaseModel]) -> BackendResult:
"""Extract document fields from a payload with bounded retries.
Args:
payload: The acquired document representation. Must carry either
``image_bytes`` (vision_direct) or ``text`` (text path).
schema: The Pydantic model defining the output contract (the core
passes ``Document``). The backend uses ``_ExtractionSchema``
for the API call and returns a dict the core validates into
``schema``.
Returns:
A ``BackendResult`` with the extracted data dict and ``None``
field_confidence (the Gemini free tier exposes no per-field signal;
scoring falls back to a neutral prior).
Raises:
RuntimeError: When all ``_MAX_RETRIES`` attempts fail. The core
catches this and routes the document to review.
"""
contents = self._build_contents(payload)
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES):
if attempt:
backoff = _BASE_BACKOFF_S * (2 ** (attempt - 1))
logger.debug(
"gemini retry attempt=%d/%d backoff=%.1fs source=%s",
attempt + 1,
_MAX_RETRIES,
backoff,
payload.source_path,
)
time.sleep(backoff)
try:
return self._call_api(contents)
except Exception as exc: # noqa: BLE001
logger.warning(
"gemini API attempt %d/%d failed source=%s error=%s",
attempt + 1,
_MAX_RETRIES,
payload.source_path,
exc,
)
last_exc = exc
raise RuntimeError(
f"Gemini extraction failed after {_MAX_RETRIES} attempts: {last_exc}"
) from last_exc
def _build_contents(self, payload: DocumentPayload) -> list[Any]:
"""Build the Gemini content list from a document payload.
Args:
payload: The document payload carrying image bytes or text.
Returns:
A list of genai ``Part`` objects for the API call.
Raises:
ValueError: If the payload has neither ``image_bytes`` nor ``text``.
"""
types = self._types
parts: list[Any] = [types.Part.from_text(text=_EXTRACT_PROMPT)]
if payload.image_bytes is not None:
mime = payload.image_mime or "image/jpeg"
parts.append(types.Part.from_bytes(data=payload.image_bytes, mime_type=mime))
elif payload.text is not None:
parts.append(types.Part.from_text(text=payload.text))
else:
raise ValueError(
"DocumentPayload must supply either image_bytes (vision_direct) "
"or text (OCR / native-PDF path)."
)
return parts
def _call_api(self, contents: list[Any]) -> BackendResult:
"""Make one Gemini API call and parse the schema-constrained response.
Args:
contents: The genai content parts produced by ``_build_contents``.
Returns:
A ``BackendResult`` with the extracted data dict.
"""
types = self._types
response = self._client.models.generate_content(
model=self._model,
contents=contents,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=_ExtractionSchema,
),
)
extracted = _ExtractionSchema.model_validate_json(response.text)
data: dict[str, Any] = extracted.model_dump()
data["line_items"] = [li.model_dump() for li in extracted.line_items]
logger.debug(
"gemini extraction complete model=%s total=%s vendor=%s",
self._model,
data.get("total"),
data.get("vendor_name"),
)
return BackendResult(
data=data,
# Gemini free tier exposes no per-field confidence; the scorer
# handles None with a neutral prior (architecture section 8).
field_confidence=None,
raw={"model": self._model},
)