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
This commit is contained in:
parent
8bb10949ec
commit
7cec4596d9
168
openspec/specs/image-recognition-gateway/spec.md
Normal file
168
openspec/specs/image-recognition-gateway/spec.md
Normal file
@ -0,0 +1,168 @@
|
||||
# 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`
|
||||
62
openspec/specs/scan-emulator/spec.md
Normal file
62
openspec/specs/scan-emulator/spec.md
Normal file
@ -0,0 +1,62 @@
|
||||
# scan-emulator Specification
|
||||
|
||||
## Purpose
|
||||
Эмулятор ИС Сканирование для разработки и тестов: воспроизводит протокол горячих папок и формат ответов Content AI,
|
||||
отдавая реальные ответы из `../fixtures/ocr` по имени файла, чтобы конвейер `scan-gateway` проверялся без доступа к
|
||||
банковской системе.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Эмулятор отвечает по протоколу горячих папок
|
||||
Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла
|
||||
`<uuid>_<name>.<ext>` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать каталог `<output>/<uuid>/` с
|
||||
набором ответов (по одному файлу на документ); каталог SHALL появиться в `<output>` атомарно целиком: набор записывается
|
||||
во временный каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно.
|
||||
|
||||
#### Scenario: Один файл
|
||||
- **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`
|
||||
- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done`
|
||||
|
||||
#### Scenario: Набор появляется целиком
|
||||
- **WHEN** эмулятор записывает набор из нескольких файлов
|
||||
- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет
|
||||
|
||||
### Requirement: Ответы берутся из фикстур по имени
|
||||
Эмулятор SHALL собрать в `SCAN_FIXTURES_DIR` **все** файлы `<name>.json` и `<name>_*.json`, где `<name>` — имя входящего
|
||||
файла без префикса `<uuid>_` и расширения (регистр расширения входящего файла не важен). Файл `<name>.json` SHALL быть
|
||||
записан как `<uuid>.json`, файл `<name>_<suffix>.json` — как `<uuid>_<suffix>.json`; в каждом `file_uid = <uuid>`.
|
||||
Фикстуры вида `<name><другие символы>.json` без разделителя `_` (например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор.
|
||||
|
||||
#### Scenario: Точное имя
|
||||
- **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json`
|
||||
- **THEN** в `Output/<uuid>/` один файл `<uuid>.json`, равный `aep_5.json` с заменённым `file_uid`
|
||||
|
||||
#### Scenario: Суффикс
|
||||
- **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json`
|
||||
- **THEN** в `Output/<uuid>/` файл `<uuid>_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid`
|
||||
|
||||
#### Scenario: Несколько файлов на одно задание
|
||||
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json`
|
||||
- **THEN** в `Output/<uuid>/` два файла: `<uuid>.json` (из `aep_3.json`) и `<uuid>_ofr.json` (из `aep_3_ofr.json`), в обоих `file_uid = <uuid>`
|
||||
|
||||
#### Scenario: Чужой префикс
|
||||
- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть также `aep_30.json`
|
||||
- **THEN** `aep_30.json` в набор не попадает
|
||||
|
||||
### Requirement: Неизвестный файл — ошибка в формате Content AI
|
||||
Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ `<uuid>.json` в формате реальных ответов-ошибок
|
||||
ИС (`aep_5.json`, `aep_8_form4.json`): `file_uid = <uuid>`, `error = {"code": "INVALID_DOCUMENT", "message": "<имя>: нет
|
||||
эталонного ответа в фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`,
|
||||
`date` — `{"form_name": "", "confidence": 0}`) и `table_structure: []`.
|
||||
|
||||
#### Scenario: Нет пары
|
||||
- **WHEN** входящий файл — `…_unknown.pdf`
|
||||
- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом
|
||||
|
||||
### Requirement: Эмулятор запускается из того же образа
|
||||
В `docs/dev/docker-compose.yml` сервис `scan-emulator` SHALL использовать образ `scan-gateway` с командой
|
||||
`python -m src.scan_emulator`, общий том `hotfolder` и `../../fixtures/ocr`, смонтированный только для чтения.
|
||||
|
||||
#### Scenario: Compose
|
||||
- **WHEN** выполнен `docker compose --profile services up -d`
|
||||
- **THEN** контейнер `scan-emulator` работает, его лог сообщает каталоги и число найденных пар фикстур
|
||||
Loading…
x
Reference in New Issue
Block a user