Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""Обход набора 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
|
||
succeeded = 0
|
||
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
|
||
succeeded += 1
|
||
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 and not succeeded: # Р-33: код первого ошибочного, только когда все json ошибочны
|
||
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)
|