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}),
]
# --- целые -----------------------------------------------------------------------------------------------------------
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

View File

@ -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

View File

@ -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

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-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)