feat(normalizer): набор json ИС через arm_statements — JSON_FAILED, TABLE_PARSE_ERROR, статус конверта (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 19:58:40 +04:00
parent fa0f3e7a7f
commit 4acb0b83b8
5 changed files with 423 additions and 73 deletions

View File

@ -13,16 +13,28 @@
появится в Срезе 3 новой реализацией протокола `Normalizer`.
"""
from dataclasses import dataclass
from typing import Protocol, Self, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
from src.normalizer.files import (
BAD_ENTRY_MESSAGE,
NO_FILES_MESSAGE,
NOT_JSON_MESSAGE,
NOT_MERGED_MESSAGE,
NOT_OBJECT_MESSAGE,
FileError,
file_error,
)
__all__ = [
"BAD_ENTRY_MESSAGE",
"NOT_IMPLEMENTED_MESSAGE",
"NOT_JSON_MESSAGE",
"NOT_MERGED_MESSAGE",
"NOT_OBJECT_MESSAGE",
"NO_FILES_MESSAGE",
"NO_TABLES_MESSAGE",
"NormalizeOutcome",
"Normalizer",
@ -31,11 +43,6 @@ __all__ = [
]
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом"
NOT_JSON_MESSAGE = "ответ ИС не разбирается как JSON"
NOT_MERGED_MESSAGE = "склеенный ответ ИС не соответствует структуре {job_id, files: [...]}"
NO_FILES_MESSAGE = "в ответе ИС нет ни одного файла"
BAD_ENTRY_MESSAGE = "элемент files не соответствует структуре {name, response, parse_error}"
class NormalizeOutcome(BaseModel):
@ -82,71 +89,6 @@ class Normalizer(Protocol):
def normalize(self, raw: object) -> NormalizeOutcome: ...
@dataclass(frozen=True)
class _FileError:
"""Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`."""
name: str
code: ErrorCode # error.code сводки, если этот файл — первый ошибочный
source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT)
message: str # сообщение файла: строка warnings
summary: str # error.message сводки, если этот файл — первый ошибочный
@property
def warning(self) -> str:
return f"{self.name}: {self.source_code}: {self.message}"
def _invalid_file(name: str, message: str) -> _FileError:
code = ErrorCode.INVALID_FORMAT
return _FileError(name, code, code.value, message, message)
def _file_error(index: int, entry: object) -> _FileError | None:
"""Ошибка файла склейки или `None`, если файл успешен.
Успех — `response.error.code == ""` (реальный успех ИС: `error = {"code": "", "message": ""}`); отсутствующий,
`null` или не-объектный `error`, а также `error` без кода (`code` нет или `null`) ошибкой не считаются (№57).
Ошибочен файл с непустым `error.code`, с `parse_error`, с корнем ответа не-объектом (`INVALID_FORMAT`).
"""
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not entry["name"]:
return _invalid_file(f"files[{index}]", BAD_ENTRY_MESSAGE)
name: str = entry["name"]
parse_error = entry.get("parse_error")
if parse_error is not None:
return _invalid_file(name, f"{NOT_JSON_MESSAGE}: {parse_error}")
response = entry.get("response")
if not isinstance(response, dict):
return _invalid_file(name, NOT_OBJECT_MESSAGE)
raw_error = response.get("error")
if not isinstance(raw_error, dict) or raw_error.get("code") in (None, ""):
return None
return _response_error(name, raw_error)
def _response_error(name: str, raw_error: dict[str, object]) -> _FileError:
"""Ошибка ИС с непустым `error.code`: код контракта либо `UNKNOWN_ERROR` с исходным кодом в сообщении."""
raw_code = raw_error.get("code")
source_code = raw_code if isinstance(raw_code, str) else repr(raw_code)
raw_message = raw_error.get("message")
if isinstance(raw_message, str) and raw_message:
message = raw_message
else:
# пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть
note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})"
message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}"
try:
code = ErrorCode(raw_code) if isinstance(raw_code, str) else None
except ValueError:
code = None
if code is not None:
return _FileError(name, code, source_code, message, message)
# код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12)
summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}"
return _FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary)
class NullNormalizer:
"""Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет.
@ -161,10 +103,10 @@ class NullNormalizer:
files: list[object] = raw["files"]
if not files:
return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE)
errors: list[_FileError] = []
errors: list[FileError] = []
succeeded = 0
for index, entry in enumerate(files):
error = _file_error(index, entry)
error = file_error(index, entry)
if error is None:
succeeded += 1
else:
@ -182,5 +124,8 @@ def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = N
)
_file_error = file_error # прежнее имя: его берёт старый OcrNormalizer до задачи 4
# в конце модуля: `OcrNormalizer` берёт `NormalizeOutcome` и помощники ошибок из этого модуля лениво (цикл импортов)
from src.normalizer.ocr import NO_TABLES_MESSAGE, OcrNormalizer # noqa: E402

View File

@ -0,0 +1,84 @@
"""Ошибки файлов склейки ответа ИС (успех файла — `error.code == ""`, №57); общие для нормализаторов."""
from dataclasses import dataclass
from arm_statements import Rules, StatementWarning, WarningCode, make_warning
from src.contract import ErrorCode
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом"
NOT_JSON_MESSAGE = "ответ ИС не разбирается как JSON"
NOT_MERGED_MESSAGE = "склеенный ответ ИС не соответствует структуре {job_id, files: [...]}"
NO_FILES_MESSAGE = "в ответе ИС нет ни одного файла"
BAD_ENTRY_MESSAGE = "элемент files не соответствует структуре {name, response, parse_error}"
@dataclass(frozen=True)
class FileError:
"""Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`."""
name: str
code: ErrorCode # error.code сводки, если этот файл — первый ошибочный
source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT)
message: str # сообщение файла: строка warnings
summary: str # error.message сводки, если этот файл — первый ошибочный
@property
def warning(self) -> str:
"""Строка `warnings` прежнего формата (до задачи 4)."""
return f"{self.name}: {self.source_code}: {self.message}"
def to_warning(self, rules: Rules) -> StatementWarning:
"""Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил."""
return make_warning(
WarningCode.JSON_FAILED, f"{self.source_code}: {self.message}", rules, locator=f"file:{self.name}"
)
def invalid_file(name: str, message: str) -> FileError:
code = ErrorCode.INVALID_FORMAT
return FileError(name, code, code.value, message, message)
def file_error(index: int, entry: object) -> FileError | None:
"""Ошибка файла склейки или `None`, если файл успешен.
Успех — `response.error.code == ""` (реальный успех ИС: `error = {"code": "", "message": ""}`); отсутствующий,
`null` или не-объектный `error`, а также `error` без кода (`code` нет или `null`) ошибкой не считаются (№57).
Ошибочен файл с непустым `error.code`, с `parse_error`, с корнем ответа не-объектом (`INVALID_FORMAT`).
"""
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not entry["name"]:
return invalid_file(f"files[{index}]", BAD_ENTRY_MESSAGE)
name: str = entry["name"]
parse_error = entry.get("parse_error")
if parse_error is not None:
return invalid_file(name, f"{NOT_JSON_MESSAGE}: {parse_error}")
response = entry.get("response")
if not isinstance(response, dict):
return invalid_file(name, NOT_OBJECT_MESSAGE)
raw_error = response.get("error")
if not isinstance(raw_error, dict) or raw_error.get("code") in (None, ""):
return None
return response_error(name, raw_error)
def response_error(name: str, raw_error: dict[str, object]) -> FileError:
"""Ошибка ИС с непустым `error.code`: код контракта либо `UNKNOWN_ERROR` с исходным кодом в сообщении."""
raw_code = raw_error.get("code")
source_code = raw_code if isinstance(raw_code, str) else repr(raw_code)
raw_message = raw_error.get("message")
if isinstance(raw_message, str) and raw_message:
message = raw_message
else:
# пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть
note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})"
message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}"
try:
code = ErrorCode(raw_code) if isinstance(raw_code, str) else None
except ValueError:
code = None
if code is not None:
return FileError(name, code, source_code, message, message)
# код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12)
summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}"
return FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary)

View File

@ -0,0 +1,121 @@
"""Обход набора json ИС: документы по таблицам, предупреждения файла, статус и код ошибки набора.
Спека scan-gateway «Ошибки набора json», statement-normalization «Шаг 8 — статус документа и конверта».
Решения Р-32/Р-33: json без таблиц и «все json ошибочны» — `JSON_FAILED` на каждый файл; код и сообщение ошибки
набора — от первого ошибочного файла.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from arm_statements import (
EnvelopeStatus,
Rules,
StatementDocument,
StatementWarning,
WarningCode,
envelope_status,
make_warning,
normalize_candidate,
)
from src.contract import ErrorCode
from src.normalizer.files import NO_FILES_MESSAGE, NOT_MERGED_MESSAGE, FileError, file_error
from src.normalizer.ocr.adapter import header_cells, table_candidate
__all__ = ["NO_TABLES_MESSAGE", "AnswerSetResult", "SetError", "normalize_answer_set"]
NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"
NO_DATA_OCR_MESSAGE = "ответ ИС без data_ocr"
NO_TABLE_STRUCTURE_MESSAGE = "ответ ИС без table_structure"
EMPTY_TABLES_MESSAGE = "в ответе ИС нет таблиц"
EXCEPTION_TEXT_LIMIT = 300 # P5-9
@dataclass(frozen=True)
class SetError:
code: ErrorCode
message: str
@dataclass(frozen=True)
class AnswerSetResult:
"""`error is not None` тогда и только тогда, когда `status is ERROR`."""
status: EnvelopeStatus
documents: tuple[StatementDocument, ...]
file_warnings: tuple[StatementWarning, ...]
error: SetError | None
def _set_error(code: ErrorCode, message: str) -> AnswerSetResult:
return AnswerSetResult(EnvelopeStatus.ERROR, (), (), SetError(code, message))
def normalize_answer_set(raw: object, rules: Rules) -> AnswerSetResult:
if not isinstance(raw, dict) or not isinstance(raw.get("files"), list):
return _set_error(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE)
files: list[object] = raw["files"]
if not files:
return _set_error(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE)
documents: list[StatementDocument] = []
warnings: list[StatementWarning] = []
first_error: FileError | None = None
for index, entry in enumerate(files):
error = file_error(index, entry)
if error is not None:
warnings.append(error.to_warning(rules))
first_error = first_error or error
continue
assert isinstance(entry, dict) # проверено file_error
_normalize_file(entry, rules, documents, warnings)
status = envelope_status([d.status for d in documents], warnings)
if status is not EnvelopeStatus.ERROR:
return AnswerSetResult(status, tuple(documents), tuple(warnings), None)
if first_error is not None:
error_info = SetError(first_error.code, first_error.summary)
else:
error_info = SetError(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE)
return AnswerSetResult(status, (), tuple(warnings), error_info)
def _json_failed(name: str, message: str, rules: Rules) -> StatementWarning:
return make_warning(WarningCode.JSON_FAILED, message, rules, locator=f"file:{name}")
def _normalize_file(
entry: Mapping[str, object], rules: Rules, documents: list[StatementDocument], warnings: list[StatementWarning]
) -> None:
name = str(entry["name"])
response = entry["response"]
assert isinstance(response, dict) # проверено file_error
data_ocr = response.get("data_ocr")
if not isinstance(data_ocr, dict):
warnings.append(_json_failed(name, NO_DATA_OCR_MESSAGE, rules))
return
tables = data_ocr.get("table_structure")
if not isinstance(tables, list):
warnings.append(_json_failed(name, NO_TABLE_STRUCTURE_MESSAGE, rules))
return
if not tables:
warnings.append(_json_failed(name, EMPTY_TABLES_MESSAGE, rules))
return
header = header_cells(data_ocr)
for i, table in enumerate(tables):
try:
result = normalize_candidate(table_candidate(name, i, table, header, rules), rules)
except Exception as exc: # noqa: BLE001 -- изоляция разбора одной таблицы (P5-9)
text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}"
warnings.append(
make_warning(
WarningCode.TABLE_PARSE_ERROR,
f"BACKEND_PARSING_ERROR: {text}",
rules,
locator=f"file:{name};table:{i}",
)
)
continue
if result.document is not None:
documents.append(result.document)
warnings.extend(result.file_warnings)

View File

@ -0,0 +1,49 @@
"""Сборщики ответов ИС для тестов нормализатора (набор json, задачи 3, 4 и 6)."""
from typing import Any
OK = {"code": "", "message": ""}
BAD = {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}
HEAD = {
"organization": {"form_name": "ООО «Пример»", "confidence": 100.0},
"inn": {"form_name": "7700000000", "confidence": 100.0},
"units": {"form_name": "тыс. руб.", "confidence": 100.0},
"date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100.0},
}
BALANCE = [
("Наименование показателя", "Код", "На 31 декабря 2024 г."),
("Запасы", "1210", "60"),
("Дебиторы", "1230", "40"),
("Итого II", "1200", "100"),
("НМА", "1110", "50"),
("Итого I", "1100", "50"),
("БАЛАНС", "1600", "150"),
("Капитал", "1310", "150"),
("Итого III", "1300", "150"),
("БАЛАНС", "1700", "150"),
]
INCOME = [
("Наименование показателя", "Код", "За 2024 г."),
("Выручка", "2110", "100"),
("Себестоимость", "2120", "(60)"),
("Валовая прибыль", "2100", "40"),
("Прибыль от продаж", "2200", "40"),
]
def ocr_table(label: str, rows: list[tuple[str, ...]]) -> dict[str, Any]:
return {
"label": {"form_name": label, "confidence": 100.0},
"rows": [
{"row_index": i, "cells": [{"column_index": c, "value": v, "confidence": 100.0} for c, v in enumerate(r)]}
for i, r in enumerate(rows)
],
}
def response(*tables: dict[str, Any], error: dict[str, str] = OK, head: dict[str, Any] = HEAD) -> dict[str, Any]:
return {"file_uid": "x", "error": error, "data_ocr": {**head, "table_structure": list(tables)}}
def merged(*files: tuple[str, Any]) -> dict[str, Any]:
return {"job_id": "j", "files": [{"name": n, "response": r, "parse_error": None} for n, r in files]}

View File

@ -0,0 +1,151 @@
from datetime import date
from typing import Any
import pytest
import src.normalizer.ocr.answer_set as answer_set_module
from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules
from src.contract import ErrorCode
from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set
from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response
RULES = default_rules()
def test_one_json_two_forms_copy_header() -> None:
res = normalize_answer_set(
merged(
(
"j.json",
response(
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)
),
)
),
RULES,
)
assert [(d.locator, d.document_type, d.header.form_name) for d in res.documents] == [
("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"),
("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах"),
]
assert {d.header.inn.value for d in res.documents} == {"7700000000"}
assert res.documents[0].status is DocStatus.DONE and res.error is None
def test_lost_ofr_json_is_file_error() -> None:
res = normalize_answer_set(
merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD))),
RULES,
)
assert res.status is EnvelopeStatus.NEEDS_CHECK and len(res.documents) == 1
assert [(w.code, w.severity, w.locator, w.message) for w in res.file_warnings] == [
("JSON_FAILED", Severity.ERROR, "file:j_ofr.json", "INVALID_DOCUMENT: Не удалось определить тип документа.")
]
def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> None:
real = answer_set_module.normalize_candidate
def flaky(candidate: Any, rules: Any = None) -> Any:
if candidate.locator.endswith("table:1"):
raise RuntimeError("сломалось")
return real(candidate, rules)
monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky)
res = normalize_answer_set(
merged(
(
"j.json",
response(
ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME)
),
)
),
RULES,
)
assert [d.document_type for d in res.documents] == [DocumentType.BALANCE]
[w] = res.file_warnings
assert (w.code, w.severity, w.locator) == ("TABLE_PARSE_ERROR", Severity.ERROR, "file:j.json;table:1")
assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось"
assert res.status is EnvelopeStatus.NEEDS_CHECK
def test_broken_table_structure_is_table_parse_error() -> None:
res = normalize_answer_set(
merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), {"label": {}, "rows": "x"}))), RULES
)
assert [w.code for w in res.file_warnings] == ["TABLE_PARSE_ERROR"] and len(res.documents) == 1
def test_text_only_table_skipped_and_no_documents() -> None:
res = normalize_answer_set(merged(("j.json", response(ocr_table("", [("Руководитель", "Иванов")])))), RULES)
assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [
("TABLE_SKIPPED", Severity.INFO, "file:j.json;table:0")
]
assert res.status is EnvelopeStatus.ERROR
assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE)
def test_lost_table_with_numbers() -> None:
res = normalize_answer_set(
merged(
("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("", [("Итого", "", "15 000")])))
),
RULES,
)
assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [
("TABLE_LOST", Severity.ERROR, "file:j.json;table:1")
]
assert res.status is EnvelopeStatus.NEEDS_CHECK
def test_all_json_failed_first_code_and_all_listed() -> None:
res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")), RULES)
assert res.status is EnvelopeStatus.ERROR and res.documents == ()
assert (res.error.code, res.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"])
assert [(w.code, w.locator) for w in res.file_warnings] == [
("JSON_FAILED", "file:j.json"),
("JSON_FAILED", "file:j_ofr.json"),
]
@pytest.mark.parametrize(
("data_ocr", "message"),
[
(None, "ответ ИС без data_ocr"),
({**HEAD, "table_structure": None}, "ответ ИС без table_structure"),
({**HEAD, "table_structure": []}, "в ответе ИС нет таблиц"),
],
)
def test_successful_json_without_tables(data_ocr: object, message: str) -> None:
res = normalize_answer_set(merged(("j.json", {"error": OK, "data_ocr": data_ocr})), RULES)
assert [(w.code, w.severity, w.message) for w in res.file_warnings] == [("JSON_FAILED", Severity.ERROR, message)]
assert res.error is not None and res.error.code is ErrorCode.RECOGNITION_FAILED
@pytest.mark.parametrize("raw", [None, {"files": "x"}, {"job_id": "j"}])
def test_not_merged(raw: object) -> None:
res = normalize_answer_set(raw, RULES)
assert res.status is EnvelopeStatus.ERROR and res.error is not None
assert res.error.code is ErrorCode.INVALID_FORMAT
# Сквозной тест дат шлюза: строки OCR из test_parses_real_forms (правило — в пакете, план 1, задача 5)
@pytest.mark.parametrize(
("label", "expected"),
[
("На 30 июня\n2025 г", date(2025, 6, 30)),
("За январь - июнь\n2025 г.", date(2025, 6, 30)),
("30 сентября 2025 r.", date(2025, 9, 30)),
("3l.12.2О24", date(2024, 12, 31)),
("9 мес. 2025", date(2025, 9, 30)),
("30.09.2025 0:00:00", date(2025, 9, 30)),
],
)
def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None:
rows = [("Наименование показателя", "Код", label), *BALANCE[1:]]
head = {**HEAD, "date": {"form_name": "2|0|2|4|", "confidence": 75.0}}
res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows), head=head))), RULES)
doc = res.documents[0]
assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)]
assert doc.header.report_date.value == date(2024, 12, 31)