# 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. горячая папка: /_ 6. опрос //.json каждые SCAN_POLL_SECONDS, до дедлайна 7. сырой ответ ──► 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__*` | платформа | - Входящий файл: `/_`. `original_name` приходит из запроса и не доверяется: берётся последний компонент пути, символы, недопустимые в Windows/SMB (`: < > " | ? *` и управляющие), заменяются на `_`, завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы временный `.<имя>.part` умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось — `RECOGNITION_FAILED`. - Запись атомарна: сначала `.<имя>.part` в том же каталоге, затем переименование. Каталог `Input` создаётся при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. - Ответ ИС: `//.json` (читается только этот файл). `job_id` проверяется на безопасность пути. Ответ, который ещё пишется (не JSON), перечитывается на следующем опросе. - Таймаут ожидания: `min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS)`; опрос каждые `SCAN_POLL_SECONDS`. Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется. - Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре). - Эмулятор ИС в `local`-режиме: для файла `_<имя>` ждёт `SCAN_EMULATOR_DELAY`, пишет ответ из фикстуры (`<имя без расширения>.json`, затем первый по алфавиту `<имя без расширения>*.json`; `file_uid` подставляется), потом переименовывает входящий файл в `….done`. Нет фикстуры — ответ-ошибка `INVALID_DOCUMENT`. Dot-файлы и `.done` игнорируются. ## Переменные окружения Файл окружения — `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_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды | | `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` | `Input`, `Output` | подкаталоги корня (как у шлюза) | | `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных ответов ИС (`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`: код пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`) | да | | `NOT_IMPLEMENTED` | ответ ИС без ошибки (нормализатор Среза 0 — заглушка) | да | | `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал; неизвестный код ИС; непредвиденная ошибка | да | ## Контракт 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 = :`. - Ответы — subject `recognition.result`: ack `status=processing`, `Nats-Msg-Id = ack::`; итог `status = done | needs_check | error`, `Nats-Msg-Id = result::`. `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///aep_5.pdf` (имя файла должно совпадать с фикстурой эмулятора: `aep_5.*` -> `aep_5.json`, `aep_8.*` -> `aep_8_form4.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: :1`. 5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` — конверт `error`. Для `aep_5`/`aep_8` это `INVALID_DOCUMENT` с сообщением ИС (обе реальные фикстуры — ответы-ошибки); для неизвестного имени — `INVALID_DOCUMENT` эмулятора. В бакете появится `raw/.json`, `raw_s3_key` указан в конверте. 6. Путь «успех -> `NOT_IMPLEMENTED`» требует успешной фикстуры: добавьте в `SCAN_FIXTURES_DIR` синтетический `<имя>.json` с `error: null`. `NullNormalizer` содержимое `data_ocr` не смотрит; непустой `data_ocr` в фикстуре `ok_doc.json` из `test_roundtrip.py` — требование тестовой фикстуры (форма ответа ИС), а не поведения `NullNormalizer`. ### Проверки ```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/`): ошибка ИС пробрасывается с её кодом, любой успешный ответ даёт `NOT_IMPLEMENTED`. Настоящая нормализация `data_ocr` в `statementDocument` — Срез 3. - Реальные фикстуры `aep_5.json` и `aep_8_form4.json` — ответы-ошибки ИС (`INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`): успешных образцов нет, это открытый вопрос Среза 3. - Режим `smb` покрыт unit-тестами с подменённым `smbclient`; на реальной шаре ИС Сканирование не проверялся. Фактические имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код. - Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до `reply_deadline` ядра. - Зависший вызов SMB (`poll`/`put`, до ~60 с по умолчанию `smbclient`) дедлайном воркера не прерывается: вызов идёт в потоке без собственного таймаута, поэтому `TIMEOUT` может прийти позже дедлайна на это время. - Недописанный ответ ИС: ответ не-JSON перечитывается через `SCAN_POLL_SECONDS` и принимается, когда стал JSON или перестал меняться между опросами; на дедлайне берётся последний прочитанный. Не-JSON ответ, принятый так, даёт `INVALID_FORMAT` (сырой ответ сохраняется в `raw/`). - Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на такой файл он отвечает `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. - Контейнер `scan-emulator` в compose отсутствует.