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}),
|
||||
]
|
||||
|
||||
# --- целые -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user