File size: 19,719 Bytes
14990f1
8089454
 
 
5531435
 
 
9b2aa9f
 
 
8089454
 
 
 
 
 
 
 
b6beb2d
8089454
 
 
 
 
 
 
 
 
5531435
8089454
 
 
b6beb2d
 
 
 
8089454
 
 
 
5531435
8089454
 
5531435
8089454
 
 
 
 
 
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b2aa9f
5531435
 
 
9b2aa9f
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8089454
5531435
8089454
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
8089454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5531435
8089454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5531435
8089454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5531435
 
 
 
 
 
9b2aa9f
5531435
 
 
 
9b2aa9f
5531435
9b2aa9f
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
9b2aa9f
8089454
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8089454
 
5531435
 
8089454
5531435
 
9b2aa9f
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b2aa9f
5531435
8089454
5531435
8089454
5531435
 
 
 
 
8089454
 
5531435
 
 
 
 
 
8089454
 
 
5531435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8089454
 
 
 
5531435
 
9b2aa9f
8089454
5531435
8089454
 
 
 
 
 
 
 
 
 
 
 
5531435
8089454
 
5531435
 
 
 
 
 
 
 
8089454
 
 
 
5531435
8089454
5531435
 
8089454
 
 
 
 
 
 
5531435
8089454
9b2aa9f
8089454
5531435
 
 
8089454
14990f1
 
8089454
5531435
 
 
 
8089454
 
 
 
 
 
 
 
 
 
5531435
 
8089454
5531435
8089454
5531435
 
8089454
5531435
 
8089454
 
 
 
5531435
8089454
 
 
 
 
 
 
 
 
 
 
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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
"""Stateless Gradio web demo for the DocField Extract pipeline.

Architecture rule 1: this module is a thin presentation wrapper over
``core.process_document``. No pipeline logic lives here; the web layer only
calls the core and renders what it returns. In particular the renderers *read*
``result.decision`` -- they never re-derive it.

Rule codes appear only in the details tab. ``_RULE_COPY`` carries the
plain-language wording and ``tests/test_web.py`` fails if a rule ever lacks an
entry, so a new rule cannot silently leak "H5" into the user-facing surface.

Privacy (NFR-2 / docs/04_project_setup.md): the free Gemini tier may train on
inputs, so a visible notice is shown at the top of every page. Only synthetic
or publicly-available documents should be uploaded to the hosted demo.

Stateless: nothing is written to disk or a database. The watcher owns
persistence; the demo renders results and discards them.

Launch: ``uv run python -m docfield.web.app`` (or via this module's
``if __name__ == "__main__"`` block).
"""

from __future__ import annotations

import logging
import shutil
import tempfile
from pathlib import Path
from typing import Any, NamedTuple

import gradio as gr

from docfield.backends.base import create_backend
from docfield.config import load_config
from docfield.core import ExtractionResult, process_document
from docfield.validation.rules import RuleResult

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Static copy
# ---------------------------------------------------------------------------

# Privacy notice (NFR-2). Deliberately unchanged.
_PRIVACY_NOTICE = """
> **SYNTHETIC / PUBLIC DOCUMENTS ONLY**
> This demo uses the Gemini free tier, which **may train on your inputs**.
> Do **not** upload real invoices, receipts, or any document containing
> personal or financial data. Use only synthetic or publicly-available files.
""".strip()

_INTRO = (
    "Extracts the key fields from a receipt or invoice, then checks the "
    "arithmetic to decide whether the result is safe to use without a person "
    "reading it."
)

_HOW_IT_WORKS = """
A language model reads the document and fills in the fields. It is not trusted
on its own. A fixed set of arithmetic and presence checks then runs over what it
produced: do the line items add up, does the subtotal plus tax equal the total,
is there a total at all. Those checks are ordinary code, not the model, so they
give the same answer every time.

If every must-pass check clears and confidence is high enough, the document is
accepted automatically. Otherwise it goes to review. Review is the safe default,
not a failure: a field that needs a second look costs a few seconds of someone's
attention, while a wrong number that gets accepted is copied onward silently.
""".strip()

# ---------------------------------------------------------------------------
# Plain-language rule copy
# ---------------------------------------------------------------------------


class _RuleCopy(NamedTuple):
    """Wording for one rule: ``title`` for skips, ``ok``/``bad`` for outcomes.

    Three strings rather than one, because a single assertion plus a status
    column reads as a contradiction on failure ("Line items add up -- Failed").
    ``bad`` doubles as the verdict deciding reason, so it must read standalone.
    """

    title: str
    ok: str
    bad: str


# Keyed by rule code. Lives here rather than in validation/rules.py: this is UI
# copy with a different audience and revision cadence than the rules themselves,
# and rules.py is a pure, I/O-free leaf whose RuleResult.message is already the
# technical surface. Colocation would not prevent drift anyway -- the drift guard
# in tests/test_web.py is what does that.
_RULE_COPY: dict[str, _RuleCopy] = {
    "H1": _RuleCopy(
        "Amounts and references are the right kind of value",
        "The total, tax and invoice number are each the right kind of value.",
        "The total, tax or invoice number is not the kind of value it should be.",
    ),
    "H2": _RuleCopy(
        "Subtotal plus tax equals the total",
        "The subtotal plus the tax equals the stated total.",
        "The subtotal plus the tax does not equal the stated total.",
    ),
    # Deliberately not "the subtotal": the rule reconciles against subtotal when
    # present and falls back to total otherwise, so naming either one would be
    # false half the time. Picking the noun here by reading document.subtotal
    # would duplicate the rule's own branch in the web layer.
    "H3": _RuleCopy(
        "Line items add up to the stated amount",
        "The line items add up to the amount the document states.",
        "The line items do not add up to the amount the document states.",
    ),
    "H4": _RuleCopy(
        "The document has a total",
        "The document has a total, and it is not negative.",
        "No usable total was found on the document.",
    ),
    "S1": _RuleCopy(
        "The document date is plausible",
        "The document has a date, and it is not in the future.",
        "The document has no date, or its date is in the future.",
    ),
    "S2": _RuleCopy(
        "The currency code is a known currency",
        "The currency code on the document is a known currency.",
        "The currency code on the document is not a known currency.",
    ),
    "S3": _RuleCopy(
        "The document names a vendor",
        "The document names a vendor.",
        "No vendor name was found on the document.",
    ),
    "S4": _RuleCopy(
        "Each line's quantity times price matches its amount",
        "On every line, quantity times unit price matches the line amount.",
        "On at least one line, quantity times unit price does not match the line amount.",
    ),
}

# Fallback for a rule with no copy yet. Generic plain language, never the raw
# code: a bare "H5" appearing in the user-facing surface is exactly what this
# module exists to prevent, and it would only ever fire unnoticed.
_UNKNOWN_RULE = _RuleCopy(
    "Additional validation check",
    "An additional check passed.",
    "An additional check did not pass.",
)

_STATUS_LABEL = {"pass": "Passed", "fail": "Failed", "skip": "Not applicable"}

_SEVERITY_GROUP: dict[str, tuple[str, str]] = {
    "hard": (
        "Must pass to auto-accept",
        "If any of these fails, the document goes to review no matter how "
        "confident the model was.",
    ),
    "soft": (
        "Quality signals",
        "These do not force review on their own. Each one that fails lowers the "
        "confidence score.",
    ),
}

# ---------------------------------------------------------------------------
# Empty states -- shared by the widget defaults, the no-file path, and the tests
# ---------------------------------------------------------------------------

_EMPTY_VERDICT = (
    "### No document yet\n\n"
    "Upload a receipt or invoice and press **Extract**. The verdict, and the "
    "reasons behind it, appear here."
)
_EMPTY_FIELDS = "_Nothing extracted yet._"
_EMPTY_CHECKS = "_No checks have run yet._"
_EMPTY_DETAILS = "_No run yet._"

_NO_FILE_VERDICT = "### No file selected\n\nChoose a file above, then press **Extract**."

# ---------------------------------------------------------------------------
# Field rendering
# ---------------------------------------------------------------------------


def _fmt_money(value: float | None, currency: str | None = None) -> str:
    if value is None:
        return "-"
    prefix = f"{currency} " if currency else ""
    return f"{prefix}{value:,.2f}"


def _fmt_date(value: Any) -> str:
    if value is None:
        return "-"
    return str(value)


def _fmt_conf(value: float | None) -> str:
    if value is None:
        return "-"
    return f"{value:.0%}"


def _render_fields(result: ExtractionResult) -> str:
    """Build the extracted-fields markdown block."""
    doc = result.document
    conf = doc.field_confidence
    currency = doc.currency

    rows: list[tuple[str, str, float | None]] = [
        ("Type",            str(doc.doc_type),                                conf.get("doc_type")),
        ("Vendor",          doc.vendor_name or "-",                           conf.get("vendor_name")),
        ("Address",         doc.vendor_address or "-",                        conf.get("vendor_address")),
        ("Invoice No.",     doc.invoice_number or "-",                        conf.get("invoice_number")),
        ("Date",            _fmt_date(doc.document_date),                     conf.get("document_date")),
        ("Due date",        _fmt_date(doc.due_date),                          conf.get("due_date")),
        ("Currency",        doc.currency or "-",                              conf.get("currency")),
        ("Subtotal",        _fmt_money(doc.subtotal, currency),               conf.get("subtotal")),
        ("Tax",             _fmt_money(doc.tax, currency),                    conf.get("tax")),
        ("Total",           _fmt_money(doc.total, currency),                  conf.get("total")),
        ("Line items",      str(len(doc.line_items)),                         None),
    ]

    lines = ["| Field | Value | Confidence |", "|---|---|---|"]
    for label, value, raw_conf in rows:
        lines.append(f"| {label} | {value} | {_fmt_conf(raw_conf)} |")

    if doc.line_items:
        lines.append("")
        lines.append("**Line items**")
        lines.append("| # | Description | Qty | Unit price | Amount |")
        lines.append("|---|---|---|---|---|")
        for i, item in enumerate(doc.line_items, 1):
            desc = item.description or "-"
            qty = f"{item.quantity}" if item.quantity is not None else "-"
            up = _fmt_money(item.unit_price, currency)
            amt = _fmt_money(item.amount, currency)
            lines.append(f"| {i} | {desc} | {qty} | {up} | {amt} |")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Verdict and plain-language checks
# ---------------------------------------------------------------------------


def _rule_copy(code: str) -> _RuleCopy:
    """Plain-language copy for a rule code; never returns the raw code."""
    return _RULE_COPY.get(code, _UNKNOWN_RULE)


def _render_check_line(rule: RuleResult) -> str:
    """Render one rule outcome as a plain-language bullet (never a rule code).

    Evidence is attached on failure (the actionable part) and on skip (what was
    not applicable), but omitted on pass -- restating eight satisfied checks in
    worse English turns the plain-language surface back into a log dump.
    """
    copy = _rule_copy(rule.code)
    status = _STATUS_LABEL.get(rule.status, rule.status)

    if rule.status == "pass":
        return f"- **{status}** -- {copy.ok}"
    if rule.status == "fail":
        return f"- **{status}** -- {copy.bad} _({rule.message})_"
    return f"- **{status}** -- {copy.title}. _({rule.message})_"


def _render_checks(result: ExtractionResult) -> str:
    """Build the plain-language checks block, grouped hard then soft."""
    lines: list[str] = []

    if result.error:
        # Without this the tab reads as findings about the document ("No vendor
        # name was found") when in fact nothing was ever read from it.
        lines.append(
            "_No fields were extracted, so these checks ran against an empty document._"
        )
        lines.append("")

    for severity in ("hard", "soft"):
        heading, blurb = _SEVERITY_GROUP[severity]
        # Preserve report order (H1..H4, S1..S4); do not sort.
        rules = [r for r in result.report.results if r.severity == severity]
        if not rules:
            continue
        lines.append(f"### {heading}")
        lines.append("")
        lines.append(f"_{blurb}_")
        lines.append("")
        lines.extend(_render_check_line(r) for r in rules)
        lines.append("")

    return "\n".join(lines).rstrip()


def _review_reason(result: ExtractionResult, *, threshold: float) -> str | None:
    """Explain, in one plain-language passage, why a document went to review.

    Precedence: a processing failure outranks a hard-rule failure, which
    outranks falling short of the confidence threshold. The decision itself is
    read from ``result``, never recomputed here (architecture rule 1). Returns
    ``None`` when the document was accepted.
    """
    if result.decision != "review":
        return None

    if result.error:
        return (
            "Something went wrong while reading this document, so it was sent to "
            f"review instead of being accepted. _({result.error})_"
        )

    hard = result.report.hard_failures
    if len(hard) == 1:
        rule = hard[0]
        return f"{_rule_copy(rule.code).bad} _({rule.message})_"
    if hard:
        # Rule order is arbitrary, so "the specific failure" is legitimately
        # plural here -- picking one to blame would be a coin toss.
        bullets = "\n".join(
            f"- {_rule_copy(r.code).bad} _({r.message})_" for r in hard
        )
        return "More than one must-pass check failed:\n\n" + bullets

    # Exactly the negation of route()'s ``confidence >= threshold``, so a
    # document sitting on the boundary cannot fall through to the catch-all.
    if result.confidence < threshold:
        return (
            "Every must-pass check cleared, but overall confidence came out at "
            f"{result.confidence:.0%}, below the {threshold:.0%} needed to accept "
            "automatically."
        )

    return "This document was held back for a person to confirm."


def _render_verdict(result: ExtractionResult, *, threshold: float) -> str:
    """Build the headline verdict block shown above the tabs.

    The numeric confidence is deliberately absent from the accepted verdict: the
    current backend exposes no per-field signal, so a genuine accept would read
    "50%, threshold 50%", which looks like a coin flip and explains nothing. It
    remains in the details view and in the low-confidence reason, where it is
    the actual explanation.
    """
    if result.decision != "review":
        return (
            "## Accepted automatically\n\n"
            "Every must-pass check cleared and confidence was high enough, so this "
            "document would be written straight through with no human step.\n\n"
            "_Check-by-check detail is in **Checks**._"
        )

    reason = _review_reason(result, threshold=threshold)
    return (
        "## Sent to review\n\n"
        f"{reason}\n\n"
        "Review is the safe default here, not a failure. The extracted fields are "
        "below for a person to confirm.\n\n"
        "_Check-by-check detail is in **Checks**._"
    )


def _render_details(result: ExtractionResult, *, threshold: float) -> str:
    """The technical view -- the one surface where rule codes belong."""
    signal = "not reported" if result.model_signal is None else f"{result.model_signal:.0%}"
    lines = [
        "### Run",
        "",
        f"- Decision: {result.decision}",
        f"- Confidence: {result.confidence:.0%} (auto-accept threshold: {threshold:.0%})",
        f"- Backend: {result.backend_name}",
        f"- Modality: {result.modality or 'unknown'}",
        f"- Model signal: {signal}",
    ]
    if result.error:
        lines.append(f"- Pipeline error: {result.error}")

    lines += ["", "### Validation rules", "", "| Rule | Severity | Status | Message |", "|---|---|---|---|"]
    for rule in result.report.results:
        lines.append(f"| {rule.code} | {rule.severity} | {rule.status} | {rule.message} |")

    return "\n".join(lines)


def _render_startup_error(exc: Exception) -> str:
    """Build the verdict block for a failure that happened before processing.

    ``load_config`` and ``create_backend`` raise before any ``ExtractionResult``
    exists, so this state cannot be expressed as a document outcome. It is the
    likeliest failure on a freshly deployed Space (a missing API key), and it
    must not read as though the uploaded document were at fault.
    """
    return (
        "## Could not run\n\n"
        "The extractor could not start, so nothing was processed. This is a setup "
        "problem with the demo, not a problem with your document.\n\n"
        f"_{exc}_"
    )


# ---------------------------------------------------------------------------
# Core processing
# ---------------------------------------------------------------------------


def _process(file_obj: Any) -> tuple[str, str, str, str]:
    """Gradio callback: returns (verdict, fields, checks, details) markdown."""
    if file_obj is None:
        return _NO_FILE_VERDICT, _EMPTY_FIELDS, _EMPTY_CHECKS, _EMPTY_DETAILS

    src = Path(file_obj)

    # Copy to a named temp file preserving the original extension so modality
    # detection works on the suffix, then clean up after processing.
    suffix = src.suffix or ".pdf"
    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
        tmp_path = Path(tmp.name)
    shutil.copy2(src, tmp_path)

    try:
        settings = load_config()
        threshold = settings.confidence_threshold
        backend = create_backend(settings)
        result: ExtractionResult = process_document(tmp_path, settings=settings, backend=backend)
    except Exception as exc:  # noqa: BLE001 -- surfaced to the user, never raised into Gradio.
        logger.exception("web: could not process %s", src.name)
        return (
            _render_startup_error(exc),
            _EMPTY_FIELDS,
            _EMPTY_CHECKS,
            f"**Startup error:** {exc}",
        )
    finally:
        tmp_path.unlink(missing_ok=True)

    return (
        _render_verdict(result, threshold=threshold),
        _render_fields(result),
        _render_checks(result),
        _render_details(result, threshold=threshold),
    )


# ---------------------------------------------------------------------------
# Gradio interface
# ---------------------------------------------------------------------------


def build_demo() -> gr.Blocks:
    """Construct the Gradio Blocks interface.

    Configuration is read inside the callback, not here, so the module imports
    cleanly on a Space whose secrets are missing -- the failure then renders as a
    verdict rather than crashing the app at startup.
    """
    with gr.Blocks(title="DocField Extract") as demo:
        gr.Markdown("# DocField Extract")
        gr.Markdown(_PRIVACY_NOTICE)
        gr.Markdown(_INTRO)

        with gr.Accordion("How the decision is made", open=False):
            gr.Markdown(_HOW_IT_WORKS)

        with gr.Row():
            upload = gr.File(
                label="Upload document (PDF / JPG / PNG / WEBP)",
                file_types=[".pdf", ".jpg", ".jpeg", ".png", ".webp", ".gif"],
                type="filepath",
            )

        run_btn = gr.Button("Extract", variant="primary")

        verdict_out = gr.Markdown(value=_EMPTY_VERDICT)

        with gr.Tab("Extracted fields"):
            fields_out = gr.Markdown(value=_EMPTY_FIELDS)

        with gr.Tab("Checks"):
            checks_out = gr.Markdown(value=_EMPTY_CHECKS)

        with gr.Tab("Details"):
            details_out = gr.Markdown(value=_EMPTY_DETAILS)

        run_btn.click(
            fn=_process,
            inputs=[upload],
            outputs=[verdict_out, fields_out, checks_out, details_out],
        )

    return demo


if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )
    build_demo().launch()