diff --git a/src/odoo_ocr/schemas/__init__.py b/src/odoo_ocr/schemas/__init__.py index a9fd7af..653591c 100644 --- a/src/odoo_ocr/schemas/__init__.py +++ b/src/odoo_ocr/schemas/__init__.py @@ -1,12 +1,13 @@ """Pydantic schemas for the Odoo OCR pipeline.""" -from .document import DocumentClass, ProcessingContext +from .document import DocumentClass, OcrResponse, ProcessingContext from .invoice import ExtractedInvoice, InvoiceLineItem from .review import ReviewIssue, ReviewResult from .vlm import VLMResponse __all__ = [ "DocumentClass", + "OcrResponse", "ProcessingContext", "ExtractedInvoice", "InvoiceLineItem", diff --git a/src/odoo_ocr/schemas/document.py b/src/odoo_ocr/schemas/document.py index 8218017..fdf57fc 100644 --- a/src/odoo_ocr/schemas/document.py +++ b/src/odoo_ocr/schemas/document.py @@ -6,7 +6,7 @@ from enum import StrEnum from pathlib import Path from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class DocumentClass(StrEnum): @@ -26,6 +26,18 @@ class ClassificationResult(BaseModel): reasoning: str = "" +class OcrResponse(BaseModel): + """Output of the OCR branch models. + + The OCR system prompt instructs the model to return a JSON object with a + single ``text`` field containing the full page OCR output. + """ + + model_config = ConfigDict(str_strip_whitespace=True) + + text: str + + class ProcessingContext(BaseModel): """Mutable-ish context passed through pipeline stages. diff --git a/src/odoo_ocr/schemas/invoice.py b/src/odoo_ocr/schemas/invoice.py index 72e24f3..799d967 100644 --- a/src/odoo_ocr/schemas/invoice.py +++ b/src/odoo_ocr/schemas/invoice.py @@ -2,10 +2,28 @@ from __future__ import annotations +import re +from datetime import date from decimal import Decimal from typing import Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator + +_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _validate_iso_date(value: Any, field_name: str | None) -> str | None: + if value is None: + return None + text = str(value).strip() + label = field_name or "date" + if not _ISO_DATE_RE.match(text): + raise ValueError(f"{label} must be an ISO 8601 date (YYYY-MM-DD), got: {value!r}") + try: + date.fromisoformat(text) + except ValueError as exc: + raise ValueError(f"{label} is not a valid calendar date: {value!r}") from exc + return text class InvoiceLineItem(BaseModel): @@ -13,11 +31,19 @@ class InvoiceLineItem(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) - description: str - quantity: Decimal = Field(default=Decimal("1"), ge=Decimal("0")) - unit_price: Decimal = Field(default=Decimal("0"), ge=Decimal("0")) - total_price: Decimal = Field(default=Decimal("0"), ge=Decimal("0")) - tax_rate: Decimal = Field(default=Decimal("0"), ge=Decimal("0")) + description: str = Field(description="Description of the product or service.") + quantity: Decimal = Field( + default=Decimal("1"), ge=Decimal("0"), description="Quantity invoiced." + ) + unit_price: Decimal = Field( + default=Decimal("0"), ge=Decimal("0"), description="Price per unit before tax." + ) + total_price: Decimal = Field( + default=Decimal("0"), ge=Decimal("0"), description="Line total before tax." + ) + tax_rate: Decimal = Field( + default=Decimal("0"), ge=Decimal("0"), description="Tax rate as a percentage, e.g. 20 for 20%." + ) @field_validator("quantity", "unit_price", "total_price", "tax_rate", mode="before") @classmethod @@ -47,6 +73,11 @@ class ExtractedInvoice(BaseModel): iban: str | None = None raw_ocr_text: str = "" + @field_validator("invoice_date", "due_date", mode="before") + @classmethod + def _validate_dates(cls, value: Any, info: ValidationInfo) -> str | None: + return _validate_iso_date(value, info.field_name) + @field_validator("subtotal", "tax_total", "total", mode="before") @classmethod def _coerce_decimal(cls, value: Any) -> Decimal: diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..f649a4d --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,84 @@ +"""Tests for invoice schema validation.""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from odoo_ocr.schemas import ExtractedInvoice, InvoiceLineItem + + +def test_accepts_valid_iso_dates() -> None: + invoice = ExtractedInvoice( + vendor_name="A", + invoice_number="1", + invoice_date="2024-01-01", + due_date="2024-02-01", + ) + assert invoice.invoice_date == "2024-01-01" + assert invoice.due_date == "2024-02-01" + + +def test_rejects_non_iso_date_format() -> None: + with pytest.raises(ValidationError, match="ISO 8601"): + ExtractedInvoice( + vendor_name="A", + invoice_number="1", + invoice_date="May 1, 2024", + ) + + +def test_rejects_invalid_calendar_date() -> None: + with pytest.raises(ValidationError, match="not a valid calendar date"): + ExtractedInvoice( + vendor_name="A", + invoice_number="1", + invoice_date="2024-02-30", + ) + + +def test_due_date_optional() -> None: + invoice = ExtractedInvoice( + vendor_name="A", + invoice_number="1", + invoice_date="2024-01-01", + ) + assert invoice.due_date is None + + +def test_invoice_line_item_decimal_coercion() -> None: + line = InvoiceLineItem( + description="Widgets", + quantity="2", + unit_price="10.5", + total_price="21.0", + tax_rate="20", + ) + assert line.quantity == Decimal("2") + assert line.unit_price == Decimal("10.5") + assert line.total_price == Decimal("21.0") + assert line.tax_rate == Decimal("20") + + +def test_invoice_line_item_rejects_negative_values() -> None: + with pytest.raises(ValidationError): + InvoiceLineItem(description="X", quantity=-1) + with pytest.raises(ValidationError): + InvoiceLineItem(description="X", unit_price=-1) + with pytest.raises(ValidationError): + InvoiceLineItem(description="X", tax_rate=-5) + + +def test_line_items_sum() -> None: + invoice = ExtractedInvoice( + vendor_name="A", + invoice_number="1", + invoice_date="2024-01-01", + line_items=[ + InvoiceLineItem(description="X", total_price=Decimal("100")), + InvoiceLineItem(description="Y", total_price=Decimal("50.50")), + ], + ) + assert invoice.line_items_sum() == Decimal("150.50")