"""On-host vision fallback for invoice-field extraction from a scan-only PDF or image.
:func:`~application.ledger.extract_invoice_fields` reads a PDF's embedded text
layer. A scan-only or image-only invoice has no text layer at all, so that
primitive raises. This module supplies the on-host fallback: rasterise the PDF
(or use an image directly) into in-memory base64 PNG pages
(:func:`~adapters.outbound.llm.rasterise_pdf_pages_to_base64_png`) and read them
with the same LOCAL Ollama vision model the classification path already uses
(:class:`~application.ledger._vision_classifier.LocalVisionLLMClassifier`), fully on-host
(``sensitive-financial-data-secure-storage-only``). Nothing is written to disk and
nothing leaves the machine; this needs no cloud consent gate.
The vision model's role here is strictly *transcription*, never *derivation*: the
prompt instructs it to copy each field's printed value verbatim (or emit ``null``
when a field is not visibly printed) and forbids it from computing, inferring, or
estimating any figure. Every field the model returns is re-validated through the
exact same grounded heuristics the text-layer path uses --
:func:`~core.identity.validate_spanish_tax_id`,
:func:`~core.parsing.parse_date`, and :class:`~decimal.Decimal` parsing via
:func:`~core.decimal.normalize_decimal_separators` -- so a malformed or
hallucinated value is rejected (left ``None``) rather than trusted. This mirrors the
document-printed-value semantics
:func:`~application.ledger.extract_invoice_fields` already has for text-layer PDFs:
both paths recover what is *printed on the
document*, never a registry-derived or model-computed tax figure
(``evidence-read-never-emits-regulated-numbers`` in spirit -- the persisted
:class:`~domain.invoices.Invoice` this draft eventually confirms into still goes
through the operator review step before anything is minted).
Gated by :attr:`~core.ServiceCapability.LLM_VISION`: an operator who has opted
out of on-host vision reading gets a typed refusal naming the capability toggle,
never a silent empty draft.
See Also:
:class:`~application.ledger.InvoiceDraft`
Typed draft this vision path returns after grounded re-validation.
:func:`~application.ledger.extract_invoice_fields`
Text-layer extraction primitive this module complements for scan-only
or image-only evidence.
:func:`~application.ledger.extract_invoice_draft_from_evidence`
Orchestration layer that falls back to this on-host reader.
:class:`~application.ledger._vision_classifier.LocalVisionLLMClassifier`
Sibling local Ollama vision transport used for classification and
split suggestions.
"""
from __future__ import annotations
import asyncio
import base64
import re
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, Field
from ...adapters.outbound.llm import LLMClient, LLMProvider, LLMRequest, MultimodalImageInput
from ...core import STRICT_FROZEN_CONFIG
from ...core.config import Settings, load_settings
from ...core.decimal import normalize_decimal_separators
from ...core.hashing import sha256_hex
from ...core.identity import IdentityError, validate_spanish_tax_id
from ...core.parsing import parse_date
from ._evidence import PurchaseInvoiceEvidenceInputError
from ._evidence_draft import InvoiceDraft
__all__ = [
"LocalVisionInvoiceFieldExtractor",
"extract_invoice_fields_from_images",
]
# One JSON object, allowing the model to wrap it in prose or a code fence; the
# first balanced-looking candidate is taken (mirrors the classification parser's
# tolerance for chatty local models).
_JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
_FIELD_EXTRACTION_PROMPT = """\
You are transcribing fields from a scanned Spanish invoice image. Look at the \
image and copy each field's value EXACTLY as printed. Do not calculate, infer, \
estimate, or guess any value. If a field is not visibly printed on the document, \
its value is null.
Return ONLY one JSON object with exactly these keys (no other text):
{{
"supplier_tax_id": <string or null, the supplier's NIF/NIE/CIF exactly as printed>,
"invoice_number": <string or null, the invoice number exactly as printed>,
"invoice_date": <string or null, the invoice date exactly as printed, e.g. "10/03/2026">,
"taxable_base": <string or null, the "base imponible" amount exactly as printed, e.g. "100,00">,
"iva_rate": <string or null, the IVA percentage exactly as printed, e.g. "21">,
"iva_amount": <string or null, the IVA cuota amount exactly as printed, e.g. "21,00">,
"grand_total": <string or null, the invoice total amount exactly as printed, e.g. "121,00">
}}
"""
class _VisionExtractedFields(BaseModel):
"""Raw string fields the vision model transcribed, before grounded re-validation.
Every field is an optional string: the model is instructed to transcribe the
printed value verbatim (never compute or infer it) and this schema accepts
whatever string it returns. Grounded re-validation into typed values (a
checksum-valid tax id, a parsed date, a parsed Decimal) happens in
:func:`_ground_extracted_fields`, never here -- a malformed or hallucinated
string must be rejected downstream, not coerced at the schema boundary.
"""
model_config = STRICT_FROZEN_CONFIG
supplier_tax_id: str | None = Field(default=None)
invoice_number: str | None = Field(default=None)
invoice_date: str | None = Field(default=None)
taxable_base: str | None = Field(default=None)
iva_rate: str | None = Field(default=None)
iva_amount: str | None = Field(default=None)
grand_total: str | None = Field(default=None)
def _extract_json_object(text: str) -> str | None:
match = _JSON_OBJECT_RE.search(text)
return match.group(0) if match else None
def _grounded_tax_id(raw: str | None) -> str | None:
if raw is None:
return None
try:
return validate_spanish_tax_id(raw)
except IdentityError:
return None
def _grounded_invoice_number(raw: str | None) -> str | None:
if raw is None:
return None
trimmed = raw.strip()
return trimmed or None
def _grounded_date(raw: str | None) -> str | None:
"""Parse *raw* as a day-first (``DD-MM-YYYY`` / ``DD/MM/YYYY``) or ISO-8601 date.
A vision model transcribing a printed Spanish invoice date returns the
day-first form the document actually shows (mirroring the text-layer
heuristic's ``_DATE_RE``); ISO-8601 is tried second in case the model
normalises the printed value itself. Only these two real, registered
:data:`~core.parsing._DateFmt` members are ever passed -- an invented
format string silently degrades to one of the two delegates
(:func:`~core.parsing._parse_date` has no third branch), which would
make a "fallback" attempt a silent no-op duplicate.
"""
if raw is None:
return None
cleaned = raw.strip()
for fmt in ("ddmmyyyy", "iso8601"):
parsed = parse_date(cleaned, fmt=fmt, on_error="none")
if parsed is not None:
return parsed.isoformat()
return None
def _grounded_decimal(raw: str | None) -> Decimal | None:
if raw is None:
return None
normalized = normalize_decimal_separators(raw.strip(), strip_thousands=True)
try:
return Decimal(normalized)
except InvalidOperation:
return None
def _ground_extracted_fields(fields: _VisionExtractedFields, *, raw_text_length: int) -> InvoiceDraft:
"""Re-validate the model's transcribed strings into a grounded :class:`InvoiceDraft`.
A field the model transcribed but that fails grounded validation (an invalid
tax-id checksum, an unparsable date, a non-numeric amount) is dropped to
``None`` rather than trusted -- the same "never fabricate" discipline the
text-layer heuristics apply.
"""
return InvoiceDraft(
supplier_tax_id=_grounded_tax_id(fields.supplier_tax_id),
invoice_number=_grounded_invoice_number(fields.invoice_number),
invoice_date=_grounded_date(fields.invoice_date),
taxable_base=_grounded_decimal(fields.taxable_base),
iva_rate=_grounded_decimal(fields.iva_rate),
iva_amount=_grounded_decimal(fields.iva_amount),
grand_total=_grounded_decimal(fields.grand_total),
raw_text_length=raw_text_length,
)