document-extract-agent / tests /test_core_smoke.py
kennethzychew's picture
phase 5: set CONFIDENCE_THRESHOLD=0.50, record SROIE results (T10)
51f6fc8
Raw
History Blame Contribute Delete
9 kB
"""End-to-end smoke tests for the core pipeline (build-plan task 3.1).
These exercise ``process_document`` from path to decision with the offline
``StubBackend`` and an injected acquirer -- no network, no API key, no Ollama
server, no real parser. They cover the acceptance criterion ("runs end-to-end on
a couple of sample files with a stub backend; returns a populated result with a
decision") and the precision/robustness postures the core must honor:
- the clean default document auto-accepts and its pipeline fields are populated,
- a hard-rule failure (totals that do not reconcile) forces review regardless of
the model's confidence,
- a backend that raises is caught and routed to review (rule 6), not propagated,
- detect -> acquire wiring passes the right modality through,
- the core writes nothing (no side effects).
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from doc_agent.backends.base import DocumentPayload
from doc_agent.backends.stub import DEFAULT_STUB_DOCUMENT, StubBackend
from doc_agent.config import Settings, load_config
from doc_agent.core import ExtractionResult, process_document
from doc_agent.parsing.detect import Modality
from doc_agent.schema.models import Document
# A fixed reference date well after the stub document's 2024-01-15, so the S1
# future-date check is deterministic regardless of the real clock.
TODAY = date(2024, 6, 1)
def _offline_settings() -> Settings:
"""A valid Settings instance that needs no API key or network.
Ollama + ocr_then_text is the one fully-offline valid combination; it only
supplies ``confidence_threshold`` here, since every test injects the backend.
"""
return load_config(extraction_backend="ollama", image_strategy="ocr_then_text")
def _acquire_capturing(seen: dict[str, Modality]) -> object:
"""Build an acquirer that records the modality it was handed.
Args:
seen: A dict the acquirer writes ``{"modality": modality}`` into, so a
test can assert detect -> acquire passed the right parse path.
Returns:
An ``Acquire`` callable returning a minimal text payload.
"""
def _acquire(path: Path, modality: Modality) -> DocumentPayload:
seen["modality"] = modality
return DocumentPayload(modality=modality, source_path=path, text="ignored by stub")
return _acquire
def _acquire_fixed(path: Path, modality: Modality) -> DocumentPayload:
"""A trivial acquirer returning a fixed payload (the stub ignores it)."""
return DocumentPayload(modality=modality, source_path=path, text="ignored by stub")
def test_process_document_accepts_clean_document() -> None:
"""The clean default stub document runs end-to-end and auto-accepts (AC)."""
result = process_document(
"invoice.pdf",
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_fixed,
today=TODAY,
)
assert isinstance(result, ExtractionResult)
assert result.decision == "accept"
assert result.accepted
assert result.error is None
def test_accepted_result_is_fully_populated() -> None:
"""The returned document carries the pipeline-populated fields and values."""
result = process_document(
"receipt.png",
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_fixed,
today=TODAY,
)
document = result.document
# Extracted values survived validation into the populated document.
assert document.total == pytest.approx(11.77)
assert document.invoice_number == "STUB-0001"
assert len(document.line_items) == 2
# Pipeline-populated fields are set (not the schema defaults).
assert document.decision == "accept"
assert document.field_confidence # non-empty: the stub exposed a signal
assert document.validation["hard_failed"] is False
# The result surfaces the routing rationale.
assert 0.0 <= result.confidence <= 1.0
assert result.model_signal is not None
assert result.backend_name == "stub"
assert result.source_path == Path("receipt.png")
assert not result.report.hard_failed
def test_detect_then_acquire_passes_modality_through() -> None:
"""Detection feeds the acquirer the modality inferred from the extension."""
seen: dict[str, Modality] = {}
pdf_result = process_document(
"scan.pdf",
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_capturing(seen),
today=TODAY,
)
assert seen["modality"] == "native_pdf"
assert pdf_result.modality == "native_pdf"
img_result = process_document(
"photo.jpg",
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_capturing(seen),
today=TODAY,
)
assert seen["modality"] == "image"
assert img_result.modality == "image"
def test_hard_failure_forces_review_despite_high_confidence() -> None:
"""A non-reconciling total hard-fails (H2) and routes to review (rule 5)."""
broken = dict(DEFAULT_STUB_DOCUMENT)
broken["total"] = 999.00 # subtotal 11.00 + tax 0.77 != 999.00
# Confidence is uniformly high, proving the hard failure -- not the score --
# is what forces review.
backend = StubBackend(data=broken, field_confidence={"total": 0.99})
result = process_document(
"tampered.pdf",
settings=_offline_settings(),
backend=backend,
acquire=_acquire_fixed,
today=TODAY,
)
assert result.decision == "review"
assert result.report.hard_failed
assert "H2" in [r.code for r in result.report.hard_failures]
def test_low_confidence_clean_document_routes_to_review() -> None:
"""A clean doc below threshold routes to review (threshold from settings)."""
# No model signal -> neutral 0.5. Pin an explicit threshold above that neutral
# prior so the test exercises "below threshold -> review" independent of the
# empirically-set default (the eval lowered it to 0.50).
backend = StubBackend(field_confidence={})
settings = _offline_settings().model_copy(update={"confidence_threshold": 0.85})
result = process_document(
"uncertain.pdf",
settings=settings,
backend=backend,
acquire=_acquire_fixed,
today=TODAY,
)
assert not result.report.hard_failed
assert result.model_signal is None
assert result.confidence < 0.85
assert result.decision == "review"
def test_backend_failure_routes_to_review_not_crash() -> None:
"""A backend that raises is caught and routed to review (rule 6)."""
class ExplodingBackend:
name = "exploding"
def extract(self, payload: DocumentPayload, schema: type[Document]) -> object:
raise RuntimeError("simulated transient backend failure")
result = process_document(
"boom.pdf",
settings=_offline_settings(),
backend=ExplodingBackend(),
acquire=_acquire_fixed,
today=TODAY,
)
assert result.decision == "review"
assert result.error is not None
assert "simulated transient backend failure" in result.error
# The shape is still consistent: an empty document whose validation is recorded.
assert result.document.decision == "review"
assert result.document.validation["error"] == result.error
def test_malformed_backend_data_routes_to_review() -> None:
"""Unparseable extracted data fails schema validation and routes to review."""
# A non-numeric total cannot be coerced -> Document validation raises ->
# caught and routed to review rather than recording a wrong number.
backend = StubBackend(data={"total": "not-a-number"})
result = process_document(
"garbled.png",
settings=_offline_settings(),
backend=backend,
acquire=_acquire_fixed,
today=TODAY,
)
assert result.decision == "review"
assert result.error is not None
def test_processes_a_batch_of_mixed_files() -> None:
"""A couple of sample files of different modalities all yield a decision (AC)."""
paths = ["a.pdf", "b.png", "c.jpeg", "d.tiff"]
results = [
process_document(
p,
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_fixed,
today=TODAY,
)
for p in paths
]
assert len(results) == len(paths)
assert all(r.decision in ("accept", "review") for r in results)
assert all(r.error is None for r in results)
def test_core_writes_nothing(tmp_path: Path) -> None:
"""The core performs no side effects: the working directory is untouched."""
before = set(tmp_path.iterdir())
process_document(
tmp_path / "nothing-written.pdf",
settings=_offline_settings(),
backend=StubBackend(),
acquire=_acquire_fixed,
today=TODAY,
)
assert set(tmp_path.iterdir()) == before