Andrey Epifancev fe0fc0ea08 docs: решения контроллера R-1..R-7 (pre-flight) в design.md slice-0-skeleton
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 02:21:59 +04:00

15 KiB
Raw Blame History

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) контейнерный путь не проверен