fix: целочисленные float (1.0) принимаются как целые, bool в header_variant отвергается

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 23:08:25 +04:00
parent 0837e18a0a
commit d7f6c280bb
4 changed files with 126 additions and 17 deletions

View File

@ -149,6 +149,27 @@ Money = Annotated[
WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}), 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)] NonEmptyStr = Annotated[str, Field(min_length=1)]
@ -200,12 +221,12 @@ class S3Ref(ContractModel):
class Metrics(ContractModel): class Metrics(ContractModel):
"""Метрики обработки; все поля необязательны.""" """Метрики обработки; все поля необязательны."""
duration_ms: OptionalNotNull[int] = None duration_ms: OptionalNotNull[IntLike] = None
pages: OptionalNotNull[int] = None pages: OptionalNotNull[IntLike] = None
rows: OptionalNotNull[int] = None rows: OptionalNotNull[IntLike] = None
fields_total: OptionalNotNull[int] = None fields_total: OptionalNotNull[IntLike] = None
fields_low_confidence: OptionalNotNull[int] = None fields_low_confidence: OptionalNotNull[IntLike] = None
fields_unrecognized: OptionalNotNull[int] = None fields_unrecognized: OptionalNotNull[IntLike] = None
warnings: OptionalNotNull[list[str]] = None warnings: OptionalNotNull[list[str]] = None

View File

@ -9,7 +9,7 @@ from typing import Annotated, Literal
from pydantic import Field from pydantic import Field
from src.contract.common import ContractModel, ContractUUID, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime
from src.contract.subjects import Pipeline from src.contract.subjects import Pipeline
FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"]
@ -24,4 +24,4 @@ class RecognitionRequest(ContractModel):
original_name: NonEmptyStr # имя файла, как загрузил пользователь original_name: NonEmptyStr # имя файла, как загрузил пользователь
s3: S3Ref # исходный файл в роли uploads s3: S3Ref # исходный файл в роли uploads
reply_deadline: UtcDatetime # срок ответа, UTC reply_deadline: UtcDatetime # срок ответа, UTC
attempt: Annotated[int, Field(ge=1)] # номер попытки, с 1 attempt: Annotated[IntLike, Field(ge=1)] # номер попытки, с 1

View File

@ -16,6 +16,8 @@ from src.contract.common import (
ContractModel, ContractModel,
ContractUUID, ContractUUID,
DataType, DataType,
HeaderVariant,
IntLike,
LaxErrorCode, LaxErrorCode,
Metrics, Metrics,
Money, Money,
@ -65,12 +67,12 @@ class OsvHeader(ContractModel):
organization_inn: str | None = None organization_inn: str | None = None
account_code_raw: str # «60», «58.03» account_code_raw: str # «60», «58.03»
period_text: str # «1 квартал 2025 г.» period_text: str # «1 квартал 2025 г.»
period_year: int | None = None period_year: IntLike | None = None
period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None
is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02)
data_type: DataType | None = None data_type: DataType | None = None
currency: str | None = None currency: str | None = None
header_variant: OptionalNotNull[Literal[1, 2]] = None header_variant: OptionalNotNull[HeaderVariant] = None
class OsvGroup(ContractModel): class OsvGroup(ContractModel):
@ -82,9 +84,9 @@ class OsvGroup(ContractModel):
class OsvRow(ContractModel): class OsvRow(ContractModel):
row_number: int row_number: IntLike
level: int level: IntLike
parent_row_number: int | None = None parent_row_number: IntLike | None = None
row_value: str row_value: str
group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"]
group_name: str | None = None group_name: str | None = None
@ -102,8 +104,8 @@ class OsvRow(ContractModel):
class OsvChecks(ContractModel): class OsvChecks(ContractModel):
total_row_matches_root: OptionalNotNull[bool] = None total_row_matches_root: OptionalNotNull[bool] = None
balance_identity_violations: OptionalNotNull[list[int]] = None # row_number, где opening−turnover≠closing balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing
parent_sum_violations: OptionalNotNull[list[int]] = None parent_sum_violations: OptionalNotNull[list[IntLike]] = None
class OsvDocument(ContractModel): class OsvDocument(ContractModel):
@ -136,13 +138,13 @@ class StatementHeader(ContractModel):
class StatementColumn(ContractModel): class StatementColumn(ContractModel):
column_index: int column_index: IntLike
label: str # «На 30 сентября 2025 г.» label: str # «На 30 сентября 2025 г.»
period_date: ContractDate | None = None period_date: ContractDate | None = None
class StatementValue(ContractModel): class StatementValue(ContractModel):
column_index: int column_index: IntLike
amount: Money = None amount: Money = None
string_value: str | None = None string_value: str | None = None
confidence: Confidence = None confidence: Confidence = None

View File

@ -219,6 +219,9 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
("request/attempt-2", "request-tabular.json", _set("attempt", 2), True), ("request/attempt-2", "request-tabular.json", _set("attempt", 2), True),
("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False), ("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False),
("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False), ("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False),
("request/attempt-1.0", "request-tabular.json", _set("attempt", 1.0), True),
("request/attempt-0.0", "request-tabular.json", _set("attempt", 0.0), False),
("request/attempt-nan", "request-tabular.json", _set("attempt", float("nan")), False),
("request/attempt-bool", "request-tabular.json", _set("attempt", True), False), ("request/attempt-bool", "request-tabular.json", _set("attempt", True), False),
("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False), ("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False),
("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False), ("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False),
@ -276,6 +279,8 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True),
("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), ("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False),
("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False),
("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True),
("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False),
("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True), ("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True),
("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True), ("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True),
("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True), ("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True),
@ -360,6 +365,74 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
True, True,
), ),
("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False), ("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False),
("result/osv-level-1.0", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.0), True),
("result/osv-level-1.5", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.5), False),
("result/osv-level-bool", "result-osv-minimal.json", _set("documents.0.rows.0.level", True), False),
("result/osv-level-string", "result-osv-minimal.json", _set("documents.0.rows.0.level", "1"), False),
("result/osv-row_number-2.0", "result-osv-minimal.json", _set("documents.0.rows.0.row_number", 2.0), True),
(
"result/osv-parent_row_number-1.0",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.0),
True,
),
(
"result/osv-parent_row_number-1.5",
"result-osv-minimal.json",
_set("documents.0.rows.1.parent_row_number", 1.5),
False,
),
("result/osv-period_year-2025.0", "result-osv-minimal.json", _set("documents.0.header.period_year", 2025.0), True),
(
"result/osv-period_year-nan",
"result-osv-minimal.json",
_set("documents.0.header.period_year", float("nan")),
False,
),
("result/osv-header_variant-2.0", "result-osv-minimal.json", _set("documents.0.header.header_variant", 2.0), True),
(
"result/osv-header_variant-true",
"result-osv-minimal.json",
_set("documents.0.header.header_variant", True),
False,
),
("result/osv-header_variant-1.5", "result-osv-minimal.json", _set("documents.0.header.header_variant", 1.5), False),
(
"result/osv-violations-1.0",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.0]),
True,
),
(
"result/osv-violations-1.5",
"result-osv-minimal.json",
_set("documents.0.checks.parent_sum_violations", [3.5]),
False,
),
(
"result/statement-column_index-1.0",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.0),
True,
),
(
"result/statement-column_index-1.5",
"result-statement-minimal.json",
_set("documents.0.columns.0.column_index", 1.5),
False,
),
(
"result/statement-value-column_index-0.0",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", 0.0),
True,
),
(
"result/statement-value-column_index-bool",
"result-statement-minimal.json",
_set("documents.0.lines.0.values.0.column_index", False),
False,
),
("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False), ("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False),
("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False), ("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False),
("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True), ("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True),
@ -560,3 +633,16 @@ def test_request_subject_matches_example_pipeline() -> None:
for name in ("request-tabular.json", "request-image.json"): for name in ("request-tabular.json", "request-image.json"):
request = RecognitionRequest.model_validate(_example(name)) request = RecognitionRequest.model_validate(_example(name))
assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request" assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request"
def test_integer_valued_float_is_normalized_to_int() -> None:
"""`1.0` (pandas) принимается как целое и уходит дальше целым числом: `1`."""
payload = _example("result-osv-minimal.json")
payload["documents"][0]["rows"][0]["level"] = 1.0
payload["documents"][0]["header"]["header_variant"] = 2.0
result = RecognitionResult.model_validate(payload)
wire = result.to_wire()
assert wire["documents"][0]["rows"][0]["level"] == 1
assert type(wire["documents"][0]["rows"][0]["level"]) is int
assert type(wire["documents"][0]["header"]["header_variant"]) is int
VALIDATORS["result"].validate(wire)