File size: 14,157 Bytes
90d0304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
90d0304
 
 
 
b6beb2d
 
 
 
 
90d0304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a6af4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90d0304
 
7a6af4c
90d0304
7a6af4c
 
90d0304
7a6af4c
 
 
 
 
 
90d0304
 
 
7a6af4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e2eec89
b6beb2d
e2eec89
 
 
 
 
 
 
 
7a6af4c
 
 
e2eec89
7a6af4c
 
 
 
 
 
f1a9398
90d0304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a6af4c
90d0304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1a9398
90d0304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""The reusable core pipeline: ``process_document(path) -> ExtractionResult``.

This is the one piece of logic both entry points share (architecture section 1).
It chains the six stages of the data flow (architecture section 7) -- detect
modality, acquire a payload, call the backend, validate, score, route -- and
returns a result object. It performs **no** side effects: no file moves, no
database writes (CLAUDE.md architectural rule 1). The watcher persists and moves
files; the web demo renders and discards; the core only computes.

Two seams keep the core decoupled from its environment:

- **The backend** (``ExtractionBackend``) is injected or built from config via
  the factory. The core never imports a provider SDK (rule 2).
- **Acquisition** (``Acquire``) -- turning a path into a ``DocumentPayload`` with
  Docling/OCR -- is a callable the core depends on but does not implement here.
  The real parser is build-plan phases 2.2/2.3 (a supervised TOMORROW task); for
  now the default raises a clear error and callers inject an acquirer (the smoke
  test a fixed payload, the web demo its uploaded bytes).

Robustness follows the precision posture and rule 6 ("the loop never dies on one
document"): a failure anywhere in the per-document pipeline is caught, logged
with full context, and turned into a ``review`` result rather than propagating.
A missing field is cheap (review catches it); a crash that halts the batch, or a
confidently-wrong number written on auto-accept, are the costly outcomes this
guards against. Misconfiguration (an unbuildable backend) still fails fast,
before the per-document loop -- that is a startup error, not a document error.

See ``docs/02_architecture.md`` sections 3 and 7.
"""

from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from pathlib import Path

from docfield.backends.base import (
    DocumentPayload,
    ExtractionBackend,
    create_backend,
)
from docfield.config import Settings, load_config
from docfield.parsing.detect import Modality, detect_modality
from docfield.routing.score import route, score
from docfield.schema.models import Decision, Document
from docfield.validation.rules import ValidationReport, validate

logger = logging.getLogger(__name__)

# The acquisition seam: map a document path + its detected modality to the
# payload a backend consumes. The real implementation (Docling for native PDFs,
# OCR for images) is build-plan phases 2.2/2.3 -- a supervised TOMORROW task --
# so the core depends on this interface but does not implement it here, keeping
# acquisition swappable and the core testable offline.
Acquire = Callable[[Path, Modality], DocumentPayload]


@dataclass(frozen=True)
class ExtractionResult:
    """The outcome of running the core pipeline over one document.

    A plain value object: it carries everything an entry point needs to persist,
    render, or queue the document, and nothing it needs to recompute. The
    populated ``document`` (with ``field_confidence``, ``validation``, and
    ``decision`` filled in) is the record to store; the remaining fields surface
    the routing rationale for logging and the web demo's explanation panel.

    Attributes:
        document: The validated ``Document`` with its pipeline fields populated.
            On an error this is an empty document (so downstream code can rely on
            the shape); inspect ``error`` to distinguish.
        report: The structured validation report (hard/soft rule outcomes).
        confidence: The document-level confidence score in ``[0, 1]``.
        decision: The routing decision ("accept" | "review"); mirrors
            ``document.decision``.
        modality: The detected parse path, or ``None`` if detection itself failed.
        backend_name: The backend that produced (or would have produced) the data.
        source_path: The original document path, for logging/diagnostics.
        model_signal: The aggregated backend confidence fed to scoring, or
            ``None`` when the backend exposed none (scoring treated it as neutral).
        error: ``None`` on success; a short message when the document was routed
            to review because a stage raised (rule 6).
    """

    document: Document
    report: ValidationReport
    confidence: float
    decision: Decision
    modality: Modality | None
    backend_name: str
    source_path: Path | None = None
    model_signal: float | None = None
    error: str | None = None

    @property
    def accepted(self) -> bool:
        """Whether the document was auto-accepted (decision == "accept")."""
        return self.decision == "accept"


# MIME types for image modality, keyed by lower-case file extension.
_MIME_BY_SUFFIX: dict[str, str] = {
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".png": "image/png",
    ".gif": "image/gif",
    ".webp": "image/webp",
    ".tif": "image/tiff",
    ".tiff": "image/tiff",
    ".bmp": "image/bmp",
}


def _load_image_payload(path: Path) -> DocumentPayload:
    """Load an image file's raw bytes for vision-direct extraction.

    Args:
        path: Path to the image file to read.

    Returns:
        A ``DocumentPayload`` with ``image_bytes`` and ``image_mime`` set.
    """
    mime = _MIME_BY_SUFFIX.get(path.suffix.lower(), "image/jpeg")
    return DocumentPayload(
        modality="image",
        source_path=path,
        image_bytes=path.read_bytes(),
        image_mime=mime,
    )


def _make_acquire(settings: Settings) -> Acquire:
    """Create the default acquire callable wired to current settings.

    Handles ``image`` + ``vision_direct`` by reading raw bytes (T2). All other
    paths (``native_pdf``, ``ocr_then_text``) raise until T3/T4/T5 wire them in.

    Args:
        settings: Validated runtime configuration (``image_strategy`` is read).

    Returns:
        An ``Acquire`` callable that maps (path, modality) to a payload.
    """

    def _acquire(path: Path, modality: Modality) -> DocumentPayload:
        if modality == "image" and settings.image_strategy == "vision_direct":
            return _load_image_payload(path)
        if modality == "native_pdf":
            from docfield.parsing.docling_parser import parse_pdf

            text = parse_pdf(path)
            return DocumentPayload(
                modality="native_pdf",
                source_path=path,
                text=text,
                metadata={"parser": "docling"},
            )
        raise NotImplementedError(
            f"Acquisition for modality={modality!r} with "
            f"IMAGE_STRATEGY={settings.image_strategy!r} is not yet wired. "
            "The OCR path (image+ocr_then_text) needs T4. "
            "Inject an acquire callable or use IMAGE_STRATEGY=vision_direct with an image."
        )

    return _acquire


def aggregate_model_signal(field_confidence: dict[str, float] | None) -> float | None:
    """Reduce a backend's per-field confidence to one document-level signal.

    Scoring consumes a single scalar (``routing.score.score``), so the per-field
    map a backend exposes is averaged into one number. The mean is a documented,
    tunable default; the operative precision lever is the arithmetic cross-check
    in validation, not this signal, and the auto-accept threshold is set
    empirically by the evaluation harness. An absent or empty map returns
    ``None`` so the scorer falls back to its neutral prior rather than inventing
    confidence.

    Args:
        field_confidence: The backend's per-field confidence, or ``None``.

    Returns:
        The mean confidence in ``[0, 1]``, or ``None`` when no signal was given.
    """
    if not field_confidence:
        return None
    values = list(field_confidence.values())
    return sum(values) / len(values)


def process_document(
    path: str | Path,
    *,
    settings: Settings | None = None,
    backend: ExtractionBackend | None = None,
    acquire: Acquire | None = None,
    today: date | None = None,
) -> ExtractionResult:
    """Run the full extraction pipeline over one document.

    Chains detect -> acquire -> ``backend.extract`` -> validate -> score ->
    route and returns an :class:`ExtractionResult`. The function has no side
    effects (no file moves, no persistence); callers own those. Any failure in
    the per-document stages is caught and turned into a ``review`` result with
    the error recorded, so one bad document never crashes a batch and the web
    demo never shows a stack trace (rule 6, precision posture).

    Backend selection follows the factory: an injected ``backend`` is used as-is
    (tests, an entry point forcing one); otherwise it is built from ``settings``.
    A misconfigured/unbuildable backend raises *before* the per-document try
    block -- that is a startup error and should fail fast, not silently route a
    document to review.

    Args:
        path: The document to process. Only the path is needed; the file is
            opened (if at all) by the injected acquirer, not here.
        settings: Validated configuration; loaded from the environment when
            ``None``. Supplies ``confidence_threshold`` and, when ``backend`` is
            not injected, the backend selection.
        backend: An explicit backend to use; built from ``settings`` via the
            factory when ``None``.
        acquire: The acquisition callable mapping ``(path, modality)`` to a
            payload; defaults to a placeholder that raises until the real parser
            is wired in (phases 2.2/2.3).
        today: Reference date for the validation future-date check; defaults to
            ``date.today()``. Injected in tests for determinism.

    Returns:
        An :class:`ExtractionResult` with a populated document and a decision.
        Never raises for a document-level problem -- those route to review.

    Raises:
        ConfigError: If configuration is invalid or the configured backend
            cannot be built (a startup error, surfaced before processing).
    """
    settings = settings or load_config()
    backend = backend or create_backend(settings)
    acquire = acquire or _make_acquire(settings)
    reference_date = today if today is not None else date.today()
    source_path = Path(path)
    backend_name = backend.name

    modality: Modality | None = None
    try:
        modality = detect_modality(source_path)
        payload = acquire(source_path, modality)
        backend_result = backend.extract(payload, Document)

        # Structured output is enforced, not regex-parsed (rule 4): the backend's
        # data dict is validated into a Document, which raises on malformed input
        # and routes the document to review via the except below.
        document = Document.model_validate(backend_result.data)

        report = validate(document, today=reference_date)
        model_signal = aggregate_model_signal(backend_result.field_confidence)
        confidence = score(document, report, model_signal)
        decision = route(confidence, report, threshold=settings.confidence_threshold)

        populated = document.model_copy(
            update={
                "field_confidence": dict(backend_result.field_confidence or {}),
                "validation": report.to_dict(),
                "decision": decision,
            }
        )

        logger.info(
            "processed document path=%s modality=%s backend=%s decision=%s "
            "confidence=%.3f model_signal=%s hard_failures=%s soft_failures=%s",
            source_path,
            modality,
            backend_name,
            decision,
            confidence,
            f"{model_signal:.3f}" if model_signal is not None else "none",
            [r.code for r in report.hard_failures],
            [r.code for r in report.soft_failures],
        )

        return ExtractionResult(
            document=populated,
            report=report,
            confidence=confidence,
            decision=decision,
            modality=modality,
            backend_name=backend_name,
            source_path=source_path,
            model_signal=model_signal,
        )
    except Exception as exc:  # noqa: BLE001 -- rule 6: never let one document halt the loop.
        return _review_on_error(source_path, modality, backend_name, exc, reference_date)


def _review_on_error(
    source_path: Path,
    modality: Modality | None,
    backend_name: str,
    exc: Exception,
    today: date,
) -> ExtractionResult:
    """Build a ``review`` result for a document whose pipeline stage raised.

    Implements rule 6: log the failure with full context (so the bug is visible)
    and route to review with the reason recorded, rather than propagating. The
    document is an empty one validated through the same rules (so its shape and
    ``validation`` report are consistent with the success path); an absent
    ``total`` hard-fails ``H4``, which already forces review.

    Args:
        source_path: The document that failed.
        modality: The detected modality, or ``None`` if detection failed first.
        backend_name: The backend in use, for the log record.
        exc: The exception raised by a pipeline stage.
        today: Reference date passed through to validation.

    Returns:
        An :class:`ExtractionResult` with ``decision="review"`` and ``error`` set.
    """
    logger.error(
        "document processing failed; routing to review path=%s modality=%s backend=%s error=%s",
        source_path,
        modality,
        backend_name,
        exc,
        exc_info=True,
    )

    document = Document()
    report = validate(document, today=today)
    populated = document.model_copy(
        update={
            "validation": {**report.to_dict(), "error": str(exc)},
            "decision": "review",
        }
    )
    return ExtractionResult(
        document=populated,
        report=report,
        confidence=0.0,
        decision="review",
        modality=modality,
        backend_name=backend_name,
        source_path=source_path,
        model_signal=None,
        error=str(exc),
    )