Spaces:
Sleeping
Sleeping
Commit ·
7a6af4c
1
Parent(s): 12a1092
feat(gemini): Gemini backend + vision-direct image acquire (T2)
Browse filesGeminiBackend: multimodal call, _ExtractionSchema with string dates for
clean JSON schema, schema-constrained output via response_schema, 3-attempt
exponential backoff. Factory registers the gemini builder. core.py replaces
the placeholder _default_acquire with _make_acquire(settings), handling
image+vision_direct by loading raw bytes; native_pdf/OCR still deferred.
11 mocked unit tests cover parsing, null fields, retry, backoff, exhaustion,
and factory wiring. Verified on a real SGD receipt: vendor, date, line items,
and subtotal correct; H2 correctly caught total!=subtotal+tax and routed
to review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PROGRESS_TOMORROW.md +1 -1
- scripts/smoke_gemini.py +58 -0
- src/doc_agent/backends/base.py +23 -4
- src/doc_agent/backends/gemini.py +225 -0
- src/doc_agent/core.py +51 -17
- tests/test_backends.py +15 -7
- tests/test_gemini.py +245 -0
PROGRESS_TOMORROW.md
CHANGED
|
@@ -53,7 +53,7 @@ Paddle-on-3.11 risk to T4 instead of breaking everything at once.
|
|
| 53 |
`config.py` accepts it.
|
| 54 |
Check: `uv run python -c "from doc_agent.config import load_config; c=load_config(); print(c.extraction_backend, bool(c.gemini_api_key))"` → `gemini True`.
|
| 55 |
|
| 56 |
-
- [
|
| 57 |
Implement `src/doc_agent/backends/gemini.py` per architecture §5: multimodal
|
| 58 |
call, schema-constrained JSON output, bounded retries + timeout, model id from
|
| 59 |
config; register its builder in the factory. Wire a minimal real `acquire` for
|
|
|
|
| 53 |
`config.py` accepts it.
|
| 54 |
Check: `uv run python -c "from doc_agent.config import load_config; c=load_config(); print(c.extraction_backend, bool(c.gemini_api_key))"` → `gemini True`.
|
| 55 |
|
| 56 |
+
- [x] **T2 — Gemini backend + real image acquire** (build plan 2.5) ⭐ milestone
|
| 57 |
Implement `src/doc_agent/backends/gemini.py` per architecture §5: multimodal
|
| 58 |
call, schema-constrained JSON output, bounded retries + timeout, model id from
|
| 59 |
config; register its builder in the factory. Wire a minimal real `acquire` for
|
scripts/smoke_gemini.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick manual smoke script for T2: run process_document on a real image.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python scripts/smoke_gemini.py path/to/receipt.jpg
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from doc_agent.core import process_document
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main() -> None:
|
| 14 |
+
"""Run the pipeline on a single image and print the result."""
|
| 15 |
+
if len(sys.argv) != 2:
|
| 16 |
+
print("Usage: uv run python scripts/smoke_gemini.py <image_path>")
|
| 17 |
+
sys.exit(1)
|
| 18 |
+
|
| 19 |
+
path = Path(sys.argv[1])
|
| 20 |
+
if not path.exists():
|
| 21 |
+
print(f"File not found: {path}")
|
| 22 |
+
sys.exit(1)
|
| 23 |
+
|
| 24 |
+
print(f"Processing: {path}")
|
| 25 |
+
result = process_document(path)
|
| 26 |
+
|
| 27 |
+
print(f"\ndecision : {result.decision}")
|
| 28 |
+
print(f"confidence : {result.confidence:.3f}")
|
| 29 |
+
print(f"backend : {result.backend_name}")
|
| 30 |
+
print(f"modality : {result.modality}")
|
| 31 |
+
if result.error:
|
| 32 |
+
print(f"error : {result.error}")
|
| 33 |
+
|
| 34 |
+
doc = result.document
|
| 35 |
+
print("\n--- extracted fields ---")
|
| 36 |
+
print(f"doc_type : {doc.doc_type}")
|
| 37 |
+
print(f"vendor_name : {doc.vendor_name}")
|
| 38 |
+
print(f"invoice_num : {doc.invoice_number}")
|
| 39 |
+
print(f"date : {doc.document_date}")
|
| 40 |
+
print(f"currency : {doc.currency}")
|
| 41 |
+
print(f"subtotal : {doc.subtotal}")
|
| 42 |
+
print(f"tax : {doc.tax}")
|
| 43 |
+
print(f"total : {doc.total}")
|
| 44 |
+
print(f"line_items : {len(doc.line_items)} item(s)")
|
| 45 |
+
for item in doc.line_items:
|
| 46 |
+
print(f" {item.description} qty={item.quantity} price={item.unit_price} amt={item.amount}")
|
| 47 |
+
|
| 48 |
+
print("\n--- validation ---")
|
| 49 |
+
v = result.report
|
| 50 |
+
print(f"hard_failed : {v.hard_failed}")
|
| 51 |
+
if v.hard_failures:
|
| 52 |
+
print(f"hard failures: {[r.code for r in v.hard_failures]}")
|
| 53 |
+
if v.soft_failures:
|
| 54 |
+
print(f"soft failures: {[r.code for r in v.soft_failures]}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|
src/doc_agent/backends/base.py
CHANGED
|
@@ -156,11 +156,30 @@ def _build_stub(settings: Settings) -> ExtractionBackend:
|
|
| 156 |
return StubBackend()
|
| 157 |
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
# Registry of buildable backends: name -> builder. Builders import their adapter
|
| 160 |
# lazily so selecting one backend never imports another's (possibly heavy or
|
| 161 |
-
# optional) provider SDK.
|
| 162 |
-
#
|
| 163 |
_BACKEND_BUILDERS: dict[str, BackendBuilder] = {
|
|
|
|
| 164 |
"stub": _build_stub,
|
| 165 |
}
|
| 166 |
|
|
@@ -202,7 +221,7 @@ def create_backend(settings: Settings, *, name: str | None = None) -> Extraction
|
|
| 202 |
available = ", ".join(available_backends())
|
| 203 |
raise ConfigError(
|
| 204 |
f"Unknown or unavailable extraction backend {backend_name!r}. "
|
| 205 |
-
f"Available backends: {available}.
|
| 206 |
-
f"
|
| 207 |
)
|
| 208 |
return builder(settings)
|
|
|
|
| 156 |
return StubBackend()
|
| 157 |
|
| 158 |
|
| 159 |
+
def _build_gemini(settings: Settings) -> ExtractionBackend:
|
| 160 |
+
"""Construct the Gemini backend (lazy import of google-genai).
|
| 161 |
+
|
| 162 |
+
The import is local so this module stays a dependency leaf: importing
|
| 163 |
+
``doc_agent.backends.base`` never pulls in ``google.genai`` unless the
|
| 164 |
+
Gemini backend is actually selected.
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
settings: Validated runtime configuration (API key, model, timeout).
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
A ready-to-use ``GeminiBackend`` instance.
|
| 171 |
+
"""
|
| 172 |
+
from doc_agent.backends.gemini import GeminiBackend
|
| 173 |
+
|
| 174 |
+
return GeminiBackend(settings)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
# Registry of buildable backends: name -> builder. Builders import their adapter
|
| 178 |
# lazily so selecting one backend never imports another's (possibly heavy or
|
| 179 |
+
# optional) provider SDK. Ollama registers its builder here when implemented
|
| 180 |
+
# (build plan phase 2.6).
|
| 181 |
_BACKEND_BUILDERS: dict[str, BackendBuilder] = {
|
| 182 |
+
"gemini": _build_gemini,
|
| 183 |
"stub": _build_stub,
|
| 184 |
}
|
| 185 |
|
|
|
|
| 221 |
available = ", ".join(available_backends())
|
| 222 |
raise ConfigError(
|
| 223 |
f"Unknown or unavailable extraction backend {backend_name!r}. "
|
| 224 |
+
f"Available backends: {available}. "
|
| 225 |
+
f"(The Ollama adapter is added in build phase 2.6.)"
|
| 226 |
)
|
| 227 |
return builder(settings)
|
src/doc_agent/backends/gemini.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini extraction backend using the Google GenAI SDK.
|
| 2 |
+
|
| 3 |
+
Calls the Gemini API with multimodal (image) or text input, requests
|
| 4 |
+
schema-constrained JSON output (CLAUDE.md rule 4), and applies bounded retries
|
| 5 |
+
with exponential backoff before surfacing a ``RuntimeError`` that the core
|
| 6 |
+
catches and routes to review (rule 6 -- exhausted retries never crash the loop).
|
| 7 |
+
|
| 8 |
+
Architecture rules honoured here:
|
| 9 |
+
- Rule 2: no direct SDK import at module load; ``google.genai`` is imported
|
| 10 |
+
inside ``__init__`` (lazy, so this module stays a dependency leaf until
|
| 11 |
+
the Gemini backend is actually selected).
|
| 12 |
+
- Rule 3: the model identifier comes from ``Settings.gemini_model`` (config),
|
| 13 |
+
never hardcoded.
|
| 14 |
+
- Rule 4: schema-constrained JSON output via ``response_schema``; no regex.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import time
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
|
| 25 |
+
from doc_agent.backends.base import BackendResult, DocumentPayload
|
| 26 |
+
from doc_agent.config import Settings
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
_EXTRACT_PROMPT: str = """\
|
| 31 |
+
You are a document-extraction assistant. Extract every available field from \
|
| 32 |
+
this document and return them as JSON.
|
| 33 |
+
|
| 34 |
+
Rules:
|
| 35 |
+
- Set any absent or illegible field to null.
|
| 36 |
+
- Dates must be ISO 8601 strings (YYYY-MM-DD) or null.
|
| 37 |
+
- Monetary amounts must be plain numbers with no currency symbols.
|
| 38 |
+
- doc_type must be exactly "receipt", "invoice", or "other".
|
| 39 |
+
- currency must be an ISO 4217 code (e.g. "USD", "SGD") or null.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
_MAX_RETRIES: int = 3
|
| 43 |
+
_BASE_BACKOFF_S: float = 1.0
|
| 44 |
+
_TIMEOUT_MS: int = 60_000 # 60 s in milliseconds (HttpOptions.timeout unit)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class _LineItem(BaseModel):
|
| 48 |
+
"""Gemini-serializable line item (all primitives so the JSON schema is clean)."""
|
| 49 |
+
|
| 50 |
+
description: str | None = None
|
| 51 |
+
quantity: float | None = None
|
| 52 |
+
unit_price: float | None = None
|
| 53 |
+
amount: float | None = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class _ExtractionSchema(BaseModel):
|
| 57 |
+
"""Schema sent to Gemini for constrained JSON output.
|
| 58 |
+
|
| 59 |
+
Uses ``str`` for date fields so Gemini's JSON schema remains simple;
|
| 60 |
+
``Document``'s validators downstream coerce them to ``datetime.date``
|
| 61 |
+
(CLAUDE.md rule 4 -- structured output is enforced at validation time,
|
| 62 |
+
not by regex).
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
doc_type: str = "other"
|
| 66 |
+
vendor_name: str | None = None
|
| 67 |
+
vendor_address: str | None = None
|
| 68 |
+
invoice_number: str | None = None
|
| 69 |
+
document_date: str | None = None
|
| 70 |
+
due_date: str | None = None
|
| 71 |
+
currency: str | None = None
|
| 72 |
+
line_items: list[_LineItem] = Field(default_factory=list)
|
| 73 |
+
subtotal: float | None = None
|
| 74 |
+
tax: float | None = None
|
| 75 |
+
total: float | None = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class GeminiBackend:
|
| 79 |
+
"""Extraction backend that calls the Gemini multimodal API.
|
| 80 |
+
|
| 81 |
+
Accepts image bytes (``vision_direct`` mode) or plain text (native-PDF /
|
| 82 |
+
OCR path), enforces schema-constrained JSON output, and retries up to
|
| 83 |
+
``_MAX_RETRIES`` times with exponential backoff on transient failures.
|
| 84 |
+
|
| 85 |
+
Attributes:
|
| 86 |
+
name: Backend identifier used in logs and the factory registry.
|
| 87 |
+
"""
|
| 88 |
+
|
| 89 |
+
name = "gemini"
|
| 90 |
+
|
| 91 |
+
def __init__(self, settings: Settings) -> None:
|
| 92 |
+
"""Build the Gemini client from validated settings.
|
| 93 |
+
|
| 94 |
+
Imports ``google.genai`` lazily here so the module stays a dependency
|
| 95 |
+
leaf until this backend is actually selected (architecture rule 2).
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
settings: Validated runtime configuration supplying the API key,
|
| 99 |
+
model identifier, and timeout.
|
| 100 |
+
"""
|
| 101 |
+
from google import genai
|
| 102 |
+
from google.genai import types as _t
|
| 103 |
+
|
| 104 |
+
self._model: str = settings.gemini_model
|
| 105 |
+
self._types = _t
|
| 106 |
+
self._client = genai.Client(
|
| 107 |
+
api_key=settings.gemini_api_key,
|
| 108 |
+
http_options=_t.HttpOptions(timeout=_TIMEOUT_MS),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def extract(self, payload: DocumentPayload, schema: type[BaseModel]) -> BackendResult:
|
| 112 |
+
"""Extract document fields from a payload with bounded retries.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
payload: The acquired document representation. Must carry either
|
| 116 |
+
``image_bytes`` (vision_direct) or ``text`` (text path).
|
| 117 |
+
schema: The Pydantic model defining the output contract (the core
|
| 118 |
+
passes ``Document``). The backend uses ``_ExtractionSchema``
|
| 119 |
+
for the API call and returns a dict the core validates into
|
| 120 |
+
``schema``.
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
A ``BackendResult`` with the extracted data dict and ``None``
|
| 124 |
+
field_confidence (the Gemini free tier exposes no per-field signal;
|
| 125 |
+
scoring falls back to a neutral prior).
|
| 126 |
+
|
| 127 |
+
Raises:
|
| 128 |
+
RuntimeError: When all ``_MAX_RETRIES`` attempts fail. The core
|
| 129 |
+
catches this and routes the document to review.
|
| 130 |
+
"""
|
| 131 |
+
contents = self._build_contents(payload)
|
| 132 |
+
last_exc: Exception | None = None
|
| 133 |
+
|
| 134 |
+
for attempt in range(_MAX_RETRIES):
|
| 135 |
+
if attempt:
|
| 136 |
+
backoff = _BASE_BACKOFF_S * (2 ** (attempt - 1))
|
| 137 |
+
logger.debug(
|
| 138 |
+
"gemini retry attempt=%d/%d backoff=%.1fs source=%s",
|
| 139 |
+
attempt + 1,
|
| 140 |
+
_MAX_RETRIES,
|
| 141 |
+
backoff,
|
| 142 |
+
payload.source_path,
|
| 143 |
+
)
|
| 144 |
+
time.sleep(backoff)
|
| 145 |
+
try:
|
| 146 |
+
return self._call_api(contents)
|
| 147 |
+
except Exception as exc: # noqa: BLE001
|
| 148 |
+
logger.warning(
|
| 149 |
+
"gemini API attempt %d/%d failed source=%s error=%s",
|
| 150 |
+
attempt + 1,
|
| 151 |
+
_MAX_RETRIES,
|
| 152 |
+
payload.source_path,
|
| 153 |
+
exc,
|
| 154 |
+
)
|
| 155 |
+
last_exc = exc
|
| 156 |
+
|
| 157 |
+
raise RuntimeError(
|
| 158 |
+
f"Gemini extraction failed after {_MAX_RETRIES} attempts: {last_exc}"
|
| 159 |
+
) from last_exc
|
| 160 |
+
|
| 161 |
+
def _build_contents(self, payload: DocumentPayload) -> list[Any]:
|
| 162 |
+
"""Build the Gemini content list from a document payload.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
payload: The document payload carrying image bytes or text.
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
A list of genai ``Part`` objects for the API call.
|
| 169 |
+
|
| 170 |
+
Raises:
|
| 171 |
+
ValueError: If the payload has neither ``image_bytes`` nor ``text``.
|
| 172 |
+
"""
|
| 173 |
+
types = self._types
|
| 174 |
+
parts: list[Any] = [types.Part.from_text(text=_EXTRACT_PROMPT)]
|
| 175 |
+
|
| 176 |
+
if payload.image_bytes is not None:
|
| 177 |
+
mime = payload.image_mime or "image/jpeg"
|
| 178 |
+
parts.append(types.Part.from_bytes(data=payload.image_bytes, mime_type=mime))
|
| 179 |
+
elif payload.text is not None:
|
| 180 |
+
parts.append(types.Part.from_text(text=payload.text))
|
| 181 |
+
else:
|
| 182 |
+
raise ValueError(
|
| 183 |
+
"DocumentPayload must supply either image_bytes (vision_direct) "
|
| 184 |
+
"or text (OCR / native-PDF path)."
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
return parts
|
| 188 |
+
|
| 189 |
+
def _call_api(self, contents: list[Any]) -> BackendResult:
|
| 190 |
+
"""Make one Gemini API call and parse the schema-constrained response.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
contents: The genai content parts produced by ``_build_contents``.
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
A ``BackendResult`` with the extracted data dict.
|
| 197 |
+
"""
|
| 198 |
+
types = self._types
|
| 199 |
+
response = self._client.models.generate_content(
|
| 200 |
+
model=self._model,
|
| 201 |
+
contents=contents,
|
| 202 |
+
config=types.GenerateContentConfig(
|
| 203 |
+
response_mime_type="application/json",
|
| 204 |
+
response_schema=_ExtractionSchema,
|
| 205 |
+
),
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
extracted = _ExtractionSchema.model_validate_json(response.text)
|
| 209 |
+
data: dict[str, Any] = extracted.model_dump()
|
| 210 |
+
data["line_items"] = [li.model_dump() for li in extracted.line_items]
|
| 211 |
+
|
| 212 |
+
logger.debug(
|
| 213 |
+
"gemini extraction complete model=%s total=%s vendor=%s",
|
| 214 |
+
self._model,
|
| 215 |
+
data.get("total"),
|
| 216 |
+
data.get("vendor_name"),
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return BackendResult(
|
| 220 |
+
data=data,
|
| 221 |
+
# Gemini free tier exposes no per-field confidence; the scorer
|
| 222 |
+
# handles None with a neutral prior (architecture section 8).
|
| 223 |
+
field_confidence=None,
|
| 224 |
+
raw={"model": self._model},
|
| 225 |
+
)
|
src/doc_agent/core.py
CHANGED
|
@@ -100,29 +100,63 @@ class ExtractionResult:
|
|
| 100 |
return self.decision == "accept"
|
| 101 |
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
Args:
|
| 112 |
-
path:
|
| 113 |
-
modality: The detected modality (unused).
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
"""
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
"
|
| 121 |
-
|
| 122 |
-
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
def _aggregate_model_signal(field_confidence: dict[str, float] | None) -> float | None:
|
| 127 |
"""Reduce a backend's per-field confidence to one document-level signal.
|
| 128 |
|
|
@@ -193,7 +227,7 @@ def process_document(
|
|
| 193 |
"""
|
| 194 |
settings = settings or load_config()
|
| 195 |
backend = backend or create_backend(settings)
|
| 196 |
-
acquire = acquire or
|
| 197 |
reference_date = today if today is not None else date.today()
|
| 198 |
source_path = Path(path)
|
| 199 |
backend_name = backend.name
|
|
|
|
| 100 |
return self.decision == "accept"
|
| 101 |
|
| 102 |
|
| 103 |
+
# MIME types for image modality, keyed by lower-case file extension.
|
| 104 |
+
_MIME_BY_SUFFIX: dict[str, str] = {
|
| 105 |
+
".jpg": "image/jpeg",
|
| 106 |
+
".jpeg": "image/jpeg",
|
| 107 |
+
".png": "image/png",
|
| 108 |
+
".gif": "image/gif",
|
| 109 |
+
".webp": "image/webp",
|
| 110 |
+
".tif": "image/tiff",
|
| 111 |
+
".tiff": "image/tiff",
|
| 112 |
+
".bmp": "image/bmp",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _load_image_payload(path: Path) -> DocumentPayload:
|
| 117 |
+
"""Load an image file's raw bytes for vision-direct extraction.
|
| 118 |
|
| 119 |
Args:
|
| 120 |
+
path: Path to the image file to read.
|
|
|
|
| 121 |
|
| 122 |
+
Returns:
|
| 123 |
+
A ``DocumentPayload`` with ``image_bytes`` and ``image_mime`` set.
|
| 124 |
"""
|
| 125 |
+
mime = _MIME_BY_SUFFIX.get(path.suffix.lower(), "image/jpeg")
|
| 126 |
+
return DocumentPayload(
|
| 127 |
+
modality="image",
|
| 128 |
+
source_path=path,
|
| 129 |
+
image_bytes=path.read_bytes(),
|
| 130 |
+
image_mime=mime,
|
| 131 |
)
|
| 132 |
|
| 133 |
|
| 134 |
+
def _make_acquire(settings: Settings) -> Acquire:
|
| 135 |
+
"""Create the default acquire callable wired to current settings.
|
| 136 |
+
|
| 137 |
+
Handles ``image`` + ``vision_direct`` by reading raw bytes (T2). All other
|
| 138 |
+
paths (``native_pdf``, ``ocr_then_text``) raise until T3/T4/T5 wire them in.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
settings: Validated runtime configuration (``image_strategy`` is read).
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
An ``Acquire`` callable that maps (path, modality) to a payload.
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def _acquire(path: Path, modality: Modality) -> DocumentPayload:
|
| 148 |
+
if modality == "image" and settings.image_strategy == "vision_direct":
|
| 149 |
+
return _load_image_payload(path)
|
| 150 |
+
raise NotImplementedError(
|
| 151 |
+
f"Acquisition for modality={modality!r} with "
|
| 152 |
+
f"IMAGE_STRATEGY={settings.image_strategy!r} is not yet wired. "
|
| 153 |
+
"Native-PDF parsing needs T3 (Docling); the OCR path needs T4. "
|
| 154 |
+
"Inject an acquire callable or use IMAGE_STRATEGY=vision_direct with an image."
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return _acquire
|
| 158 |
+
|
| 159 |
+
|
| 160 |
def _aggregate_model_signal(field_confidence: dict[str, float] | None) -> float | None:
|
| 161 |
"""Reduce a backend's per-field confidence to one document-level signal.
|
| 162 |
|
|
|
|
| 227 |
"""
|
| 228 |
settings = settings or load_config()
|
| 229 |
backend = backend or create_backend(settings)
|
| 230 |
+
acquire = acquire or _make_acquire(settings)
|
| 231 |
reference_date = today if today is not None else date.today()
|
| 232 |
source_path = Path(path)
|
| 233 |
backend_name = backend.name
|
tests/test_backends.py
CHANGED
|
@@ -69,12 +69,18 @@ def test_factory_override_takes_precedence_over_config() -> None:
|
|
| 69 |
def test_factory_reads_backend_name_from_settings() -> None:
|
| 70 |
"""With no override the factory resolves the name from settings.
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
"""
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
with pytest.raises(ConfigError, match="ollama"):
|
| 79 |
create_backend(_ollama_settings())
|
| 80 |
|
|
@@ -98,10 +104,12 @@ def test_unknown_backend_raises_config_error() -> None:
|
|
| 98 |
assert "stub" in message
|
| 99 |
|
| 100 |
|
| 101 |
-
def
|
| 102 |
-
"""The registry
|
| 103 |
names = available_backends()
|
| 104 |
assert "stub" in names
|
|
|
|
|
|
|
| 105 |
assert list(names) == sorted(names)
|
| 106 |
|
| 107 |
|
|
|
|
| 69 |
def test_factory_reads_backend_name_from_settings() -> None:
|
| 70 |
"""With no override the factory resolves the name from settings.
|
| 71 |
|
| 72 |
+
Gemini is now registered (T2), so _gemini_settings() succeeds.
|
| 73 |
+
Ollama is still unregistered, so _ollama_settings() still raises a
|
| 74 |
+
ConfigError naming the configured backend.
|
| 75 |
"""
|
| 76 |
+
from unittest.mock import patch
|
| 77 |
+
|
| 78 |
+
from doc_agent.backends.gemini import GeminiBackend
|
| 79 |
+
|
| 80 |
+
with patch("google.genai.Client"):
|
| 81 |
+
backend = create_backend(_gemini_settings())
|
| 82 |
+
assert isinstance(backend, GeminiBackend)
|
| 83 |
+
|
| 84 |
with pytest.raises(ConfigError, match="ollama"):
|
| 85 |
create_backend(_ollama_settings())
|
| 86 |
|
|
|
|
| 104 |
assert "stub" in message
|
| 105 |
|
| 106 |
|
| 107 |
+
def test_available_backends_lists_registered_backends() -> None:
|
| 108 |
+
"""The registry exposes gemini and stub (sorted); ollama is not yet registered."""
|
| 109 |
names = available_backends()
|
| 110 |
assert "stub" in names
|
| 111 |
+
assert "gemini" in names
|
| 112 |
+
assert "ollama" not in names
|
| 113 |
assert list(names) == sorted(names)
|
| 114 |
|
| 115 |
|
tests/test_gemini.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mocked unit tests for the Gemini extraction backend (T2).
|
| 2 |
+
|
| 3 |
+
No real API calls are made -- the google.genai client is bypassed by
|
| 4 |
+
constructing ``GeminiBackend`` via ``object.__new__`` and injecting mock
|
| 5 |
+
attributes directly. This keeps the tests deterministic, free of network/quota,
|
| 6 |
+
and fast.
|
| 7 |
+
|
| 8 |
+
Covers the acceptance criteria for T2:
|
| 9 |
+
- schema-valid JSON response is parsed into a ``BackendResult`` whose data
|
| 10 |
+
validates into a ``Document``,
|
| 11 |
+
- a transient failure on attempt 1 is retried and succeeds on attempt 2,
|
| 12 |
+
- all ``_MAX_RETRIES`` attempts failing raises ``RuntimeError`` (which the
|
| 13 |
+
core catches and routes to review).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from unittest.mock import MagicMock, call, patch
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
from doc_agent.backends.base import DocumentPayload
|
| 24 |
+
from doc_agent.backends.gemini import GeminiBackend, _ExtractionSchema, _MAX_RETRIES
|
| 25 |
+
from doc_agent.schema.models import Document
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Helpers
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _make_backend() -> tuple[GeminiBackend, MagicMock]:
|
| 34 |
+
"""Build a GeminiBackend bypassing __init__ with a mock client.
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
A tuple of (backend, mock_client) where mock_client is the object
|
| 38 |
+
wired to ``backend._client``.
|
| 39 |
+
"""
|
| 40 |
+
mock_types = MagicMock()
|
| 41 |
+
mock_client = MagicMock()
|
| 42 |
+
|
| 43 |
+
backend = object.__new__(GeminiBackend)
|
| 44 |
+
backend._model = "gemini-test"
|
| 45 |
+
backend._types = mock_types
|
| 46 |
+
backend._client = mock_client
|
| 47 |
+
return backend, mock_client
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _json_response(data: dict) -> MagicMock:
|
| 51 |
+
"""Build a mock Gemini response whose ``.text`` is a valid JSON string.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
data: Fields to include in the ``_ExtractionSchema`` response.
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
A MagicMock whose ``.text`` attribute is a JSON-serialised
|
| 58 |
+
``_ExtractionSchema`` populated from ``data``.
|
| 59 |
+
"""
|
| 60 |
+
schema_fields = _ExtractionSchema.model_fields.keys()
|
| 61 |
+
filtered = {k: v for k, v in data.items() if k in schema_fields}
|
| 62 |
+
text = _ExtractionSchema(**filtered).model_dump_json()
|
| 63 |
+
mock_resp = MagicMock()
|
| 64 |
+
mock_resp.text = text
|
| 65 |
+
return mock_resp
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _image_payload() -> DocumentPayload:
|
| 69 |
+
"""A minimal vision-direct image payload."""
|
| 70 |
+
return DocumentPayload(
|
| 71 |
+
modality="image",
|
| 72 |
+
image_bytes=b"fake-image-bytes",
|
| 73 |
+
image_mime="image/jpeg",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _text_payload() -> DocumentPayload:
|
| 78 |
+
"""A minimal text payload (native-PDF / OCR path)."""
|
| 79 |
+
return DocumentPayload(
|
| 80 |
+
modality="native_pdf",
|
| 81 |
+
text="Invoice #001 Total: $42.50",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Schema-valid parsing
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_extract_image_returns_schema_valid_data() -> None:
|
| 91 |
+
"""A valid Gemini JSON response parses into Document-compatible data (AC)."""
|
| 92 |
+
backend, mock_client = _make_backend()
|
| 93 |
+
mock_client.models.generate_content.return_value = _json_response(
|
| 94 |
+
{
|
| 95 |
+
"doc_type": "receipt",
|
| 96 |
+
"vendor_name": "Test Cafe",
|
| 97 |
+
"invoice_number": "R-001",
|
| 98 |
+
"document_date": "2024-03-15",
|
| 99 |
+
"currency": "USD",
|
| 100 |
+
"subtotal": 10.00,
|
| 101 |
+
"tax": 0.80,
|
| 102 |
+
"total": 10.80,
|
| 103 |
+
}
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
result = backend.extract(_image_payload(), Document)
|
| 107 |
+
|
| 108 |
+
assert result.data["doc_type"] == "receipt"
|
| 109 |
+
assert result.data["vendor_name"] == "Test Cafe"
|
| 110 |
+
assert result.data["total"] == pytest.approx(10.80)
|
| 111 |
+
# The dict must validate cleanly into the Document schema.
|
| 112 |
+
doc = Document.model_validate(result.data)
|
| 113 |
+
assert doc.vendor_name == "Test Cafe"
|
| 114 |
+
assert doc.total == pytest.approx(10.80)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_extract_text_returns_schema_valid_data() -> None:
|
| 118 |
+
"""A text payload (native-PDF / OCR) is accepted and parsed correctly."""
|
| 119 |
+
backend, mock_client = _make_backend()
|
| 120 |
+
mock_client.models.generate_content.return_value = _json_response(
|
| 121 |
+
{"doc_type": "invoice", "invoice_number": "INV-42", "total": 99.99}
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
result = backend.extract(_text_payload(), Document)
|
| 125 |
+
|
| 126 |
+
assert result.data["doc_type"] == "invoice"
|
| 127 |
+
assert result.data["total"] == pytest.approx(99.99)
|
| 128 |
+
Document.model_validate(result.data) # must not raise
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_extract_null_fields_become_none() -> None:
|
| 132 |
+
"""Absent fields in the JSON response survive as None through the Document."""
|
| 133 |
+
backend, mock_client = _make_backend()
|
| 134 |
+
mock_client.models.generate_content.return_value = _json_response(
|
| 135 |
+
{"doc_type": "other", "total": None, "vendor_name": None}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
result = backend.extract(_image_payload(), Document)
|
| 139 |
+
doc = Document.model_validate(result.data)
|
| 140 |
+
|
| 141 |
+
assert doc.total is None
|
| 142 |
+
assert doc.vendor_name is None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_field_confidence_is_none() -> None:
|
| 146 |
+
"""GeminiBackend returns None field_confidence (no per-field signal)."""
|
| 147 |
+
backend, mock_client = _make_backend()
|
| 148 |
+
mock_client.models.generate_content.return_value = _json_response({"total": 5.00})
|
| 149 |
+
|
| 150 |
+
result = backend.extract(_image_payload(), Document)
|
| 151 |
+
|
| 152 |
+
assert result.field_confidence is None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_payload_without_image_or_text_raises() -> None:
|
| 156 |
+
"""A payload with neither image_bytes nor text raises ValueError."""
|
| 157 |
+
backend, _ = _make_backend()
|
| 158 |
+
bad_payload = DocumentPayload(modality="image")
|
| 159 |
+
|
| 160 |
+
with pytest.raises(ValueError, match="image_bytes"):
|
| 161 |
+
backend.extract(bad_payload, Document)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# Retry logic
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_retry_succeeds_on_second_attempt() -> None:
|
| 170 |
+
"""A transient error on attempt 1 is retried; attempt 2 returns data."""
|
| 171 |
+
backend, mock_client = _make_backend()
|
| 172 |
+
good_response = _json_response({"doc_type": "receipt", "total": 7.77})
|
| 173 |
+
mock_client.models.generate_content.side_effect = [
|
| 174 |
+
RuntimeError("transient network error"),
|
| 175 |
+
good_response,
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
with patch("doc_agent.backends.gemini.time.sleep") as mock_sleep:
|
| 179 |
+
result = backend.extract(_image_payload(), Document)
|
| 180 |
+
|
| 181 |
+
assert result.data["total"] == pytest.approx(7.77)
|
| 182 |
+
assert mock_client.models.generate_content.call_count == 2
|
| 183 |
+
mock_sleep.assert_called_once() # one backoff sleep between attempts
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_retry_all_attempts_fail_raises_runtime_error() -> None:
|
| 187 |
+
"""All _MAX_RETRIES attempts failing raises RuntimeError (core catches it)."""
|
| 188 |
+
backend, mock_client = _make_backend()
|
| 189 |
+
mock_client.models.generate_content.side_effect = TimeoutError("request timed out")
|
| 190 |
+
|
| 191 |
+
with patch("doc_agent.backends.gemini.time.sleep"):
|
| 192 |
+
with pytest.raises(RuntimeError, match=f"failed after {_MAX_RETRIES}"):
|
| 193 |
+
backend.extract(_image_payload(), Document)
|
| 194 |
+
|
| 195 |
+
assert mock_client.models.generate_content.call_count == _MAX_RETRIES
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_retry_backoff_grows_exponentially() -> None:
|
| 199 |
+
"""Each retry sleeps longer than the previous one (exponential backoff)."""
|
| 200 |
+
backend, mock_client = _make_backend()
|
| 201 |
+
mock_client.models.generate_content.side_effect = OSError("network")
|
| 202 |
+
|
| 203 |
+
sleep_calls: list[float] = []
|
| 204 |
+
with patch("doc_agent.backends.gemini.time.sleep", side_effect=lambda s: sleep_calls.append(s)):
|
| 205 |
+
with pytest.raises(RuntimeError):
|
| 206 |
+
backend.extract(_image_payload(), Document)
|
| 207 |
+
|
| 208 |
+
# _MAX_RETRIES attempts -> (_MAX_RETRIES - 1) sleeps.
|
| 209 |
+
assert len(sleep_calls) == _MAX_RETRIES - 1
|
| 210 |
+
for i in range(1, len(sleep_calls)):
|
| 211 |
+
assert sleep_calls[i] > sleep_calls[i - 1]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_no_sleep_on_first_attempt() -> None:
|
| 215 |
+
"""The first attempt is made immediately without any sleep."""
|
| 216 |
+
backend, mock_client = _make_backend()
|
| 217 |
+
mock_client.models.generate_content.return_value = _json_response({"total": 1.00})
|
| 218 |
+
|
| 219 |
+
with patch("doc_agent.backends.gemini.time.sleep") as mock_sleep:
|
| 220 |
+
backend.extract(_image_payload(), Document)
|
| 221 |
+
|
| 222 |
+
mock_sleep.assert_not_called()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ---------------------------------------------------------------------------
|
| 226 |
+
# Factory integration
|
| 227 |
+
# ---------------------------------------------------------------------------
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_factory_builds_gemini_backend() -> None:
|
| 231 |
+
"""create_backend resolves 'gemini' and returns a GeminiBackend."""
|
| 232 |
+
from doc_agent.backends.base import create_backend
|
| 233 |
+
from doc_agent.config import load_config
|
| 234 |
+
|
| 235 |
+
settings = load_config(
|
| 236 |
+
extraction_backend="gemini",
|
| 237 |
+
gemini_api_key="test-key",
|
| 238 |
+
image_strategy="vision_direct",
|
| 239 |
+
)
|
| 240 |
+
# Patch the google.genai.Client so no real HTTP client is constructed.
|
| 241 |
+
with patch("google.genai.Client"):
|
| 242 |
+
backend = create_backend(settings)
|
| 243 |
+
|
| 244 |
+
assert isinstance(backend, GeminiBackend)
|
| 245 |
+
assert backend.name == "gemini"
|