diff --git a/CLAUDE.md b/CLAUDE.md index 8069b60..a16d0cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,8 +33,8 @@ CORS, импорт `raisa_fastapi_protected_api`; `db/`, `models/`, `repositorie **Добавлено:** `api/src/gateway/` (горячие папки: положить `UUID_имя.pdf` во «Входящие», опрашивать «Исходящие/UUID/UUID.json»; режим `local` — обычный каталог для dev, `smb` — как в шаблоне через `smbclient`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`), `api/src/normalizer/` (ответ Content AI → `statementDocument`; Срез 3), -`api/src/contract/` (pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в CI), -`api/src/core/worker.py` (durable-подписка `recognition.image.request` → S3 → горячая папка → ожидание ≤ `reply_deadline` +`api/src/contract/` (pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в `make check`), +`api/src/core/worker.py` (подписка queue group `recognition.image.request` → S3 → горячая папка → ожидание ≤ `reply_deadline` → S3 `raw/` и `results/` → `recognition.result`), `api/src/scan_emulator/` (эмулятор ИС Сканирование для compose: следит за «Входящими», кладёт `../fixtures/ocr/aep_N.json` по имени файла в «Исходящие»; запускается тем же образом, `SCAN_EMULATOR=1`), `Dockerfile`, `api/pyproject.toml` (ruff, mypy). Проверки: `pytest`, `ruff check . && ruff format --check .`, `mypy src`. diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index a8ec999..9478e39 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -6,12 +6,13 @@ `reference-old-arm/api/src/core/PDFWorker`): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr[]`), таймаут 5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном -процессе с ядром — это и есть то, от чего уходит DECISION-4. +процессе с ядром — это и есть то, от чего уходит DECISION-4. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен +до прода, CI в Gitea нет. ## Goals / Non-Goals -**Goals:** конвейер «NATS → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в unit-тестах -без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов. +**Goals:** конвейер «NATS → ack → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в +unit-тестах без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов. **Non-Goals:** см. proposal. ## Decisions @@ -23,17 +24,19 @@ (как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Режим — `SCAN_HOTFOLDER_MODE = local | smb`. -### D-2. Без in-memory реестра заданий: состояние — в NATS и в горячей папке -Задание «в работе» = неподтверждённое сообщение consumer'а. Падение инстанции → сообщение не ack'нуто, но `max_deliver = 1` -(OQ-15: OCR без повторов) → повторной отправки в ИС не будет; ядро закроет задание по `reply_deadline` (`timeout`). Так -нет расхождения между кэшем и шарой, как в старом коде. *Альтернатива* — хранить задания в Redis/файле: лишняя -инфраструктура ради ситуации, которую ядро уже обрабатывает. +### D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу +NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении +запроса шлюз сразу публикует ack `processing` и держит process-local множество `job_id` «в работе/обработано» (LRU, +`DONE_CACHE_SIZE=1000`): повторный запрос (ядро не дождалось ack) → повторный ack, второй файл в ИС не уходит. Падение +инстанции после ack → ядро закроет задание по `reply_deadline` (`timeout`); повторной отправки в ИС не будет (OQ-15, +0 повторов). *Альтернатива* — хранить задания в Redis/файле: лишняя инфраструктура ради ситуации, которую ядро уже обрабатывает. ### D-3. Параллелизм — `SCAN_MAX_INFLIGHT` задач, каждая ждёт свой ответ -Воркер делает `fetch(n)` на свободные слоты и запускает `asyncio.Task` на задание; ожидание — опрос `poll(job_id)` каждые -`SCAN_POLL_SECONDS` (5) до `deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS)`. `ack_wait` consumer'а -= `SCAN_TIMEOUT_SECONDS + 120`. При таймауте — результат `TIMEOUT`, файл во входящем каталоге не удаляется (ИС может -ответить позже; «поздний» ответ игнорируется — его никто не опрашивает; уборка каталога — задача эксплуатации, вне среза). +Подписка `queue="scan-gateway"`; `on_request` валидирует, публикует ack и кладёт задание во внутреннюю очередь; семафор +`SCAN_MAX_INFLIGHT` (4) ограничивает одновременные обработки; ожидание — опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` +(5) до `deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS)`. При таймауте — результат `TIMEOUT`, файл во +входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — задача +эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий. ### D-4. Сырой ответ — в S3 `raw/{job_id}.json`, в конверте `raw_s3_key` По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется @@ -53,19 +56,25 @@ не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его. ### D-7. Ошибки транспорта — результат, не молчание -Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH`; S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку упал → -`RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее → `UNKNOWN_ERROR`. -Конверт публикуется всегда; ack — после публикации; провал публикации → nak 5 с. +Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку +упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее → +`UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error, +ядро закроет по `reply_deadline`. -### D-8. NATS — как у ядра и парсера -Без учётных данных при пустых `NATS_SEED/NATS_JWT`, иначе nkeys/JWT шаблона; stream не создаёт; повтор подписки каждые -10 с; `/healthcheck` отдаёт `nats` и `hotfolder`. +### D-8. NATS — как у ядра и парсера, без JetStream +Без учётных данных при пустых `NATS_SEED/NATS_JWT`, иначе nkeys/JWT шаблона; автопереподключение `nats-py`; подписка +queue group восстанавливается клиентом; `/healthcheck` отдаёт `nats` и `hotfolder`. ### D-9. Тесты Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end -«put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — все коды D-7 и таймаут (с укороченными -настройками). `SmbHotFolder` — unit с подменённым `smbclient`. Интеграционный (маркер `integration`): compose с -`scan-emulator` — `request-image.json` для `aep_5.pdf` → конверт `NOT_IMPLEMENTED` с `raw_s3_key`, объект `raw/` в MinIO. +«put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды +D-7 и таймаут (с укороченными настройками). `SmbHotFolder` — unit с подменённым `smbclient`. Интеграционный (маркер +`integration`): compose с `scan-emulator` — `request-image.json` для `aep_5.pdf` → ack, затем конверт `NOT_IMPLEMENTED` +с `raw_s3_key`, объект `raw/` в MinIO. + +### D-10. Dev без Nexus, Python 3.11 +`Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*`. Конфигураций CI нет: +`make check` / `make check-full`. ## Risks / Trade-offs @@ -73,6 +82,8 @@ Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается вызовы старого `PDFWorker`; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду. - [Имена каталогов ИС отличаются от `Input/Output`] → настройки, не код. - [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре. +- [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди; + ядро переводит в `timeout`, replay — Срез 3+. - [Эмулятор знает только две пары фикстур] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые пары добавляются в `fixtures/ocr` без изменения кода. @@ -90,10 +101,11 @@ Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается | № | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-03 | D-1: протокол `HotFolder`, режимы `local`/`smb`, имена каталогов `Input`/`Output` по умолчанию | dev без SMB; имена из старого кода | смена настройки | -| 2 | 2026-10-03 | D-2: без реестра заданий, `max_deliver = 1` | OQ-15, таймаут закрывает ядро | добавить хранилище состояния, если понадобятся повторы | +| 2 | 2026-10-03 | D-2: queue group `scan-gateway`, ack сразу, process-local LRU `job_id`, без реестра на диске | NATS без JetStream (ответ 03.10); OQ-15 | добавить хранилище состояния, если понадобятся повторы OCR | | 3 | 2026-10-03 | D-3: `SCAN_MAX_INFLIGHT = 4`, опрос 5 с, таймаут 300 с | ТЗ 3 (5 мин) | смена настроек | | 4 | 2026-10-03 | D-4: сырой ответ сохраняется до нормализации | данные для инцидентов даже при падении нормализатора | нет | | 5 | 2026-10-03 | D-5: `NullNormalizer` пробрасывает ошибки ИС, успех → `NOT_IMPLEMENTED` | честный статус; коды Content AI совпадают с контрактом | нет | | 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки | общие соглашения горячих папок; `aep_8.pdf` ↔ `aep_8_form4.json` | нет | -| 7 | 2026-10-03 | D-7: таблица транспортных ошибок | ядро не ждёт таймаута, если шлюз жив | нет | +| 7 | 2026-10-03 | D-7: таблица транспортных ошибок, публикация с 3 повторами | ядро не ждёт таймаута, если шлюз жив | нет | | 8 | 2026-10-03 | `external_task_id` в ядро не передаётся (идентификатор ИС = `job_id` в имени файла) | у протокола горячих папок нет отдельного id задания | поле DBML остаётся NULL | +| 9 | 2026-10-03 | D-10: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега | diff --git a/openspec/changes/slice-0-skeleton/proposal.md b/openspec/changes/slice-0-skeleton/proposal.md index 78637bb..68b9e36 100644 --- a/openspec/changes/slice-0-skeleton/proposal.md +++ b/openspec/changes/slice-0-skeleton/proposal.md @@ -1,15 +1,15 @@ # Proposal: Срез 0 — скелет scan-gateway **Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** scan-gateway. **Issue:** RSHB/arm-scan-gateway#1. -**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, CI-шаблон). +**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, проверки качества). **Соседние:** `../arm-core/openspec/changes/slice-0-skeleton`, `../tabular-parser/openspec/changes/slice-0-skeleton`. ## Why Репозиторий — копия каркаса `arm-core` с фронтом и БД, тогда как по DECISION-4 это stateless-адаптер внешней ИС Сканирование. Самая рискованная часть адаптера — не разбор JSON (Срез 3), а обмен через горячие папки с таймаутом и -без доступа к БД: положить файл, дождаться ответа, не потерять задание. Срез 0 строит этот конвейер и эмулятор ИС -Сканирование, чтобы он работал в compose без доступа к банковской системе. +без доступа к БД: положить файл, дождаться ответа, не потерять задание — причём на NATS без JetStream. Срез 0 строит +этот конвейер и эмулятор ИС Сканирование, чтобы он работал в compose без доступа к банковской системе. ## What Changes @@ -18,18 +18,19 @@ `alembic/`, `alembic.ini`; настройка `OPENBAO.DATABASE`; зависимости `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md`. **SMB остаётся** (`smbprotocol`, `OPENBAO.SMB`, сессия в lifespan). - **Инструменты**: `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + инструменты, - `Dockerfile` (строка убрана из `.gitignore`), `Makefile`, `.env.example`, `.env.test`; `Settings` читает `ENV_FILE`; - `SERVICE_NAME = "scan-gateway"`. + `Dockerfile` (`python:3.11-slim`; строка убрана из `.gitignore`), `Makefile` с общими целями + `venv`, `emulator`, + `.env.example`, `.env.test`; `Settings` читает `ENV_FILE`; `SERVICE_NAME = "scan-gateway"`. - **Контракт**: вендоренные схемы `api/contracts/` + pydantic-модели `src/contract/`; тест идентичности с `../docs/contracts`. - **Горячие папки** `src/gateway/`: протокол `HotFolder` (`put(name, data)`, `poll(uuid) -> bytes | None`) с двумя реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона; прод). Имена подкаталогов настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`); файл кладётся как `_`, ответ ожидается в `//.json`. -- **Воркер** `src/core/worker.py`: durable pull-consumer `scan-gateway` на `recognition.image.request` (`max_deliver` 1 — - OQ-15), до `SCAN_MAX_INFLIGHT` (4) заданий параллельно: S3 `uploads/…` → горячая папка → опрос каждые - `SCAN_POLL_SECONDS` (5) до `min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → сырой ответ в S3 `raw/{job_id}.json` - → нормализатор → `results/{file_id}/{job_id}/result.json` → конверт `recognition.result` (`raw_s3_key` заполнен) → ack. - Таймаут → `error.code = TIMEOUT`. +- **Воркер** `src/core/worker.py` (NATS без JetStream): подписка на `recognition.image.request` в queue group + `scan-gateway`; по получении — сразу ack `processing` в `recognition.result`; до `SCAN_MAX_INFLIGHT` (4) заданий + параллельно: S3 `uploads/…` → горячая папка → опрос каждые `SCAN_POLL_SECONDS` (5) до + `min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → сырой ответ в S3 `raw/{job_id}.json` → нормализатор → + `results/{file_id}/{job_id}/result.json` → конверт результата (`raw_s3_key` заполнен). Таймаут → `error.code = TIMEOUT`. + Process-local множество `job_id` в работе: повторный запрос получает ack и не создаёт второй файл в горячей папке. - **Нормализатор как интерфейс** `src/normalizer/`: протокол `Normalizer(raw_json) -> documents | error`; в Срезе 0 — `NullNormalizer`: ошибки Content AI (`error.code` из ответа: `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`) пробрасываются как есть; успешный ответ → `NOT_IMPLEMENTED` (Срез 3 реализует §11). @@ -40,13 +41,13 @@ - **Ошибки транспорта** → результат: запрос не по схеме → `SCHEMA_MISMATCH`; S3 не читается → `FILE_READ_ERROR`; горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON → `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`. - **Healthcheck** дополняется полями `nats` и `hotfolder` (`ok`/`down`). -- **CI**: `.gitea/workflows/ci.yml` из шаблона docs. +- **Проверки качества** вместо CI: `make check` и `make check-full` по `../docs/dev/checks.md`. ## Capabilities ### New Capabilities -- `image-recognition-gateway`: адаптер ИС Сканирование — приём задания из NATS, обмен файлами через горячие папки с - таймаутом, сохранение сырого ответа, результат по контракту v1.0. +- `image-recognition-gateway`: адаптер ИС Сканирование — приём и подтверждение задания из NATS без JetStream, обмен + файлами через горячие папки с таймаутом, сохранение сырого ответа, результат по контракту v1.0. - `scan-emulator`: эмулятор ИС Сканирование для разработки и тестов, воспроизводящий протокол горячих папок на фикстурах `../fixtures/ocr`. ### Modified Capabilities @@ -54,16 +55,18 @@ ## Impact -- **Таблицы DBML:** сервис не пишет в БД. Косвенно: `recognition_job` ядра получает `status`, `error_code` - (`TIMEOUT`, `NOT_IMPLEMENTED`, коды Content AI), `service_name = scan-gateway`, `external_task_id` — не передаётся - (у ИС Сканирование идентификатор = наш `job_id` в имени файла). Сырой ответ `raw/{job_id}.json` в БД не регистрируется (v5.13 п. 5). +- **Таблицы DBML:** сервис не пишет в БД. Косвенно: `recognition_job` ядра получает `status = processing` (`started_at`), + затем `status`, `error_code` (`TIMEOUT`, `NOT_IMPLEMENTED`, коды Content AI), `service_name = scan-gateway`; + `external_task_id` не передаётся (у ИС Сканирование идентификатор = наш `job_id` в имени файла). Сырой ответ + `raw/{job_id}.json` в БД не регистрируется (v5.13 п. 5). - **Правила:** §11 — только как контракт нормализатора; §9.4 — статусы задания; OQ-13/14/15 — допущения в коде нормализатора (Срез 3). - **Зависимости:** удаляются БД/фронт; остаётся `smbprotocol`; добавляются `ruff`, `mypy`, `pytest*`, `jsonschema`. -- **Внешние системы:** NATS JetStream, MinIO/S3, SMB-шара ИС Сканирование (прод) / локальный каталог (dev). +- **Внешние системы:** NATS без JetStream, MinIO/S3, SMB-шара ИС Сканирование (прод) / локальный каталог (dev). ## Non-goals - Нормализация ответа Content AI в `statementDocument` (§11, OQ-13, OQ-14) — Срез 3. - PNG/JPG — транспорт не различает типы, но проверяется только PDF (пары в `fixtures/ocr`). -- Повторы OCR (`max_attempts` = 1 по OQ-15), heartbeat `processing`. +- Повторы OCR (`max_attempts` = 1 по OQ-15), периодический heartbeat `processing`. - Реальная SMB-шара в тестах — `SmbHotFolder` покрыт только unit-тестами с подменой `smbclient`. +- Конфигурации CI. diff --git a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md index c0565dc..c4d9401 100644 --- a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md @@ -2,8 +2,9 @@ ## Purpose -Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает задание из NATS, передаёт файл через горячие -папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по контракту v1.0. Без БД и фронта. +Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream, +передаёт файл через горячие папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по +контракту v1.0. Без БД и фронта. ## ADDED Requirements @@ -16,10 +17,24 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO - **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: Передача файла в ИС Сканирование через горячие папки -Для запроса `recognition.image.request`, проходящего схему, сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и -записать его во входящий каталог как `_`; затем SHALL опрашивать исходящий каталог -`//.json` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. +После подтверждения сервис 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…` @@ -37,50 +52,60 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO - **WHEN** ответ ИС получен, а нормализатор бросил исключение - **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR` -### Requirement: Результат по контракту и подтверждение +### Requirement: Результат по контракту Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с -`service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ≠ error` — файл результата в -`results/{file_id}/{job_id}/result.json` и `result_s3`; запрос SHALL подтверждаться только после публикации конверта. +`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`, запрос подтверждён +- **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 выполняться (consumer `max_deliver = 1`). +Если ответ не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; +повторная отправка файла в ИС MUST NOT выполняться. #### Scenario: ИС молчит - **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется -- **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге, запрос подтверждён +- **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → -`SCHEMA_MISMATCH`; объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не удалась → -`RECOGNITION_FAILED`; ответ ИС не разбирается как JSON → `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. -Во всех случаях конверт SHALL быть опубликован, запрос подтверждён, процесс MUST NOT завершаться. +`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) заданий одновременно; `ack_wait` consumer'а -SHALL превышать `SCAN_TIMEOUT_SECONDS` не менее чем на 120 с. +Сервис SHALL обрабатывать не более `SCAN_MAX_INFLIGHT` (по умолчанию 4) заданий одновременно; задания сверх лимита +SHALL ждать слот во внутренней очереди, их ack SHALL быть опубликован сразу при получении. #### Scenario: Пять заданий подряд - **WHEN** в очереди пять запросов при `SCAN_MAX_INFLIGHT = 4` -- **THEN** четыре файла появляются во входящем каталоге сразу, пятый — после завершения любого из четырёх +- **THEN** пять ack'ов опубликованы сразу, четыре файла появляются во входящем каталоге, пятый — после завершения любого из четырёх ### Requirement: Копия контракта идентична источнику -`api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs. +`api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs; +проверка входит в `make check`. -#### Scenario: CI сервиса +#### 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` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 1fbb8d9..4f93a14 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -1,7 +1,7 @@ # Tasks: Срез 0 — скелет scan-gateway Репозиторий — scan-gateway; команды — из `api/`. Зависимости: 3.1 ждёт контракт v1.0 (`docs` 1.1–1.4); интеграционные -тесты 6.x — compose (`docs` 2.1, 5.3). Каждая задача ≤ половины дня. +тесты 6.x — compose (`docs` 2.1, 5.3). Каждая задача ≤ половины дня. CI нет — проверки локальные. ## 1. Вырезать фронт и БД из шаблона (SMB остаётся) @@ -9,30 +9,32 @@ `api/src/models/`, `api/src/repositories/`; убрать `Dockerfile` из `.gitignore`; проверка: `git status` — только удаления и `.gitignore` - [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, - `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `NATS_MAX_DELIVER=1`, `SCAN_HOTFOLDER_MODE="local"`, - `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, - `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, - `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv, `python run_server.py` с `.env.example`, `/healthcheck` 200 + `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, + `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, + `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, + `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), + `python run_server.py` с `.env.example`, `/healthcheck` 200 - [ ] 1.3 `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск против compose и эмулятора), `CLAUDE.md` — актуализировать «Соглашения шаблона» по факту; проверка: упомянутые файлы существуют ## 2. Инструменты - [ ] 2.1 `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, - pytest-asyncio, pytest-env, httpx, jsonschema; проверка: `ruff check . && ruff format --check . && mypy src` зелёные -- [ ] 2.2 `Dockerfile` (python:3.12-slim; `CMD python run_server.py`, эмулятор — через `command` в compose), `Makefile` - (`up`, `test`, `test-integration`, `lint`, `emulator`), `.env.example`, `.env.test`; проверка: `docker build` успешен + pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11, без `raisa_*`), `lint`, `test`, + `test-integration`, `check`, `check-full`, `up`, `emulator`; проверка: `make venv && make lint` зелёные +- [ ] 2.2 `Dockerfile` (`python:3.11-slim`; `CMD python run_server.py`, эмулятор — через `command` в compose), + `.env.example`, `.env.test`; проверка: `docker build` успешен без Nexus ## 3. Контракт -- [ ] 3.1 `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели; - `tests/unit/test_contract.py` — идентичность (skip без docs), примеры, round-trip; проверка: тесты зелёные +- [ ] 3.1 `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (конверт со + статусами `processing | done | needs_check | error`); `tests/unit/test_contract.py` — идентичность (skip без docs), + примеры, round-trip; проверка: тесты зелёные ## 4. Горячие папки и эмулятор -- [ ] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy, создание `Output//` - не требуется — читаем, если есть); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет - `_`, poll возвращает `None` до появления ответа и байты после +- [ ] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` + на `tmp_path`; проверка: put пишет `_`, poll возвращает `None` до появления ответа и байты после - [ ] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется - [ ] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск @@ -46,22 +48,24 @@ ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`, синтетическом ответе с `error`; проверка: зелёный - [ ] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys, - подписка с повтором, publish с `Nats-Msg-Id`, `ack_wait = SCAN_TIMEOUT_SECONDS + 120`, `max_deliver = 1`); - проверка: unit с фейками, интеграционный тест подключения к compose-NATS -- [ ] 5.3 `src/core/worker.py`: семафор `SCAN_MAX_INFLIGHT`, `fetch` на свободные слоты, `handle(msg)`: валидация → S3 get → - `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → результат/конверт → publish → ack; - таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с фейками и `LocalHotFolder` + - `process_once` эмулятора — сценарии spec (успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с - укороченными таймаутами, RECOGNITION_FAILED при отсутствии каталога, SCHEMA_MISMATCH, FILE_READ_ERROR) -- [ ] 5.4 Тест параллелизма: пять заданий при `SCAN_MAX_INFLIGHT = 4` — четыре файла во входящем каталоге сразу, пятый после - освобождения слота (фейковые S3/NATS, `LocalHotFolder`); проверка: зелёный + автопереподключение, `subscribe_requests(queue="scan-gateway", cb)`, `publish` с `Nats-Msg-Id` и повторами); + проверка: unit с фейками, интеграционный тест подключения к compose-NATS без JetStream +- [ ] 5.3 `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор + `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → + результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с + фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, + успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с укороченными таймаутами, RECOGNITION_FAILED при + отсутствии каталога, SCHEMA_MISMATCH без ack, FILE_READ_ERROR) +- [ ] 5.4 Тест параллелизма: пять заданий при `SCAN_MAX_INFLIGHT = 4` — пять ack сразу, четыре файла во входящем каталоге, + пятый после освобождения слота (фейковые S3/NATS, `LocalHotFolder`); проверка: зелёный -## 6. Интеграция, CI, завершение +## 6. Интеграция, проверки, завершение - [ ] 6.1 `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в - MinIO `uploads/…`, опубликовать `request-image.json`, дождаться конверта `NOT_IMPLEMENTED` с `raw_s3_key`, проверить - объект `raw/.json` в MinIO и ack запроса; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный -- [ ] 6.2 `.gitea/workflows/ci.yml` из `../docs/dev/ci/python-service.yml` (блок `env`: scan-gateway, порт 8002); проверка: - зелёный run на ветке `feat/1-slice-0-skeleton` -- [ ] 6.3 Прогон DoD: `make up` → `make test` → `make test-integration` → ручная проверка из `docs/dev/README.md`; - `openspec validate slice-0-skeleton --strict`; результаты — в описание PR + MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем + `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → + `INVALID_DOCUMENT`; проверка: зелёный +- [ ] 6.2 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs + на ветке `feat/1-slice-0-skeleton` +- [ ] 6.3 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md`; `openspec validate + slice-0-skeleton --strict`; вывод `make check-full` — в описание PR diff --git a/openspec/config.yaml b/openspec/config.yaml index 508f387..28a2569 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -7,7 +7,7 @@ context: | ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). - Стек: Python 3.12, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL, pydantic v2, NATS JetStream, S3 (MinIO), + Стек: Python 3.11, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL 16, pydantic v2, NATS без JetStream (ack и повторы — на уровне приложения), S3 (MinIO, miniopy-async), loguru; фронт — React + TypeScript, rsbuild, antd, Redux Toolkit, biome. Язык документов и коммитов — русский, идентификаторы — английские, как в DBML.