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."""
|
"""Pydantic schemas for the Odoo OCR pipeline."""
|
||||||
|
|
||||||
from .document import DocumentClass, ProcessingContext
|
from .document import DocumentClass, OcrResponse, ProcessingContext
|
||||||
from .invoice import ExtractedInvoice, InvoiceLineItem
|
from .invoice import ExtractedInvoice, InvoiceLineItem
|
||||||
from .review import ReviewIssue, ReviewResult
|
from .review import ReviewIssue, ReviewResult
|
||||||
from .vlm import VLMResponse
|
from .vlm import VLMResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DocumentClass",
|
"DocumentClass",
|
||||||
|
"OcrResponse",
|
||||||
"ProcessingContext",
|
"ProcessingContext",
|
||||||
"ExtractedInvoice",
|
"ExtractedInvoice",
|
||||||
"InvoiceLineItem",
|
"InvoiceLineItem",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from enum import StrEnum
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class DocumentClass(StrEnum):
|
class DocumentClass(StrEnum):
|
||||||
@@ -26,6 +26,18 @@ class ClassificationResult(BaseModel):
|
|||||||
reasoning: str = ""
|
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):
|
class ProcessingContext(BaseModel):
|
||||||
"""Mutable-ish context passed through pipeline stages.
|
"""Mutable-ish context passed through pipeline stages.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
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):
|
class InvoiceLineItem(BaseModel):
|
||||||
@@ -13,11 +31,19 @@ class InvoiceLineItem(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(str_strip_whitespace=True)
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
description: str
|
description: str = Field(description="Description of the product or service.")
|
||||||
quantity: Decimal = Field(default=Decimal("1"), ge=Decimal("0"))
|
quantity: Decimal = Field(
|
||||||
unit_price: Decimal = Field(default=Decimal("0"), ge=Decimal("0"))
|
default=Decimal("1"), ge=Decimal("0"), description="Quantity invoiced."
|
||||||
total_price: Decimal = Field(default=Decimal("0"), ge=Decimal("0"))
|
)
|
||||||
tax_rate: Decimal = Field(default=Decimal("0"), ge=Decimal("0"))
|
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")
|
@field_validator("quantity", "unit_price", "total_price", "tax_rate", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -47,6 +73,11 @@ class ExtractedInvoice(BaseModel):
|
|||||||
iban: str | None = None
|
iban: str | None = None
|
||||||
raw_ocr_text: str = ""
|
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")
|
@field_validator("subtotal", "tax_total", "total", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _coerce_decimal(cls, value: Any) -> Decimal:
|
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