Add OcrResponse schema and tighten invoice validation

- Adds OcrResponse Pydantic schema for OCR branch JSON output.

- Validates invoice_date and due_date as strict ISO 8601 calendar dates.

- Adds field descriptions to InvoiceLineItem for clearer VLM prompts.

- Expands schema tests for dates, decimal coercion, negatives, and line_items_sum().
This commit is contained in:
2026-08-21 16:44:09 +02:00
parent 2e81fa57bd
commit 17e0496d2f
4 changed files with 136 additions and 8 deletions
+2 -1
View File
@@ -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",
+13 -1
View File
@@ -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.
+37 -6
View File
@@ -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:
+84
View File
@@ -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")