Срез 0 [5.1]: нормализатор ответа ИС (src/normalizer): Normalizer, NormalizeOutcome, NullNormalizer

Ошибка ИС пробрасывается с кодом из ответа, неизвестный код/вид ошибки -> UNKNOWN_ERROR с исходным
представлением, успех -> NOT_IMPLEMENTED, не объект -> INVALID_FORMAT. Пакет добавлен в strict-список mypy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 03:18:17 +04:00
parent 9ad1b88748
commit f448992e16
5 changed files with 409 additions and 3 deletions

View File

@ -25,7 +25,7 @@ explicit_package_bases = true
# Строгий режим для собственного кода сервиса (`src`). `strict = true` в overrides mypy (2.x) применяется глобально,
# а не к модулю, поэтому флаги семейства strict перечислены явно.
[[tool.mypy.overrides]]
module = ["src.contract.*", "src.gateway.*", "src.scan_emulator.*"]
module = ["src.contract.*", "src.gateway.*", "src.normalizer.*", "src.scan_emulator.*"]
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true

View File

@ -0,0 +1,108 @@
"""Нормализатор ответа ИС Сканирование (D-5, R-12, slice-0-skeleton).
Нормализатор получает УЖЕ разобранный JSON ответа ИС (`raw: object`): `json.loads` и отображение ошибки разбора в
`INVALID_FORMAT` (D-7) — забота воркера. Результат — `NormalizeOutcome`, из которого воркер собирает конверт
и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`.
В Срезе 0 единственная реализация — `NullNormalizer`: ошибка ИС пробрасывается с её кодом, успех -> `NOT_IMPLEMENTED`.
Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) появится в Срезе 3 новой реализацией протокола `Normalizer`.
"""
from typing import Protocol, Self, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
__all__ = ["NOT_IMPLEMENTED_MESSAGE", "NormalizeOutcome", "Normalizer", "NullNormalizer"]
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом"
class NormalizeOutcome(BaseModel):
"""Итог нормализации: статус, документы, ошибка, метрики.
Инварианты: `status == error` <=> `error` задана; для done/needs_check `documents` непусты (как в схеме файла
результата), для error — пусты. Статус `processing` исключён: он не бывает итогом.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
status: ResultStatus
documents: list[StatementDocument] = Field(default_factory=list)
error: MessageError | None = None
metrics: Metrics | None = None
@model_validator(mode="after")
def _check_invariants(self) -> Self:
if self.status is ResultStatus.ERROR:
if self.error is None:
raise ValueError("status=error требует error")
if self.documents:
raise ValueError("status=error не может содержать documents")
else:
if self.error is not None:
raise ValueError(f"status={self.status.value} не может содержать error")
if not self.documents:
raise ValueError(f"status={self.status.value} требует непустой documents")
return self
@runtime_checkable
class Normalizer(Protocol):
"""Превращает разобранный JSON-ответ ИС в `NormalizeOutcome`; не бросает исключений на любом входе."""
def normalize(self, raw: object) -> NormalizeOutcome: ...
def _error_outcome(code: ErrorCode, message: str) -> NormalizeOutcome:
return NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code=code, message=message))
def _is_response_error(raw_error: object) -> NormalizeOutcome:
"""Ошибка ИС -> исход с её кодом; всё нестандартное -> UNKNOWN_ERROR с исходным представлением."""
if not isinstance(raw_error, dict):
text = f"ИС Сканирование вернула ошибку неожиданного вида: {raw_error!r}"
return _error_outcome(ErrorCode.UNKNOWN_ERROR, text)
raw_code = raw_error.get("code")
raw_message = raw_error.get("message")
if isinstance(raw_message, str) and raw_message:
message: str | None = raw_message
message_note = ""
else:
message = None
# пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть
message_note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})"
code: ErrorCode | None = None
if isinstance(raw_code, str):
try:
code = ErrorCode(raw_code)
except ValueError:
code = None
if code is not None:
default = f"ИС Сканирование вернула ошибку {code.value} без сообщения{message_note}"
return _error_outcome(code, message or default)
if raw_code is None and "code" not in raw_error:
head = "ИС Сканирование вернула ошибку без кода"
else:
head = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}"
if message:
return _error_outcome(ErrorCode.UNKNOWN_ERROR, f"{head}: {message}")
# ни кода, ни сообщения: единственное, что можно сохранить, — исходный объект
return _error_outcome(ErrorCode.UNKNOWN_ERROR, f"{head}, сообщения нет: {raw_error!r}")
class NullNormalizer:
"""Заглушка Среза 0: ошибка ИС пробрасывается, успех — `NOT_IMPLEMENTED` (D-5)."""
def normalize(self, raw: object) -> NormalizeOutcome:
if not isinstance(raw, dict):
return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE)
raw_error = raw.get("error")
if raw_error is not None:
return _is_response_error(raw_error)
return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)

View File

@ -0,0 +1,298 @@
"""Нормализатор ответа ИС Сканирование (D-5, D-7, R-10, R-12, slice-0-skeleton).
Ошибки ИС — на копиях реальных `../fixtures/ocr/*.json` (маркер `fixtures`; обе реальные фикстуры — ответы с ошибкой
INVALID_DOCUMENT, O-1); успех (`NOT_IMPLEMENTED`) — на синтетическом ответе, собранном из `aep_5.json` (R-10).
"""
import copy
import json
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument
from src.normalizer import NormalizeOutcome, Normalizer, NullNormalizer
API_DIR = Path(__file__).resolve().parents[2]
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у test_emulator.py
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом"
def _real(name: str) -> dict[str, Any]:
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
loaded: dict[str, Any] = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))
return loaded
def _success_response() -> dict[str, Any]:
"""Синтетический успех ИС: форма `aep_5.json` без ошибки и с заполненными label и строками таблицы."""
return {
"file_uid": "aep_5",
"error": None,
"data_ocr": {
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.5},
"organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.0},
"inn": {"inn": "7701234567", "confidence": 99.0},
"units": {"measurement": "тыс. руб.", "confidence": 95.0},
"date": {"value": "30 сентября 2025 г.", "confidence": 96.0},
"table_structure": {
"rows": [
{
"row_index": 0,
"cells": [
{"column_index": 0, "value": "Наименование показателя", "confidence": 99.0},
{"column_index": 1, "value": "Код", "confidence": 99.0},
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 98.0},
],
},
{
"row_index": 1,
"cells": [
{"column_index": 0, "value": "Основные средства", "confidence": 97.0},
{"column_index": 1, "value": "1150", "confidence": 99.0},
{"column_index": 2, "value": "1 234", "confidence": 90.0},
],
},
]
},
},
}
def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | None = None) -> None:
assert outcome.status is ResultStatus.ERROR
assert outcome.error is not None
assert outcome.error.code is code
if message is not None:
assert outcome.error.message == message
assert outcome.documents == []
assert outcome.metrics is None
@pytest.mark.fixtures
class TestRealFixtures:
@pytest.mark.parametrize("name", ["aep_5.json", "aep_8_form4.json"])
def test_is_error_passes_through(self, name: str) -> None:
raw = _real(name)
assert raw["error"] is not None # O-1: обе реальные фикстуры — ответы с ошибкой
outcome = NullNormalizer().normalize(raw)
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, raw["error"]["message"])
assert outcome.error is not None
assert outcome.error.message == "Не удалось определить тип документа."
class TestIsError:
def test_known_code_and_message_verbatim(self) -> None:
raw = {"file_uid": "x", "error": {"code": "FILE_READ_ERROR", "message": "Файл повреждён: стр. 3 «пусто»"}}
_assert_error(NullNormalizer().normalize(raw), ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»")
@pytest.mark.parametrize("code", [c.value for c in ErrorCode])
def test_every_contract_code_passes(self, code: str) -> None:
raw = {"error": {"code": code, "message": "сообщение ИС"}}
_assert_error(NullNormalizer().normalize(raw), ErrorCode(code), "сообщение ИС")
def test_unknown_code_becomes_unknown_error_keeping_original(self) -> None:
raw = {"error": {"code": "QUOTA_EXCEEDED", "message": "Превышена квота"}}
outcome = NullNormalizer().normalize(raw)
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert "QUOTA_EXCEEDED" in outcome.error.message
assert "Превышена квота" in outcome.error.message
def test_lowercase_code_is_unknown(self) -> None:
outcome = NullNormalizer().normalize({"error": {"code": "invalid_document", "message": "m"}})
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert "invalid_document" in outcome.error.message
@pytest.mark.parametrize("error", ["Что-то сломалось", ["a", "b"], 42, True, {}, 0, ""])
def test_error_not_an_object_or_empty(self, error: object) -> None:
outcome = NullNormalizer().normalize({"error": error})
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert repr(error) in outcome.error.message
def test_error_string_message_kept(self) -> None:
outcome = NullNormalizer().normalize({"error": "Что-то сломалось"})
assert outcome.error is not None
assert "Что-то сломалось" in outcome.error.message
def test_missing_code(self) -> None:
outcome = NullNormalizer().normalize({"error": {"message": "Нет кода"}})
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert "Нет кода" in outcome.error.message
@pytest.mark.parametrize("code", [None, 5, ["INVALID_DOCUMENT"], {"a": 1}])
def test_code_not_a_string(self, code: object) -> None:
outcome = NullNormalizer().normalize({"error": {"code": code, "message": "m-text"}})
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert repr(code) in outcome.error.message
assert "m-text" in outcome.error.message
@pytest.mark.parametrize("error", [{"code": "INVALID_DOCUMENT"}, {"code": "INVALID_DOCUMENT", "message": None}])
def test_missing_message_gets_default(self, error: dict[str, Any]) -> None:
outcome = NullNormalizer().normalize({"error": error})
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT)
assert outcome.error is not None
assert outcome.error.message
assert "INVALID_DOCUMENT" in outcome.error.message
def test_empty_message_gets_default(self) -> None:
outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": ""}})
_assert_error(outcome, ErrorCode.UNREADABLE)
assert outcome.error is not None
assert outcome.error.message
def test_message_not_a_string_is_not_lost(self) -> None:
outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": {"detail": "x"}}})
_assert_error(outcome, ErrorCode.UNREADABLE)
assert outcome.error is not None
assert "{'detail': 'x'}" in outcome.error.message
def test_unknown_code_and_no_message(self) -> None:
outcome = NullNormalizer().normalize({"error": {"code": "WEIRD"}})
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None
assert "WEIRD" in outcome.error.message
def test_error_wins_over_data(self) -> None:
raw = _success_response()
raw["error"] = {"code": "RECOGNITION_FAILED", "message": "сбой"}
_assert_error(NullNormalizer().normalize(raw), ErrorCode.RECOGNITION_FAILED, "сбой")
def test_input_is_not_mutated(self) -> None:
raw = {"error": {"code": "WEIRD", "message": "m"}}
before = copy.deepcopy(raw)
NullNormalizer().normalize(raw)
assert raw == before
class TestSuccessIsNotImplemented:
def test_error_null(self) -> None:
outcome = NullNormalizer().normalize(_success_response())
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
def test_error_key_absent(self) -> None:
raw = _success_response()
del raw["error"]
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
def test_minimal_object(self) -> None:
_assert_error(NullNormalizer().normalize({}), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
@pytest.mark.fixtures
def test_real_fixture_with_error_removed(self) -> None:
raw = _real("aep_5.json")
raw["error"] = None
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE)
class TestNotAnObject:
@pytest.mark.parametrize("raw", [[], [{"error": None}], "строка", "", None, 0, 42, 1.5, True])
def test_invalid_format(self, raw: object) -> None:
_assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE)
class TestNormalizeOutcome:
def test_error_outcome(self) -> None:
outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
assert outcome.status is ResultStatus.ERROR
assert outcome.documents == []
assert outcome.metrics is None
def test_error_requires_error(self) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(status="error")
@pytest.mark.parametrize("status", ["done", "needs_check"])
def test_success_forbids_error(self, status: str) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(
status=status,
documents=[_statement()],
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
)
@pytest.mark.parametrize("status", ["done", "needs_check"])
def test_success_requires_documents(self, status: str) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(status=status)
@pytest.mark.parametrize("status", ["done", "needs_check"])
def test_success_outcome(self, status: str) -> None:
metrics = Metrics(pages=2, rows=10)
outcome = NormalizeOutcome(status=status, documents=[_statement()], metrics=metrics)
assert outcome.status is ResultStatus(status)
assert outcome.error is None
assert outcome.metrics == metrics
assert len(outcome.documents) == 1
def test_error_forbids_documents(self) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(
status="error",
documents=[_statement()],
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
)
def test_error_may_carry_metrics(self) -> None:
outcome = NormalizeOutcome(
status="error",
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
metrics=Metrics(pages=1),
)
assert outcome.metrics == Metrics(pages=1)
@pytest.mark.parametrize("status", ["processing", "ok", "", None])
def test_status_restricted(self, status: object) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(status=status, error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
def test_frozen(self) -> None:
outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"))
with pytest.raises(ValidationError):
outcome.status = ResultStatus.DONE
def test_unknown_field_forbidden(self) -> None:
with pytest.raises(ValidationError):
NormalizeOutcome(
status="error",
error=MessageError(code=ErrorCode.UNREADABLE, message="m"),
extra_field=1,
)
class TestProtocol:
def test_null_normalizer_is_normalizer(self) -> None:
assert isinstance(NullNormalizer(), Normalizer)
def test_object_without_normalize_is_not(self) -> None:
assert not isinstance(object(), Normalizer)
def test_custom_implementation_is_accepted(self) -> None:
class Custom:
def normalize(self, raw: object) -> NormalizeOutcome:
return NormalizeOutcome(status="done", documents=[_statement()])
normalizer: Normalizer = Custom()
assert normalizer.normalize({}).status is ResultStatus.DONE
def _statement() -> StatementDocument:
return StatementDocument.model_validate(
{
"document_type": "BALANCE",
"locator": "pages:1-2",
"header": {},
"columns": [],
"lines": [],
}
)

View File

@ -56,7 +56,7 @@
### Task 10: [5.1] `src/normalizer/__init__.py`
- [ ] `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`, синтетическом ответе с `error`; проверка: зелёный
- [x] `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`, синтетическом ответе с `error`; проверка: зелёный
### Task 11: [5.2] `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py`

View File

@ -44,7 +44,7 @@
## 5. Воркер и нормализатор-заглушка
- [ ] 5.1 `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из
- [x] 5.1 `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из
ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`,
синтетическом ответе с `error`; проверка: зелёный
- [ ] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys,