From f448992e169775d57b01a92f3aede5338304e029 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:18:17 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[5.1]:=20=D0=BD?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=98=D0=A1=20?= =?UTF-8?q?(src/normalizer):=20Normalizer,=20NormalizeOutcome,=20NullNorma?= =?UTF-8?q?lizer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ошибка ИС пробрасывается с кодом из ответа, неизвестный код/вид ошибки -> UNKNOWN_ERROR с исходным представлением, успех -> NOT_IMPLEMENTED, не объект -> INVALID_FORMAT. Пакет добавлен в strict-список mypy. Co-Authored-By: Claude Fable 5.1 --- api/pyproject.toml | 2 +- api/src/normalizer/__init__.py | 108 +++++++ api/tests/unit/test_normalizer.py | 298 ++++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 5 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 api/src/normalizer/__init__.py create mode 100644 api/tests/unit/test_normalizer.py diff --git a/api/pyproject.toml b/api/pyproject.toml index 985539a..cea3ce9 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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 diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py new file mode 100644 index 0000000..3d96929 --- /dev/null +++ b/api/src/normalizer/__init__.py @@ -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) diff --git a/api/tests/unit/test_normalizer.py b/api/tests/unit/test_normalizer.py new file mode 100644 index 0000000..87d7b79 --- /dev/null +++ b/api/tests/unit/test_normalizer.py @@ -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": [], + } + ) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 793b24a..1888cbc 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -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` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 6544062..3ee86f0 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -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,