# image-recognition-gateway Specification ## Purpose Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream, передаёт файл через горячие папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по контракту v1.0. Без БД и фронта. ## 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 быть подписан на `files.img_uploaded` в queue group `scan-gateway`. Получив запрос, проходящий схему, сервис SHALL до чтения файла из S3 опубликовать в `files.tabular_parsed` конверт `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, во входящем каталоге остаётся один файл `.pdf` ### Requirement: Передача файла в ИС Сканирование через горячие папки После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как `.`, где `` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога и переименования нет; если `/.` уже существует, он SHALL быть перезаписан содержимым объекта S3. Затем сервис SHALL опрашивать исходящий каталог `//` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется; пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются. #### Scenario: Файл положен во входящий каталог - **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…` - **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет #### Scenario: Назначение уже существует - **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе) - **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется #### Scenario: Запрос без расширения - **WHEN** получен запрос с `original_name = "scan"` без точки - **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет #### Scenario: Ответ из одного файла - **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` - **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру #### Scenario: Ответ из нескольких файлов - **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` - **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется #### Scenario: Набор ещё пишется - **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад - **THEN** ответ не считается готовым, опрос продолжается до дедлайна #### Scenario: Ошибка посреди записи - **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода - **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте ### Requirement: Сырой ответ сохраняется в S3 до нормализации Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "", "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}` (файлы по возрастанию имени) и записаны в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; файл, не разобранный как JSON, SHALL получить `response = null` и заполненный `parse_error` и MUST NOT прерывать задание. Ключ SHALL передаваться в конверте результата полем `raw_s3_key` (одна строка `raw/{job_id}.json`); в БД объект не регистрируется (ядро его не создаёт). #### Scenario: Нормализатор упал - **WHEN** ответ ИС получен, а нормализатор бросил исключение - **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR` #### Scenario: Склеенный сырой ответ - **WHEN** ИС положила в `Output//` файлы `.json` и `_ofr.json` - **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`.json`, затем `_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null` #### Scenario: Файл не JSON - **WHEN** один из файлов ответа не разбирается как JSON - **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает ### Requirement: Результат по контракту Сервис SHALL публиковать в `files.tabular_parsed` конверт по `recognition-result-message.schema.json` с `service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл результата (`recognition-result.schema.json`, `schema_version = "1.0"` — `CONTRACT_VERSION` сервиса, решение №14; схема допускает любой MINOR) в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; отсутствующий или `null` `error` ошибкой не считается), и ошибочным, если `error.code` непуст, `parse_error` не `null` либо корень ответа файла не является объектом JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться проверкой «`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС», а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено. #### Scenario: Успешный ответ ИС в Срезе 0 - **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`) - **THEN** начиная со Среза 3a конверт больше не `NOT_IMPLEMENTED`: поведение — по сценарию «Успешный ответ ИС» ниже #### Scenario: Успешный ответ ИС - **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`) - **THEN** конверт `status = needs_check`, `raw_s3_key = raw/.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`) #### Scenario: Ошибка ИС - **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `.json: INVALID_DOCUMENT: <сообщение>` #### Scenario: Ошибка одного из документов - **WHEN** ответ состоит из `.json` (успех, `aep_3.json`) и `_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`) - **THEN** конверт `status = needs_check`, документы собраны из `.json`, `metrics.warnings` содержит строку `_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации #### Scenario: Все документы ошибочны - **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT` #### Scenario: Все файлы не JSON - **WHEN** единственный файл ответа не разбирается как JSON - **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит одну строку `.json: INVALID_FORMAT: <сообщение разбора>` #### Scenario: Корень ответа не объект - **WHEN** файл ответа — корректный JSON, но корень — массив - **THEN** этот файл ошибочен с кодом `INVALID_FORMAT`; остальные файлы оцениваются по общим правилам #### Scenario: Код ошибки ИС вне перечня контракта - **WHEN** единственный файл ответа содержит `error.code = SOME_NEW_CODE` - **THEN** конверт `status = error`, `error.code = UNKNOWN_ERROR`, `error.message` содержит `SOME_NEW_CODE`, строка в `metrics.warnings` — `.json: SOME_NEW_CODE: <сообщение>` ### Requirement: Таймаут ожидания ответа Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; повторная отправка файла в ИС MUST NOT выполняться. #### Scenario: ИС молчит - **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется - **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге #### Scenario: Набор не успел настояться до дедлайна - **WHEN** в `Output//` есть `*.json`, но набор имён или размеров менялся позже, чем `SCAN_SETTLE_SECONDS` до дедлайна - **THEN** ответ не считается готовым, опубликован конверт `TIMEOUT`, недостающие файлы не ожидаются ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором → `RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт. Во всех случаях конверт 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 из `OPENBAO__NATS__NATS_SEED`/`NATS_JWT` (значения — секреты); MUST NOT требовать stream'ов и consumer'ов JetStream; при разрыве SHALL переподключаться и восстанавливать подписку; `/healthcheck.nats` SHALL отражать состояние. #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса - **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok` ### Requirement: Очистка горячей папки под флагом При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от исхода нормализации), сервис SHALL удалить всё содержимое `//` (json-файлы ответа и любые прочие) и сам каталог ``, входящий файл `/.` и его вариант `/..done`, если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться (при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`. #### Scenario: Очистка включена - **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/` - **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done` #### Scenario: Очистка выключена - **WHEN** `SCAN_CLEANUP=false`, ответ прочитан - **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27) #### Scenario: Нет прав на удаление - **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError` - **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok` #### Scenario: Таймаут не чистит - **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна - **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output//` (если есть) не тронуты ### Requirement: Нормализация ответа ИС в `statementDocument` Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный `statementDocument` по §11 и §11.1 `05-domain-rules.md`: - `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`, по большинству); иначе `OTHER`. Если `label` и коды дают разный тип: при единодушных кодах (все коды данных одной группы) — тип по кодам, предупреждение `тип по label (