15 KiB
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 пишет _»); спека требует 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) |
контейнерный путь не проверен |