Срез 0: скелет scan-gateway — без фронта и БД, горячие папки local/smb, воркер NATS без JetStream, эмулятор ИС Сканирование #3

Merged
aep merged 68 commits from feat/1-slice-0-skeleton into main 2026-10-04 18:41:20 +03:00
8 changed files with 817 additions and 238 deletions
Showing only changes of commit 973cf2717f - Show all commits

View File

@ -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` и запускает - `on_request(msg)` — колбэк подписки NATS. Только разбирает и проверяет тело, регистрирует `job_id` и запускает
задачу задания; ничего не ждёт (nats-py вызывает колбэки одной подписки последовательно, T11.4). задачу задания; ничего не ждёт (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.config import Settings
from src.core.nats import PublishFailed from src.core.nats import PublishFailed
from src.gateway.hotfolder import AnswerFile, HotFolder, inbox_name 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__ = [ __all__ = [
"MAX_ERROR_MESSAGE_CHARS", "MAX_ERROR_MESSAGE_CHARS",
"MAX_WARNINGS",
"MAX_WARNING_CHARS",
"IncomingMessage", "IncomingMessage",
"ObjectStorage", "ObjectStorage",
"ResultPublisher", "ResultPublisher",
@ -64,6 +68,10 @@ __all__ = [
# Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на # Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на
# символ (T10.1). Длинное сообщение обрезается с многоточием. # символ (T10.1). Длинное сообщение обрезается с многоточием.
MAX_ERROR_MESSAGE_CHARS = 4000 MAX_ERROR_MESSAGE_CHARS = 4000
# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт
# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю
MAX_WARNINGS = 100
MAX_WARNING_CHARS = 1000
# Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов # Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов
MAX_FRAGMENT_CHARS = 300 MAX_FRAGMENT_CHARS = 300
_ELLIPSIS = "…" _ELLIPSIS = "…"
@ -160,7 +168,7 @@ class _JobContext:
@dataclass(frozen=True) @dataclass(frozen=True)
class _WaitResult: class _WaitResult:
answer: bytes | None answer: list[AnswerFile] | None
polls: int polls: int
failures: int failures: int
last_error: BaseException | None last_error: BaseException | None
@ -519,17 +527,17 @@ class Worker:
) )
return name, self._clock() return name, self._clock()
async def _accept_answer( # noqa: PLR0911 - каждая ветка — строка таблицы D-7 async def _accept_answer(
self, request: RecognitionRequest, answer: bytes, context: _JobContext self, request: RecognitionRequest, answer: list[AnswerFile], context: _JobContext
) -> RecognitionResultMessage: ) -> RecognitionResultMessage:
"""Ответ ИС: сырой в S3, нормализатор, файл результата; конверт итога.""" """Набор ответов ИС: склейка, сырой в S3, нормализатор, файл результата; конверт итога."""
job_id = str(request.job_id) job_id = str(request.job_id)
# 4. Сырой ответ в S3 как есть — до нормализации (D-4, R-18) # 4. Склейка всех файлов набора (не JSON — parse_error файла, R-24) и сырой ответ в S3 до нормализации (D-4)
parsed, parse_error = _parse_json(answer) merged = merge_answers(job_id, answer)
key = raw_key(job_id) key = raw_key(job_id)
try: 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: except Exception as exc:
logger.error( logger.error(
"Сырой ответ ИС не сохранён. job_id={job_id}, error={error}", job_id=job_id, error=_describe(exc) "Сырой ответ ИС не сохранён. 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)}" request, ErrorCode.UNKNOWN_ERROR, f"сырой ответ ИС не сохранён в S3 ({key}): {_describe(exc)}"
) )
context.raw_s3_key = key 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: try:
outcome = await asyncio.to_thread(self._normalizer.normalize, parsed) outcome = await asyncio.to_thread(self._normalizer.normalize, merged)
except Exception as exc: except Exception as exc:
logger.exception("Нормализатор ответа ИС упал. job_id={job_id}", job_id=job_id) logger.exception("Нормализатор ответа ИС упал. job_id={job_id}", job_id=job_id)
return self._error( return self._error(
request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key
) )
metrics = _outcome_metrics(outcome, key)
if outcome.status is ResultStatus.ERROR: if outcome.status is ResultStatus.ERROR:
error = outcome.error error = outcome.error
if error is None: # исключено инвариантом NormalizeOutcome if error is None: # исключено инвариантом NormalizeOutcome
raise ValueError("NormalizeOutcome со status=error без error") 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 # 6. Успех: файл результата в S3, затем конверт с result_s3
result = RecognitionResult.model_validate( result = RecognitionResult.model_validate(
@ -570,7 +572,7 @@ class Worker:
"status": outcome.status, "status": outcome.status,
"raw_s3_key": key, "raw_s3_key": key,
"documents": list(outcome.documents), "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) target = result_key(request.file_id, request.job_id)
@ -593,25 +595,23 @@ class Worker:
S3Ref(bucket=self._storage.bucket, key=target), S3Ref(bucket=self._storage.bucket, key=target),
needs_check=outcome.status is ResultStatus.NEEDS_CHECK, needs_check=outcome.status is ResultStatus.NEEDS_CHECK,
raw_s3_key=key, raw_s3_key=key,
metrics=outcome.metrics, metrics=metrics,
) )
async def _wait_answer(self, job_id: str, deadline: datetime) -> _WaitResult: async def _wait_answer(self, job_id: str, deadline: datetime) -> _WaitResult:
"""Опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` до дедлайна (последний опрос — в момент дедлайна). """Опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` до дедлайна (последний опрос — в момент дедлайна).
Исключения `poll` (SMB и прочие) — warning, опрос продолжается (R-14). Ответ не JSON может быть недописан Исключения `poll` (SMB и прочие) — warning, опрос продолжается (R-14). Первый готовый набор (`poll` отдаёт его
(ИС пишет на шару без атомарного переименования): он перечитывается через период опроса и принимается, когда только после настаивания, D-1) принимается сразу: файл, не разобранный как JSON, не перечитывается — он
стал JSON или перестал меняться; на дедлайне берётся последний прочитанный. станет `parse_error` в склейке (R-24). Ненастоявшийся к дедлайну набор — `None` (таймаут).
""" """
polls = failures = 0 polls = failures = 0
last_error: BaseException | None = None last_error: BaseException | None = None
pending: bytes | None = None
while True: while True:
polls += 1 polls += 1
answer: bytes | None answer: list[AnswerFile] | None
try: try:
# переходно (Task 3): склейка всех файлов answer = await asyncio.to_thread(self._hotfolder.poll, job_id)
answer = _first_answer(await asyncio.to_thread(self._hotfolder.poll, job_id))
except Exception as exc: except Exception as exc:
failures += 1 failures += 1
last_error = exc last_error = exc
@ -621,22 +621,30 @@ class Worker:
job_id=job_id, job_id=job_id,
error=_describe(exc), error=_describe(exc),
) )
if answer is not None: if answer:
if answer == pending or _parse_json(answer)[1] is None: return _WaitResult(answer, polls, failures, last_error)
return _WaitResult(answer, polls, failures, last_error)
pending = answer
now = self._clock() now = self._clock()
if now >= deadline: 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())) 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: def _is_uuid(value: object) -> bool:

70
api/src/gateway/merge.py Normal file
View File

@ -0,0 +1,70 @@
"""Склейка набора файлов ответа ИС Сканирование в один ответ (D-4, OQ-78, R-24, решения №55, №60).
ИС кладёт в `<output>/<job_id>/` по одному `*.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")

View File

@ -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` и отображение ошибки разбора в Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response",
`INVALID_FORMAT` (D-7) — забота воркера. Результат — `NormalizeOutcome`, из которого воркер собирает конверт "parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой
и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. (`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт
(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`.
В Срезе 0 единственная реализация — `NullNormalizer`: ошибка ИС пробрасывается с её кодом, успех -> `NOT_IMPLEMENTED`. В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`),
Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) появится в Срезе 3 новой реализацией протокола `Normalizer`. при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`)
появится в Срезе 3 новой реализацией протокола `Normalizer`.
""" """
from dataclasses import dataclass
from typing import Protocol, Self, runtime_checkable from typing import Protocol, Self, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument 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_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3"
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом" 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): class NormalizeOutcome(BaseModel):
"""Итог нормализации: статус, документы, ошибка, метрики. """Итог нормализации: статус, документы, ошибка, метрики, предупреждения.
Инварианты: `status == error` <=> `error` задана; для done/needs_check `documents` непусты (как в схеме файла `warnings` — строки для `metrics.warnings` конверта (R-25; внутренний тип, контракт не меняется): воркер
результата), для error — пусты. Статус `processing` исключён: он не бывает итогом. опускает поле, если список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check
`documents` непусты (как в схеме файла результата), для error — пусты. Статус `processing` исключён: он не бывает
итогом.
""" """
model_config = ConfigDict(frozen=True, extra="forbid") model_config = ConfigDict(frozen=True, extra="forbid")
@ -33,6 +42,7 @@ class NormalizeOutcome(BaseModel):
documents: list[StatementDocument] = Field(default_factory=list) documents: list[StatementDocument] = Field(default_factory=list)
error: MessageError | None = None error: MessageError | None = None
metrics: Metrics | None = None metrics: Metrics | None = None
warnings: list[str] = Field(default_factory=list)
@model_validator(mode="after") @model_validator(mode="after")
def _check_invariants(self) -> Self: def _check_invariants(self) -> Self:
@ -51,58 +61,106 @@ class NormalizeOutcome(BaseModel):
@runtime_checkable @runtime_checkable
class Normalizer(Protocol): class Normalizer(Protocol):
"""Превращает разобранный JSON-ответ ИС в `NormalizeOutcome`; не бросает исключений на любом входе.""" """Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`; не бросает исключений на любом входе."""
def normalize(self, raw: object) -> NormalizeOutcome: ... def normalize(self, raw: object) -> NormalizeOutcome: ...
def _error_outcome(code: ErrorCode, message: str) -> NormalizeOutcome: @dataclass(frozen=True)
return NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code=code, message=message)) 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: def _invalid_file(name: str, message: str) -> _FileError:
"""Ошибка ИС -> исход с её кодом; всё нестандартное -> UNKNOWN_ERROR с исходным представлением.""" code = ErrorCode.INVALID_FORMAT
if not isinstance(raw_error, dict): return _FileError(name, code, code.value, message, message)
text = f"ИС Сканирование вернула ошибку неожиданного вида: {raw_error!r}"
return _error_outcome(ErrorCode.UNKNOWN_ERROR, text)
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") 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") raw_message = raw_error.get("message")
if isinstance(raw_message, str) and raw_message: if isinstance(raw_message, str) and raw_message:
message: str | None = raw_message message = raw_message
message_note = ""
else: 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 try:
if isinstance(raw_code, str): code = ErrorCode(raw_code) if isinstance(raw_code, str) else None
try: except ValueError:
code = ErrorCode(raw_code) code = None
except ValueError:
code = None
if code is not None: if code is not None:
default = f"ИС Сканирование вернула ошибку {code.value} без сообщения{message_note}" return _FileError(name, code, source_code, message, message)
return _error_outcome(code, message or default) # код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12)
summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}"
if raw_code is None and "code" not in raw_error: return _FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary)
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: 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: def normalize(self, raw: object) -> NormalizeOutcome:
if not isinstance(raw, dict): if not isinstance(raw, dict) or not isinstance(raw.get("files"), list):
return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE) return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE)
raw_error = raw.get("error") files: list[object] = raw["files"]
if raw_error is not None: if not files:
return _is_response_error(raw_error) return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE)
return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_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 []
)

View File

@ -240,14 +240,17 @@ class Roundtrip:
return body, envelopes[1] return body, envelopes[1]
async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes) -> None: 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"] job_id = body["job_id"]
assert result.body["status"] == "error" assert result.body["status"] == "error"
assert result.body["file_id"] == body["file_id"] assert result.body["file_id"] == body["file_id"]
assert result.body["raw_s3_key"] == f"raw/{job_id}.json" assert result.body["raw_s3_key"] == f"raw/{job_id}.json"
raw = await self.s3.get_bytes(f"raw/{job_id}.json") raw = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json"))
assert raw == self.answer_path(job_id).read_bytes() # как есть, байт в байт assert raw["job_id"] == job_id
assert json.loads(raw)["file_uid"] == 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") stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
assert stat.content_type == JSON_CONTENT_TYPE assert stat.content_type == JSON_CONTENT_TYPE
# результат-файл пишется только для done/needs_check # результат-файл пишется только для done/needs_check
@ -265,7 +268,8 @@ def _write_fixtures(fixtures: Path) -> None:
if REAL_FIXTURES.is_dir(): if REAL_FIXTURES.is_dir():
shutil.copy(_real_fixture("aep_5", ".json"), fixtures / "aep_5.json") shutil.copy(_real_fixture("aep_5", ".json"), fixtures / "aep_5.json")
template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8")) 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") (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"] == "Не удалось определить тип документа." assert result.body["error"]["message"] == "Не удалось определить тип документа."
await roundtrip.check_files(body, result, data) await roundtrip.check_files(body, result, data)
raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) 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, без второго файла и второго результата # повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата
await roundtrip.publish({**body, "attempt": 2}) 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) body, result = await roundtrip.run("ok_doc.pdf", data)
job_id = body["job_id"] job_id = body["job_id"]
assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} 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")) raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json"))
assert raw["error"] is None [entry] = raw["files"]
assert raw["data_ocr"] == SUCCESS_DATA_OCR 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: 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"] job_id = body["job_id"]
error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"} error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"}
assert result.body["error"] == error assert result.body["error"] == error
assert json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) == { raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json"))
"file_uid": job_id, assert raw == {
"error": error, "job_id": job_id,
"data_ocr": [], "files": [
{
"name": f"{job_id}.json",
"response": {"file_uid": job_id, "error": error, "data_ocr": []},
"parse_error": None,
}
],
} }

View File

@ -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<html>", 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

View File

@ -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`; обе реальные фикстуры — ответы с ошибкой Вход `NullNormalizer` — склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}` (D-4).
INVALID_DOCUMENT, O-1); успех (`NOT_IMPLEMENTED`) — на синтетическом ответе, собранном из `aep_5.json` (R-10). Успех файла — `error.code == ""` (реальный успешный ответ несёт `error = {"code": "", "message": ""}`, а не `null`);
ошибочный файл — непустой `error.code`, `parse_error` или корень ответа не объект. Сводка: все файлы ошибочны — код
первого по имени; есть успешный — `NOT_IMPLEMENTED`; ошибочные файлы — строки `warnings`.
Реальные ответы `../fixtures/ocr/*.json` — маркер `fixtures`.
""" """
import copy import copy
@ -13,14 +16,16 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument 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] API_DIR = Path(__file__).resolve().parents[2]
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у test_emulator.py # `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у test_emulator.py
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3" REAL_MESSAGE = "Не удалось определить тип документа."
NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом" SUCCESS_ERROR = {"code": "", "message": ""}
def _real(name: str) -> dict[str, Any]: def _real(name: str) -> dict[str, Any]:
@ -30,39 +35,37 @@ def _real(name: str) -> dict[str, Any]:
return loaded return loaded
def _success_response() -> dict[str, Any]: def _real_set(*names: str) -> dict[str, Any]:
"""Синтетический успех ИС: форма `aep_5.json` без ошибки и с заполненными label и строками таблицы.""" """Склейка реальных ответов под именами каталога ответа: `aep_N.json` — `<job>.json`, `aep_N_<x>.json` —
return { `<job>_<x>.json`."""
"file_uid": "aep_5", if not REAL_FIXTURES.is_dir():
"error": None, pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
"data_ocr": { files = []
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.5}, for name in names:
"organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.0}, suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2]
"inn": {"inn": "7701234567", "confidence": 99.0}, target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json"
"units": {"measurement": "тыс. руб.", "confidence": 95.0}, files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes()))
"date": {"value": "30 сентября 2025 г.", "confidence": 96.0}, return dict(merge_answers(JOB, files))
"table_structure": {
"rows": [
{ def _file(name: str, response: object = None, parse_error: str | None = None) -> dict[str, Any]:
"row_index": 0, return {"name": name, "response": response, "parse_error": parse_error}
"cells": [
{"column_index": 0, "value": "Наименование показателя", "confidence": 99.0},
{"column_index": 1, "value": "Код", "confidence": 99.0}, def _merged(*files: dict[str, Any]) -> dict[str, Any]:
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 98.0}, return {"job_id": JOB, "files": list(files)}
],
},
{ def _one(response: object) -> dict[str, Any]:
"row_index": 1, return _merged(_file(f"{JOB}.json", response))
"cells": [
{"column_index": 0, "value": "Основные средства", "confidence": 97.0},
{"column_index": 1, "value": "1150", "confidence": 99.0}, def _success(**extra: Any) -> dict[str, Any]:
{"column_index": 2, "value": "1 234", "confidence": 90.0}, 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: 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 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 @pytest.mark.fixtures
class TestRealFixtures: class TestRealFixtures:
@pytest.mark.parametrize("name", ["aep_5.json", "aep_8_form4.json"]) """Регрессия: реальные успешные ответы (`error = {"code": "", "message": ""}`) — не ошибка."""
def test_is_error_passes_through(self, name: str) -> None:
raw = _real(name) @pytest.mark.parametrize("name", ["aep_6.json", "aep_3.json"])
assert raw["error"] is not None # O-1: обе реальные фикстуры — ответы с ошибкой def test_real_success_is_not_an_error(self, name: str) -> None:
outcome = NullNormalizer().normalize(raw) assert _real(name)["error"] == SUCCESS_ERROR
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT, raw["error"]["message"]) _assert_not_implemented(NullNormalizer().normalize(_real_set(name)), [])
assert outcome.error is not None
assert outcome.error.message == "Не удалось определить тип документа." 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: def test_known_code_and_message_verbatim(self) -> None:
raw = {"file_uid": "x", "error": {"code": "FILE_READ_ERROR", "message": "Файл повреждён: стр. 3 «пусто»"}} outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»")))
_assert_error(NullNormalizer().normalize(raw), ErrorCode.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]) @pytest.mark.parametrize("code", [c.value for c in ErrorCode])
def test_every_contract_code_passes(self, code: str) -> None: def test_every_contract_code_passes(self, code: str) -> None:
raw = {"error": {"code": code, "message": "сообщение ИС"}} _assert_error(NullNormalizer().normalize(_one(_failure(code))), ErrorCode(code), "сообщение ИС")
_assert_error(NullNormalizer().normalize(raw), ErrorCode(code), "сообщение ИС")
def test_unknown_code_becomes_unknown_error_keeping_original(self) -> None: def test_unknown_code_is_unknown_error_keeping_original(self) -> None:
raw = {"error": {"code": "QUOTA_EXCEEDED", "message": "Превышена квота"}} outcome = NullNormalizer().normalize(_one(_failure("SOME_NEW_CODE", "Превышена квота")))
outcome = NullNormalizer().normalize(raw)
_assert_error(outcome, ErrorCode.UNKNOWN_ERROR) _assert_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None 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 "Превышена квота" in outcome.error.message
assert outcome.warnings == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"]
def test_lowercase_code_is_unknown(self) -> None: 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_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None assert outcome.error is not None
assert "invalid_document" in outcome.error.message 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, ""]) @pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True])
def test_error_not_an_object_or_empty(self, error: object) -> None: def test_code_not_a_string_is_unknown_error(self, code: object) -> None:
outcome = NullNormalizer().normalize({"error": error}) outcome = NullNormalizer().normalize(_one(_failure(code, "m-text")))
_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_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None assert outcome.error is not None
assert repr(code) in outcome.error.message assert repr(code) in outcome.error.message
assert "m-text" 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}]) @pytest.mark.parametrize("message", [None, ""])
def test_missing_message_gets_default(self, error: dict[str, Any]) -> None: def test_missing_or_empty_message_gets_default(self, message: object) -> None:
outcome = NullNormalizer().normalize({"error": error}) response = _failure("INVALID_DOCUMENT", message)
outcome = NullNormalizer().normalize(_one(response))
_assert_error(outcome, ErrorCode.INVALID_DOCUMENT) _assert_error(outcome, ErrorCode.INVALID_DOCUMENT)
assert outcome.error is not None assert outcome.error is not None
assert outcome.error.message
assert "INVALID_DOCUMENT" in 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: def test_message_absent_gets_default(self) -> None:
outcome = NullNormalizer().normalize({"error": {"code": "UNREADABLE", "message": ""}}) outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}}))
_assert_error(outcome, ErrorCode.UNREADABLE) _assert_error(outcome, ErrorCode.UNREADABLE)
assert outcome.error is not None assert outcome.error is not None
assert outcome.error.message assert outcome.error.message
def test_message_not_a_string_is_not_lost(self) -> None: 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_error(outcome, ErrorCode.UNREADABLE)
assert outcome.error is not None assert outcome.error is not None
assert "{'detail': 'x'}" in outcome.error.message assert "{'detail': 'x'}" in outcome.error.message
def test_unknown_code_and_no_message(self) -> None: 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_error(outcome, ErrorCode.UNKNOWN_ERROR)
assert outcome.error is not None assert outcome.error is not None
assert "WEIRD" in outcome.error.message assert "WEIRD" in outcome.error.message
def test_error_wins_over_data(self) -> None: def test_error_wins_over_data(self) -> None:
raw = _success_response() response = _success(error={"code": "RECOGNITION_FAILED", "message": "сбой"})
raw["error"] = {"code": "RECOGNITION_FAILED", "message": "сбой"} _assert_error(NullNormalizer().normalize(_one(response)), ErrorCode.RECOGNITION_FAILED, "сбой")
_assert_error(NullNormalizer().normalize(raw), 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: def test_input_is_not_mutated(self) -> None:
raw = {"error": {"code": "WEIRD", "message": "m"}} merged = _merged(_file("a.json", _failure("WEIRD", "m")), _file("b.json", None, "e"))
before = copy.deepcopy(raw) before = copy.deepcopy(merged)
NullNormalizer().normalize(raw) NullNormalizer().normalize(merged)
assert raw == before assert merged == before
class TestSuccessIsNotImplemented: class TestMergedStructure:
def test_error_null(self) -> None: @pytest.mark.parametrize("raw", [[], "строка", None, 0, True])
outcome = NullNormalizer().normalize(_success_response()) def test_not_an_object_is_invalid_format(self, raw: object) -> None:
_assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) _assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT)
def test_error_key_absent(self) -> None: @pytest.mark.parametrize(
raw = _success_response() "raw", [{}, {"job_id": JOB}, {"job_id": JOB, "files": None}, {"job_id": JOB, "files": {"a.json": {}}}]
del raw["error"] )
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) 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: def test_no_files_is_invalid_format(self) -> None:
_assert_error(NullNormalizer().normalize({}), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) _assert_error(NullNormalizer().normalize(_merged()), ErrorCode.INVALID_FORMAT)
@pytest.mark.fixtures @pytest.mark.parametrize("entry", [None, "a.json", [], {"response": _success()}])
def test_real_fixture_with_error_removed(self) -> None: def test_malformed_entry_is_erroneous_file(self, entry: object) -> None:
raw = _real("aep_5.json") merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]}
raw["error"] = None outcome = NullNormalizer().normalize(merged)
_assert_error(NullNormalizer().normalize(raw), ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED)
[warning] = outcome.warnings
assert warning.startswith("files[0]: INVALID_FORMAT: ")
def test_real_merge_output_is_accepted(self) -> None:
class TestNotAnObject: merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", json.dumps(_success()).encode())])
@pytest.mark.parametrize("raw", [[], [{"error": None}], "строка", "", None, 0, 42, 1.5, True]) _assert_not_implemented(NullNormalizer().normalize(merged), [])
def test_invalid_format(self, raw: object) -> None:
_assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE)
class TestNormalizeOutcome: class TestNormalizeOutcome:
@ -207,6 +292,13 @@ class TestNormalizeOutcome:
assert outcome.status is ResultStatus.ERROR assert outcome.status is ResultStatus.ERROR
assert outcome.documents == [] assert outcome.documents == []
assert outcome.metrics is None 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: def test_error_requires_error(self) -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):

View File

@ -19,22 +19,25 @@ from jsonschema import Draft202012Validator, FormatChecker
from src.contract import ( from src.contract import (
ErrorCode, ErrorCode,
MessageError, MessageError,
Metrics,
ResultStatus, ResultStatus,
StatementDocument, StatementDocument,
) )
from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM from src.core.clients import JSON_CONTENT_TYPE
from src.core.worker import MAX_ERROR_MESSAGE_CHARS from src.core.worker import MAX_ERROR_MESSAGE_CHARS, MAX_WARNING_CHARS, MAX_WARNINGS
from src.gateway.hotfolder import AnswerFile 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 ( from tests.worker_helpers import (
API_DIR, API_DIR,
BUCKET, BUCKET,
CONTRACTS_DIR, CONTRACTS_DIR,
PDF_BYTES, PDF_BYTES,
SUCCESS_ANSWER, SUCCESS_ANSWER,
Env,
EnvFactory, EnvFactory,
emulator, emulator,
eventually, eventually,
merged_raw,
) )
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в тестах эмулятора # `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в тестах эмулятора
@ -44,6 +47,29 @@ RESULT_VALIDATOR = Draft202012Validator(
format_checker=FormatChecker(), format_checker=FormatChecker(),
) )
STATEMENT_EXAMPLE = json.loads((CONTRACTS_DIR / "examples" / "result-statement-minimal.json").read_text("utf-8")) 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 чистый # Логи воркера перехватываются (`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["error"]["message"] == NOT_IMPLEMENTED_MESSAGE
assert body["file_id"] == request["file_id"] assert body["file_id"] == request["file_id"]
assert [mid for mid, _ in env.published()] == [f"ack:{job_id}:3", f"result:{job_id}:3"] 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 "metrics" not in body # ошибочных файлов нет — warnings опущено
assert raw == env.local.answer_path(job_id).read_bytes() # как есть, байт в байт answer = json.loads(env.local.answer_path(job_id).read_bytes())
assert json.loads(raw) == {**SUCCESS_ANSWER, "file_uid": job_id} assert answer["error"] == {"code": "", "message": ""} # успех в форме реального ответа ИС
assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == JSON_CONTENT_TYPE 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.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"] 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) body = await env.run(request)
assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"] == expected["message"] 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: 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["raw_s3_key"] == f"raw/{job_id}.json"
assert result["documents"] == STATEMENT_EXAMPLE["documents"] assert result["documents"] == STATEMENT_EXAMPLE["documents"]
assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE 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")) 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) 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() env = await make_env()
request = env.request() request = env.request()
job_id = request["job_id"] job_id = request["job_id"]
garbage = b"\xff\xfe<html>not json"
await env.deliver(request) await env.deliver(request)
await eventually(lambda: len(env.hotfolder.puts) == 1) await eventually(lambda: len(env.hotfolder.puts) == 1)
env.answer(job_id, garbage) env.answer(job_id, b"\xff\xfe<html>not json")
await asyncio.wait_for(env.worker.wait_idle(), 5) await asyncio.wait_for(env.worker.wait_idle(), 5)
[(_, body)] = env.results(job_id) [(_, body)] = env.results(job_id)
assert_error(body, ErrorCode.INVALID_FORMAT, raw=True) assert_error(body, ErrorCode.INVALID_FORMAT, raw=True)
assert env.storage.objects[(BUCKET, f"raw/{job_id}.json")] == garbage [entry] = _raw(env, job_id)["files"]
assert env.storage.content_types[(BUCKET, f"raw/{job_id}.json")] == OCTET_STREAM 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: async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None:
"""ИС пишет ответ не атомарно (SMB): недописанный JSON перечитывается через период опроса.""" """R-24: набор уже настоялся — недописанный JSON не перечитывается, а становится `parse_error` файла."""
env = await make_env() 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)]] env.hotfolder.poll_script = [None, [AnswerFile("x.json", full[:10])], [AnswerFile("x.json", full)]]
body = await env.run(env.request()) body = await env.run(env.request())
assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) assert_error(body, ErrorCode.INVALID_FORMAT, raw=True)
assert env.storage.objects[(BUCKET, f"raw/{body['job_id']}.json")] == full 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: async def test_merged_raw_contains_every_file_by_name(make_env: EnvFactory) -> None:
"""Переходно (Task 3 заменит склейкой всех файлов): из готового набора берётся первый файл (по имени).""" """Сценарий «Склеенный сырой ответ»: оба файла в `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() env = await make_env()
first = json.dumps({**SUCCESS_ANSWER, "file_uid": "x"}).encode() request = env.request()
env.hotfolder.poll_script = [[AnswerFile("x.json", first), AnswerFile("x_ofr.json", b'{"file_uid": "second"}')]] job_id = request["job_id"]
body = await env.run(env.request()) 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_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_error(body, ErrorCode.INVALID_DOCUMENT, raw=True)
assert body["error"]["message"].startswith("bad ") assert body["error"]["message"].startswith("bad ")
assert body["error"]["message"].endswith(" tail") 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: async def test_lone_surrogate_in_original_name_is_schema_mismatch(make_env: EnvFactory) -> None:

View File

@ -39,21 +39,44 @@ MESSAGE_VALIDATOR = Draft202012Validator(
format_checker=FormatChecker(), 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] = { SUCCESS_ANSWER: dict[str, Any] = {
"file_uid": "ok_doc", "file_uid": "ok_doc",
"error": None, "error": {"code": "", "message": ""},
"data_ocr": { "data_ocr": {
"label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0}, "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0},
"organization": {"form_name": "ООО Ромашка", "confidence": 97.5}, "organization": {"form_name": "ООО Ромашка", "confidence": 97.5},
"inn": {"form_name": "7700000000", "confidence": 99.0}, "inn": {"form_name": "7700000000", "confidence": 99.0},
"units": {"form_name": "тыс. руб.", "confidence": 95.0}, "units": {"form_name": "тыс. руб.", "confidence": 95.0},
"date": {"form_name": "30.09.2025", "confidence": 96.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: class FakeStorage:
"""S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`.""" """S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`."""