Andrey Epifancev 3a08b990dd feat(contract): модели v1.2 — DISCLOSURE, StatementChecks, границы периода ОСВ; копия схемы
Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 14:23:27 +04:00

234 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Общие основы моделей контракта v1.1: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки.
Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false`
(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) —
так обратно совместимый MINOR-релиз (необязательные поля) не ломает потребителя. Типы строгие (`strict=True`):
JSON Schema не приводит `"1"` к числу, а число — к строке; поля со строковой записью (uuid, date-time, date, деньги)
разбирают строки явно.
"""
import re
import uuid
from datetime import UTC, date, datetime
from decimal import Decimal
from enum import StrEnum
from typing import Annotated, Any, Literal, TypeVar
from pydantic import (
AfterValidator,
AwareDatetime,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
PlainSerializer,
PlainValidator,
WithJsonSchema,
model_validator,
)
CONTRACT_VERSION = "1.2"
# Шаблоны версий — буквально из схем
SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR
RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version
T = TypeVar("T")
class _NotNull:
"""Метка поля, необязательного в схеме, но не допускающего явный `null` (в схеме тип без `null`)."""
NOT_NULL = _NotNull()
# Необязательное поле без `null`: отсутствует (None в модели) или значение типа T; явный null во входе — ошибка
OptionalNotNull = Annotated[T | None, NOT_NULL]
class ContractModel(BaseModel):
"""База моделей контракта: неизменяемые, строгие, незнакомые поля отбрасываются."""
model_config = ConfigDict(extra="ignore", strict=True, frozen=True)
@model_validator(mode="before")
@classmethod
def _reject_explicit_null(cls, data: Any) -> Any:
"""Схема не допускает `null`, где тип без `null`: `{"result_s3": null}` — ошибка, а не «нет поля»."""
if isinstance(data, dict):
for name, field in cls.model_fields.items():
key = field.alias or name
if NOT_NULL in field.metadata and key in data and data[key] is None:
raise ValueError(f"поле {key!r} не допускает null (в схеме контракта нет null в типе)")
return data
def to_wire(self) -> dict[str, Any]:
"""Тело для NATS / файла S3: JSON-словарь только из заданных полей (uuid, время, деньги — строки)."""
return self.model_dump(mode="json", exclude_unset=True)
# --- UUID -----------------------------------------------------------------------------------------------------------
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", re.ASCII)
def _require_canonical_uuid(value: object) -> object:
"""Строка канонического вида 8-4-4-4-12 (как `format: uuid` схемы) либо готовый `uuid.UUID`."""
if isinstance(value, uuid.UUID) or (isinstance(value, str) and _UUID_RE.fullmatch(value)):
return value
raise ValueError("ожидается uuid строкой вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
ContractUUID = Annotated[uuid.UUID, BeforeValidator(_require_canonical_uuid), Field(strict=False)]
# --- время ----------------------------------------------------------------------------------------------------------
_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII)
def _require_text_or_datetime(value: object) -> object:
if isinstance(value, str | datetime):
return value
raise ValueError("ожидается строка date-time (RFC 3339) с часовым поясом")
def _to_utc(value: datetime) -> datetime:
return value.astimezone(UTC)
# `reply_deadline`: срок ответа, UTC. Часовой пояс обязателен (aware); смещение приводится к UTC
UtcDatetime = Annotated[
AwareDatetime,
BeforeValidator(_require_text_or_datetime),
AfterValidator(_to_utc),
Field(strict=False),
]
def _require_iso_date(value: object) -> object:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, str) and _DATE_RE.fullmatch(value):
return value
raise ValueError("ожидается дата строкой YYYY-MM-DD")
ContractDate = Annotated[date, BeforeValidator(_require_iso_date), Field(strict=False)]
# --- деньги ---------------------------------------------------------------------------------------------------------
MONEY_PATTERN = r"^-?\d+(\.\d{1,2})?$" # буквально из `$defs.money`
_MONEY_RE = re.compile(r"-?\d+(\.\d{1,2})?", re.ASCII)
def _parse_money(value: object) -> Decimal | None:
"""Сумма — строка по шаблону схемы (копейки не теряются) или `null`; `float` / `int` — ошибка."""
if value is None:
return None
if isinstance(value, Decimal):
text = format(value, "f") if value.is_finite() else ""
elif isinstance(value, str):
text = value
else:
raise ValueError(
"сумма должна быть строкой вида '106311116.46' (float и int недопустимы, чтобы не терять копейки)"
)
if not _MONEY_RE.fullmatch(text):
raise ValueError(f"сумма {text!r} не соответствует шаблону {MONEY_PATTERN} (до двух знаков после точки)")
return Decimal(text)
def _dump_money(value: Decimal | None) -> str | None:
return None if value is None else format(value, "f")
# В модели — `Decimal`; в JSON — строка ровно в той записи, как пришла («1100», «-12.30»)
Money = Annotated[
Decimal | None,
PlainValidator(_parse_money),
PlainSerializer(_dump_money, return_type=str | None, when_used="json"),
WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}),
]
# --- целые -----------------------------------------------------------------------------------------------------------
def int_like(value: object) -> object:
"""JSON Schema 2020-12 считает `1.0` целым: `float` с целым значением приводится к `int` (pandas пишет `5.0`).
`bool` (в JSON это не число) отвергается явно; нецелые `float` (в том числе NaN и inf) и строки остаются как есть
и отвергаются строгой проверкой `int`.
"""
if isinstance(value, bool):
raise ValueError("ожидается целое число, а не булево значение")
if isinstance(value, float) and value.is_integer():
return int(value)
return value
# Целое по схеме (`type: integer`): `int` или целочисленный `float`; bool, строка и `1.5` — ошибка
IntLike = Annotated[int, BeforeValidator(int_like)]
# `header_variant`: enum [1, 2] схемы; `True` и `1.5` отвергаются, `1.0` равно 1
HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)]
# --- скаляры --------------------------------------------------------------------------------------------------------
NonEmptyStr = Annotated[str, Field(min_length=1)]
SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)]
ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)]
# Уверенность распознавания, проценты (число 0–100)
Confidence = Annotated[float, Field(ge=0, le=100)] | None
# --- общие объекты --------------------------------------------------------------------------------------------------
class ServiceName(StrEnum):
TABULAR_PARSER = "tabular-parser"
SCAN_GATEWAY = "scan-gateway"
class ErrorCode(StrEnum):
UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT"
UNREADABLE = "UNREADABLE"
TIMEOUT = "TIMEOUT"
SCHEMA_MISMATCH = "SCHEMA_MISMATCH"
NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
FILE_READ_ERROR = "FILE_READ_ERROR"
INVALID_FORMAT = "INVALID_FORMAT"
INVALID_DOCUMENT = "INVALID_DOCUMENT"
RECOGNITION_FAILED = "RECOGNITION_FAILED"
UNKNOWN_ERROR = "UNKNOWN_ERROR"
# Строгий режим модели не принимает значение enum строкой: поле разбирает строку само (как JSON Schema `enum`)
LaxServiceName = Annotated[ServiceName, Field(strict=False)]
LaxErrorCode = Annotated[ErrorCode, Field(strict=False)]
class ServiceInfo(ContractModel):
"""Кто ответил: `service {name, version}`."""
name: LaxServiceName
version: str
class S3Ref(ContractModel):
"""Указатель на объект S3: `{bucket, key}`."""
bucket: NonEmptyStr
key: NonEmptyStr
class Metrics(ContractModel):
"""Метрики обработки; все поля необязательны."""
duration_ms: OptionalNotNull[IntLike] = None
pages: OptionalNotNull[IntLike] = None
rows: OptionalNotNull[IntLike] = None
fields_total: OptionalNotNull[IntLike] = None
fields_low_confidence: OptionalNotNull[IntLike] = None
fields_unrecognized: OptionalNotNull[IntLike] = None
warnings: OptionalNotNull[list[str]] = None
DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"]