125 lines
17 KiB
Markdown
125 lines
17 KiB
Markdown
# 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. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен
|
||
до прода, CI в Gitea нет.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:** конвейер «NATS → ack → 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. Без реестра заданий на диске: состояние — в процессе и в горячей папке, 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` задач, каждая ждёт свой ответ
|
||
Подписка `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 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется
|
||
**до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в 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` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку
|
||
упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее →
|
||
`UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error,
|
||
ядро закроет по `reply_deadline`.
|
||
|
||
### 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 — 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
|
||
|
||
- [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] → `SmbHotFolder` минимален и повторяет
|
||
вызовы старого `PDFWorker`; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду.
|
||
- [Имена каталогов ИС отличаются от `Input/Output`] → настройки, не код.
|
||
- [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре.
|
||
- [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди;
|
||
ядро переводит в `timeout`, replay — Срез 3+.
|
||
- [Эмулятор знает только две пары фикстур] → ошибка `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: 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: таблица транспортных ошибок, публикация с 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 | ответы пользователя | смена тега |
|
||
| 10 | 2026-10-04 | R-1: `.env.example` создаёт задача 1.2 (минимальный рабочий local-режим), задача 2.2 дополняет и добавляет `.env.test` | проверка 1.2 требует `.env.example`, а план создаёт его в 2.2 | переделка файла |
|
||
| 11 | 2026-10-04 | R-2: из `app.py` убирается модульный код nkeys/NATS (переезжает в `src/core/nats.py`); `/healthcheck` в 1.2 отдаёт `uptime`+`version`, поля `nats`/`hotfolder` добавляет 5.3 | пустые `NATS_SEED/NATS_JWT` не должны ломать импорт (spec «NATS без JetStream») | нет |
|
||
| 12 | 2026-10-04 | R-3: задача 1.3 (README/CLAUDE.md) выполняется после 6.1, до 6.2 | проверка «упомянутые файлы существуют» невыполнима до появления файлов | нет |
|
||
| 13 | 2026-10-04 | R-4: `LocalHotFolder` открывает `root`, `input_dir`, `output_dir`, `answer_path(job_id)`; эмулятор работает с ними обычными операциями ФС, протокол `HotFolder` остаётся `put/poll/healthy` | у протокола нет листинга входящих и записи ответа, нужных эмулятору (D-6) | рефакторинг эмулятора |
|
||
| 14 | 2026-10-04 | R-5: `put(name, data)` пишет файл `name` как есть, имя `<job_id>_<original_name>` собирает вызывающий (`inbox_name`); запись атомарна (`.part` + rename), `Input/` создаётся, корень — нет; `healthy()` = корень существует; эмулятор игнорирует dot-файлы и `.done` | неоднозначность плана («put пишет <uuid>_<name>»); спека требует `RECOGNITION_FAILED` при отсутствии корня | нет |
|
||
| 15 | 2026-10-04 | R-6: фабрика `create_hotfolder(settings)` в `src/gateway/__init__.py` (задача 4.2), smb импортируется лениво | ни одна задача не владела фабрикой; в `local` smb-модуль не должен импортироваться | нет |
|
||
| 16 | 2026-10-04 | R-7: интеграционный тест 6.1 запускает воркер шлюза и эмулятор в процессе теста против compose NATS/MinIO с tmp-каталогом; точный фрагмент compose для `scan-emulator` — в `open-questions-proposal.md` | контейнера `scan-emulator` в compose пока нет (его добавит docs в фазе 2) | контейнерный путь не проверен |
|
||
| 17 | 2026-10-04 | R-8: `argon2-cffi==23.1.0` и `argon2-cffi-bindings==25.1.0` остаются в `requirements.txt` (транзитивные зависимости `miniopy-async`) | спека запрещает только SQLAlchemy/asyncpg/alembic/raisa_fastapi_protected_api; формулировка плана «без argon2-cffi» убрала бы фиксацию версий в freeze-файле (T2.1) | две лишние строки в requirements |
|
||
| 18 | 2026-10-04 | Задача 1.2: вложенные модели `OPENBAO` остаются `BaseSettings`, блок SMB — `SMBSettings()` с пустыми значениями по умолчанию, полноту проверяет `Settings` в режиме `smb` | иначе pydantic-settings не разбирает вложенные ENV-ключи | нет |
|
||
| 19 | 2026-10-04 | Задача 1.2: добавлено `MINIO_SECURE` (в `.env.example` `false`); 5.2 передаёт `secure=` в `Minio(...)` | MinIO в compose работает по http | нет |
|
||
| 20 | 2026-10-04 | Задача 1.2: `SCAN_POLL_SECONDS`/`SCAN_TIMEOUT_SECONDS` — `PositiveFloat` | укороченные таймауты в тестах (дробные секунды) | нет |
|
||
| 21 | 2026-10-04 | Задача 1.2: из lifespan убраны S3 и NATS (вернутся в 5.2/5.3) | подключение переезжает в `src/core/nats.py`/воркер (R-2) | нет |
|
||
| 22 | 2026-10-04 | Задача 1.2: `app.py` — фабрика `create_app(settings)`, модульный `settings` убран; конфиг читается лениво (`get_settings()` с `lru_cache`) | тесты без побочных эффектов при импорте | нет |
|