File size: 14,436 Bytes
b6beb2d
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b6beb2d
 
 
 
 
 
 
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b2aa9f
 
5531435
 
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
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
"""Tests for the Gradio demo's rendering layer (``docfield.web.app``).

The demo's job is to answer "why did it decide that" without making the reader
learn rule codes. Two properties carry that, and both are easy to break silently:

1. **Rule codes never reach the user-facing surfaces.** The verdict and the
   checks list must be plain language; codes belong only in the details tab.
   A rule added without copy would otherwise leak "H5" into the UI at the exact
   moment nobody is looking, so the drift guards below fail the build instead.
2. **The stated reason is the real one.** Cause-of-review precedence
   (processing failure > hard rule > low confidence) and the threshold
   comparison are pinned, because a wrong reason is worse than no reason.

Results are built by running the real pipeline with ``StubBackend`` and an
injected acquirer, following ``tests/test_core_smoke.py``. Using genuine
``validate()`` output rather than hand-built reports is what makes the drift
guard meaningful. No network, no API key, no Gradio server.
"""

from __future__ import annotations

import re
from datetime import date
from pathlib import Path
from typing import Any

import gradio as gr
import pytest

from docfield.backends.base import DocumentPayload
from docfield.backends.stub import DEFAULT_STUB_DOCUMENT, StubBackend
from docfield.config import ConfigError, Settings, load_config
from docfield.core import ExtractionResult, process_document
from docfield.parsing.detect import Modality
from docfield.schema.models import Document
from docfield.validation.rules import validate
from docfield.web import app as webapp

TODAY = date(2024, 6, 1)

# Matches a bare rule code (H1..H4, S1..S4) as a whole word.
_CODE_RE = re.compile(r"\b[HS][0-9]\b")


def _offline_settings(**overrides: Any) -> Settings:
    """Settings needing no API key or network (Ollama + OCR is the offline combo)."""
    return load_config(extraction_backend="ollama", image_strategy="ocr_then_text", **overrides)


def _acquire(path: Path, modality: Modality) -> DocumentPayload:
    return DocumentPayload(modality=modality, source_path=path, text="ignored by stub")


def _run(data: dict[str, Any] | None = None, **backend_kwargs: Any) -> ExtractionResult:
    """Run the real pipeline over a stub backend and return the result."""
    backend = StubBackend(data=data or dict(DEFAULT_STUB_DOCUMENT), **backend_kwargs)
    return process_document(
        "receipt.png",
        settings=_offline_settings(),
        backend=backend,
        acquire=_acquire,
        today=TODAY,
    )


class _ExplodingBackend:
    """A backend that always raises, to drive the pipeline-error path."""

    name = "exploding"

    def extract(self, payload: DocumentPayload, schema: type[Document]) -> Any:
        raise RuntimeError("backend unavailable")


def _accepted() -> ExtractionResult:
    return _run()


def _hard_failed() -> ExtractionResult:
    """Totals that do not reconcile -> H2 fails."""
    return _run({**DEFAULT_STUB_DOCUMENT, "total": 999.00})


def _two_hard_failures() -> ExtractionResult:
    """Both the H2 sum and the H3 line-item sum disagree with the stated figures."""
    return _run({**DEFAULT_STUB_DOCUMENT, "subtotal": 500.00, "total": 999.00})


def _low_confidence(threshold: float = 0.85) -> ExtractionResult:
    """Clean document with no model signal, decided against a raised threshold.

    The threshold must be applied at decision time, not only at render time: the
    renderers read ``result.decision`` rather than recomputing it, so handing a
    renderer a threshold the pipeline never saw would describe a decision that
    was never made.
    """
    backend = StubBackend(data=dict(DEFAULT_STUB_DOCUMENT), field_confidence={})
    return process_document(
        "receipt.png",
        settings=_offline_settings(confidence_threshold=threshold),
        backend=backend,
        acquire=_acquire,
        today=TODAY,
    )


def _errored() -> ExtractionResult:
    return process_document(
        "receipt.png",
        settings=_offline_settings(),
        backend=_ExplodingBackend(),
        acquire=_acquire,
        today=TODAY,
    )


ALL_RESULTS = {
    "accepted": _accepted,
    "hard_failed": _hard_failed,
    "low_confidence": _low_confidence,
    "errored": _errored,
}


# ---------------------------------------------------------------------------
# Drift guards -- the maps must track the rules
# ---------------------------------------------------------------------------


def _emitted_codes() -> set[str]:
    """Every rule code a real validate() call can emit."""
    codes: set[str] = set()
    for doc in (Document(), Document.model_validate(DEFAULT_STUB_DOCUMENT)):
        codes.update(r.code for r in validate(doc, today=TODAY).results)
    return codes


def test_every_emitted_rule_code_has_plain_language_copy() -> None:
    """A rule with no copy would render as a bare code in the UI."""
    missing = _emitted_codes() - set(webapp._RULE_COPY)
    assert not missing, f"validation rules with no plain-language copy: {sorted(missing)}"


def test_no_stale_rule_copy() -> None:
    """Copy for a rule that no longer exists is a documentation lie."""
    stale = set(webapp._RULE_COPY) - _emitted_codes()
    assert not stale, f"copy for rules that no longer exist: {sorted(stale)}"


def test_rule_copy_is_itself_plain_language() -> None:
    """No copy string may contain a rule code, and none may be empty."""
    for code, copy in webapp._RULE_COPY.items():
        for field, text in copy._asdict().items():
            assert text.strip(), f"{code}.{field} is empty"
            assert not _CODE_RE.search(text), f"{code}.{field} contains a rule code: {text!r}"


# ---------------------------------------------------------------------------
# Rule codes must not leak into the user-facing surfaces
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("name", sorted(ALL_RESULTS))
def test_checks_block_contains_no_rule_codes(name: str) -> None:
    rendered = webapp._render_checks(ALL_RESULTS[name]())
    assert not _CODE_RE.search(rendered), rendered


@pytest.mark.parametrize("name", sorted(ALL_RESULTS))
def test_verdict_block_contains_no_rule_codes(name: str) -> None:
    rendered = webapp._render_verdict(ALL_RESULTS[name](), threshold=0.50)
    assert not _CODE_RE.search(rendered), rendered


def test_details_block_does_contain_rule_codes() -> None:
    """The split is intentional, so assert it from both sides."""
    rendered = webapp._render_details(_hard_failed(), threshold=0.50)
    assert "H2" in rendered
    assert "S1" in rendered


# ---------------------------------------------------------------------------
# Verdict and cause-of-review precedence
# ---------------------------------------------------------------------------


def test_accept_verdict_states_acceptance_plainly() -> None:
    rendered = webapp._render_verdict(_accepted(), threshold=0.50)
    assert "Accepted automatically" in rendered
    assert "Sent to review" not in rendered


def test_accept_verdict_omits_the_numeric_confidence() -> None:
    """Deliberate: the backend reports no per-field signal, so the number would
    read as a coin flip on the happy path. It stays in the details tab."""
    result = _accepted()
    rendered = webapp._render_verdict(result, threshold=0.50)
    assert f"{result.confidence:.0%}" not in rendered


def test_hard_failure_verdict_names_the_specific_check_and_its_evidence() -> None:
    result = _hard_failed()
    rendered = webapp._render_verdict(result, threshold=0.50)
    assert "Sent to review" in rendered
    assert webapp._RULE_COPY["H2"].bad in rendered
    assert "999.0" in rendered  # the raw arithmetic, as evidence


def test_multiple_hard_failures_are_all_listed() -> None:
    result = _two_hard_failures()
    assert len(result.report.hard_failures) > 1
    rendered = webapp._render_verdict(result, threshold=0.50)
    for rule in result.report.hard_failures:
        assert webapp._RULE_COPY[rule.code].bad in rendered


@pytest.mark.parametrize("threshold", [0.85, 0.95])
def test_low_confidence_verdict_names_both_numbers(threshold: float) -> None:
    """Proves the threshold reached the renderer instead of being hardcoded."""
    result = _low_confidence(threshold)
    assert result.decision == "review"
    assert not result.report.hard_failed  # low confidence is the only cause
    rendered = webapp._render_verdict(result, threshold=threshold)
    assert f"{result.confidence:.0%}" in rendered
    assert f"{threshold:.0%}" in rendered


def test_pipeline_error_outranks_hard_failure() -> None:
    """The error path validates an empty document, so H4 also fails there.

    The reason must name the processing failure, not the downstream symptom.
    """
    result = _errored()
    assert result.report.hard_failed  # H4: no total on the empty document
    rendered = webapp._render_verdict(result, threshold=0.50)
    assert "Something went wrong" in rendered
    assert "backend unavailable" in rendered
    assert webapp._RULE_COPY["H4"].bad not in rendered


def test_accepted_result_has_no_review_reason() -> None:
    assert webapp._review_reason(_accepted(), threshold=0.50) is None


def test_review_reason_never_falls_through_at_the_threshold_boundary() -> None:
    """``confidence < threshold`` must be the exact negation of route()'s ``>=``.

    A document scoring exactly the threshold is accepted, so no reason is owed.
    One step below, a reason must be found -- if the comparison here were ``<=``
    or a rounded form, a boundary review would reach the useless catch-all.
    """
    at_boundary = _low_confidence(0.50)
    assert at_boundary.decision == "accept"
    assert webapp._review_reason(at_boundary, threshold=0.50) is None

    just_under = _low_confidence(0.51)
    assert just_under.decision == "review"
    reason = webapp._review_reason(just_under, threshold=0.51)
    assert reason is not None
    assert "held back for a person to confirm" not in reason


# ---------------------------------------------------------------------------
# Checks block content
# ---------------------------------------------------------------------------


def test_checks_lists_both_severity_groups() -> None:
    rendered = webapp._render_checks(_accepted())
    assert "Must pass to auto-accept" in rendered
    assert "Quality signals" in rendered


def test_skip_renders_as_not_applicable() -> None:
    """A skipped rule is not a failure and must not read like one."""
    result = _run({"total": "10.00", "vendor_name": "Acme"})  # no currency, no line items
    statuses = {r.code: r.status for r in result.report.results}
    assert statuses["S2"] == "skip"  # absent currency skips since the S2 change

    rendered = webapp._render_checks(result)
    assert "Not applicable" in rendered
    assert "SKIP" not in rendered


def test_failing_check_shows_evidence_and_passing_check_does_not() -> None:
    """Evidence is actionable on failure and noise on success; details has both."""
    result = _hard_failed()
    checks = webapp._render_checks(result)
    details = webapp._render_details(result, threshold=0.50)

    h2 = result.report.by_code("H2")
    h4 = result.report.by_code("H4")
    assert h2 is not None and h2.status == "fail"
    assert h4 is not None and h4.status == "pass"

    assert h2.message in checks       # failure evidence is shown
    assert h4.message not in checks   # passing evidence is not
    assert h4.message in details      # but it is one tab away


def test_error_result_notes_the_checks_ran_on_an_empty_document() -> None:
    rendered = webapp._render_checks(_errored())
    assert "ran against an empty document" in rendered


# ---------------------------------------------------------------------------
# Details block
# ---------------------------------------------------------------------------


def test_details_carries_the_technical_view() -> None:
    result = _accepted()
    rendered = webapp._render_details(result, threshold=0.50)
    assert "stub" in rendered
    assert f"{result.confidence:.0%}" in rendered
    assert "50%" in rendered
    assert str(result.modality) in rendered


def test_details_shows_the_pipeline_error_when_present() -> None:
    assert "backend unavailable" in webapp._render_details(_errored(), threshold=0.50)


# ---------------------------------------------------------------------------
# Callback wiring and empty state
# ---------------------------------------------------------------------------


def test_process_with_no_file_returns_the_empty_state() -> None:
    assert webapp._process(None) == (
        webapp._NO_FILE_VERDICT,
        webapp._EMPTY_FIELDS,
        webapp._EMPTY_CHECKS,
        webapp._EMPTY_DETAILS,
    )


def test_process_returns_four_values() -> None:
    """Catches an outputs-arity mismatch that otherwise only shows in a browser."""
    assert len(webapp._process(None)) == 4


def test_startup_error_is_rendered_not_raised(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A missing API key is the likeliest failure on a fresh Space deploy.

    It raises before any ExtractionResult exists, so it cannot be expressed as a
    document outcome and must not surface as a traceback.
    """
    doc = tmp_path / "receipt.png"
    doc.write_bytes(b"not really a png")  # copied before load_config is reached

    def _boom() -> Settings:
        raise ConfigError("GEMINI_API_KEY is not set")

    monkeypatch.setattr(webapp, "load_config", _boom)

    verdict, fields, checks, details = webapp._process(str(doc))
    assert "Could not run" in verdict
    assert "setup problem with the demo" in verdict
    assert "GEMINI_API_KEY is not set" in verdict
    assert fields == webapp._EMPTY_FIELDS
    assert checks == webapp._EMPTY_CHECKS


def test_build_demo_constructs_without_configuration() -> None:
    """Also proves the module reads no config at import or build time, which
    would otherwise crash the Space whenever a secret is absent."""
    assert isinstance(webapp.build_demo(), gr.Blocks)


def test_privacy_notice_carries_the_training_warning() -> None:
    """NFR-2: the hosted demo must warn that free-tier inputs may be trained on."""
    assert "SYNTHETIC / PUBLIC DOCUMENTS ONLY" in webapp._PRIVACY_NOTICE
    assert "may train on your inputs" in webapp._PRIVACY_NOTICE