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)