Срез 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:
parent
83e55af941
commit
973cf2717f
@ -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
70
api/src/gateway/merge.py
Normal 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")
|
||||
@ -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 []
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
101
api/tests/unit/test_merge.py
Normal file
101
api/tests/unit/test_merge.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user