From 973cf2717f62cab5af0044bd86e9e31163c8163d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 14:27:07 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[7.2]:=20=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA=D0=B0=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=20=D0=98=D0=A1,=20=D1=81=D1=8B=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=20raw/=20=3D=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA=D0=B0,=20N?= =?UTF-8?q?ullNormalizer=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC,=20metrics.warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлена критическая ошибка: NullNormalizer считал ответ ошибкой при `error is not None`, а у реального успеха ИС error = {"code": "", "message": ""} — aep_3.json и aep_6.json давали UNKNOWN_ERROR. Успех файла теперь — error.code == "" (отсутствующий/null/не-объектный error и error без кода ошибкой не считаются); регрессия — на реальных фикстурах (маркер fixtures). - src/gateway/merge.py: merge_answers(job_id, files) -> MergedResponse {"job_id", "files": [{"name", "response", "parse_error"}]} по возрастанию имени; не JSON — parse_error файла; dump_merged — UTF-8 (с ASCII-экранированием при одиночном суррогате). - Воркер: опрос отдаёт готовый набор → склейка → raw/{job_id}.json (application/json) до нормализации → Normalizer.normalize(склейка). Перечитывание «не JSON» убрано (R-24). - NullNormalizer: все файлы ошибочны — код/сообщение первого по имени (parse_error и не-объект — INVALID_FORMAT, код вне контракта — UNKNOWN_ERROR с исходным кодом); есть успешный — NOT_IMPLEMENTED; ошибочные файлы — строки `<имя>: <код ИС>: <сообщение>`. - NormalizeOutcome.warnings (R-25) → metrics.warnings конверта и файла результата; пустые опускаются; не больше 100 строк по 1000 символов, лишние — сводной строкой (R-30). - Тесты: test_merge.py, test_normalizer.py переписан под склейку, test_worker.py — склейка в raw, ошибка одного файла, parse_error в warnings, все ошибочны, реальные aep_3+aep_3_ofr и aep_6, settle и таймаут ненастоявшегося набора; SUCCESS_ANSWER — в форме реального успеха. Интеграционный test_roundtrip.py минимально переведён на формат склейки (прогон — Task 5). Co-Authored-By: Claude Sonnet 5.5 --- api/src/core/worker.py | 84 ++++--- api/src/gateway/merge.py | 70 ++++++ api/src/normalizer/__init__.py | 150 +++++++---- api/tests/integration/test_roundtrip.py | 36 ++- api/tests/unit/test_merge.py | 101 ++++++++ api/tests/unit/test_normalizer.py | 320 +++++++++++++++--------- api/tests/unit/test_worker.py | 265 ++++++++++++++++++-- api/tests/worker_helpers.py | 29 ++- 8 files changed, 817 insertions(+), 238 deletions(-) create mode 100644 api/src/gateway/merge.py create mode 100644 api/tests/unit/test_merge.py diff --git a/api/src/core/worker.py b/api/src/core/worker.py index 4a7dd75..b5a8415 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -1,6 +1,7 @@ -"""Воркер scan-gateway: запрос NATS → ack → S3 → горячая папка → ответ ИС → S3 `raw/` → нормализатор → конверт. +"""Воркер scan-gateway: запрос NATS → ack → S3 → горячая папка → набор ответов ИС → склейка → S3 `raw/` → +нормализатор → конверт. -Spec image-recognition-gateway; design D-2, D-3, D-4, D-7; решения R-14..R-20. +Spec image-recognition-gateway; design D-2, D-3, D-4, D-5, D-7; решения R-14..R-20, R-24, R-25, R-30. - `on_request(msg)` — колбэк подписки NATS. Только разбирает и проверяет тело, регистрирует `job_id` и запускает задачу задания; ничего не ждёт (nats-py вызывает колбэки одной подписки последовательно, T11.4). @@ -48,10 +49,13 @@ from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM from src.core.config import Settings from src.core.nats import PublishFailed from src.gateway.hotfolder import AnswerFile, HotFolder, inbox_name -from src.normalizer import Normalizer +from src.gateway.merge import dump_merged, merge_answers +from src.normalizer import NormalizeOutcome, Normalizer __all__ = [ "MAX_ERROR_MESSAGE_CHARS", + "MAX_WARNINGS", + "MAX_WARNING_CHARS", "IncomingMessage", "ObjectStorage", "ResultPublisher", @@ -64,6 +68,10 @@ __all__ = [ # Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на # символ (T10.1). Длинное сообщение обрезается с многоточием. MAX_ERROR_MESSAGE_CHARS = 4000 +# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт +# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю +MAX_WARNINGS = 100 +MAX_WARNING_CHARS = 1000 # Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов MAX_FRAGMENT_CHARS = 300 _ELLIPSIS = "…" @@ -160,7 +168,7 @@ class _JobContext: @dataclass(frozen=True) class _WaitResult: - answer: bytes | None + answer: list[AnswerFile] | None polls: int failures: int last_error: BaseException | None @@ -519,17 +527,17 @@ class Worker: ) return name, self._clock() - async def _accept_answer( # noqa: PLR0911 - каждая ветка — строка таблицы D-7 - self, request: RecognitionRequest, answer: bytes, context: _JobContext + async def _accept_answer( + self, request: RecognitionRequest, answer: list[AnswerFile], context: _JobContext ) -> RecognitionResultMessage: - """Ответ ИС: сырой в S3, нормализатор, файл результата; конверт итога.""" + """Набор ответов ИС: склейка, сырой в S3, нормализатор, файл результата; конверт итога.""" job_id = str(request.job_id) - # 4. Сырой ответ в S3 как есть — до нормализации (D-4, R-18) - parsed, parse_error = _parse_json(answer) + # 4. Склейка всех файлов набора (не JSON — parse_error файла, R-24) и сырой ответ в S3 до нормализации (D-4) + merged = merge_answers(job_id, answer) key = raw_key(job_id) try: - await self._storage.put_bytes(key, answer, JSON_CONTENT_TYPE if parse_error is None else OCTET_STREAM) + await self._storage.put_bytes(key, dump_merged(merged), JSON_CONTENT_TYPE) except Exception as exc: logger.error( "Сырой ответ ИС не сохранён. job_id={job_id}, error={error}", job_id=job_id, error=_describe(exc) @@ -538,27 +546,21 @@ class Worker: request, ErrorCode.UNKNOWN_ERROR, f"сырой ответ ИС не сохранён в S3 ({key}): {_describe(exc)}" ) context.raw_s3_key = key - if parse_error is not None: - return self._error( - request, - ErrorCode.INVALID_FORMAT, - f"ответ ИС Сканирование не разбирается как JSON: {parse_error}", - raw_s3_key=key, - ) - # 5. Нормализатор (D-5); его исключение — UNKNOWN_ERROR с сохранённым сырым ответом + # 5. Нормализатор получает склеенный ответ (D-5); его исключение — UNKNOWN_ERROR с сохранённым сырым ответом try: - outcome = await asyncio.to_thread(self._normalizer.normalize, parsed) + outcome = await asyncio.to_thread(self._normalizer.normalize, merged) except Exception as exc: logger.exception("Нормализатор ответа ИС упал. job_id={job_id}", job_id=job_id) return self._error( request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key ) + metrics = _outcome_metrics(outcome, key) if outcome.status is ResultStatus.ERROR: error = outcome.error if error is None: # исключено инвариантом NormalizeOutcome raise ValueError("NormalizeOutcome со status=error без error") - return self._error(request, error.code, error.message, raw_s3_key=key, metrics=outcome.metrics) + return self._error(request, error.code, error.message, raw_s3_key=key, metrics=metrics) # 6. Успех: файл результата в S3, затем конверт с result_s3 result = RecognitionResult.model_validate( @@ -570,7 +572,7 @@ class Worker: "status": outcome.status, "raw_s3_key": key, "documents": list(outcome.documents), - **({"metrics": outcome.metrics} if outcome.metrics is not None else {}), + **({"metrics": metrics} if metrics is not None else {}), } ) target = result_key(request.file_id, request.job_id) @@ -593,25 +595,23 @@ class Worker: S3Ref(bucket=self._storage.bucket, key=target), needs_check=outcome.status is ResultStatus.NEEDS_CHECK, raw_s3_key=key, - metrics=outcome.metrics, + metrics=metrics, ) async def _wait_answer(self, job_id: str, deadline: datetime) -> _WaitResult: """Опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` до дедлайна (последний опрос — в момент дедлайна). - Исключения `poll` (SMB и прочие) — warning, опрос продолжается (R-14). Ответ не JSON может быть недописан - (ИС пишет на шару без атомарного переименования): он перечитывается через период опроса и принимается, когда - стал JSON или перестал меняться; на дедлайне берётся последний прочитанный. + Исключения `poll` (SMB и прочие) — warning, опрос продолжается (R-14). Первый готовый набор (`poll` отдаёт его + только после настаивания, D-1) принимается сразу: файл, не разобранный как JSON, не перечитывается — он + станет `parse_error` в склейке (R-24). Ненастоявшийся к дедлайну набор — `None` (таймаут). """ polls = failures = 0 last_error: BaseException | None = None - pending: bytes | None = None while True: polls += 1 - answer: bytes | None + answer: list[AnswerFile] | None try: - # переходно (Task 3): склейка всех файлов - answer = _first_answer(await asyncio.to_thread(self._hotfolder.poll, job_id)) + answer = await asyncio.to_thread(self._hotfolder.poll, job_id) except Exception as exc: failures += 1 last_error = exc @@ -621,22 +621,30 @@ class Worker: job_id=job_id, error=_describe(exc), ) - if answer is not None: - if answer == pending or _parse_json(answer)[1] is None: - return _WaitResult(answer, polls, failures, last_error) - pending = answer + if answer: + return _WaitResult(answer, polls, failures, last_error) now = self._clock() if now >= deadline: - return _WaitResult(pending, polls, failures, last_error) + return _WaitResult(None, polls, failures, last_error) await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds())) -def _first_answer(files: list[AnswerFile] | None) -> bytes | None: - """Байты первого (по имени) файла готового набора ответа: один файл — его байты, как раньше. +def _outcome_metrics(outcome: NormalizeOutcome, raw_s3_key: str) -> Metrics | None: + """`metrics` конверта: метрики исхода + `warnings` исхода (R-25) в конце `metrics.warnings`. - Переходно (Task 3): склейка всех файлов набора заменит этот выбор. + Пустые `warnings` опускаются. Строки безопасны для конверта (`clean_text`), их не больше `MAX_WARNINGS` по + `MAX_WARNING_CHARS` символов; лишние сводятся в последнюю строку со ссылкой на сырой ответ (R-30). """ - return files[0].data if files else None + metrics = outcome.metrics + lines = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] + if not lines: + return metrics + if len(lines) > MAX_WARNINGS: + hidden = len(lines) - (MAX_WARNINGS - 1) + lines = [*lines[: MAX_WARNINGS - 1], f"ещё предупреждений: {hidden}; все ошибки файлов — в {raw_s3_key}"] + warnings = [clean_text(line, MAX_WARNING_CHARS) for line in lines] + base = metrics.model_dump(exclude_none=True) if metrics is not None else {} + return Metrics.model_validate({**base, "warnings": warnings}) def _is_uuid(value: object) -> bool: diff --git a/api/src/gateway/merge.py b/api/src/gateway/merge.py new file mode 100644 index 0000000..c4c92be --- /dev/null +++ b/api/src/gateway/merge.py @@ -0,0 +1,70 @@ +"""Склейка набора файлов ответа ИС Сканирование в один ответ (D-4, OQ-78, R-24, решения №55, №60). + +ИС кладёт в `//` по одному `*.json` на распознанный документ; `HotFolder.poll` отдаёт готовый набор. +`merge_answers` разбирает каждый файл `json.loads` независимо и собирает +`{"job_id": str, "files": [{"name": str, "response": <разобранный json> | None, "parse_error": str | None}, ...]}` +по возрастанию имени. Файл, не разобранный как JSON, получает `response = None` и `parse_error` и не перечитывается: +к моменту склейки набор уже настоялся (R-24). Корень ответа не интерпретируется (не-объект — забота нормализатора). + +Склейка живёт на стороне транспорта (`src/gateway/`): её вход — файлы горячей папки, выход — сырой объект +`raw/{job_id}.json` и вход нормализатора; реализация нормализатора (Срез 3) подменяется, склейка — нет. +""" + +import json +from collections.abc import Iterable +from typing import TypedDict + +from src.gateway.hotfolder import AnswerFile + +__all__ = ["MergedFile", "MergedResponse", "dump_merged", "merge_answers"] + +# Предел текста `parse_error`: описание ошибки разбора, а не данные +_MAX_PARSE_ERROR_CHARS = 300 + + +class MergedFile(TypedDict): + """Один файл ответа в склейке: имя в каталоге ответа и разобранный JSON либо описание ошибки разбора.""" + + name: str + response: object + parse_error: str | None + + +class MergedResponse(TypedDict): + """Склеенный ответ ИС: объект `raw/{job_id}.json` и вход `Normalizer.normalize`.""" + + job_id: str + files: list[MergedFile] + + +def _parse(data: bytes) -> tuple[object, str | None]: + """(разобранный JSON, None) или (None, описание ошибки разбора).""" + try: + return json.loads(data), None + except (ValueError, RecursionError) as exc: # UnicodeDecodeError — подкласс ValueError + text = f"{type(exc).__name__}: {exc}" + if len(text) > _MAX_PARSE_ERROR_CHARS: + text = text[: _MAX_PARSE_ERROR_CHARS - 1] + "…" + return None, text + + +def merge_answers(job_id: str, files: Iterable[AnswerFile]) -> MergedResponse: + """Склеить набор файлов ответа: каждый разобран независимо, порядок — по возрастанию имени. Не бросает на + содержимом файлов.""" + merged: list[MergedFile] = [] + for answer in sorted(files, key=lambda item: item.name): + response, parse_error = _parse(answer.data) + merged.append({"name": answer.name, "response": response, "parse_error": parse_error}) + return {"job_id": job_id, "files": merged} + + +def dump_merged(merged: MergedResponse) -> bytes: + """Байты объекта `raw/{job_id}.json`: UTF-8 JSON с читаемой кириллицей. + + Одиночный суррогат (из `\\udXXX` в ответе ИС) в UTF-8 не кодируется — тогда объект пишется с экранированием + `\\uXXXX` (ASCII): он остаётся валидным JSON и разбирается в то же значение. + """ + try: + return json.dumps(merged, ensure_ascii=False).encode("utf-8") + except UnicodeEncodeError: + return json.dumps(merged, ensure_ascii=True).encode("ascii") diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py index 3d96929..2b2f180 100644 --- a/api/src/normalizer/__init__.py +++ b/api/src/normalizer/__init__.py @@ -1,30 +1,39 @@ -"""Нормализатор ответа ИС Сканирование (D-5, R-12, slice-0-skeleton). +"""Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; slice-0-skeleton). -Нормализатор получает УЖЕ разобранный JSON ответа ИС (`raw: object`): `json.loads` и отображение ошибки разбора в -`INVALID_FORMAT` (D-7) — забота воркера. Результат — `NormalizeOutcome`, из которого воркер собирает конверт -и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. +Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response", +"parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой +(`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт +(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. -В Срезе 0 единственная реализация — `NullNormalizer`: ошибка ИС пробрасывается с её кодом, успех -> `NOT_IMPLEMENTED`. -Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) появится в Срезе 3 новой реализацией протокола `Normalizer`. +В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`), +при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) +появится в Срезе 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 -__all__ = ["NOT_IMPLEMENTED_MESSAGE", "NormalizeOutcome", "Normalizer", "NullNormalizer"] +__all__ = ["NOT_IMPLEMENTED_MESSAGE", "NOT_OBJECT_MESSAGE", "NormalizeOutcome", "Normalizer", "NullNormalizer"] 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): - """Итог нормализации: статус, документы, ошибка, метрики. + """Итог нормализации: статус, документы, ошибка, метрики, предупреждения. - Инварианты: `status == error` <=> `error` задана; для done/needs_check `documents` непусты (как в схеме файла - результата), для error — пусты. Статус `processing` исключён: он не бывает итогом. + `warnings` — строки для `metrics.warnings` конверта (R-25; внутренний тип, контракт не меняется): воркер + опускает поле, если список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check + `documents` непусты (как в схеме файла результата), для error — пусты. Статус `processing` исключён: он не бывает + итогом. """ model_config = ConfigDict(frozen=True, extra="forbid") @@ -33,6 +42,7 @@ class NormalizeOutcome(BaseModel): documents: list[StatementDocument] = Field(default_factory=list) error: MessageError | None = None metrics: Metrics | None = None + warnings: list[str] = Field(default_factory=list) @model_validator(mode="after") def _check_invariants(self) -> Self: @@ -51,58 +61,106 @@ class NormalizeOutcome(BaseModel): @runtime_checkable class Normalizer(Protocol): - """Превращает разобранный JSON-ответ ИС в `NormalizeOutcome`; не бросает исключений на любом входе.""" + """Превращает склеенный ответ ИС (D-4) в `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)) +@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 _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) +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: str | None = raw_message - message_note = "" + message = raw_message else: - message = None # пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть - message_note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})" + note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})" + message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}" - code: ErrorCode | None = None - if isinstance(raw_code, str): - try: - code = ErrorCode(raw_code) - except ValueError: - code = None + try: + code = ErrorCode(raw_code) if isinstance(raw_code, str) else None + 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}") + 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: ошибка ИС пробрасывается, успех — `NOT_IMPLEMENTED` (D-5).""" + """Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет. + + Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по + возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл — + строка `warnings` `<имя>: <код ИС>: <сообщение>`. Структура склейки нарушена — `INVALID_FORMAT`. + """ 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) + if not isinstance(raw, dict) or not isinstance(raw.get("files"), list): + return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE) + files: list[object] = raw["files"] + if not files: + return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE) + errors: list[_FileError] = [] + succeeded = 0 + for index, entry in enumerate(files): + error = _file_error(index, entry) + if error is None: + succeeded += 1 + else: + errors.append(error) + warnings = [error.warning for error in errors] + if succeeded: + return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings) + first = errors[0] + return _error_outcome(first.code, first.summary, warnings) + + +def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = None) -> NormalizeOutcome: + return NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code=code, message=message), warnings=warnings or [] + ) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 9edb929..7a05b59 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -240,14 +240,17 @@ class Roundtrip: return body, envelopes[1] async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes) -> None: - """Итог `error` с `raw_s3_key`; сырой ответ в MinIO = ответ эмулятора; входящий файл = объект S3.""" + """Итог `error` с `raw_s3_key`; сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий = объект S3.""" job_id = body["job_id"] assert result.body["status"] == "error" assert result.body["file_id"] == body["file_id"] assert result.body["raw_s3_key"] == f"raw/{job_id}.json" - raw = await self.s3.get_bytes(f"raw/{job_id}.json") - assert raw == self.answer_path(job_id).read_bytes() # как есть, байт в байт - assert json.loads(raw)["file_uid"] == job_id + raw = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json")) + assert raw["job_id"] == job_id + first = raw["files"][0] + assert first["name"] == f"{job_id}.json" + assert first["response"] == json.loads(self.answer_path(job_id).read_bytes()) + assert first["response"]["file_uid"] == job_id stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json") assert stat.content_type == JSON_CONTENT_TYPE # результат-файл пишется только для done/needs_check @@ -265,7 +268,8 @@ def _write_fixtures(fixtures: Path) -> None: if REAL_FIXTURES.is_dir(): shutil.copy(_real_fixture("aep_5", ".json"), fixtures / "aep_5.json") template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8")) - success = {**template, "file_uid": "ok_doc", "error": None, "data_ocr": SUCCESS_DATA_OCR} + # успех — в форме реальных ответов ИС: `error = {"code": "", "message": ""}`, а не `null` (OQ-77) + success = {**template, "file_uid": "ok_doc", "error": {"code": "", "message": ""}, "data_ocr": SUCCESS_DATA_OCR} (fixtures / "ok_doc.json").write_text(json.dumps(success, ensure_ascii=False, indent=2), encoding="utf-8") @@ -384,7 +388,7 @@ async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(ro assert result.body["error"]["message"] == "Не удалось определить тип документа." await roundtrip.check_files(body, result, data) raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) - assert raw["error"] == expected_error + assert raw["files"][0]["response"]["error"] == expected_error # повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата await roundtrip.publish({**body, "attempt": 2}) @@ -401,9 +405,11 @@ async def test_success_answer_gives_not_implemented(roundtrip: Roundtrip) -> Non body, result = await roundtrip.run("ok_doc.pdf", data) job_id = body["job_id"] assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} + assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) - assert raw["error"] is None - assert raw["data_ocr"] == SUCCESS_DATA_OCR + [entry] = raw["files"] + assert entry["response"]["error"] == {"code": "", "message": ""} + assert entry["response"]["data_ocr"] == SUCCESS_DATA_OCR async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None: @@ -412,8 +418,14 @@ async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip) job_id = body["job_id"] error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"} assert result.body["error"] == error - assert json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) == { - "file_uid": job_id, - "error": error, - "data_ocr": [], + raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) + assert raw == { + "job_id": job_id, + "files": [ + { + "name": f"{job_id}.json", + "response": {"file_uid": job_id, "error": error, "data_ocr": []}, + "parse_error": None, + } + ], } diff --git a/api/tests/unit/test_merge.py b/api/tests/unit/test_merge.py new file mode 100644 index 0000000..f61575d --- /dev/null +++ b/api/tests/unit/test_merge.py @@ -0,0 +1,101 @@ +"""Склейка набора файлов ответа ИС в один ответ (spec image-recognition-gateway «Сырой ответ…», D-4, R-24). + +`merge_answers(job_id, files)` разбирает каждый файл `json.loads` независимо: не JSON — `response = null` и +`parse_error`, задание не падает; файлы — по возрастанию имени. `dump_merged` — байты объекта `raw/{job_id}.json`. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import dump_merged, merge_answers + +API_DIR = Path(__file__).resolve().parents[2] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +JOB = "3f1c2b7a-0000-4000-8000-000000000001" + + +def test_structure_for_one_file() -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"file_uid": "x", "error": {"code": "", "message": ""}}')]) + assert merged == { + "job_id": JOB, + "files": [ + { + "name": f"{JOB}.json", + "response": {"file_uid": "x", "error": {"code": "", "message": ""}}, + "parse_error": None, + } + ], + } + + +def test_files_are_ordered_by_name() -> None: + files = [ + AnswerFile(f"{JOB}_ofr.json", b'{"n": 2}'), + AnswerFile("0.json", b'{"n": 0}'), + AnswerFile(f"{JOB}.json", b'{"n": 1}'), + ] + merged = merge_answers(JOB, files) + assert [f["name"] for f in merged["files"]] == ["0.json", f"{JOB}.json", f"{JOB}_ofr.json"] + assert [f["response"] for f in merged["files"]] == [{"n": 0}, {"n": 1}, {"n": 2}] + + +@pytest.mark.parametrize( + "data", + [b"", b"not json", b'{"a": ', b"\xff\xfe", b"[" * 100_000], + ids=["empty", "text", "truncated", "bad_utf8", "deep"], +) +def test_invalid_json_becomes_parse_error_and_others_are_parsed(data: bytes) -> None: + files = [AnswerFile(f"{JOB}.json", b'{"ok": true}'), AnswerFile(f"{JOB}_ofr.json", data)] + merged = merge_answers(JOB, files) + first, second = merged["files"] + assert first == {"name": f"{JOB}.json", "response": {"ok": True}, "parse_error": None} + assert second["name"] == f"{JOB}_ofr.json" + assert second["response"] is None + assert isinstance(second["parse_error"], str) + assert second["parse_error"] + + +@pytest.mark.parametrize("data", [b"[]", b'"text"', b"42", b"null"]) +def test_valid_json_of_any_root_is_kept_as_is(data: bytes) -> None: + """Корень не объект — это забота нормализатора (`INVALID_FORMAT` файла), склейка хранит разобранное как есть.""" + [entry] = merge_answers(JOB, [AnswerFile(f"{JOB}.json", data)])["files"] + assert entry["response"] == json.loads(data) + assert entry["parse_error"] is None + + +def test_empty_set_gives_empty_files() -> None: + assert merge_answers(JOB, []) == {"job_id": JOB, "files": []} + + +def test_dump_is_utf8_json_that_round_trips() -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", '{"m": "Не удалось"}'.encode())]) + data = dump_merged(merged) + assert "Не удалось".encode() in data # кириллица читаема в S3, не \\uXXXX + assert json.loads(data) == merged + + +def test_dump_with_lone_surrogate_is_valid_utf8() -> None: + """`\\ud800` в ответе ИС разбирается в одиночный суррогат: объект всё равно записывается валидным UTF-8.""" + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"m": "bad \\ud800 tail"}')]) + data = dump_merged(merged) + data.decode("utf-8") + assert json.loads(data) == merged + + +@pytest.mark.fixtures +def test_real_fixtures_are_merged(tmp_path: Path) -> None: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + for name in ("aep_3.json", "aep_3_ofr.json"): + shutil.copy(REAL_FIXTURES / name, tmp_path / name) + files = [AnswerFile(p.name, p.read_bytes()) for p in sorted(tmp_path.iterdir())] + merged = merge_answers(JOB, files) + assert [f["name"] for f in merged["files"]] == ["aep_3.json", "aep_3_ofr.json"] + assert all(f["parse_error"] is None for f in merged["files"]) + assert merged["files"][0]["response"]["error"] == {"code": "", "message": ""} + assert merged["files"][1]["response"]["error"]["code"] == "INVALID_DOCUMENT" + assert json.loads(dump_merged(merged)) == merged diff --git a/api/tests/unit/test_normalizer.py b/api/tests/unit/test_normalizer.py index 87d7b79..7a751b6 100644 --- a/api/tests/unit/test_normalizer.py +++ b/api/tests/unit/test_normalizer.py @@ -1,7 +1,10 @@ -"""Нормализатор ответа ИС Сканирование (D-5, D-7, R-10, R-12, slice-0-skeleton). +"""Нормализатор ответа ИС Сканирование (spec image-recognition-gateway «Результат по контракту», D-5, R-12, R-25). -Ошибки ИС — на копиях реальных `../fixtures/ocr/*.json` (маркер `fixtures`; обе реальные фикстуры — ответы с ошибкой -INVALID_DOCUMENT, O-1); успех (`NOT_IMPLEMENTED`) — на синтетическом ответе, собранном из `aep_5.json` (R-10). +Вход `NullNormalizer` — склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}` (D-4). +Успех файла — `error.code == ""` (реальный успешный ответ несёт `error = {"code": "", "message": ""}`, а не `null`); +ошибочный файл — непустой `error.code`, `parse_error` или корень ответа не объект. Сводка: все файлы ошибочны — код +первого по имени; есть успешный — `NOT_IMPLEMENTED`; ошибочные файлы — строки `warnings`. +Реальные ответы `../fixtures/ocr/*.json` — маркер `fixtures`. """ import copy @@ -13,14 +16,16 @@ import pytest from pydantic import ValidationError from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument -from src.normalizer import NormalizeOutcome, Normalizer, NullNormalizer +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import merge_answers +from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, 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-объектом" +JOB = "3f1c2b7a-0000-4000-8000-000000000001" +REAL_MESSAGE = "Не удалось определить тип документа." +SUCCESS_ERROR = {"code": "", "message": ""} def _real(name: str) -> dict[str, Any]: @@ -30,39 +35,37 @@ def _real(name: str) -> dict[str, Any]: 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 _real_set(*names: str) -> dict[str, Any]: + """Склейка реальных ответов под именами каталога ответа: `aep_N.json` — `.json`, `aep_N_.json` — + `_.json`.""" + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + files = [] + for name in names: + suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2] + target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json" + files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes())) + return dict(merge_answers(JOB, files)) + + +def _file(name: str, response: object = None, parse_error: str | None = None) -> dict[str, Any]: + return {"name": name, "response": response, "parse_error": parse_error} + + +def _merged(*files: dict[str, Any]) -> dict[str, Any]: + return {"job_id": JOB, "files": list(files)} + + +def _one(response: object) -> dict[str, Any]: + return _merged(_file(f"{JOB}.json", response)) + + +def _success(**extra: Any) -> dict[str, Any]: + return {"file_uid": JOB, "error": dict(SUCCESS_ERROR), "data_ocr": {"table_structure": []}, **extra} + + +def _failure(code: object, message: object = "сообщение ИС") -> dict[str, Any]: + return {"file_uid": JOB, "error": {"code": code, "message": message}, "data_ocr": {"table_structure": []}} def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | None = None) -> None: @@ -75,130 +78,212 @@ def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | Non assert outcome.metrics is None +def _assert_not_implemented(outcome: NormalizeOutcome, warnings: list[str]) -> None: + _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) + assert outcome.warnings == warnings + + @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 == "Не удалось определить тип документа." + """Регрессия: реальные успешные ответы (`error = {"code": "", "message": ""}`) — не ошибка.""" + + @pytest.mark.parametrize("name", ["aep_6.json", "aep_3.json"]) + def test_real_success_is_not_an_error(self, name: str) -> None: + assert _real(name)["error"] == SUCCESS_ERROR + _assert_not_implemented(NullNormalizer().normalize(_real_set(name)), []) + + def test_aep_3_with_ofr_gives_not_implemented_and_one_warning(self) -> None: + outcome = NullNormalizer().normalize(_real_set("aep_3.json", "aep_3_ofr.json")) + _assert_not_implemented(outcome, [f"{JOB}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]) + + @pytest.mark.parametrize("name", ["aep_3_ofr.json", "aep_5.json", "aep_8_form4.json"]) + def test_real_error_alone_is_invalid_document(self, name: str) -> None: + merged = _real_set(name) + [entry] = merged["files"] + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, REAL_MESSAGE) + assert outcome.warnings == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"] -class TestIsError: +class TestFileSuccess: + """Успех файла — `error.code == ""`; отсутствующий/`null` `error` и `error` без кода ошибкой не считаются.""" + + def test_success_shape_of_real_response(self) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one(_success())), []) + + @pytest.mark.parametrize( + "error", + [None, {"code": None, "message": "m"}, {"message": "Нет кода"}, {}, "Что-то сломалось", ["a"], 42, 0, ""], + ids=["null", "code_null", "no_code", "empty_object", "string", "list", "int", "zero", "empty_string"], + ) + def test_no_error_code_is_success(self, error: object) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one(_success(error=error))), []) + + def test_error_key_absent(self) -> None: + response = _success() + del response["error"] + _assert_not_implemented(NullNormalizer().normalize(_one(response)), []) + + def test_minimal_object(self) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one({})), []) + + +class TestFileError: 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 «пусто»") + outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»"))) + _assert_error(outcome, ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»") + assert outcome.warnings == [f"{JOB}.json: 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), "сообщение ИС") + _assert_error(NullNormalizer().normalize(_one(_failure(code))), ErrorCode(code), "сообщение ИС") - def test_unknown_code_becomes_unknown_error_keeping_original(self) -> None: - raw = {"error": {"code": "QUOTA_EXCEEDED", "message": "Превышена квота"}} - outcome = NullNormalizer().normalize(raw) + def test_unknown_code_is_unknown_error_keeping_original(self) -> None: + outcome = NullNormalizer().normalize(_one(_failure("SOME_NEW_CODE", "Превышена квота"))) _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) assert outcome.error is not None - assert "QUOTA_EXCEEDED" in outcome.error.message + assert "SOME_NEW_CODE" in outcome.error.message assert "Превышена квота" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"] def test_lowercase_code_is_unknown(self) -> None: - outcome = NullNormalizer().normalize({"error": {"code": "invalid_document", "message": "m"}}) + outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m"))) _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) assert outcome.error is not None assert "invalid_document" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: invalid_document: m"] - @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"}}) + @pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True]) + def test_code_not_a_string_is_unknown_error(self, code: object) -> None: + outcome = NullNormalizer().normalize(_one(_failure(code, "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 + assert outcome.warnings == [f"{JOB}.json: {code!r}: m-text"] - @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}) + @pytest.mark.parametrize("message", [None, ""]) + def test_missing_or_empty_message_gets_default(self, message: object) -> None: + response = _failure("INVALID_DOCUMENT", message) + outcome = NullNormalizer().normalize(_one(response)) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT) assert outcome.error is not None - assert outcome.error.message assert "INVALID_DOCUMENT" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"] - def test_empty_message_gets_default(self) -> None: - outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": ""}}) + def test_message_absent_gets_default(self) -> None: + outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}})) _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"}}}) + outcome = NullNormalizer().normalize(_one(_failure("UNREADABLE", {"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"}}) + outcome = NullNormalizer().normalize(_one({"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, "сбой") + response = _success(error={"code": "RECOGNITION_FAILED", "message": "сбой"}) + _assert_error(NullNormalizer().normalize(_one(response)), ErrorCode.RECOGNITION_FAILED, "сбой") + + def test_parse_error_is_invalid_format(self) -> None: + """Сценарий «Все файлы не JSON».""" + merged = _merged(_file(f"{JOB}.json", None, "JSONDecodeError: Expecting value: line 1 column 1 (char 0)")) + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_FORMAT) + assert outcome.error is not None + assert "Expecting value" in outcome.error.message + [warning] = outcome.warnings + assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ") + assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning + + @pytest.mark.parametrize("response", [[], [{"error": None}], "строка", "", None, 0, 42, 1.5, True]) + def test_root_not_object_is_invalid_format(self, response: object) -> None: + """Сценарий «Корень ответа не объект».""" + outcome = NullNormalizer().normalize(_one(response)) + _assert_error(outcome, ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE) + assert outcome.warnings == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"] + + +class TestSummary: + def test_all_files_erroneous_takes_first_by_name(self) -> None: + """Сценарий «Все документы ошибочны»: первый — INVALID_DOCUMENT, второй — не JSON.""" + merged = _merged( + _file(f"{JOB}.json", _failure("INVALID_DOCUMENT", "нет типа")), + _file(f"{JOB}_ofr.json", None, "JSONDecodeError: Expecting value"), + ) + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа") + assert len(outcome.warnings) == 2 + assert outcome.warnings[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа" + assert outcome.warnings[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ") + + def test_first_erroneous_parse_error_gives_invalid_format(self) -> None: + merged = _merged( + _file(f"{JOB}.json", None, "JSONDecodeError: x"), + _file(f"{JOB}_ofr.json", _failure("INVALID_DOCUMENT")), + ) + _assert_error(NullNormalizer().normalize(merged), ErrorCode.INVALID_FORMAT) + + def test_one_success_gives_not_implemented_with_warnings_for_the_rest(self) -> None: + """Сценарий «Ошибка одного из документов».""" + merged = _merged( + _file(f"{JOB}.json", _success()), + _file(f"{JOB}_ofr.json", _failure("INVALID_DOCUMENT", "нет типа")), + _file(f"{JOB}_x.json", [1]), + ) + _assert_not_implemented( + NullNormalizer().normalize(merged), + [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет типа", f"{JOB}_x.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"], + ) + + def test_success_not_first_still_not_implemented(self) -> None: + merged = _merged(_file("a.json", _failure("UNREADABLE", "u")), _file("b.json", _success())) + _assert_not_implemented(NullNormalizer().normalize(merged), ["a.json: UNREADABLE: u"]) + + def test_several_successes_no_warnings(self) -> None: + merged = _merged(_file("a.json", _success()), _file("b.json", _success(error=None))) + _assert_not_implemented(NullNormalizer().normalize(merged), []) def test_input_is_not_mutated(self) -> None: - raw = {"error": {"code": "WEIRD", "message": "m"}} - before = copy.deepcopy(raw) - NullNormalizer().normalize(raw) - assert raw == before + merged = _merged(_file("a.json", _failure("WEIRD", "m")), _file("b.json", None, "e")) + before = copy.deepcopy(merged) + NullNormalizer().normalize(merged) + assert merged == before -class TestSuccessIsNotImplemented: - def test_error_null(self) -> None: - outcome = NullNormalizer().normalize(_success_response()) - _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) +class TestMergedStructure: + @pytest.mark.parametrize("raw", [[], "строка", None, 0, True]) + def test_not_an_object_is_invalid_format(self, raw: object) -> None: + _assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT) - def test_error_key_absent(self) -> None: - raw = _success_response() - del raw["error"] - _assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) + @pytest.mark.parametrize( + "raw", [{}, {"job_id": JOB}, {"job_id": JOB, "files": None}, {"job_id": JOB, "files": {"a.json": {}}}] + ) + def test_files_not_a_list_is_invalid_format(self, raw: object) -> None: + _assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT) - def test_minimal_object(self) -> None: - _assert_error(NullNormalizer().normalize({}), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) + def test_no_files_is_invalid_format(self) -> None: + _assert_error(NullNormalizer().normalize(_merged()), ErrorCode.INVALID_FORMAT) - @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) + @pytest.mark.parametrize("entry", [None, "a.json", [], {"response": _success()}]) + def test_malformed_entry_is_erroneous_file(self, entry: object) -> None: + merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]} + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED) + [warning] = outcome.warnings + assert warning.startswith("files[0]: INVALID_FORMAT: ") - -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) + def test_real_merge_output_is_accepted(self) -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", json.dumps(_success()).encode())]) + _assert_not_implemented(NullNormalizer().normalize(merged), []) class TestNormalizeOutcome: @@ -207,6 +292,13 @@ class TestNormalizeOutcome: assert outcome.status is ResultStatus.ERROR assert outcome.documents == [] assert outcome.metrics is None + assert outcome.warnings == [] + + def test_warnings_are_kept(self) -> None: + outcome = NormalizeOutcome( + status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), warnings=["a: B: c"] + ) + assert outcome.warnings == ["a: B: c"] def test_error_requires_error(self) -> None: with pytest.raises(ValidationError): diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index ce631ed..c6fc0ce 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -19,22 +19,25 @@ from jsonschema import Draft202012Validator, FormatChecker from src.contract import ( ErrorCode, MessageError, + Metrics, ResultStatus, StatementDocument, ) -from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM -from src.core.worker import MAX_ERROR_MESSAGE_CHARS +from src.core.clients import JSON_CONTENT_TYPE +from src.core.worker import MAX_ERROR_MESSAGE_CHARS, MAX_WARNING_CHARS, MAX_WARNINGS from src.gateway.hotfolder import AnswerFile -from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NormalizeOutcome +from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome from tests.worker_helpers import ( API_DIR, BUCKET, CONTRACTS_DIR, PDF_BYTES, SUCCESS_ANSWER, + Env, EnvFactory, emulator, eventually, + merged_raw, ) # `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в тестах эмулятора @@ -44,6 +47,29 @@ RESULT_VALIDATOR = Draft202012Validator( format_checker=FormatChecker(), ) STATEMENT_EXAMPLE = json.loads((CONTRACTS_DIR / "examples" / "result-statement-minimal.json").read_text("utf-8")) +REAL_MESSAGE = "Не удалось определить тип документа." +INVALID_DOCUMENT_ANSWER = { + "file_uid": "x", + "error": {"code": "INVALID_DOCUMENT", "message": REAL_MESSAGE}, + "data_ocr": {"table_structure": []}, +} + + +def _json(obj: object) -> bytes: + return json.dumps(obj, ensure_ascii=False).encode("utf-8") + + +def _raw(env: Env, job_id: str) -> Any: + """Склеенный сырой ответ из S3: всегда валидный JSON с типом application/json.""" + key = (BUCKET, f"raw/{job_id}.json") + assert env.storage.content_types[key] == JSON_CONTENT_TYPE + return json.loads(env.storage.objects[key]) + + +def _real_fixture(name: str) -> bytes: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + return (REAL_FIXTURES / name).read_bytes() # Логи воркера перехватываются (`log_lines` из conftest): вывод pytest чистый @@ -168,10 +194,10 @@ async def test_success_answer_gives_not_implemented_with_raw_key(make_env: EnvFa assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE assert body["file_id"] == request["file_id"] assert [mid for mid, _ in env.published()] == [f"ack:{job_id}:3", f"result:{job_id}:3"] - raw = env.storage.objects[(BUCKET, f"raw/{job_id}.json")] - assert raw == env.local.answer_path(job_id).read_bytes() # как есть, байт в байт - assert json.loads(raw) == {**SUCCESS_ANSWER, "file_uid": job_id} - assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == JSON_CONTENT_TYPE + assert "metrics" not in body # ошибочных файлов нет — warnings опущено + answer = json.loads(env.local.answer_path(job_id).read_bytes()) + assert answer["error"] == {"code": "", "message": ""} # успех в форме реального ответа ИС + assert _raw(env, job_id) == merged_raw(job_id, (f"{job_id}.json", answer, None)) # сырой ответ сохранён раньше публикации результата assert env.events.index(("s3_put", f"raw/{job_id}.json")) < env.events.index(("publish", f"result:{job_id}:3")) assert env.inbox() == [f"{job_id}_ok_doc.pdf.done"] @@ -192,7 +218,9 @@ async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_pa body = await env.run(request) assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert body["error"]["message"] == expected["message"] - assert (BUCKET, f"raw/{request['job_id']}.json") in env.storage.objects + job_id = request["job_id"] + assert body["metrics"] == {"warnings": [f"{job_id}.json: INVALID_DOCUMENT: {expected['message']}"]} + assert _raw(env, job_id)["files"][0]["response"]["error"] == expected async def test_unknown_name_gets_emulator_invalid_document(make_env: EnvFactory) -> None: @@ -228,7 +256,9 @@ async def test_successful_outcome_writes_result_file(make_env: EnvFactory, statu assert result["raw_s3_key"] == f"raw/{job_id}.json" assert result["documents"] == STATEMENT_EXAMPLE["documents"] assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE - assert normalizer.calls == [{**SUCCESS_ANSWER, "file_uid": job_id}] + # нормализатор получает склеенный ответ — тот же объект, что записан в raw/ + assert normalizer.calls == [merged_raw(job_id, (f"{job_id}.json", {**SUCCESS_ANSWER, "file_uid": job_id}, None))] + assert normalizer.calls == [_raw(env, job_id)] assert env.events.index(("s3_put", key)) < env.events.index(("publish", f"result:{job_id}:1")) @@ -268,39 +298,219 @@ async def test_raw_write_failure_is_unknown_error_without_raw_key(make_env: EnvF assert normalizer.calls == [] # без сохранённого сырого ответа нормализатор не вызывается (D-4) -async def test_answer_not_json_is_invalid_format_and_raw_stored_as_is(make_env: EnvFactory) -> None: +async def test_answer_not_json_is_parse_error_and_invalid_format(make_env: EnvFactory) -> None: + """Сценарий «Все файлы не JSON»: файл — `parse_error` в склейке, задание заканчивается `INVALID_FORMAT`.""" env = await make_env() request = env.request() job_id = request["job_id"] - garbage = b"\xff\xfenot json" await env.deliver(request) await eventually(lambda: len(env.hotfolder.puts) == 1) - env.answer(job_id, garbage) + env.answer(job_id, b"\xff\xfenot json") await asyncio.wait_for(env.worker.wait_idle(), 5) [(_, body)] = env.results(job_id) assert_error(body, ErrorCode.INVALID_FORMAT, raw=True) - assert env.storage.objects[(BUCKET, f"raw/{job_id}.json")] == garbage - assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == OCTET_STREAM + [entry] = _raw(env, job_id)["files"] + assert entry["name"] == f"{job_id}.json" + assert entry["response"] is None + assert entry["parse_error"] + [warning] = body["metrics"]["warnings"] + assert warning.startswith(f"{job_id}.json: INVALID_FORMAT: ") + assert entry["parse_error"] in warning -async def test_partially_written_answer_is_reread(make_env: EnvFactory) -> None: - """ИС пишет ответ не атомарно (SMB): недописанный JSON перечитывается через период опроса.""" +async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None: + """R-24: набор уже настоялся — недописанный JSON не перечитывается, а становится `parse_error` файла.""" env = await make_env() - full = json.dumps({**SUCCESS_ANSWER, "file_uid": "x"}).encode() + full = _json({**SUCCESS_ANSWER, "file_uid": "x"}) env.hotfolder.poll_script = [None, [AnswerFile("x.json", full[:10])], [AnswerFile("x.json", full)]] body = await env.run(env.request()) - assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) - assert env.storage.objects[(BUCKET, f"raw/{body['job_id']}.json")] == full + assert_error(body, ErrorCode.INVALID_FORMAT, raw=True) + assert env.hotfolder.polls == 2 + [entry] = _raw(env, body["job_id"])["files"] + assert entry["response"] is None + assert entry["parse_error"] -async def test_several_answer_files_first_by_name_is_used(make_env: EnvFactory) -> None: - """Переходно (Task 3 заменит склейкой всех файлов): из готового набора берётся первый файл (по имени).""" +async def test_merged_raw_contains_every_file_by_name(make_env: EnvFactory) -> None: + """Сценарий «Склеенный сырой ответ»: оба файла в `raw/`, по возрастанию имени, нормализатор получает склейку.""" + normalizer = StubNormalizer( + NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="NOT_IMPLEMENTED", message="x")) + ) + env = await make_env(normalizer=normalizer) + request = env.request() + job_id = request["job_id"] + first = {**SUCCESS_ANSWER, "file_uid": job_id} + second = {**INVALID_DOCUMENT_ANSWER, "file_uid": job_id} + env.hotfolder.poll_script = [ + [AnswerFile(f"{job_id}.json", _json(first)), AnswerFile(f"{job_id}_ofr.json", _json(second))] + ] + await env.run(request) + expected = merged_raw(job_id, (f"{job_id}.json", first, None), (f"{job_id}_ofr.json", second, None)) + assert _raw(env, job_id) == expected + assert normalizer.calls == [expected] + assert env.events.index(("s3_put", f"raw/{job_id}.json")) < env.events.index(("publish", f"result:{job_id}:1")) + + +async def test_one_erroneous_file_does_not_fail_the_job(make_env: EnvFactory) -> None: + """Сценарий «Ошибка одного из документов»: `NOT_IMPLEMENTED`, ровно одна строка warnings про `_ofr`.""" env = await make_env() - first = json.dumps({**SUCCESS_ANSWER, "file_uid": "x"}).encode() - env.hotfolder.poll_script = [[AnswerFile("x.json", first), AnswerFile("x_ofr.json", b'{"file_uid": "second"}')]] - body = await env.run(env.request()) + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [ + AnswerFile(f"{job_id}.json", _json(SUCCESS_ANSWER)), + AnswerFile(f"{job_id}_ofr.json", _json(INVALID_DOCUMENT_ANSWER)), + ] + ] + body = await env.run(request) assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) - assert env.storage.objects[(BUCKET, f"raw/{body['job_id']}.json")] == first + assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE + assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + + +async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [ + AnswerFile(f"{job_id}.json", _json(SUCCESS_ANSWER)), + AnswerFile(f"{job_id}_bad.json", b"{not json"), + AnswerFile(f"{job_id}_list.json", b"[1, 2]"), + ] + ] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + bad, listed = body["metrics"]["warnings"] + assert bad.startswith(f"{job_id}_bad.json: INVALID_FORMAT: ") + assert listed == f"{job_id}_list.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}" + files = _raw(env, job_id)["files"] + assert [f["name"] for f in files] == [f"{job_id}.json", f"{job_id}_bad.json", f"{job_id}_list.json"] + assert files[1]["response"] is None and files[1]["parse_error"] + assert files[2] == {"name": f"{job_id}_list.json", "response": [1, 2], "parse_error": None} + + +async def test_all_files_erroneous_take_code_of_first(make_env: EnvFactory) -> None: + """Сценарий «Все документы ошибочны»: код первого по имени, две строки warnings, не-JSON — INVALID_FORMAT.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [AnswerFile(f"{job_id}.json", _json(INVALID_DOCUMENT_ANSWER)), AnswerFile(f"{job_id}_ofr.json", b"garbage")] + ] + body = await env.run(request) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + assert body["error"]["message"] == REAL_MESSAGE + first, second = body["metrics"]["warnings"] + assert first == f"{job_id}.json: INVALID_DOCUMENT: {REAL_MESSAGE}" + assert second.startswith(f"{job_id}_ofr.json: INVALID_FORMAT: ") + + +async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + job_id = request["job_id"] + answer = {"file_uid": job_id, "error": {"code": "SOME_NEW_CODE", "message": "новое"}, "data_ocr": {}} + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", _json(answer))]] + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) + assert "SOME_NEW_CODE" in body["error"]["message"] + assert body["metrics"] == {"warnings": [f"{job_id}.json: SOME_NEW_CODE: новое"]} + + +@pytest.mark.fixtures +async def test_real_aep_6_success_is_not_implemented_without_warnings(make_env: EnvFactory) -> None: + """Регрессия: реальный успешный ответ `aep_6.json` (`error = {"code": "", "message": ""}`) — не ошибка ИС.""" + data = _real_fixture("aep_6.json") + env = await make_env() + request = env.request(original_name="aep_6.pdf") + job_id = request["job_id"] + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", data)]] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE + assert "metrics" not in body + assert _raw(env, job_id) == merged_raw(job_id, (f"{job_id}.json", json.loads(data), None)) + + +@pytest.mark.fixtures +async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env: EnvFactory) -> None: + main, ofr = _real_fixture("aep_3.json"), _real_fixture("aep_3_ofr.json") + env = await make_env() + request = env.request(original_name="aep_3.pdf") + job_id = request["job_id"] + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", main), AnswerFile(f"{job_id}_ofr.json", ofr)]] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + assert _raw(env, job_id) == merged_raw( + job_id, (f"{job_id}.json", json.loads(main), None), (f"{job_id}_ofr.json", json.loads(ofr), None) + ) + + +@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) +async def test_outcome_warnings_join_metrics_in_envelope_and_result(make_env: EnvFactory, status: ResultStatus) -> None: + document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) + outcome = NormalizeOutcome( + status=status, documents=[document], metrics=Metrics(pages=2, warnings=["свои"]), warnings=["a.json: X: y"] + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert body["metrics"] == {"pages": 2, "warnings": ["свои", "a.json: X: y"]} + result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")]) + RESULT_VALIDATOR.validate(result) + assert result["metrics"] == body["metrics"] + + +async def test_error_outcome_without_warnings_keeps_its_metrics(make_env: EnvFactory) -> None: + outcome = NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code="UNREADABLE", message="x"), metrics=Metrics(pages=1) + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + async with emulator(env): + body = await env.run(env.request()) + assert body["metrics"] == {"pages": 1} + + +async def test_huge_warnings_are_bounded(make_env: EnvFactory) -> None: + """R-30: число строк warnings и длина каждой ограничены — конверт остаётся меньше лимита NATS.""" + lines = [f"f{i}.json: INVALID_DOCUMENT: " + "я" * 50_000 for i in range(MAX_WARNINGS * 3)] + outcome = NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=lines + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + async with emulator(env): + body = await env.run(env.request()) + warnings = body["metrics"]["warnings"] + assert len(warnings) == MAX_WARNINGS + assert all(len(line) <= MAX_WARNING_CHARS for line in warnings) + assert warnings[0].startswith("f0.json: INVALID_DOCUMENT: я") + assert warnings[-2].startswith(f"f{MAX_WARNINGS - 2}.json") + assert str(MAX_WARNINGS * 3 - (MAX_WARNINGS - 1)) in warnings[-1] + assert f"raw/{body['job_id']}.json" in warnings[-1] + assert all(len(payload) < 900_000 for _, payload, _ in env.client.published) + + +async def test_answer_ready_only_after_settle(make_env: EnvFactory) -> None: + """Сценарий «Ответ из одного файла» с настоящим settle: ответ принимается после стабилизации набора.""" + env = await make_env(SCAN_SETTLE_SECONDS=0.05) + async with emulator(env): + body = await env.run(env.request()) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert env.hotfolder.polls >= 2 # первый опрос, увидевший набор, его ещё не отдаёт + + +async def test_set_not_settled_before_deadline_is_timeout(make_env: EnvFactory) -> None: + """Сценарий «Набор не успел настояться до дедлайна»: `*.json` есть, но не настоялся — `TIMEOUT`, без raw.""" + env = await make_env(SCAN_SETTLE_SECONDS=30, SCAN_TIMEOUT_SECONDS=0.2) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert env.local.answer_path(request["job_id"]).exists() + assert (BUCKET, f"raw/{request['job_id']}.json") not in env.storage.objects # --- таймаут ------------------------------------------------------------------------------------------------------ @@ -576,6 +786,11 @@ async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert body["error"]["message"].startswith("bad ") assert body["error"]["message"].endswith(" tail") + [warning] = body["metrics"]["warnings"] + assert warning.startswith(f"{job_id}.json: INVALID_DOCUMENT: bad ") + assert warning.endswith(" tail") + # сырой ответ записан валидным JSON, суррогат сохранён экранированным + assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail" async def test_lone_surrogate_in_original_name_is_schema_mismatch(make_env: EnvFactory) -> None: diff --git a/api/tests/worker_helpers.py b/api/tests/worker_helpers.py index 502fe44..74f4df8 100644 --- a/api/tests/worker_helpers.py +++ b/api/tests/worker_helpers.py @@ -39,21 +39,44 @@ MESSAGE_VALIDATOR = Draft202012Validator( format_checker=FormatChecker(), ) -# Синтетический успешный ответ ИС (R-10): форма `fixtures/ocr/aep_5.json`, `error = null`, непустой `data_ocr` +# Успешный ответ ИС в форме реальных `fixtures/ocr/aep_3.json`, `aep_6.json` (OQ-77): успех — `error.code == ""`, +# `error` — объект `{"code": "", "message": ""}`, а не `null`; `table_structure` — список таблиц с `label` и `rows` SUCCESS_ANSWER: dict[str, Any] = { "file_uid": "ok_doc", - "error": None, + "error": {"code": "", "message": ""}, "data_ocr": { "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0}, "organization": {"form_name": "ООО Ромашка", "confidence": 97.5}, "inn": {"form_name": "7700000000", "confidence": 99.0}, "units": {"form_name": "тыс. руб.", "confidence": 95.0}, "date": {"form_name": "30.09.2025", "confidence": 96.0}, - "table_structure": [{"line_code": "1600", "line_name": "БАЛАНС", "values": ["5678", "-12.3"]}], + "table_structure": [ + { + "label": {"form_name": "Бухгалтерский баланс", "confidence": 99.0}, + "rows": [ + { + "row_index": 0, + "cells": [ + {"column_index": 0, "value": "БАЛАНС", "confidence": 97.0}, + {"column_index": 1, "value": "1600", "confidence": 99.0}, + {"column_index": 2, "value": "5678", "confidence": 96.0}, + ], + } + ], + } + ], }, } +def merged_raw(job_id: str, *files: tuple[str, object, str | None]) -> dict[str, Any]: + """Ожидаемый склеенный ответ `raw/{job_id}.json` (D-4): `(имя, response, parse_error)` по возрастанию имени.""" + return { + "job_id": job_id, + "files": [{"name": name, "response": response, "parse_error": error} for name, response, error in files], + } + + # --- фейки -------------------------------------------------------------------------------------------------------- class FakeStorage: """S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`."""