Срез 0 [7.2]: склейка ответа ИС, сырой raw/ = склейка, NullNormalizer по файлам, metrics.warnings

- Исправлена критическая ошибка: 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 14:27:07 +04:00
parent 83e55af941
commit 973cf2717f
8 changed files with 817 additions and 238 deletions

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` и запускает
задачу задания; ничего не ждёт (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:

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` и отображение ошибки разбора в
`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 []
)

View File

@ -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,
}
],
}

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`; обе реальные фикстуры — ответы с ошибкой
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` — `<job>.json`, `aep_N_<x>.json` —
`<job>_<x>.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):

View File

@ -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\xfe<html>not json"
await env.deliver(request)
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)
[(_, 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:

View File

@ -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`."""