dodil/erp-brick-expenses

DODIL ERP brick: expenses

src/brick_expenses/domain/ai.py

"""The AI receipt reader's contract (module.yaml ai_steps.read_receipt): suggest, after consent, never confirm.

Plain Python. The model only SUGGESTS. This module decides what of its answer may become a line - every field checked like one a person typed, a bad
answer is no answer (the person types it) - and which fields stay marked until the person confirms or corrects them."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Optional

from brick_expenses.domain.errors import RuleBroken

STEP = "read_receipt"
TEXT_VERSION = "2026-10-25.1"
CONSENT_TEXT = ("The photo or PDF of a receipt you add is read by a language model on the platform's sovereign models, in the EU/UK region: it never leaves "
                "that region. The model suggests the date, merchant, total, currency, VAT, the supplier's VAT number and the category; it never confirms anything - "
                "every field it filled stays marked until you confirm or correct it, and your approver can see which fields the model filled and which you changed. "
                "You can say no (you type the fields) and change your mind at any time: from then on no new image of yours is sent to the model, and what was "
                "read before stays as you confirmed it. Nobody can agree for you.")
# the line fields a suggestion fills and the person must confirm (the names edit_line takes)
SUGGESTED = ("category_code", "expense_date", "merchant", "amount", "vat_amount", "vat_number")


def consented(row: Optional[dict]) -> bool:
    return bool(row) and row["decision"] == "given"


def _money(v, what: str, zero_ok: bool = False) -> Decimal:
    try:
        d = Decimal(str(v).strip())
    except (InvalidOperation, ValueError):
        raise RuleBroken(f"the model's {what} {v!r} is not an amount") from None
    if not d.is_finite() or d.as_tuple().exponent < -2 or d < 0 or (d == 0 and not zero_ok) or d >= Decimal("1e13"):
        raise RuleBroken(f"the model's {what} {v!r} is not an amount a line can hold")
    return d


def fields(out: dict) -> dict:
    """The model's answer as line fields, checked: {expense_date, merchant, amount, currency, vat_amount, vat_number, category_code, category_reason,
    confidence}; RuleBroken (so: no suggestion) when a field a line cannot do without is unusable."""
    f = (out or {}).get("fields") or {}
    try:
        when = date.fromisoformat(str(f.get("date")).strip())
    except ValueError:
        raise RuleBroken(f"the model's date {f.get('date')!r} is not a date") from None
    merchant = str(f.get("merchant") or "").strip()[:200]
    cur = str(f.get("currency") or "").strip().upper()
    code = str(f.get("category_code") or "").strip()
    if not merchant or len(cur) != 3 or not cur.isalpha() or not code:
        raise RuleBroken("the model did not read a merchant, a currency and a category")
    return {"expense_date": when, "merchant": merchant, "amount": _money(f.get("total"), "total"), "currency": cur,
            "vat_amount": _money(f.get("vat") or 0, "VAT", zero_ok=True), "vat_number": str(f.get("vat_number") or "").strip()[:30],
            "category_code": code, "category_reason": str(f.get("category_reason") or "")[:300],
            "confidence": {k: int(v) for k, v in ((out or {}).get("confidence") or {}).items() if isinstance(v, (int, float))}}