fix: целочисленные float (1.0) принимаются как целые, bool в header_variant отвергается
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0837e18a0a
commit
d7f6c280bb
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user