# Spec Delta: image-recognition-gateway ## Purpose Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream, передаёт файл через горячие папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по контракту v1.0. Без БД и фронта. ## ADDED Requirements ### Requirement: Сервис не зависит от БД и фронта Репозиторий MUST NOT содержать `web/`, `alembic/`, `src/db/`, `src/models/`, `src/repositories/`; зависимости `SQLAlchemy`, `asyncpg`, `alembic`, `raisa_fastapi_protected_api` MUST NOT присутствовать; `smbprotocol` SHALL остаться. Для старта SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFOLDER_MODE=local`, `SCAN_HOTFOLDER_PATH`. #### Scenario: Старт в режиме локальных папок - **WHEN** сервис запущен с `SCAN_HOTFOLDER_MODE=local` и существующим каталогом `SCAN_HOTFOLDER_PATH` - **THEN** `GET /healthcheck` → 200 с полями `uptime`, `version`, `nats`, `hotfolder = ok`; `OPENBAO__SMB__*` не требуются ### Requirement: Подтверждение запроса до обработки Сервис SHALL быть подписан на `recognition.image.request` в queue group `scan-gateway`. Получив запрос, проходящий схему, сервис SHALL до чтения файла из S3 опубликовать в `recognition.result` конверт `status = processing` (`service.name = "scan-gateway"`, `Nats-Msg-Id = ack::`). Повторный запрос с `job_id`, который уже обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться. #### Scenario: Ack раньше файла - **WHEN** получен запрос для `aep_5.pdf` - **THEN** конверт `processing` опубликован раньше, чем файл появился во входящем каталоге #### Scenario: Повторный запрос - **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС - **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `_aep_5.pdf` ### Requirement: Передача файла в ИС Сканирование через горячие папки После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как `_`; затем SHALL опрашивать исходящий каталог `//.json` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. #### Scenario: Файл положен во входящий каталог - **WHEN** получен запрос для `aep_5.pdf` с `job_id = 3f1c…` - **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3 #### Scenario: Ответ получен - **WHEN** в `Output/3f1c…/3f1c….json` появляется ответ ИС до истечения таймаута - **THEN** опрос прекращается не позднее чем через `SCAN_POLL_SECONDS`, ответ передаётся дальше по конвейеру ### Requirement: Сырой ответ сохраняется в S3 до нормализации Полученный ответ ИС SHALL быть записан как есть в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; ключ SHALL передаваться в конверте результата полем `raw_s3_key`; в БД объект не регистрируется (ядро его не создаёт). #### Scenario: Нормализатор упал - **WHEN** ответ ИС получен, а нормализатор бросил исключение - **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR` ### Requirement: Результат по контракту Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с `service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз. В Срезе 0 нормализатор SHALL возвращать: при `error ≠ null` в ответе ИС — `status = error` с кодом из ответа; иначе — `error.code = NOT_IMPLEMENTED`. #### Scenario: Успешный ответ ИС в Срезе 0 - **WHEN** ответ ИС содержит `data_ocr` без `error` - **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.json` #### Scenario: Ошибка ИС - **WHEN** ответ ИС содержит `error.code = INVALID_DOCUMENT` - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен ### Requirement: Таймаут ожидания ответа Если ответ не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; повторная отправка файла в ИС MUST NOT выполняться. #### Scenario: ИС молчит - **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется - **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не удалась → `RECOGNITION_FAILED`; ответ ИС не разбирается как JSON → `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться. #### Scenario: Горячая папка недоступна - **WHEN** каталог `SCAN_HOTFOLDER_PATH` не существует в момент обработки - **THEN** конверт `RECOGNITION_FAILED` с сообщением о недоступности горячей папки; `/healthcheck.hotfolder = down` ### Requirement: Параллельная обработка ограничена Сервис SHALL обрабатывать не более `SCAN_MAX_INFLIGHT` (по умолчанию 4) заданий одновременно; задания сверх лимита SHALL ждать слот во внутренней очереди, их ack SHALL быть опубликован сразу при получении. #### Scenario: Пять заданий подряд - **WHEN** в очереди пять запросов при `SCAN_MAX_INFLIGHT = 4` - **THEN** пять ack'ов опубликованы сразу, четыре файла появляются во входящем каталоге, пятый — после завершения любого из четырёх ### Requirement: Копия контракта идентична источнику `api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs; проверка входит в `make check`. #### Scenario: `make check` - **WHEN** выполняются тесты контракта при доступном `../docs` - **THEN** идентичность подтверждена, примеры валидны ### Requirement: Соединение с NATS без JetStream При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT; MUST NOT требовать stream'ов и consumer'ов JetStream; при разрыве SHALL переподключаться и восстанавливать подписку; `/healthcheck.nats` SHALL отражать состояние. #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса - **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`