validops-east-3's picture
Deploy 72701a1d9ec33b1842d0a516ffdee753aaa754a9
efff1ff verified
Raw History Blame Contribute Delete
3.68 kB
"""Typed request/response contracts and the error hierarchy."""
from __future__ import annotations
import enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
class FieldStatus(str, enum.Enum):
"""Outcome of a single key extraction.
`EXTRACTED` is only assigned above the confidence threshold; anything else
is surfaced to the caller rather than being silently returned as fact.
This distinction is the whole point of the service: the underlying model
will always emit *some* span, so absence must be modelled explicitly.
"""
EXTRACTED = "extracted"
LOW_CONFIDENCE = "low_confidence"
NOT_FOUND = "not_found"
ERROR = "error"
class SourceType(str, enum.Enum):
PDF_TEXT_LAYER = "pdf_text_layer"
PDF_OCR = "pdf_ocr"
IMAGE_OCR = "image_ocr"
class ExtractedField(BaseModel):
key: str
value: str | None = None
status: FieldStatus = FieldStatus.NOT_FOUND
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
page: int | None = None
source: SourceType | None = None
error: str | None = None
@property
def is_usable(self) -> bool:
return self.status is FieldStatus.EXTRACTED
class DocumentInfo(BaseModel):
document_id: str
page_count: int
pages_processed: int
word_count: int
source_type: SourceType
truncated: bool = False
class ExtractRequest(BaseModel):
"""Request body. `file` arrives separately as an UploadFile."""
keys: list[str] = Field(..., min_length=1)
confidence_threshold: float | None = Field(default=None, ge=0.0, le=1.0)
max_pages: int | None = Field(default=None, ge=1, le=50)
@field_validator("keys")
@classmethod
def _validate_keys(cls, v: list[str]) -> list[str]:
cleaned = [k.strip() for k in v if k and k.strip()]
if not cleaned:
raise ValueError("at least one non-empty key is required")
if any(len(k) > 200 for k in cleaned):
raise ValueError("each key must be 200 characters or fewer")
return cleaned
@field_validator("keys")
@classmethod
def _dedupe(cls, v: list[str]) -> list[str]:
"""Preserve order but drop exact duplicates.
Repeating a key costs a full forward pass and returns an identical
answer, so duplicates are collapsed rather than rejected.
"""
seen: set[str] = set()
out: list[str] = []
for k in v:
if k not in seen:
seen.add(k)
out.append(k)
return out
class ExtractResponse(BaseModel):
document: DocumentInfo
fields: list[ExtractedField]
latency_ms: float
confidence_threshold: float
@property
def values(self) -> dict[str, str | None]:
return {f.key: f.value for f in self.fields}
# --------------------------------------------------------------------------
# errors
# --------------------------------------------------------------------------
class DocExtractError(Exception):
"""Base class. `status_code` is what the HTTP layer will return."""
status_code = 500
def __init__(self, message: str, detail: Any = None):
super().__init__(message)
self.message = message
self.detail = detail
class ValidationError(DocExtractError):
status_code = 422
class UnsupportedMediaError(DocExtractError):
status_code = 415
class PayloadTooLargeError(DocExtractError):
status_code = 413
class CorruptDocumentError(DocExtractError):
status_code = 422
class ExtractionTimeoutError(DocExtractError):
status_code = 504
class ModelUnavailableError(DocExtractError):
status_code = 503