From d7f6c280bb8cc5ae01c040103971dbed409b41da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 23:08:25 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=86=D0=B5=D0=BB=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20float=20(1.0)=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D0=BA=20=D1=86=D0=B5=D0=BB=D1=8B=D0=B5,?= =?UTF-8?q?=20bool=20=D0=B2=20header=5Fvariant=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B3=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/contract/common.py | 33 +++++++++++--- api/src/contract/request.py | 4 +- api/src/contract/result.py | 20 +++++---- api/tests/test_contract.py | 86 +++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/api/src/contract/common.py b/api/src/contract/common.py index 6d14baf..d0e27a8 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -149,6 +149,27 @@ Money = Annotated[ 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)] @@ -200,12 +221,12 @@ class S3Ref(ContractModel): class Metrics(ContractModel): """Метрики обработки; все поля необязательны.""" - duration_ms: OptionalNotNull[int] = None - pages: OptionalNotNull[int] = None - rows: OptionalNotNull[int] = None - fields_total: OptionalNotNull[int] = None - fields_low_confidence: OptionalNotNull[int] = None - fields_unrecognized: OptionalNotNull[int] = None + 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 diff --git a/api/src/contract/request.py b/api/src/contract/request.py index c5eb3f8..5165080 100644 --- a/api/src/contract/request.py +++ b/api/src/contract/request.py @@ -9,7 +9,7 @@ from typing import Annotated, Literal 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 FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] @@ -24,4 +24,4 @@ class RecognitionRequest(ContractModel): original_name: NonEmptyStr # имя файла, как загрузил пользователь s3: S3Ref # исходный файл в роли uploads reply_deadline: UtcDatetime # срок ответа, UTC - attempt: Annotated[int, Field(ge=1)] # номер попытки, с 1 + attempt: Annotated[IntLike, Field(ge=1)] # номер попытки, с 1 diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 6e37dea..d591872 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -16,6 +16,8 @@ from src.contract.common import ( ContractModel, ContractUUID, DataType, + HeaderVariant, + IntLike, LaxErrorCode, Metrics, Money, @@ -65,12 +67,12 @@ class OsvHeader(ContractModel): organization_inn: str | None = None account_code_raw: str # «60», «58.03» period_text: str # «1 квартал 2025 г.» - period_year: int | None = None + period_year: IntLike | None = None period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) data_type: DataType | None = None currency: str | None = None - header_variant: OptionalNotNull[Literal[1, 2]] = None + header_variant: OptionalNotNull[HeaderVariant] = None class OsvGroup(ContractModel): @@ -82,9 +84,9 @@ class OsvGroup(ContractModel): class OsvRow(ContractModel): - row_number: int - level: int - parent_row_number: int | None = None + 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 @@ -102,8 +104,8 @@ class OsvRow(ContractModel): class OsvChecks(ContractModel): total_row_matches_root: OptionalNotNull[bool] = None - balance_identity_violations: OptionalNotNull[list[int]] = None # row_number, где opening−turnover≠closing - parent_sum_violations: OptionalNotNull[list[int]] = None + balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing + parent_sum_violations: OptionalNotNull[list[IntLike]] = None class OsvDocument(ContractModel): @@ -136,13 +138,13 @@ class StatementHeader(ContractModel): class StatementColumn(ContractModel): - column_index: int + column_index: IntLike label: str # «На 30 сентября 2025 г.» period_date: ContractDate | None = None class StatementValue(ContractModel): - column_index: int + column_index: IntLike amount: Money = None string_value: str | None = None confidence: Confidence = None diff --git a/api/tests/test_contract.py b/api/tests/test_contract.py index c992588..4d315f9 100644 --- a/api/tests/test_contract.py +++ b/api/tests/test_contract.py @@ -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-string", "request-tabular.json", _set("attempt", "1"), 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/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), 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/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-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-rows", "result-message-done.json", _set("metrics.rows", 10), 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, ), ("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-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), @@ -560,3 +633,16 @@ def test_request_subject_matches_example_pipeline() -> None: for name in ("request-tabular.json", "request-image.json"): request = RecognitionRequest.model_validate(_example(name)) 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)