30 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: object) -> NormalizeOutcome(status, documents, error, metrics) (raw — уже разобранный JSON ответа ИС: json.loads и отображение ошибки разбора в INVALID_FORMAT — на стороне воркера, не-объект JSON -> INVALID_FORMAT в нормализаторе). 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) |
контейнерный путь не проверен |
| 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) |
тесты без побочных эффектов при импорте | нет |
| 23 | 2026-10-04 | Задача 2.1: Makefile в корне репозитория (рецепты cd api && …, как в arm-core); строгие флаги mypy заданы заранее для src.contract, src.gateway, src.scan_emulator, код шаблона — нестрогий; httpx не дублируется в requirements-dev.txt (приходит из requirements.txt) |
единообразие с arm-core; новые пакеты пишутся строго с первого дня | нет |
| 24 | 2026-10-04 | Задача 2.2: строка .dockerignore убрана из .gitignore (как в arm-core), файл закоммичен; conftest.py берёт окружение из .env.test (ENV_FILE=.env.test в pytest.ini), setdefault убран; тесты запускаются из api/ |
шаблонная строка в .gitignore не давала закоммитить .dockerignore; единый источник окружения тестов |
тесты зависят от cwd=api/ |
| 25 | 2026-10-04 | Задача 3.1: модели src/contract скопированы из arm-core (включая модели ОСВ и tabular-подписки, не используемые шлюзом); extra="ignore" вместо forbid |
схемы не содержат additionalProperties: false, MINOR добавляет необязательные поля; спека требует валидировать все примеры docs; близость к arm-core упрощает синхронизацию |
при расхождении arm-core копию синхронизируют вручную |
| 26 | 2026-10-04 | Задача 3.1: конструкторы конвертов build_ack_message/build_error_message/build_done_message принимают ServiceInfo параметром (ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION)); is_supported_major — fullmatch по шаблону схемы без int() |
конструкторы не зависят от окружения; int() падает на сверхдлинной строке (T6.1) |
нет |
| 27 | 2026-10-04 | R-9: методы протокола HotFolder синхронные; воркер вызывает их через asyncio.to_thread |
блокирующие вызовы ФС/smbclient; общий контракт для двух реализаций |
обернуть в async позже |
| 28 | 2026-10-04 | Задача 4.1: answer_path бросает ValueError для небезопасного job_id; общие validate_name/validate_job_id в hotfolder.py; put отвергает имена с разделителями, NUL, точкой в начале |
job_id и original_name приходят из запроса NATS |
воркер не должен принимать ValueError за недоступность ИС |
| 29 | 2026-10-04 | Задача 4.2: inbox_name заменяет символы, недопустимые в Windows/SMB (: < > " | ? *, управляющие), на _ и отбрасывает завершающие точки/пробелы; put остаётся строгим (T8.1) |
допустимое имя пользователя (Отчёт: 2024.pdf) не должно проваливать задание; для ИС имя ничего не значит |
имя во входящем каталоге отличается от загруженного |
| 30 | 2026-10-04 | Задача 4.2: SMB_PATH — относительный (share/dir, склеивается с хостом) или полный UNC; учётные данные передаются в каждый вызов smbclient, если задан логин; poll возвращает None на STATUS_SHARING_VIOLATION/STATUS_DELETE_PENDING; монопольное открытие ответа сохранено; mkdir существующего Input с ACCESS_DENIED не роняет put, если каталог существует |
повторяет вызовы старого PDFWorker; ИС может ещё писать ответ; урезанные ACL шары |
проверить на реальной шаре в Срезе 3 |
| 31 | 2026-10-04 | Задача 4.2: временный файл .part лежит прямо в Input; читается один <job_id>.json (старый код читал все файлы Output/<id>/) |
спека; реальная ИС может забирать файлы без маски — риск вынесен в open-questions-proposal.md |
запись .part в служебный каталог, чтение всех файлов подкаталога |
| 32 | 2026-10-04 | R-10 (наблюдение O-1): оба реальных ответа fixtures/ocr/aep_5.json и aep_8_form4.json сами являются ответами-ошибками ИС (error = INVALID_DOCUMENT, пустой data_ocr), поэтому путь «успех -> NOT_IMPLEMENTED» проверяется на синтетическом успешном ответе (копия aep_5.json с error = null и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с ok_doc.json); реальные фикстуры используются для сценария «ошибка ИС проброшена» |
невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что aep_5.json — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3) |
| 33 | 2026-10-04 | R-11: эмулятор читает обычные переменные окружения через собственные настройки (SCAN_HOTFOLDER_PATH, SCAN_INPUT_DIR, SCAN_OUTPUT_DIR, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY, SCAN_EMULATOR_POLL_SECONDS), а не Settings шлюза; process_once синхронный, delay спится один раз за проход, ответ пишется атомарно (.part + rename), затем входящий файл переименовывается в .done |
контейнеру эмулятора не нужны переменные MinIO/NATS | нет |
| 34 | 2026-10-04 | Задача 4.3: без аргументов from_env подмешивает .env/ENV_FILE под os.environ (чтобы make emulator работал с api/.env); сообщение «нет пары» — <имя без uuid-префикса, с расширением>: нет эталонного ответа в фикстурах; битая фикстура -> ответ INVALID_DOCUMENT с сообщением о повреждённой фикстуре, вход получает .done; нет Input/ — не ошибка, нет корня при старте — код выхода 2, нет каталога фикстур — warning |
удобство dev; защита от зацикливания | нет |
| 35 | 2026-10-04 | R-12: Normalizer.normalize(raw: object) получает уже разобранный JSON ответа ИС; json.loads и отображение ошибки разбора в INVALID_FORMAT — на стороне воркера (D-7); NullNormalizer возвращает INVALID_FORMAT для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в ErrorCode контракта (регистр значим), иначе UNKNOWN_ERROR с исходным кодом в сообщении; пустой message = отсутствует (текст по умолчанию) |
D-5 писал raw: dict, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов |
если воркер захочет отдать декодирование нормализатору — перенести json.loads |
| 36 | 2026-10-04 | Задача 5.1: NormalizeOutcome (pydantic, frozen) — инвариант: done/needs_check требуют непустой documents, error запрещает documents, error обязателен тогда и только тогда, когда status = error |
повторяет minItems: 1 схемы recognition-result для успешных статусов; ошибка конструктора раньше, чем ошибка сборки result.json |
нет |
| 37 | 2026-10-04 | R-13: PUBLISH_RETRIES=3 — число повторов после первой попытки (до 4 попыток всего); PublishFailed.attempts — фактическое число попыток; tabular-parser должен толковать так же |
буквальный текст спеки «повторить до трёх раз» и D-7 | одна попытка больше/меньше |
| 38 | 2026-10-04 | Задача 5.2: если задан только один из NATS_SEED/NATS_JWT — NatsConfigError (как в arm-core); шаблонный nats_callback.py удалён; ошибки S3: S3ObjectNotFoundError ⊂ S3ReadError ⊂ S3StorageError, S3WriteError; после publish/subscribe вызывается flush; connect() блокирует до доступности NATS — вызывать в фоновой задаче |
fail-fast на неполные учётные данные; без flush nats-py буферизует публикации при переподключении | нет |
| 39 | 2026-10-04 | R-14: исключения hotfolder.poll логируются и опрос продолжается до дедлайна; если все попытки опроса до дедлайна завершились исключением — RECOGNITION_FAILED («горячая папка недоступна»), иначе TIMEOUT |
ошибки SMB (в т.ч. не-ENOENT) переходящие | выбор кода при постоянной ошибке |
| 40 | 2026-10-04 | R-15: ValueError из inbox_name (непригодное original_name) -> RECOGNITION_FAILED «недопустимое имя файла» |
в D-7 нет кода для этого случая | выбор кода |
| 41 | 2026-10-04 | R-16: запрос не по схеме / MAJOR ≠ 1 / pipeline ≠ image -> SCHEMA_MISMATCH без ack; конверт ошибки публикуется только если из сырого JSON извлекаются валидные job_id и file_id (attempt из тела, если целое ≥ 1, иначе 1), иначе — лог и отбрасывание |
схема конверта требует job_id/file_id; ядро закроет задание по reply_deadline |
ядро не узнает об ошибке неразбираемого тела |
| 42 | 2026-10-04 | R-17: ключ дедупликации — job_id (по спеке); повторный запрос публикует ack с attempt повторного запроса, второй файл не пишется; LRU размером DONE_CACHE_SIZE |
спека, D-2, OQ-15 | более высокий attempt того же job_id не перезапускает обработку |
| 43 | 2026-10-04 | R-18: ошибка записи raw/{job_id}.json -> UNKNOWN_ERROR без raw_s3_key; ответ не JSON — сырые байты сохраняются как есть, затем INVALID_FORMAT с raw_s3_key; исключение нормализатора -> UNKNOWN_ERROR с raw_s3_key |
D-4, D-7, сценарий «Нормализатор упал» | нет |
| 44 | 2026-10-04 | R-19: один бакет (глобальное ограничение): request.s3.bucket ≠ настроенного -> FILE_READ_ERROR после ack; raw/ и results/ — в настроенном бакете |
«один бакет S3» | развёртывания с несколькими бакетами отвергаются |
| 45 | 2026-10-04 | R-20: дедлайн = min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS), старт — момент записи файла в горячую папку (после получения слота); on_request не блокируется: валидирует, регистрирует job_id и запускает задачу задания (ack -> семафор -> обработка) |
ожидание слота входит в reply_deadline через min; колбэк NATS должен возвращаться быстро (T11.4) |
нет |