Склейка всех json каталога ответа (settle), SCAN_TMP_DIR вне Input, успех = error.code == "", per-file ошибки в metrics.warnings, набор файлов в эмуляторе; решения №54-62 в design.md; OQ-76/77/78/79 отмечены решёнными; группа 7 в tasks.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
86 lines
9.6 KiB
Markdown
86 lines
9.6 KiB
Markdown
# 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, проверки качества).
|
||
**Соседние:** `../arm-core/openspec/changes/slice-0-skeleton`, `../tabular-parser/openspec/changes/slice-0-skeleton`.
|
||
|
||
## Why
|
||
|
||
Репозиторий — копия каркаса `arm-core` с фронтом и БД, тогда как по DECISION-4 это stateless-адаптер внешней ИС
|
||
Сканирование. Самая рискованная часть адаптера — не разбор JSON (Срез 3), а обмен через горячие папки с таймаутом и
|
||
без доступа к БД: положить файл, дождаться ответа, не потерять задание — причём на NATS без JetStream. Срез 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` (`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(job_id) -> list[AnswerFile] | None`,
|
||
`healthy()`) с двумя реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона;
|
||
прод). Имена подкаталогов настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`, `SCAN_TMP_DIR =
|
||
"Tmp"`); файл кладётся как `<job_id>_<original_name>`; временный файл атомарной записи (`.part`) лежит в служебном
|
||
каталоге `<корень>/<SCAN_TMP_DIR>/` рядом с `Input`/`Output` (на той же шаре/ФС) и переименовывается в `Input`, так что
|
||
в `Input` недописанных файлов не бывает (OQ-79). ИС кладёт **все** распознанные документы задания одновременно в
|
||
`<output>/<job_id>/` — по одному `*.json` на документ (OQ-78); `poll` возвращает все `*.json` каталога по возрастанию
|
||
имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров не менялся `SCAN_SETTLE_SECONDS` (2).
|
||
- **Воркер** `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)` → склейка всех `*.json` ответа в один сырой ответ
|
||
`{"job_id", "files": [{"name", "response", "parse_error"}]}` в 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(merged) -> documents | error`, вход — склеенный
|
||
ответ; в Срезе 0 — `NullNormalizer`. Успех файла ИС — `error.code == ""` (поле `error` не `null`: у реального успешного
|
||
ответа `error = {"code": "", "message": ""}`); ошибка — непустой `error.code` (`FILE_READ_ERROR`, `INVALID_FORMAT`,
|
||
`INVALID_DOCUMENT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`) либо `parse_error` (файл не разобран как JSON). Если ошибочны
|
||
**все** файлы — результат `error` с кодом первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`),
|
||
каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings`. Есть хотя бы один успешный файл →
|
||
`NOT_IMPLEMENTED` (Срез 3 реализует §11), ошибочные файлы — в `metrics.warnings` так же.
|
||
- **Эмулятор ИС Сканирование** `src/scan_emulator/` (`python -m src.scan_emulator`): следит за `Input/`, через
|
||
`SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output/<uuid>/` **набор** ответов: все `SCAN_FIXTURES_DIR/<name>.json` и
|
||
`<name>_*.json` (`<name>` — имя без uuid и расширения; не префикс `<name>*`) под именами `<uuid>.json` и
|
||
`<uuid>_<suffix>.json`, с `file_uid = <uuid>` (`aep_3.pdf` → `<uuid>.json` + `<uuid>_ofr.json`, `aep_6.pdf` → один
|
||
файл, `aep_8.pdf` → `<uuid>_form4.json`); набор появляется в `Output/<uuid>/` атомарно целиком (временный каталог +
|
||
переименование каталога). Для неизвестных имён — один ответ Content AI с `error.code = INVALID_DOCUMENT`. Запускается
|
||
тем же образом в compose (`scan-emulator`).
|
||
- **Ошибки транспорта** → результат: запрос не по схеме → `SCHEMA_MISMATCH`; S3 не читается → `FILE_READ_ERROR`;
|
||
горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON (после готовности набора) → `parse_error` файла,
|
||
при ошибочности всех файлов — `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`.
|
||
- **Healthcheck** дополняется полями `nats` и `hotfolder` (`ok`/`down`).
|
||
- **Проверки качества** вместо CI: `make check` и `make check-full` по `../docs/dev/checks.md`.
|
||
|
||
## Capabilities
|
||
|
||
### New Capabilities
|
||
- `image-recognition-gateway`: адаптер ИС Сканирование — приём и подтверждение задания из NATS без JetStream, обмен
|
||
файлами через горячие папки с таймаутом, сохранение сырого ответа, результат по контракту v1.0.
|
||
- `scan-emulator`: эмулятор ИС Сканирование для разработки и тестов, воспроизводящий протокол горячих папок на фикстурах `../fixtures/ocr`.
|
||
|
||
### Modified Capabilities
|
||
<!-- openspec/specs пуст -->
|
||
|
||
## Impact
|
||
|
||
- **Таблицы 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).
|
||
|
||
## 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`.
|
||
- Конфигурации CI.
|