Spaces:
Sleeping
Sleeping
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()
|