Andrey Epifancev 7cec4596d9 chore(openspec): архив change slice-0-skeleton
Срез 0 реализован и влит (PR #3), change перенесён в openspec/changes/archive, спеки — в openspec/specs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-04 19:44:47 +04:00

169 lines
17 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.

# 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 быть подписан на `recognition.image.request` в queue group `scan-gateway`. Получив запрос, проходящий схему,
сервис SHALL до чтения файла из S3 опубликовать в `recognition.result` конверт `status = processing`
(`service.name = "scan-gateway"`, `Nats-Msg-Id = ack:<job_id>:<attempt>`). Повторный запрос с `job_id`, который уже
обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться.
#### Scenario: Ack раньше файла
- **WHEN** получен запрос для `aep_5.pdf`
- **THEN** конверт `processing` опубликован раньше, чем файл появился во входящем каталоге
#### Scenario: Повторный запрос
- **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС
- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `<job_id>_aep_5.pdf`
### Requirement: Передача файла в ИС Сканирование через горячие папки
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
`<job_id>_<original_name>`; временный файл записи SHALL лежать в служебном каталоге `<корень>/<SCAN_TMP_DIR>/`
(по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и переименовываться в `Input` атомарно, так
что во входящем каталоге MUST NOT появляться недописанные и временные файлы. Затем сервис SHALL опрашивать исходящий
каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`.
ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ. Ответ SHALL считаться
готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS`
(по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы
игнорируются.
#### Scenario: Файл положен во входящий каталог
- **WHEN** получен запрос для `aep_5.pdf` с `job_id = 3f1c…`
- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует
#### 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** ответ не считается готовым, опрос продолжается до дедлайна
### Requirement: Сырой ответ сохраняется в S3 до нормализации
Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "<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/<job_id>/` файлы `<job_id>.json` и `<job_id>_ofr.json`
- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`<job_id>.json`, затем `<job_id>_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null`
#### Scenario: Файл не JSON
- **WHEN** один из файлов ответа не разбирается как JSON
- **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает
### Requirement: Результат по контракту
Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с
`service.name = "scan-gateway"`, `Nats-Msg-Id = result:<job_id>:<attempt>`; при `status ∈ {done, needs_check}` — файл
результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз.
В Срезе 0 нормализатор получает склеенный ответ и 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`. Если есть хотя бы один успешный
файл — `status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной
строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для
`parse_error` и не-объекта — `INVALID_FORMAT`); если ошибочных файлов нет, поле `warnings` SHALL быть опущено.
#### Scenario: Успешный ответ ИС в Срезе 0
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`)
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/<job_id>.json`, поле `metrics.warnings` отсутствует
#### Scenario: Ошибка ИС
- **WHEN** единственный файл ответа `<job_id>.json` содержит `error.code = INVALID_DOCUMENT`
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_DOCUMENT: <сообщение>`
#### Scenario: Ошибка одного из документов
- **WHEN** ответ состоит из `<job_id>.json` (успех) и `<job_id>_ofr.json` (`INVALID_DOCUMENT`) — как у фикстур `aep_3.json` и `aep_3_ofr.json`
- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку `<job_id>_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` содержит одну строку `<job_id>.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` — `<job_id>.json: SOME_NEW_CODE: <сообщение>`
### Requirement: Таймаут ожидания ответа
Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
повторная отправка файла в ИС MUST NOT выполняться.
#### Scenario: ИС молчит
- **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется
- **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге
#### Scenario: Набор не успел настояться до дедлайна
- **WHEN** в `Output/<job_id>/` есть `*.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`; иное исключение → `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`