arm-scan-gateway/README.md
Andrey Epifancev 17e334f556 Срез 0 [fix docs]: README, CLAUDE.md и design.md #32 сверены с кодом
README: настройки шлюза читаются при импорте `src.app`, пакет `src` и эмулятор их не читают (после C-1);
`data_ocr` реальных фикстур — объект с пустыми полями и `table_structure: []`; непустой `data_ocr` в `ok_doc.json` —
требование тестовой фикстуры, не NullNormalizer; в «Ограничения Среза 0» — зависший вызов SMB и поздний TIMEOUT
(T12.2/T8.4), перечитывание недописанного ответа (T3.5), битая фикстура эмулятора.
CLAUDE.md: TIMEOUT по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`; заглушки шаблона перечислены.
design.md, строка 32: формулировка про `data_ocr` реальных фикстур.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 05:05:42 +04:00

239 lines
23 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>
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 (`: < > " | ? *` и управляющие), заменяются на `_`,
завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются. Если имени не осталось —
`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 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_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: <job_id>:1`.
5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` — конверт `error`. Для `aep_5`/`aep_8` это `INVALID_DOCUMENT`
с сообщением ИС (обе реальные фикстуры — ответы-ошибки); для неизвестного имени — `INVALID_DOCUMENT` эмулятора.
В бакете появится `raw/<job_id>.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 отсутствует.