arm-scan-gateway/README.md
Andrey Epifancev b127c02422 Срез 0 [7.5]: финальная волна, документы — T5.1, T5.3, T6.1, T6.3, T6.6, M-3
- README: верхняя граница задержки settle, шаг 5 «Полного круга»; критерии ошибочного файла (корень не объект)
- CLAUDE.md: по одному json на каждый распознанный документ; критерии ошибочного файла
- design.md: D-5 — оговорка про нестроковый error.code отдельным предложением; №79 — источник указания, SQL уборки в порядке FK
- open-questions-proposal.md п.20: замены ../docs/dev/README.md (185–187, 224–226, 252–253) и комментарий docker-compose.yml:184
- план: Task 6 — пометка про замену down -v (R-27)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:01:13 +04:00

284 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# scan-gateway
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.0), архитектура —
`../docs/context/03-architecture.md`.
Границы сервиса (DECISION-4):
- нет базы данных: единственный писатель в БД — `arm-core`; шлюз видит только NATS, S3 и горячие папки;
- нет фронта; HTTP — только `GET /healthcheck`;
- сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется;
- саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`).
Состояние: Срез 0 (скелет). Сквозной путь работает, нормализатор — заглушка (см. «Ограничения Среза 0»).
Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения — `openspec/changes/slice-0-skeleton/`.
## Поток обработки
```
recognition.image.request (NATS, queue group scan-gateway)
1. разбор и проверка запроса ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
2. ack: recognition.result, status=processing (до чтения S3 и до ожидания слота)
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
4. файл из S3 (s3.bucket/s3.key)
5. горячая папка: <Input>/<job_id>_<original_name> (через <Tmp>/.<имя>.part и rename)
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
не менялся SCAN_SETTLE_SECONDS
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
8. нормализатор ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
9. recognition.result: итоговый конверт (done | needs_check | error), в нём raw_s3_key
```
Каждое задание заканчивается опубликованным конвертом: сбой превращается в `error` с кодом (таблица ниже), а не в
молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по
своему `reply_deadline`. Падение шлюза после ack теряет задание до `reply_deadline` (без JetStream иначе нельзя).
Повторный запрос с `job_id`, который уже в работе или в LRU обработанных (`DONE_CACHE_SIZE`), получает повторный
ack (с `attempt` повторного запроса); файл в ИС второй раз не отправляется.
## Протокол горячих папок
Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `healthy`):
| Режим | Реализация | Для чего |
|---|---|---|
| `local` (по умолчанию) | `LocalHotFolder`, обычный каталог `SCAN_HOTFOLDER_PATH` | разработка, тесты, эмулятор |
| `smb` | `SmbHotFolder` (`api/src/gateway/smb.py`, `smbprotocol`), шара из `OPENBAO__SMB__*` | платформа |
- Входящий файл: `<SCAN_INPUT_DIR>/<job_id>_<original_name>`. `original_name` приходит из запроса и не доверяется: берётся
последний компонент пути, символы, недопустимые в Windows/SMB (`: < > " | ? *` и управляющие), заменяются на `_`,
завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы
временный `.<имя>.part` умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось —
`RECOGNITION_FAILED`.
- Запись атомарна: сначала временный `.<имя>.part` в служебном каталоге `<SCAN_TMP_DIR>` (по умолчанию `Tmp`), затем
переименование в `Input`. `Tmp` лежит рядом с `Input` и `Output`, на той же ФС/шаре, чтобы `rename` оставался
атомарным, а ИС не забрала недописанный файл из `Input`. Каталоги `Input` и `Tmp` создаются при необходимости (без
родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`.
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
несколько таблиц разных форм (баланс, ОФР); таблицы в Срезе 0 не интерпретируются (Срез 3).
- Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один `*.json`, а имена и размеры `*.json` не
менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный
набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через `SCAN_SETTLE_SECONDS` после
такого опроса и не позднее `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор; `0` — готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так
и не стабилизировался до дедлайна, даёт `TIMEOUT`.
- Таймаут ожидания: `min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS)`; опрос каждые `SCAN_POLL_SECONDS`
(ожидание settle входит в этот срок).
Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
- Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется
(сырой файл остаётся на шаре).
- Эмулятор ИС в `local`-режиме: для файла `<uuid>_<имя>` ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output/<uuid>/` весь
набор фикстур: `<имя>.json` -> `<uuid>.json` и `<имя>_<суффикс>.json` -> `<uuid>_<суффикс>.json` (`<имя>` — имя
входящего файла без расширения; именно `<имя>_*`, а не префикс `<имя>*`: `aep_3` не забирает `aep_30*`; в каждом
файле `file_uid` = `<uuid>`). Набор собирается во временном каталоге `Tmp` и появляется в `Output/<uuid>/`
атомарно целиком (rename каталога). Затем входящий файл переименовывается в `….done`. Нет фикстуры — один
ответ-ошибка `INVALID_DOCUMENT`. Dot-файлы и `.done` игнорируются.
## Склеенный ответ ИС (`raw/{job_id}.json`)
В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (контракт v1.0 не меняется: `raw_s3_key` —
по-прежнему одна строка `raw/{job_id}.json`):
```json
{
"job_id": "<job_id>",
"files": [
{"name": "<job_id>.json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null},
{"name": "<job_id>_ofr.json", "response": {"file_uid": "...", "error": {"code": "INVALID_DOCUMENT", "message": "..."}}, "parse_error": null}
]
}
```
`files` — все `*.json` каталога по возрастанию имени. Файл, который не разобрался как JSON, не роняет задание:
`response = null`, `parse_error` — текст ошибки. Нормализатору передаётся ровно этот объект.
Формат файла ответа Content AI: `{"file_uid", "error": {"code", "message"}, "data_ocr": {...}}`. **Успех — это
`error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null`
нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...).
## Переменные окружения
Файл окружения — `ENV_FILE` (по умолчанию `.env`, путь от текущего каталога, то есть от `api/`); переменные окружения
приоритетнее файла. Шаблон — `api/.env.example`. Настройки шлюза читаются при импорте `src.app`: модуль создаёт `app`
(это происходит при запуске `run_server.py` или `uvicorn src.app:app`). Импорт пакета `src` приложение не создаёт, поэтому
эмулятор (`python -m src.scan_emulator`) настройки шлюза не читает: у него свои переменные (`SCAN_HOTFOLDER_PATH`,
`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_*`) из окружения и `ENV_FILE`.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO `host:port`, без схемы |
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO |
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO |
| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | единственный бакет сервиса; запрос с другим `s3.bucket` -> `FILE_READ_ERROR` |
| `OPENBAO__MINIO__MINIO_SECURE` | `true` | https; для MinIO из compose — `false` |
| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS |
| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys/JWT платформы; оба пусты — без учётных данных; задан один — ошибка конфигурации |
| `OPENBAO__SMB__SMB_HOST`, `OPENBAO__SMB__SMB_PATH`, `OPENBAO__SMB__SMB_LOGIN`, `OPENBAO__SMB__SMB_PASSWORD` | пусто | шара горячих папок; обязательны только при `SCAN_HOTFOLDER_MODE=smb`; `SMB_PATH` — `share/dir` или полный UNC |
| `NATS_ENABLED` | `true` | `false` — воркер не запускается, подключения к NATS нет (`/healthcheck.nats = disabled`) |
| `PUBLISH_RETRIES` | `3` | повторы публикации ack/результата после первой попытки (всего до 4) |
| `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация) |
| `SCAN_HOTFOLDER_MODE` | `local` | `local` или `smb` |
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог относительно корня (на той же ФС/шаре, что `Input`/`Output`): временный `.part` записи входящего файла |
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
| `SCAN_TIMEOUT_SECONDS` | `300` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`) |
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
| `ROOT_PATH` | `/` | префикс FastAPI за прокси |
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес HTTP-сервера `run_server.py` |
| `DEBUG` | `false` | флаг шаблона платформы; поведение шлюза не меняет |
| `ENV_FILE` | `.env` | какой файл окружения читать |
Эмулятор (`python -m src.scan_emulator`) читает свои переменные и не использует настройки шлюза (в его контейнере нет
MinIO и NATS); те же значения подхватываются из `ENV_FILE`:
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_TMP_DIR` | `Input`, `Output`, `Tmp` | подкаталоги корня (как у шлюза); в `Tmp` эмулятор собирает набор ответа перед rename в `Output/<uuid>/` |
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных ответов ИС (`aep_N.json`, `aep_N_<суффикс>.json`); путь от `api/` |
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
| `SCAN_EMULATOR_POLL_SECONDS` | `1` | период просмотра «Входящих», секунды (> 0) |
## Коды ошибок
Итоговый конверт `error` содержит `error.code` и `error.message`. Нормализатор может вернуть любой код `ErrorCode`
контракта, остальные коды ставит воркер (`api/src/core/worker.py`).
| Код | Когда | Ack |
|---|---|---|
| `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет |
| `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да |
| `RECOGNITION_FAILED` | не записан файл во «Входящие» (горячая папка недоступна); недопустимое `original_name`; «Исходящие» не читались ни разу до дедлайна | да |
| `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да |
| `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да |
| код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — строка `metrics.warnings` | да |
| `NOT_IMPLEMENTED` | есть хотя бы один успешный файл (`error.code == ""`); нормализатор Среза 0 — заглушка. Ошибочные файлы набора — в `metrics.warnings` | да |
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал; неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень
ответа не объект (`INVALID_FORMAT`).
`metrics.warnings` итогового конверта содержит по строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл (и при
`NOT_IMPLEMENTED`, и когда ошибочны все файлы); если ошибочных файлов нет, поле `metrics` не передаётся.
## Контракт NATS (кратко)
Схемы и примеры — `../docs/contracts/`; их байт-в-байт копии лежат в `api/contracts/` (идентичность проверяет unit-тест
`api/tests/unit/test_contract.py`, входит в `make check`), pydantic-модели — `api/src/contract/`.
- Запрос: subject `recognition.image.request`, подписка в queue group `scan-gateway`, `Nats-Msg-Id = <job_id>:<attempt>`.
- Ответы — subject `recognition.result`: ack `status=processing`, `Nats-Msg-Id = ack:<job_id>:<attempt>`; итог
`status = done | needs_check | error`, `Nats-Msg-Id = result:<job_id>:<attempt>`. `service.name = "scan-gateway"`.
- Ключи S3: исходник — из запроса (`s3.key`), сырой ответ (склейка файлов ИС) — `raw/{job_id}.json`, результат —
`results/{file_id}/{job_id}/result.json`. Бакет один.
- NATS без JetStream: переподключение и повторная подписка — на `nats-py`, ack и повторы публикации — на уровне
приложения (`api/src/core/nats.py`).
## Запуск
Требуются Docker с compose v2, `uv`, Python 3.11. Все команды `make` — из корня репозитория; backend-команды
рецепты выполняют в `api/`. Рядом в workspace должны лежать `../docs` (compose, контракт) и `../fixtures`.
### Разработка
```bash
make up # NATS, MinIO, PostgreSQL: compose-проект arm-ka из ../docs/dev
make venv # api/.venv (Python 3.11), зависимости без raisa_*
cp api/.env.example api/.env # значения совпадают с ../docs/dev (NATS 4222, MinIO 9000, бакет arm-ka)
mkdir -p api/.hotfolder # корень горячей папки: сам не создаётся
cd api && .venv/bin/python run_server.py # сервис на :8000
make emulator # в другом терминале: эмулятор ИС Сканирование
curl -s localhost:8000/healthcheck
```
Целей `down`/`stop` в `Makefile` нет намеренно: compose-проект `arm-ka` общий для сервисов workspace.
Контейнера `scan-emulator` в compose пока нет (его добавит репозиторий `docs` позже), поэтому эмулятор запускается
локально командой `make emulator`.
### Полный круг против compose и эмулятора
Исполняемый пример — `api/tests/integration/test_roundtrip.py` (поднимает приложение и эмулятор в процессе теста,
публикует запрос, проверяет ack, итоговый конверт, объекты S3). Вручную те же шаги:
1. Выполнить «Разработку» выше: сервис и эмулятор работают, `/healthcheck` отдаёт `nats = ok`, `hotfolder = ok`.
2. Положить файл в MinIO (консоль http://localhost:9001 или `mc`) в бакет `arm-ka`, например
`uploads/<file_id>/<job_id>/aep_3.pdf` (имя файла должно совпадать с фикстурами эмулятора: `aep_3.*` -> набор
`aep_3.json` + `aep_3_ofr.json`, `aep_6.*` -> `aep_6.json`, `aep_5.*` -> `aep_5.json`).
3. Подписаться на `recognition.result` любым клиентом NATS.
4. Опубликовать в `recognition.image.request` тело по образцу `api/contracts/examples/request-image.json`
(свои `job_id` и `file_id` — UUID, `original_name`, `s3.key`, `reply_deadline` в будущем, `attempt` = 1) с заголовком
`Nats-Msg-Id: <job_id>:1`.
5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) —
конверт `error`:
- `aep_3` — `NOT_IMPLEMENTED` и `metrics.warnings` с одной строкой `<job_id>_ofr.json: INVALID_DOCUMENT: ...`
(второй файл набора — ответ-ошибка);
- `aep_6` — `NOT_IMPLEMENTED` без `metrics`;
- `aep_5` (и `aep_8`) — `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки);
- неизвестное имя — `INVALID_DOCUMENT` эмулятора.
В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте.
### Проверки
```bash
make check # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration")
make test-integration # pytest -m integration: нужны NATS и MinIO (make up)
make docker-build # docker build -t scan-gateway:dev . (образ без Nexus)
make check-full # check + test-integration + docker-build; перед PR
```
Тесты запускаются из `api/`: `.env.test` подключается через `ENV_FILE=.env.test` в `api/pytest.ini` и разрешается
относительно текущего каталога (рецепты `make` это делают сами). Маркеры: `integration` — нужна инфраструктура,
исключается из `make test`; `fixtures` — нужны реальные данные из `../fixtures` (нет репозитория — тесты пропускаются).
Тесты: `api/tests/unit/`, `api/tests/integration/`. Интеграционные тесты делят NATS с другими сервисами workspace:
если в нём работает другой экземпляр `scan-gateway`, он может забрать запрос теста из queue group.
Шаги `make check-full` идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются.
## `/healthcheck`
`GET /healthcheck` всегда отвечает 200; состояние — в полях:
| Поле | Значения |
|---|---|
| `uptime` | время старта процесса |
| `version` | `VERSION` |
| `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` |
| `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала |
Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения.
Результат проверки горячей папки кэшируется на 5 с.
## Docker-образ
Один образ (`Dockerfile`, `python:3.11-slim`, контекст сборки — корень репозитория, `docker build -t scan-gateway .`)
для шлюза и для эмулятора. По умолчанию запускается шлюз (`python run_server.py`, порт 8000); эмулятор — тем же образом
с `command: python -m src.scan_emulator`. Пакеты `raisa_*` в образ не ставятся. `api/.env` в образ не попадает (см.
`.dockerignore`): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути
`SCAN_HOTFOLDER_PATH`, эмулятору — каталог фикстур по пути `SCAN_FIXTURES_DIR`.
## Ограничения Среза 0
- `NullNormalizer` (`api/src/normalizer/`): если ошибочны все файлы ответа, код первого из них пробрасывается; если
есть успешный файл (`error.code == ""`) — `NOT_IMPLEMENTED`; ошибочные файлы — в `metrics.warnings`. Таблицы
`data_ocr` (в одном json их может быть несколько, разных форм: баланс, ОФР) не интерпретируются; настоящая
нормализация в `statementDocument` — Срез 3.
- Реальные фикстуры: `aep_3.json` (баланс и ОФР), `aep_6.json` — успешные ответы, `aep_3_ofr.json`, `aep_5.json`,
`aep_8_form4.json` — ответы-ошибки (`INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями и `table_structure: []`).
- Режим `smb` покрыт unit-тестами с подменённым `smbclient`; на реальной шаре ИС Сканирование не проверялся. Фактические
имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код.
- Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до `reply_deadline` ядра.
- Зависший вызов SMB (`poll`/`put`, до ~60 с по умолчанию `smbclient`) дедлайном воркера не прерывается: вызов идёт
в потоке без собственного таймаута, поэтому `TIMEOUT` может прийти позже дедлайна на это время.
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог.
- Контейнер `scan-emulator` в compose отсутствует.