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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user