Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v2.0.
|
||
|
||
Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров;
|
||
маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`).
|
||
Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь.
|
||
"""
|
||
|
||
from enum import StrEnum
|
||
from typing import Annotated, Literal, Self
|
||
|
||
from pydantic import Field, field_validator, model_validator
|
||
|
||
from src.contract.common import (
|
||
Confidence,
|
||
ContractDate,
|
||
ContractModel,
|
||
ContractUUID,
|
||
DataType,
|
||
DocumentWarning,
|
||
HeaderFieldName,
|
||
HeaderVariant,
|
||
IntLike,
|
||
LaxErrorCode,
|
||
Metrics,
|
||
Money,
|
||
NonEmptyStr,
|
||
OptionalNotNull,
|
||
SchemaVersion,
|
||
ServiceInfo,
|
||
)
|
||
|
||
__all__ = [
|
||
"FailedRelation",
|
||
"OsvChecks",
|
||
"OsvDocument",
|
||
"OsvGroup",
|
||
"OsvHeader",
|
||
"OsvRow",
|
||
"RecognitionResult",
|
||
"ResultError",
|
||
"ResultStatus",
|
||
"StatementChecks",
|
||
"StatementColumn",
|
||
"StatementDocument",
|
||
"StatementHeader",
|
||
"StatementLine",
|
||
"StatementValue",
|
||
]
|
||
|
||
|
||
DocumentStatus = Literal["done", "needs_check"]
|
||
|
||
|
||
class ResultStatus(StrEnum):
|
||
DONE = "done"
|
||
NEEDS_CHECK = "needs_check"
|
||
ERROR = "error"
|
||
|
||
|
||
LaxResultStatus = Annotated[ResultStatus, Field(strict=False)]
|
||
|
||
|
||
class ResultError(ContractModel):
|
||
"""`error` файла результата: в отличие от конверта, `code` и `message` необязательны."""
|
||
|
||
code: OptionalNotNull[LaxErrorCode] = None
|
||
message: OptionalNotNull[str] = None
|
||
|
||
|
||
# --- ОСВ ------------------------------------------------------------------------------------------------------------
|
||
|
||
|
||
class OsvHeader(ContractModel):
|
||
organization_name: str # как в r1 файла
|
||
organization_inn: str | None = None
|
||
account_code_raw: str # «60», «58.03»
|
||
period_text: str # «1 квартал 2025 г.»
|
||
period_year: IntLike | None = None
|
||
period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None
|
||
is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02)
|
||
period_start: ContractDate | None = None # начало периода по шапке (v1.2, OQ-131)
|
||
period_end: ContractDate | None = None
|
||
is_from_year_start: bool | None = None # period_start — 1 января; is_cumulative не переопределяет
|
||
data_type: DataType | None = None
|
||
currency: str | None = None
|
||
header_variant: OptionalNotNull[HeaderVariant] = None
|
||
|
||
|
||
class OsvGroup(ContractModel):
|
||
"""Группировка из шапки (порядок — после первого ACCOUNT)."""
|
||
|
||
type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
|
||
name: OptionalNotNull[str] = None # исходный текст из шапки
|
||
has_inn: OptionalNotNull[bool] = None
|
||
|
||
|
||
class OsvRow(ContractModel):
|
||
row_number: IntLike
|
||
level: IntLike
|
||
parent_row_number: IntLike | None = None
|
||
row_value: str
|
||
group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"]
|
||
group_name: str | None = None
|
||
account_code_raw: str | None = None # код текущей ветки, как в файле (60.1)
|
||
counterparty_name: str | None = None
|
||
counterparty_inn: str | None = None
|
||
data_type: DataType | None = None
|
||
opening_debit: Money = None
|
||
opening_credit: Money = None
|
||
turnover_debit: Money = None
|
||
turnover_credit: Money = None
|
||
closing_debit: Money = None
|
||
closing_credit: Money = None
|
||
extra: dict[str, str | None] | None = None # нераспознанные колонки таблицы: заголовок → текст ячейки (§1.2)
|
||
|
||
|
||
class OsvChecks(ContractModel):
|
||
total_row_matches_root: OptionalNotNull[bool] = None
|
||
balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing
|
||
parent_sum_violations: OptionalNotNull[list[IntLike]] = None
|
||
|
||
|
||
class OsvDocument(ContractModel):
|
||
document_type: Literal["OSV"]
|
||
locator: str # «sheet:Лист_1»
|
||
status: DocumentStatus # done — контрольные суммы §1.4 листа сошлись, иначе needs_check
|
||
warnings: list[DocumentWarning] # все уровня info, код с префиксом OSV_
|
||
header: OsvHeader
|
||
group_schema: list[OsvGroup]
|
||
columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН
|
||
rows: list[OsvRow]
|
||
checks: OptionalNotNull[OsvChecks] = None
|
||
|
||
|
||
# --- ББ / ОФР / раскрытия -------------------------------------------------------------------------------------------
|
||
|
||
PeriodKind = Literal[
|
||
"absolute",
|
||
"relative:current",
|
||
"relative:prev_year_end",
|
||
"relative:prev_prev_year_end",
|
||
"relative:prev_period",
|
||
"none",
|
||
]
|
||
LineStatus = Literal["ok", "unreadable", "missing", "detail"]
|
||
CODE_PATTERN = r"^\d{4,5}$"
|
||
INN_PATTERN = r"^(\d{10}|\d{12})$"
|
||
NonNegativeInt = Annotated[IntLike, Field(ge=0)]
|
||
|
||
|
||
class StatementHeader(ContractModel):
|
||
"""Шапка формы: значение, сырой текст и уверенность каждого поля (схема v2.0, все поля необязательны)."""
|
||
|
||
form_name: str | None = None
|
||
organization_name: str | None = None
|
||
organization_name_text: str | None = None
|
||
organization_name_confidence: Confidence = None
|
||
inn: Annotated[str, Field(pattern=INN_PATTERN)] | None = None
|
||
inn_text: str | None = None
|
||
inn_confidence: Confidence = None
|
||
report_date: ContractDate | None = None
|
||
report_date_text: str | None = None
|
||
report_date_confidence: Confidence = None
|
||
units: str | None = None # «тыс. руб.»
|
||
units_text: str | None = None
|
||
units_confidence: Confidence = None
|
||
okud: str | None = None
|
||
okud_text: str | None = None
|
||
okud_confidence: Confidence = None
|
||
okpo: str | None = None
|
||
okpo_text: str | None = None
|
||
okpo_confidence: Confidence = None
|
||
okopf: str | None = None
|
||
okopf_text: str | None = None
|
||
okopf_confidence: Confidence = None
|
||
okfs: str | None = None
|
||
okfs_text: str | None = None
|
||
okfs_confidence: Confidence = None
|
||
okei: str | None = None # код ОКЕИ единицы: 384 — тыс. руб., 383 — руб., 385 — млн руб.
|
||
okei_text: str | None = None
|
||
okei_confidence: Confidence = None
|
||
address: str | None = None
|
||
address_text: str | None = None
|
||
address_confidence: Confidence = None
|
||
needs_review_fields: OptionalNotNull[list[HeaderFieldName]] = None
|
||
|
||
@field_validator("needs_review_fields")
|
||
@classmethod
|
||
def _unique_fields(cls, value: list[HeaderFieldName] | None) -> list[HeaderFieldName] | None:
|
||
if value is not None and len(set(value)) != len(value):
|
||
raise ValueError("needs_review_fields: поля не должны повторяться (uniqueItems)")
|
||
return value
|
||
|
||
|
||
class StatementColumn(ContractModel):
|
||
column_index: NonNegativeInt # номер столбца сетки кандидата шага 1; уникален в документе
|
||
label: str # «На 30 сентября 2025 г.»
|
||
label_confidence: Confidence = None
|
||
period_date: ContractDate | None = None
|
||
period_kind: PeriodKind
|
||
|
||
|
||
class StatementValue(ContractModel):
|
||
column_index: IntLike
|
||
amount: Money
|
||
string_value: str | None = None
|
||
text: str | None
|
||
confidence: Confidence
|
||
is_recognized: bool
|
||
needs_review: bool
|
||
|
||
|
||
class StatementLine(ContractModel):
|
||
row_index: NonNegativeInt
|
||
ref: NonEmptyStr # положение в источнике; уникален в документе
|
||
line_status: LineStatus
|
||
line_code: Annotated[str, Field(pattern=CODE_PATTERN)] | None
|
||
line_code_text: str | None
|
||
line_code_confidence: Confidence = None
|
||
line_name: str | None
|
||
line_name_confidence: Confidence = None
|
||
parent_ref: str | None = None
|
||
needs_review: bool
|
||
values: list[StatementValue]
|
||
|
||
@model_validator(mode="after")
|
||
def _check_status_rules(self) -> Self:
|
||
"""Буквально из `allOf` схемы: `line_code` задан ⇔ `line_status = ok`; у `detail` обязателен `parent_ref`."""
|
||
if (self.line_status == "ok") != (self.line_code is not None):
|
||
raise ValueError("line_code задан тогда и только тогда, когда line_status = ok")
|
||
if self.line_status == "detail" and self.parent_ref is None:
|
||
raise ValueError("у строки detail обязателен parent_ref")
|
||
return self
|
||
|
||
|
||
class FailedRelation(ContractModel):
|
||
"""Нарушенное соотношение §7 по одной колонке.
|
||
|
||
`expected` — левая часть (цель), `actual` — правая по строкам ok; `null` — цели нет или все члены пусты.
|
||
"""
|
||
|
||
column_index: IntLike
|
||
relation: str # «1600 = 1100 + 1200»
|
||
codes: Annotated[list[Annotated[str, Field(pattern=CODE_PATTERN)]], Field(min_length=1)]
|
||
expected: Money
|
||
actual: Money
|
||
|
||
|
||
class StatementChecks(ContractModel):
|
||
"""Самопроверка формы общим кодом: пустые списки — «проверено, сошлось»."""
|
||
|
||
relations_failed: list[FailedRelation]
|
||
soft_relations_failed: list[FailedRelation]
|
||
|
||
|
||
class StatementDocument(ContractModel):
|
||
document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]
|
||
form_variant: Literal["full", "simplified"] | None # обязателен; null — DISCLOSURE
|
||
locator: str # «file:aep_3.json;table:0»
|
||
status: DocumentStatus
|
||
warnings: list[DocumentWarning]
|
||
header: StatementHeader
|
||
columns: list[StatementColumn]
|
||
lines: list[StatementLine]
|
||
checks: OptionalNotNull[StatementChecks] = None
|
||
|
||
@model_validator(mode="after")
|
||
def _check_checks_presence(self) -> Self:
|
||
"""`allOf` схемы: `checks` обязателен у BALANCE/INCOME со строками и запрещён у документа без строк."""
|
||
if self.lines:
|
||
if self.document_type in ("BALANCE", "INCOME") and self.checks is None:
|
||
raise ValueError(f"у {self.document_type} со строками обязателен checks")
|
||
elif self.checks is not None:
|
||
raise ValueError("у документа без строк checks не допускается")
|
||
return self
|
||
|
||
|
||
ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")]
|
||
|
||
|
||
class RecognitionResult(ContractModel):
|
||
schema_version: SchemaVersion
|
||
job_id: ContractUUID
|
||
file_id: ContractUUID
|
||
service: ServiceInfo
|
||
status: LaxResultStatus
|
||
error: ResultError | None = None
|
||
metrics: OptionalNotNull[Metrics] = None
|
||
raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json
|
||
documents: list[ResultDocument]
|
||
|
||
@model_validator(mode="after")
|
||
def _check_documents_for_success(self) -> Self:
|
||
"""`if status in (done, needs_check) then documents.minItems = 1` — буквально из схемы."""
|
||
if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents:
|
||
raise ValueError(f"при status = {self.status.value} список documents не может быть пустым")
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _check_statements_version(self) -> Self:
|
||
"""Есть `StatementDocument` ⇒ `service.statements_version` задан (`allOf` схемы)."""
|
||
if any(isinstance(d, StatementDocument) for d in self.documents) and self.service.statements_version is None:
|
||
raise ValueError("при документах ББ/ОФР обязателен service.statements_version")
|
||
return self
|