Spaces:
Sleeping
Sleeping
File size: 9,284 Bytes
d17ca40 b6beb2d d17ca40 b6beb2d d17ca40 b6beb2d d17ca40 b6beb2d d17ca40 7a6af4c b6beb2d 7a6af4c b6beb2d 7a6af4c d17ca40 7a6af4c d17ca40 7a6af4c d17ca40 7a6af4c d17ca40 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """Backend interface, payload/result types, and the config-driven factory.
Every model call in the system goes through ``ExtractionBackend`` (CLAUDE.md
architectural rule 2): the core and the entry points depend on this interface,
never on a provider SDK. Concrete adapters (Gemini, Ollama) implement
``extract`` and register a builder here; adding a backend is "implement the
interface + register in the factory; nothing else changes."
Three small types make the contract concrete:
- ``DocumentPayload`` -- the acquired representation of one document (Docling
text/layout for native PDFs, OCR text or raw image bytes for scans/photos)
handed to a backend. It is the seam between the parsing/acquire stage and
extraction: a text-only backend reads ``text``; a multimodal backend may read
``image_bytes``.
- ``BackendResult`` -- the raw structured output of a backend: the extracted
``data`` dict (validated into a ``Document`` by the core, never regex-parsed
out of free text), an optional per-field ``field_confidence`` signal, and the
``raw`` provider response for logging/debugging.
- ``ExtractionBackend`` -- the ``Protocol`` the core programs against.
The factory resolves a backend name (an explicit override or
``Settings.extraction_backend``) against a registry of builders. Builders import
their adapter lazily -- only when that backend is actually built -- so this
module stays a dependency leaf: it imports no concrete adapter at module load,
which keeps provider SDKs (and their heavy/optional deps) out of the import path
until one is selected. An unknown or not-yet-available name is a recoverable
:class:`~docfield.config.ConfigError` with an actionable message, never a crash
deep in the pipeline (architecture section 5; CLAUDE.md rule 3).
See ``docs/02_architecture.md`` section 5.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
from pydantic import BaseModel
from docfield.config import ConfigError, Settings
from docfield.parsing.detect import Modality
@dataclass(frozen=True)
class DocumentPayload:
"""The acquired representation of one document handed to a backend.
Produced by the acquisition stage (Docling for native PDFs, OCR for images,
or the raw page image for vision-direct backends) and consumed by
:meth:`ExtractionBackend.extract`. It carries whichever representations were
produced; a text-only backend (e.g. Ollama) reads ``text`` while a
multimodal backend (e.g. Gemini) may read ``image_bytes`` directly.
Attributes:
modality: The detected parse path ("native_pdf" | "image").
source_path: Original file path, for logging/diagnostics; may be ``None``
when the payload is synthesized (tests, the web demo's in-memory
upload).
text: Extracted text/layout representation, or ``None`` when only an
image is supplied.
image_bytes: Raw page-image bytes for vision-direct extraction, or
``None`` for the text-only path.
image_mime: MIME type of ``image_bytes`` (e.g. "image/png"), or ``None``.
metadata: Free-form acquisition metadata (page count, parser name,
timings) for logging; not part of the extraction contract.
"""
modality: Modality
source_path: Path | None = None
text: str | None = None
image_bytes: bytes | None = None
image_mime: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class BackendResult:
"""Raw structured output returned by an :class:`ExtractionBackend`.
The shape mirrors the architecture spec (section 5): ``{ data,
field_confidence, raw }``. The core validates ``data`` into a ``Document``
(structured output is enforced, never regex-parsed) and folds
``field_confidence`` into the document-level model signal used by scoring.
Attributes:
data: The extracted fields as a plain dict, ready to be validated into a
``Document``. Keys map to schema field names.
field_confidence: Optional per-field confidence in ``[0, 1]`` where the
backend exposes one; ``None`` when the backend reports no signal (the
scorer then treats confidence as neutral).
raw: The raw provider response (or a stand-in), retained for logging and
debugging only; the pipeline never parses it.
"""
data: dict[str, Any]
field_confidence: dict[str, float] | None = None
raw: Any = None
@runtime_checkable
class ExtractionBackend(Protocol):
"""The interface every model backend implements.
The core programs against this ``Protocol`` (CLAUDE.md architectural
rule 2), so it never depends on a concrete adapter or a provider SDK.
Implementations enforce schema-constrained output (Pydantic schema for
Gemini, JSON-schema/grammar for Ollama) and apply bounded retries/timeouts
internally; an exhausted call surfaces as an error the core routes to review
rather than crashing.
Attributes:
name: Stable identifier for the backend (e.g. "gemini", "ollama",
"stub"); used in logs and by the factory registry.
"""
name: str
def extract(self, payload: DocumentPayload, schema: type[BaseModel]) -> BackendResult:
"""Extract structured fields from one document payload.
Args:
payload: The acquired document representation (text and/or image).
schema: The Pydantic model class defining the output contract; the
backend constrains its output to this schema.
Returns:
A ``BackendResult`` with the extracted ``data`` dict and any
confidence signal the backend exposes.
"""
...
# A builder turns validated settings into a ready-to-use backend instance.
BackendBuilder = Callable[[Settings], ExtractionBackend]
def _build_stub(settings: Settings) -> ExtractionBackend:
"""Construct the offline stub backend.
The import is local so :mod:`docfield.backends.base` does not import
:mod:`docfield.backends.stub` at module load (avoiding an import cycle, the
stub imports the result/payload types from here) and so selecting one
backend never imports another's dependencies.
Args:
settings: Validated runtime configuration (unused by the stub).
Returns:
A new ``StubBackend`` instance.
"""
from docfield.backends.stub import StubBackend
return StubBackend()
def _build_gemini(settings: Settings) -> ExtractionBackend:
"""Construct the Gemini backend (lazy import of google-genai).
The import is local so this module stays a dependency leaf: importing
``docfield.backends.base`` never pulls in ``google.genai`` unless the
Gemini backend is actually selected.
Args:
settings: Validated runtime configuration (API key, model, timeout).
Returns:
A ready-to-use ``GeminiBackend`` instance.
"""
from docfield.backends.gemini import GeminiBackend
return GeminiBackend(settings)
# Registry of buildable backends: name -> builder. Builders import their adapter
# lazily so selecting one backend never imports another's (possibly heavy or
# optional) provider SDK. Ollama registers its builder here when implemented
# (build plan phase 2.6).
_BACKEND_BUILDERS: dict[str, BackendBuilder] = {
"gemini": _build_gemini,
"stub": _build_stub,
}
def available_backends() -> tuple[str, ...]:
"""Return the names of the backends the factory can currently build.
Returns:
The registered backend names, sorted for stable display in messages.
"""
return tuple(sorted(_BACKEND_BUILDERS))
def create_backend(settings: Settings, *, name: str | None = None) -> ExtractionBackend:
"""Build the configured extraction backend from settings.
Resolves the backend name from ``name`` (an explicit override, e.g. tests or
an entry point forcing a backend) falling back to
``settings.extraction_backend``, then constructs it via the registered
builder. The resolution is the single place backend selection happens, so
the rest of the system depends only on the returned interface.
Args:
settings: Validated runtime configuration.
name: Optional explicit backend name; defaults to
``settings.extraction_backend`` when ``None``.
Returns:
A ready-to-use object satisfying the ``ExtractionBackend`` protocol.
Raises:
ConfigError: If the resolved name is not a currently-available backend.
The message lists the available backends and notes that the Gemini
and Ollama adapters arrive in a later build phase.
"""
backend_name = (name or settings.extraction_backend).strip().lower()
builder = _BACKEND_BUILDERS.get(backend_name)
if builder is None:
available = ", ".join(available_backends())
raise ConfigError(
f"Unknown or unavailable extraction backend {backend_name!r}. "
f"Available backends: {available}. "
f"(The Ollama adapter is added in build phase 2.6.)"
)
return builder(settings)
|