# 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/` каждую секунду; новый файл `_.` → поиск `SCAN_FIXTURES_DIR/*.json` (точное имя, затем префикс) → через `SCAN_EMULATOR_DELAY` запись `Output//.json` (с подстановкой `file_uid = `); нет пары → ответ Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": []}`. Обработанные входящие файлы переименовываются в `_..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 |