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))}}