2026-10-04 03:20:41 +04:00

26 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: 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 нет