aditya0103's picture
Initial commit: schemas, extraction engine, dataset prep, 54 passing tests
44c2f50
Raw
History Blame Contribute Delete
2.98 kB
"""Shared building-block types used across invoice and receipt schemas."""
from __future__ import annotations
from pydantic import Field, field_validator
from src.schemas.base import StrictModel
class Address(StrictModel):
"""A postal address. All fields optional to handle partial extractions."""
line1: str | None = Field(default=None, description="Street address line 1.")
line2: str | None = Field(default=None, description="Suite / apartment / unit.")
city: str | None = None
region: str | None = Field(default=None, description="State / province / region.")
postal_code: str | None = None
country: str | None = Field(
default=None, description="ISO 3166-1 alpha-2 country code when derivable, else free text."
)
class Party(StrictModel):
"""A named entity on an invoice β€” vendor, customer, or bill-to party."""
name: str = Field(description="Legal or trade name of the entity.")
address: Address | None = Field(
default=None, description="Postal address if present on the document."
)
tax_id: str | None = Field(
default=None, description="VAT / GST / EIN / other tax identifier if present."
)
email: str | None = None
phone: str | None = None
# --- Currency helpers -------------------------------------------------------
# ISO 4217 three-letter codes we commonly see on receipts/invoices.
# Not exhaustive β€” the model may return other valid codes.
COMMON_CURRENCIES = {
"USD", "EUR", "GBP", "INR", "CAD", "AUD", "JPY", "CNY", "CHF",
"SEK", "NOK", "DKK", "SGD", "HKD", "MXN", "BRL", "ZAR", "AED",
}
def normalize_currency(value: str | None) -> str | None:
"""Uppercase and strip; leave unknown codes alone (log-and-pass)."""
if value is None:
return None
v = value.strip().upper()
return v or None
# --- Monetary amount ---
# We use `float` (not Decimal) at the schema layer because OpenAI structured
# outputs enforces strict JSON Schema β€” 'number' round-trips cleanly. A
# validator rounds to 2 decimals to prevent drift. If your domain requires
# Decimal correctness (e.g. accounting book-of-record), convert at the app
# layer after extraction.
MoneyAmount = float
def round_money(v: float | None) -> float | None:
"""Round monetary amounts to 2 decimals, preserving None."""
return None if v is None else round(float(v), 2)
class MoneyMixin(StrictModel):
"""Mixin providing a shared validator for monetary fields.
Subclasses list monetary field names in `_money_fields` and this mixin
ensures they are rounded to 2 decimals on assignment.
"""
_money_fields: tuple[str, ...] = ()
@field_validator("*", mode="before")
@classmethod
def _round_money_fields(cls, v, info):
if info.field_name in cls._money_fields and v is not None:
try:
return round(float(v), 2)
except (TypeError, ValueError):
return v
return v