docs: соглашения шаблона в CLAUDE.md и OpenSpec change slice-0-skeleton
Срез 0 (issue #1): раздел «Соглашения шаблона» (что оставлено от шаблона команды, что вырезается, что добавляется); change openspec/changes/slice-0-skeleton — proposal, design с таблицей «Decisions made without discussion», спеки и tasks; в openspec/config.yaml строки с двоеточием взяты в кавычки (YAML разбирал их как словари). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
a9159aa872
commit
561eaa5c71
24
CLAUDE.md
24
CLAUDE.md
@ -15,4 +15,26 @@
|
||||
- Dev-режим: эмулятор ИС Сканирование, возвращающий `aep_N.json` по имени файла (`SCAN_EMULATOR=1`).
|
||||
|
||||
## Соглашения шаблона
|
||||
<!-- заполняет агент -->
|
||||
Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения —
|
||||
`../docs/context/03-architecture.md`, «Соглашения шаблона команды». Сейчас репозиторий — полная копия каркаса `arm-core`;
|
||||
change `slice-0-skeleton` приводит его к сервису **без фронта и без БД**, но с SMB. Все команды — из `api/`.
|
||||
|
||||
**Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan с
|
||||
регистрацией SMB-сессии, `GET /healthcheck`, gzip/proxy middleware, обработчики ошибок), `core/config.py` (pydantic-settings,
|
||||
`OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`, `DEBUG`), `core/clients.py` (S3), `core/nats_callback.py`,
|
||||
`core/logger.py`, `core/exception_handlers.py`, `common/exceptions.py`, `pytest.ini`, `requirements*.txt` (включая
|
||||
`smbprotocol`), `raisa_config.yml`, `setup/setup.sh` + `pip.conf` (без npm-шага).
|
||||
|
||||
**Вырезано (Срез 0):** `web/` целиком и `setup/.npmrc`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`),
|
||||
CORS, импорт `raisa_fastapi_protected_api`; `db/`, `models/`, `repositories/`, `alembic/`, `alembic.ini`; из настроек —
|
||||
`OPENBAO.DATABASE`; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`;
|
||||
шаблонный `README.md` заменён описанием сервиса.
|
||||
|
||||
**Добавлено:** `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`
|
||||
→ 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`.
|
||||
|
||||
2
openspec/changes/slice-0-skeleton/.openspec.yaml
Normal file
2
openspec/changes/slice-0-skeleton/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-03
|
||||
99
openspec/changes/slice-0-skeleton/design.md
Normal file
99
openspec/changes/slice-0-skeleton/design.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Design: Срез 0 — скелет scan-gateway
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`a9159aa`). Протокол ИС Сканирование (FNR02,
|
||||
`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.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:** конвейер «NATS → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в unit-тестах
|
||||
без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов.
|
||||
**Non-Goals:** см. proposal.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы
|
||||
`HotFolder.put(name, data)`, `poll(job_id) -> bytes | None`, `healthy() -> bool`. `LocalHotFolder(root)` работает с
|
||||
обычным каталогом (dev, compose-том `hotfolder`, unit-тесты через `tmp_path`); `SmbHotFolder(host, path, login, password)`
|
||||
— через `smbclient` шаблона (UNC-пути). Подкаталоги `SCAN_INPUT_DIR`/`SCAN_OUTPUT_DIR` по умолчанию `Input`/`Output`
|
||||
(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Режим —
|
||||
`SCAN_HOTFOLDER_MODE = local | smb`.
|
||||
|
||||
### D-2. Без in-memory реестра заданий: состояние — в NATS и в горячей папке
|
||||
Задание «в работе» = неподтверждённое сообщение consumer'а. Падение инстанции → сообщение не ack'нуто, но `max_deliver = 1`
|
||||
(OQ-15: OCR без повторов) → повторной отправки в ИС не будет; ядро закроет задание по `reply_deadline` (`timeout`). Так
|
||||
нет расхождения между кэшем и шарой, как в старом коде. *Альтернатива* — хранить задания в 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`, файл во входящем каталоге не удаляется (ИС может
|
||||
ответить позже; «поздний» ответ игнорируется — его никто не опрашивает; уборка каталога — задача эксплуатации, вне среза).
|
||||
|
||||
### D-4. Сырой ответ — в S3 `raw/{job_id}.json`, в конверте `raw_s3_key`
|
||||
По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется
|
||||
**до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3.
|
||||
|
||||
### D-5. Нормализатор — интерфейс; в Срезе 0 пробрасывает ошибки ИС и отвечает `NOT_IMPLEMENTED` на успех
|
||||
`Normalizer.normalize(raw: dict) -> NormalizeOutcome(status, documents, error, metrics)`. `NullNormalizer`: если в ответе
|
||||
`error` не `null` — результат `status = error` с кодом из ответа (коды Content AI совпадают с перечнем контракта); иначе —
|
||||
`NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3». Срез 3 подменяет реализацию
|
||||
(§11, OQ-13/14), транспорт не меняется.
|
||||
|
||||
### D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint
|
||||
`python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `<uuid>_<name>.<ext>` → поиск
|
||||
`SCAN_FIXTURES_DIR/<name>*.json` (точное имя, затем префикс) → через `SCAN_EMULATOR_DELAY` запись `Output/<uuid>/<uuid>.json`
|
||||
(с подстановкой `file_uid = <uuid>`); нет пары → ответ Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT",
|
||||
"message": "..."}, "data_ocr": []}`. Обработанные входящие файлы переименовываются в `<uuid>_<name>.<ext>.done`, чтобы
|
||||
не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его.
|
||||
|
||||
### D-7. Ошибки транспорта — результат, не молчание
|
||||
Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH`; S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку упал →
|
||||
`RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее → `UNKNOWN_ERROR`.
|
||||
Конверт публикуется всегда; ack — после публикации; провал публикации → nak 5 с.
|
||||
|
||||
### D-8. NATS — как у ядра и парсера
|
||||
Без учётных данных при пустых `NATS_SEED/NATS_JWT`, иначе nkeys/JWT шаблона; stream не создаёт; повтор подписки каждые
|
||||
10 с; `/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.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] → `SmbHotFolder` минимален и повторяет
|
||||
вызовы старого `PDFWorker`; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду.
|
||||
- [Имена каталогов ИС отличаются от `Input/Output`] → настройки, не код.
|
||||
- [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
|
||||
- [Эмулятор знает только две пары фикстур] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые
|
||||
пары добавляются в `fixtures/ocr` без изменения кода.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего каталогов и путь SMB
|
||||
на стенде ИС Сканирование — нужны к Срезу 3.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 2026-10-03 | D-1: протокол `HotFolder`, режимы `local`/`smb`, имена каталогов `Input`/`Output` по умолчанию | dev без SMB; имена из старого кода | смена настройки |
|
||||
| 2 | 2026-10-03 | D-2: без реестра заданий, `max_deliver = 1` | OQ-15, таймаут закрывает ядро | добавить хранилище состояния, если понадобятся повторы |
|
||||
| 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: таблица транспортных ошибок | ядро не ждёт таймаута, если шлюз жив | нет |
|
||||
| 8 | 2026-10-03 | `external_task_id` в ядро не передаётся (идентификатор ИС = `job_id` в имени файла) | у протокола горячих папок нет отдельного id задания | поле DBML остаётся NULL |
|
||||
69
openspec/changes/slice-0-skeleton/proposal.md
Normal file
69
openspec/changes/slice-0-skeleton/proposal.md
Normal file
@ -0,0 +1,69 @@
|
||||
# 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-шаблон).
|
||||
**Соседние:** `../arm-core/openspec/changes/slice-0-skeleton`, `../tabular-parser/openspec/changes/slice-0-skeleton`.
|
||||
|
||||
## Why
|
||||
|
||||
Репозиторий — копия каркаса `arm-core` с фронтом и БД, тогда как по DECISION-4 это stateless-адаптер внешней ИС
|
||||
Сканирование. Самая рискованная часть адаптера — не разбор JSON (Срез 3), а обмен через горячие папки с таймаутом и
|
||||
без доступа к БД: положить файл, дождаться ответа, не потерять задание. Срез 0 строит этот конвейер и эмулятор ИС
|
||||
Сканирование, чтобы он работал в compose без доступа к банковской системе.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Вырезать из шаблона** (список — `CLAUDE.md`, «Соглашения шаблона»): `web/`, `setup/.npmrc` и npm-шаги
|
||||
`setup/setup.sh`; из `app.py` — mount SPA, CORS, импорт `raisa_fastapi_protected_api`; `db/`, `models/`, `repositories/`,
|
||||
`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"`.
|
||||
- **Контракт**: вендоренные схемы `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"`); файл кладётся как `<job_id>_<original_name>`,
|
||||
ответ ожидается в `<output>/<job_id>/<job_id>.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/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).
|
||||
- **Эмулятор ИС Сканирование** `src/scan_emulator/` (`python -m src.scan_emulator`): следит за `Input/`, через
|
||||
`SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output/<uuid>/<uuid>.json` содержимое `SCAN_FIXTURES_DIR/<имя без uuid и расширения>*.json`
|
||||
(`aep_5.pdf` → `aep_5.json`, `aep_8.pdf` → `aep_8_form4.json`), для неизвестных имён — ответ Content AI с
|
||||
`error.code = INVALID_DOCUMENT`. Запускается тем же образом в compose (`scan-emulator`).
|
||||
- **Ошибки транспорта** → результат: запрос не по схеме → `SCHEMA_MISMATCH`; S3 не читается → `FILE_READ_ERROR`;
|
||||
горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON → `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`.
|
||||
- **Healthcheck** дополняется полями `nats` и `hotfolder` (`ok`/`down`).
|
||||
- **CI**: `.gitea/workflows/ci.yml` из шаблона docs.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `image-recognition-gateway`: адаптер ИС Сканирование — приём задания из NATS, обмен файлами через горячие папки с
|
||||
таймаутом, сохранение сырого ответа, результат по контракту v1.0.
|
||||
- `scan-emulator`: эмулятор ИС Сканирование для разработки и тестов, воспроизводящий протокол горячих папок на фикстурах `../fixtures/ocr`.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- openspec/specs пуст -->
|
||||
|
||||
## 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).
|
||||
- **Правила:** §11 — только как контракт нормализатора; §9.4 — статусы задания; OQ-13/14/15 — допущения в коде нормализатора (Срез 3).
|
||||
- **Зависимости:** удаляются БД/фронт; остаётся `smbprotocol`; добавляются `ruff`, `mypy`, `pytest*`, `jsonschema`.
|
||||
- **Внешние системы:** 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`.
|
||||
- Реальная SMB-шара в тестах — `SmbHotFolder` покрыт только unit-тестами с подменой `smbclient`.
|
||||
@ -0,0 +1,86 @@
|
||||
# Spec Delta: image-recognition-gateway
|
||||
|
||||
## Purpose
|
||||
|
||||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает задание из NATS, передаёт файл через горячие
|
||||
папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в 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: Передача файла в ИС Сканирование через горячие папки
|
||||
Для запроса `recognition.image.request`, проходящего схему, сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и
|
||||
записать его во входящий каталог как `<job_id>_<original_name>`; затем SHALL опрашивать исходящий каталог
|
||||
`<SCAN_OUTPUT_DIR>/<job_id>/<job_id>.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:<job_id>:<attempt>`; при `status ≠ error` — файл результата в
|
||||
`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/<job_id>.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`).
|
||||
|
||||
#### Scenario: ИС молчит
|
||||
- **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется
|
||||
- **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 завершаться.
|
||||
|
||||
#### 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 с.
|
||||
|
||||
#### Scenario: Пять заданий подряд
|
||||
- **WHEN** в очереди пять запросов при `SCAN_MAX_INFLIGHT = 4`
|
||||
- **THEN** четыре файла появляются во входящем каталоге сразу, пятый — после завершения любого из четырёх
|
||||
|
||||
### Requirement: Копия контракта идентична источнику
|
||||
`api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs.
|
||||
|
||||
#### Scenario: CI сервиса
|
||||
- **WHEN** выполняются тесты контракта при доступном `../docs`
|
||||
- **THEN** идентичность подтверждена, примеры валидны
|
||||
@ -0,0 +1,46 @@
|
||||
# Spec Delta: scan-emulator
|
||||
|
||||
## Purpose
|
||||
|
||||
Эмулятор ИС Сканирование для разработки и тестов: воспроизводит протокол горячих папок и формат ответов Content AI,
|
||||
отдавая реальные ответы из `../fixtures/ocr` по имени файла, чтобы конвейер `scan-gateway` проверялся без доступа к
|
||||
банковской системе.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Эмулятор отвечает по протоколу горячих папок
|
||||
Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла
|
||||
`<uuid>_<name>.<ext>` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать `<output>/<uuid>/<uuid>.json`;
|
||||
после обработки входящий файл SHALL быть переименован в `<uuid>_<name>.<ext>.done`, чтобы не обрабатываться повторно.
|
||||
|
||||
#### Scenario: Один файл
|
||||
- **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`
|
||||
- **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done`
|
||||
|
||||
### Requirement: Ответ берётся из фикстур по имени
|
||||
Эмулятор SHALL искать в `SCAN_FIXTURES_DIR` файл `<name>.json`, затем `<name>*.json` (первый по алфавиту), где `<name>` —
|
||||
имя входящего файла без префикса `<uuid>_` и расширения; содержимое SHALL быть записано в ответ с `file_uid = <uuid>`.
|
||||
|
||||
#### Scenario: Точное имя
|
||||
- **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json`
|
||||
- **THEN** ответ равен `aep_5.json` с заменённым `file_uid`
|
||||
|
||||
#### Scenario: Префикс
|
||||
- **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json`
|
||||
- **THEN** ответ равен `aep_8_form4.json` с заменённым `file_uid`
|
||||
|
||||
### Requirement: Неизвестный файл — ошибка в формате Content AI
|
||||
Если пара не найдена, эмулятор SHALL записать ответ `{"file_uid": "<uuid>", "error": {"code": "INVALID_DOCUMENT",
|
||||
"message": "<имя>: нет эталонного ответа в фикстурах"}, "data_ocr": []}`.
|
||||
|
||||
#### Scenario: Нет пары
|
||||
- **WHEN** входящий файл — `…_unknown.pdf`
|
||||
- **THEN** ответ содержит `error.code = INVALID_DOCUMENT` и пустой `data_ocr`; `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` работает, его лог сообщает каталоги и число найденных пар фикстур
|
||||
67
openspec/changes/slice-0-skeleton/tasks.md
Normal file
67
openspec/changes/slice-0-skeleton/tasks.md
Normal file
@ -0,0 +1,67 @@
|
||||
# Tasks: Срез 0 — скелет scan-gateway
|
||||
|
||||
Репозиторий — scan-gateway; команды — из `api/`. Зависимости: 3.1 ждёт контракт v1.0 (`docs` 1.1–1.4); интеграционные
|
||||
тесты 6.x — compose (`docs` 2.1, 5.3). Каждая задача ≤ половины дня.
|
||||
|
||||
## 1. Вырезать фронт и БД из шаблона (SMB остаётся)
|
||||
|
||||
- [ ] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`,
|
||||
`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
|
||||
- [ ] 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` успешен
|
||||
|
||||
## 3. Контракт
|
||||
|
||||
- [ ] 3.1 `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели;
|
||||
`tests/unit/test_contract.py` — идентичность (skip без docs), примеры, round-trip; проверка: тесты зелёные
|
||||
|
||||
## 4. Горячие папки и эмулятор
|
||||
|
||||
- [ ] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy, создание `Output/<uuid>/`
|
||||
не требуется — читаем, если есть); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет
|
||||
`<uuid>_<name>`, 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)` и цикл; поиск
|
||||
фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`;
|
||||
`tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями
|
||||
`../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный
|
||||
|
||||
## 5. Воркер и нормализатор-заглушка
|
||||
|
||||
- [ ] 5.1 `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из
|
||||
ответа; успех → `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`); проверка: зелёный
|
||||
|
||||
## 6. Интеграция, CI, завершение
|
||||
|
||||
- [ ] 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/<job_id>.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
|
||||
@ -20,7 +20,7 @@ rules:
|
||||
- Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn)
|
||||
specs:
|
||||
- Сценарии на реальных фикстурах из ../fixtures, синтетика — только для форматов, которых там нет
|
||||
- Для парсеров: ни одного требования, завязанного на букву колонки, номер строки или имя листа
|
||||
- "Для парсеров: ни одного требования, завязанного на букву колонки, номер строки или имя листа"
|
||||
design:
|
||||
- Вычисляемые объекты консолидации (сводная ОСВ, пары ВГО, шахматка) — чистые функции в domain/, без ORM-таблиц
|
||||
- Деньги — Decimal / numeric(18,2); единицы измерения (руб. / тыс. руб.) указывать явно
|
||||
@ -37,4 +37,4 @@ operations:
|
||||
- После изменений прогонять линтеры и тесты репозитория; для web — tsc, biome, rsbuild build
|
||||
archive:
|
||||
guidance:
|
||||
- Перед архивированием: DoD среза из ../CLAUDE.md выполнен, открытые вопросы занесены в 07-open-questions.md
|
||||
- "Перед архивированием: DoD среза из ../CLAUDE.md выполнен, открытые вопросы занесены в 07-open-questions.md"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user