kennethzychew Claude Sonnet 4.6 commited on
Commit
7a6af4c
·
1 Parent(s): 12a1092

feat(gemini): Gemini backend + vision-direct image acquire (T2)

Browse files

GeminiBackend: 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 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
- - [ ] **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
 
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. Gemini and Ollama register their builders here when
162
- # implemented (build plan phases 2.5/2.6); tonight only the offline stub exists.
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}. (The Gemini and Ollama adapters "
206
- f"are added in a later build phase.)"
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
- def _default_acquire(path: Path, modality: Modality) -> DocumentPayload:
104
- """Placeholder acquirer used until the real parser is wired in.
105
-
106
- Native-PDF parsing (Docling) and the OCR path are build-plan phases 2.2/2.3,
107
- a supervised TOMORROW task. Until then callers must inject an ``acquire``
108
- callable; invoking this default is a clear, recoverable error rather than a
109
- silent empty payload.
 
 
 
 
 
 
 
 
110
 
111
  Args:
112
- path: The document path (unused).
113
- modality: The detected modality (unused).
114
 
115
- Raises:
116
- NotImplementedError: Always -- acquisition is not yet implemented.
117
  """
118
- raise NotImplementedError(
119
- "Document acquisition (Docling/OCR) is not wired yet (build-plan phases "
120
- "2.2/2.3, a supervised task). Inject an `acquire` callable for now -- the "
121
- "web demo supplies its uploaded payload and the core smoke test supplies "
122
- "a fixed one."
 
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 _default_acquire
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
- A Gemini/Ollama config resolves to a not-yet-available backend tonight, so
73
- the factory raises -- but the error names the *configured* backend, proving
74
- the name was read from settings rather than ignored.
75
  """
76
- with pytest.raises(ConfigError, match="gemini"):
77
- create_backend(_gemini_settings())
 
 
 
 
 
 
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 test_available_backends_lists_the_stub() -> None:
102
- """The registry currently exposes the offline stub (and is sorted)."""
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"