2026-10-04 02:31:50 +04:00

125 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`) | тесты без побочных эффектов при импорте | нет |