File size: 8,397 Bytes
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
 
 
d2a6765
fa6cf50
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa6cf50
d2a6765
 
 
 
 
 
 
 
 
 
 
 
fa6cf50
 
 
d2a6765
 
 
 
 
fa6cf50
 
 
d2a6765
fa6cf50
 
 
 
 
d2a6765
 
 
 
 
 
 
 
fa6cf50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2a6765
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Predict phase: run the core over a dataset slice and cache each result.

This is the **only** phase that calls a model backend and the only phase that
spends API quota. It is deliberately idempotent: an example already present in
the cache is skipped unless ``overwrite`` is set, so a re-run after an
interruption resumes rather than re-billing.

For each example the PIL image is written to a temporary file (``core`` takes a
path, not bytes) with an extension that makes modality detection classify it as
an image; ``process_document`` then runs the full detect -> acquire -> extract
-> validate -> score -> route pipeline. The gold labels, predicted document,
confidence, and validation report are cached; nothing is persisted to the app's
SQLite store (eval is not production ingestion).
"""

from __future__ import annotations

import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from docfield.backends.base import create_backend
from docfield.config import Settings, load_config
from docfield.core import process_document

from eval.cache import DEFAULT_CACHE_BASE, errored_ids, existing_ids, write_entry
from eval.datasets import WIRED_DATASETS, get_adapter

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class PredictStats:
    """Summary of a predict-phase run.

    Attributes:
        dataset: The dataset name.
        requested: The requested slice size (``limit``), or ``None`` for all.
        processed: Examples actually sent through the pipeline this run.
        skipped: Examples skipped because they were already cached.
        accepted: Processed examples the pipeline auto-accepted.
        review: Processed examples routed to review.
        errors: Processed examples whose pipeline stage raised (routed to review).
        failed: Examples that could not be prepared at all (e.g. the image would
            not encode); logged and skipped so one bad input never aborts the run.
    """

    dataset: str
    requested: int | None
    processed: int
    skipped: int
    accepted: int
    review: int
    errors: int
    failed: int


def _process_example(
    example: Any,
    dataset: str,
    labeled_fields: tuple[str, ...],
    settings: Settings,
    backend: Any,
) -> dict[str, Any]:
    """Save one example's image to a temp file, run the pipeline, build the entry.

    The temp file is created first and unlinked in a ``finally`` so it is cleaned
    up even if ``image.save`` or the pipeline raises. ``process_document`` itself
    never raises (rule 6); only image encoding can, and the caller isolates that.

    Args:
        example: The :class:`~eval.datasets.base.GoldExample` to process.
        dataset: Dataset name (recorded in the entry).
        labeled_fields: The dataset's labeled fields (recorded in the entry).
        settings: Validated configuration.
        backend: The extraction backend to use.

    Returns:
        A JSON-serializable cache entry for the example.
    """
    with tempfile.NamedTemporaryFile(suffix=example.suffix, delete=False) as handle:
        temp_path = Path(handle.name)
    try:
        example.image.save(temp_path)
        result = process_document(temp_path, settings=settings, backend=backend)
    finally:
        temp_path.unlink(missing_ok=True)
    return _build_entry(example, result, dataset, labeled_fields)


def _build_entry(
    example: Any,
    result: Any,
    dataset: str,
    labeled_fields: tuple[str, ...],
) -> dict[str, Any]:
    """Assemble a JSON-serializable cache entry from an example and its result."""
    return {
        "id": example.id,
        "dataset": dataset,
        "gold": example.gold,
        "labeled_fields": list(labeled_fields),
        "predicted": result.document.model_dump(mode="json"),
        "confidence": result.confidence,
        "decision": result.decision,
        "modality": result.modality,
        "backend": result.backend_name,
        "validation": result.report.to_dict(),
        "error": result.error,
    }


def run_predict(
    dataset: str,
    limit: int | None,
    *,
    settings: Settings | None = None,
    cache_base: Path = DEFAULT_CACHE_BASE,
    overwrite: bool = False,
    retry_errors: bool = False,
) -> PredictStats:
    """Run the pipeline over a dataset slice and cache each result.

    Args:
        dataset: Name of a wired dataset adapter (e.g. "sroie").
        limit: Number of examples to process (the held-out slice size); ``None``
            for the whole split.
        settings: Validated configuration; loaded from the environment when
            ``None`` (must select a backend that can read images).
        cache_base: Root cache directory. Defaults to ``eval/cache``.
        overwrite: Re-process and overwrite examples already cached. Defaults to
            ``False`` so re-runs resume without re-billing.
        retry_errors: Re-process *only* cached entries that recorded an error,
            leaving every successful prediction byte-identical. Use after an
            outage. Mutually exclusive with ``overwrite``.

    Returns:
        A :class:`PredictStats` summary of the run.

    Raises:
        ValueError: If ``dataset`` is not wired for the predict phase, if both
            ``overwrite`` and ``retry_errors`` are set, or if ``retry_errors``
            is set with no cache to retry from.
    """
    if overwrite and retry_errors:
        raise ValueError(
            "--overwrite and --retry-errors are mutually exclusive: the first "
            "re-runs every document, the second only the failed ones. Pick one."
        )
    if dataset not in WIRED_DATASETS:
        wired = ", ".join(sorted(WIRED_DATASETS))
        raise ValueError(
            f"Dataset {dataset!r} is scaffolded but not wired for prediction. "
            f"Wired datasets: {wired}."
        )

    adapter = get_adapter(dataset)

    if retry_errors:
        cached = existing_ids(cache_base, dataset)
        if not cached:
            raise ValueError(
                f"--retry-errors needs an existing cache for dataset {dataset!r} "
                f"under {cache_base}, but none was found. Run a normal predict first."
            )
        retry = errored_ids(cache_base, dataset)
        # Skip everything already cached that did NOT error, so successful
        # predictions are never re-run, re-billed, or rewritten.
        already = cached - retry
        logger.info(
            "eval-predict: retry-errors mode -- %d errored of %d cached will be re-run",
            len(retry),
            len(cached),
        )
    else:
        already = set() if overwrite else existing_ids(cache_base, dataset)

    settings = settings or load_config()
    backend = create_backend(settings)

    processed = skipped = accepted = review = errors = failed = 0
    logger.info("eval-predict: dataset=%s limit=%s backend=%s", dataset, limit, backend.name)

    for example in adapter.load(limit):
        if example.id in already:
            skipped += 1
            logger.info("eval-predict: skip cached id=%s", example.id)
            continue

        # Isolate per-example preparation failures (e.g. an un-encodable image) so
        # one bad input logs and is skipped rather than aborting the whole slice.
        try:
            entry = _process_example(example, dataset, adapter.labeled_fields, settings, backend)
        except Exception as exc:  # noqa: BLE001 -- never let one document halt the run.
            failed += 1
            logger.error("eval-predict: could not prepare id=%s -- skipping: %s", example.id, exc)
            continue

        write_entry(cache_base, dataset, entry)

        processed += 1
        if entry["error"]:
            errors += 1
        if entry["decision"] == "accept":
            accepted += 1
        else:
            review += 1
        logger.info(
            "eval-predict: id=%s decision=%s confidence=%.3f error=%s",
            example.id,
            entry["decision"],
            entry["confidence"],
            bool(entry["error"]),
        )

    stats = PredictStats(
        dataset=dataset,
        requested=limit,
        processed=processed,
        skipped=skipped,
        accepted=accepted,
        review=review,
        errors=errors,
        failed=failed,
    )
    logger.info("eval-predict: done %s", stats)
    return stats