23 KiB
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>
6. опрос <Output>/<job_id>/<job_id>.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__* |
платформа |
- Входящий файл:
<SCAN_INPUT_DIR>/<job_id>_<original_name>.original_nameприходит из запроса и не доверяется: берётся последний компонент пути, символы, недопустимые в Windows/SMB (: < > " | ? *и управляющие), заменяются на_, завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы временный.<имя>.partумещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось —RECOGNITION_FAILED. - Запись атомарна: сначала
.<имя>.partв том же каталоге, затем переименование. КаталогInputсоздаётся при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня —RECOGNITION_FAILED. - Ответ ИС:
<SCAN_OUTPUT_DIR>/<job_id>/<job_id>.json(читается только этот файл).job_idпроверяется на безопасность пути. Ответ, который ещё пишется (не JSON), перечитывается на следующем опросе. - Таймаут ожидания:
min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS); опрос каждыеSCAN_POLL_SECONDS. Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется. - Повторной отправки в ИС нет (OQ-15): после
TIMEOUTвходящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре). - Эмулятор ИС в
local-режиме: для файла<uuid>_<имя>ждёт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 groupscan-gateway,Nats-Msg-Id = <job_id>:<attempt>. - Ответы — subject
recognition.result: ackstatus=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.
Разработка
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). Вручную те же шаги:
- Выполнить «Разработку» выше: сервис и эмулятор работают,
/healthcheckотдаётnats = ok,hotfolder = ok. - Положить файл в MinIO (консоль http://localhost:9001 или
mc) в бакетarm-ka, напримерuploads/<file_id>/<job_id>/aep_5.pdf(имя файла должно совпадать с фикстурой эмулятора:aep_5.*->aep_5.json,aep_8.*->aep_8_form4.json). - Подписаться на
recognition.resultлюбым клиентом NATS. - Опубликовать в
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. - Ожидать: ack
processing, черезSCAN_EMULATOR_DELAY— конвертerror. Дляaep_5/aep_8этоINVALID_DOCUMENTс сообщением ИС (обе реальные фикстуры — ответы-ошибки); для неизвестного имени —INVALID_DOCUMENTэмулятора. В бакете появитсяraw/<job_id>.json,raw_s3_keyуказан в конверте. - Путь «успех ->
NOT_IMPLEMENTED» требует успешной фикстуры: добавьте вSCAN_FIXTURES_DIRсинтетический<имя>.jsonсerror: null.NullNormalizerсодержимоеdata_ocrне смотрит; непустойdata_ocrв фикстуреok_doc.jsonизtest_roundtrip.py— требование тестовой фикстуры (форма ответа ИС), а не поведенияNullNormalizer.
Проверки
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 отсутствует.