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:
parent
fa0f3e7a7f
commit
4acb0b83b8
@ -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
|
||||
|
||||
84
api/src/normalizer/files.py
Normal file
84
api/src/normalizer/files.py
Normal 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)
|
||||
121
api/src/normalizer/ocr/answer_set.py
Normal file
121
api/src/normalizer/ocr/answer_set.py
Normal 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)
|
||||
49
api/tests/unit/normalizer/ocr_builders.py
Normal file
49
api/tests/unit/normalizer/ocr_builders.py
Normal 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]}
|
||||
151
api/tests/unit/normalizer/test_answer_set.py
Normal file
151
api/tests/unit/normalizer/test_answer_set.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user