Срез 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:
parent
9ad1b88748
commit
f448992e16
@ -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
|
||||
|
||||
108
api/src/normalizer/__init__.py
Normal file
108
api/src/normalizer/__init__.py
Normal 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)
|
||||
298
api/tests/unit/test_normalizer.py
Normal file
298
api/tests/unit/test_normalizer.py
Normal 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": [],
|
||||
}
|
||||
)
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user