Andrey Epifancev 793516fa61 fix(emulator): тесты неперестроения индекса, единственный refresh за проход, различающий тест skip-списка и верхний регистр (T14.2, T14.4)
T14.2(a): Добавлен тест с шпионом на build_index, проверяющий что индекс не пересчитывается повторно на каждом проходе при неизменных фикстурах. 5 проходов — ровно 1 вызов build_index (при инициализации run), остальные используют refresh.

T14.2(b): Добавлен тест корректной обработки появления каталога фикстур после стартапа — run не выбрасывает исключение и может использовать фикстуры после их создания.

T14.1 remainder (double refresh): Исправлено поведение process_once: если index=None, то build_index один раз; если index передан, то только refresh. Обновлена docstring процесса.

T14.2(c): Добавлен мутационный тест test_skip_suffixes_not_logged_as_regex_mismatch, проверяющий что файлы с _SKIP_SUFFIXES пропускаются молча и НЕ логируются как не совпадающие с шаблоном.

T14.4: Переписан test_uppercase_extension_accepted — теперь пишет файл напрямую .PDF и проверяет нормализацию в .pdf.done, вместо использования _drop которая нормализует расширение.

Дополнительно:
- Обновлена docstring run() — точное описание: один refresh в конце каждого прохода
- Удалено "(Review Focus 5)" из docstring test_ignores_part_tmp_done
- Добавлен @pytest.mark.skipif для chmod теста при запуске от root

915 тестов пройдено, 10 деселектировано (10 избранных тестов требуют fixtures).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 02:13:23 +04:00

scan-gateway

Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений (PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его и публикует результат в recognition.result. Контракт обмена — ../docs/contracts/ (v1.0), архитектура — ../docs/context/03-architecture.md.

Границы сервиса (DECISION-4):

  • нет базы данных: единственный писатель в БД — arm-core; шлюз видит только NATS, S3 и горячие папки;
  • нет фронта; HTTP — только GET /healthcheck;
  • сырой ответ ИС лежит в S3 (raw/{job_id}.json) и в БД не регистрируется;
  • саму ИС Сканирование мы не пишем: для разработки есть эмулятор (api/src/scan_emulator/).

Состояние: Срез 0 (скелет). Сквозной путь работает, нормализатор — заглушка (см. «Ограничения Среза 0»). Правила для агента и соглашения репозитория — CLAUDE.md; требования и решения — openspec/changes/slice-0-skeleton/.

Поток обработки

recognition.image.request (NATS, queue group scan-gateway)
  1. разбор и проверка запроса        ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
  2. ack: recognition.result, status=processing            (до чтения S3 и до ожидания слота)
  3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
  4. файл из S3 (s3.bucket/s3.key)
  5. горячая папка: <Input>/<job_id>_<original_name>       (через <Tmp>/.<имя>.part и rename)
  6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
     не менялся SCAN_SETTLE_SECONDS
  7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
  8. нормализатор ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
  9. recognition.result: итоговый конверт (done | needs_check | error), в нём raw_s3_key

Каждое задание заканчивается опубликованным конвертом: сбой превращается в error с кодом (таблица ниже), а не в молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по своему reply_deadline. Падение шлюза после ack теряет задание до reply_deadline (без JetStream иначе нельзя).

Повторный запрос с job_id, который уже в работе или в LRU обработанных (DONE_CACHE_SIZE), получает повторный ack (с attempt повторного запроса); файл в ИС второй раз не отправляется.

Протокол горячих папок

Реализации интерфейса HotFolder (api/src/gateway/hotfolder.py: put, poll, healthy):

Режим Реализация Для чего
local (по умолчанию) LocalHotFolder, обычный каталог SCAN_HOTFOLDER_PATH разработка, тесты, эмулятор
smb SmbHotFolder (api/src/gateway/smb.py, smbprotocol), шара из OPENBAO__SMB__* платформа
  • Входящий файл: <SCAN_INPUT_DIR>/<job_id>_<original_name>. original_name приходит из запроса и не доверяется: берётся последний компонент пути, символы, недопустимые в Windows/SMB (: < > " | ? * и управляющие), заменяются на _, завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы временный .<имя>.part умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось — RECOGNITION_FAILED.
  • Запись атомарна: сначала временный .<имя>.part в служебном каталоге <SCAN_TMP_DIR> (по умолчанию Tmp), затем переименование в Input. Tmp лежит рядом с Input и Output, на той же ФС/шаре, чтобы rename оставался атомарным, а ИС не забрала недописанный файл из Input. Каталоги Input и Tmp создаются при необходимости (без родителей), корень горячей папки сам не создаётся: нет корня — RECOGNITION_FAILED.
  • Ответ ИС — каталог <SCAN_OUTPUT_DIR>/<job_id>/: ИС кладёт туда все распознанные документы задания одновременно, по одному *.json на файл (Output/<job_id>/*.json). Шлюз читает все *.json каталога и склеивает их в один ответ; прочие файлы каталога игнорируются. job_id проверяется на безопасность пути. В одном json может быть несколько таблиц разных форм (баланс, ОФР); таблицы в Срезе 0 не интерпретируются (Срез 3).
  • Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один *.json, а имена и размеры *.json не менялись SCAN_SETTLE_SECONDS (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через SCAN_SETTLE_SECONDS после такого опроса и не позднее SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего набор; 0 — готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так и не стабилизировался до дедлайна, даёт TIMEOUT.
  • Таймаут ожидания: min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS); опрос каждые SCAN_POLL_SECONDS (ожидание settle входит в этот срок). Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
  • Повторной отправки в ИС нет (OQ-15): после TIMEOUT входящий файл остаётся в каталоге, поздний ответ ИС теряется (сырой файл остаётся на шаре).
  • Эмулятор ИС в local-режиме: для файла <uuid>_<имя> ждёт SCAN_EMULATOR_DELAY и кладёт в Output/<uuid>/ весь набор фикстур: <имя>.json -> <uuid>.json и <имя>_<суффикс>.json -> <uuid>_<суффикс>.json (<имя> — имя входящего файла без расширения; именно <имя>_*, а не префикс <имя>*: aep_3 не забирает aep_30*; в каждом файле file_uid = <uuid>). Набор собирается во временном каталоге Tmp и появляется в Output/<uuid>/ атомарно целиком (rename каталога). Затем входящий файл переименовывается в ….done. Нет фикстуры — один ответ-ошибка INVALID_DOCUMENT. Dot-файлы и .done игнорируются.

Склеенный ответ ИС (raw/{job_id}.json)

В S3 до нормализации сохраняется склейка всех *.json каталога ответа (контракт v1.0 не меняется: raw_s3_key — по-прежнему одна строка raw/{job_id}.json):

{
  "job_id": "<job_id>",
  "files": [
    {"name": "<job_id>.json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null},
    {"name": "<job_id>_ofr.json", "response": {"file_uid": "...", "error": {"code": "INVALID_DOCUMENT", "message": "..."}}, "parse_error": null}
  ]
}

files — все *.json каталога по возрастанию имени. Файл, который не разобрался как JSON, не роняет задание: response = null, parse_error — текст ошибки. Нормализатору передаётся ровно этот объект.

Формат файла ответа Content AI: {"file_uid", "error": {"code", "message"}, "data_ocr": {...}}. Успех — это error.code == "" (error при этом не null: {"code": "", "message": ""}), поэтому проверять error != null нельзя. Ошибка — непустой error.code (INVALID_DOCUMENT, ...).

Переменные окружения

Файл окружения — ENV_FILE (по умолчанию .env, путь от текущего каталога, то есть от api/); переменные окружения приоритетнее файла. Шаблон — api/.env.example. Настройки шлюза читаются при импорте src.app: модуль создаёт app (это происходит при запуске run_server.py или uvicorn src.app:app). Импорт пакета src приложение не создаёт, поэтому эмулятор (python -m src.scan_emulator) настройки шлюза не читает: у него свои переменные (SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_*) из окружения и ENV_FILE.

Переменная По умолчанию Назначение
OPENBAO__MINIO__MINIO_URL — (обязательна) адрес MinIO host:port, без схемы
OPENBAO__MINIO__MINIO_ADMIN_LOGIN — (обязательна) логин MinIO
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD — (обязательна) пароль MinIO
OPENBAO__MINIO__MINIO_BUCKET — (обязательна) единственный бакет сервиса; запрос с другим s3.bucket -> FILE_READ_ERROR
OPENBAO__MINIO__MINIO_SECURE true https; для MinIO из compose — false
OPENBAO__NATS__NATS_URL — (обязательна) адрес NATS
OPENBAO__NATS__NATS_SEED, OPENBAO__NATS__NATS_JWT пусто nkeys/JWT платформы; оба пусты — без учётных данных; задан один — ошибка конфигурации
OPENBAO__SMB__SMB_HOST, OPENBAO__SMB__SMB_PATH, OPENBAO__SMB__SMB_LOGIN, OPENBAO__SMB__SMB_PASSWORD пусто шара горячих папок; обязательны только при SCAN_HOTFOLDER_MODE=smb; SMB_PATH — share/dir или полный UNC
NATS_ENABLED true false — воркер не запускается, подключения к NATS нет (/healthcheck.nats = disabled)
PUBLISH_RETRIES 3 повторы публикации ack/результата после первой попытки (всего до 4)
DONE_CACHE_SIZE 1000 размер LRU обработанных job_id (дедупликация)
SCAN_HOTFOLDER_MODE local local или smb
SCAN_HOTFOLDER_PATH пусто корень горячей папки в режиме local (обязателен, каталог должен существовать)
SCAN_INPUT_DIR Input «Входящие» относительно корня
SCAN_OUTPUT_DIR Output «Исходящие» относительно корня
SCAN_TMP_DIR Tmp служебный каталог относительно корня (на той же ФС/шаре, что Input/Output): временный .part записи входящего файла
SCAN_POLL_SECONDS 5 период опроса «Исходящих», секунды
SCAN_SETTLE_SECONDS 2 набор *.json в Output/<job_id>/ готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; 0 — сразу)
SCAN_TIMEOUT_SECONDS 300 потолок ожидания ответа ИС, секунды (дополнительно к reply_deadline)
SCAN_MAX_INFLIGHT 4 одновременно обрабатываемых заданий
SERVICE_NAME scan-gateway service.name в конверте
VERSION 1.0.0 версия сервиса (/healthcheck, service.version)
ROOT_PATH / префикс FastAPI за прокси
SERVER__HOST, SERVER__PORT 0.0.0.0, 8000 адрес HTTP-сервера run_server.py
DEBUG false флаг шаблона платформы; поведение шлюза не меняет
ENV_FILE .env какой файл окружения читать

Эмулятор (python -m src.scan_emulator) читает свои переменные и не использует настройки шлюза (в его контейнере нет MinIO и NATS); те же значения подхватываются из ENV_FILE:

Переменная По умолчанию Назначение
SCAN_HOTFOLDER_PATH — (обязательна) корень горячей папки; должен существовать, иначе код выхода 2
SCAN_INPUT_DIR, SCAN_OUTPUT_DIR, SCAN_TMP_DIR Input, Output, Tmp подкаталоги корня (как у шлюза); в Tmp эмулятор собирает набор ответа перед rename в Output/<uuid>/
SCAN_FIXTURES_DIR ../../fixtures/ocr каталог эталонных ответов ИС (aep_N.json, aep_N_<суффикс>.json); путь от api/
SCAN_EMULATOR_DELAY 2 задержка перед выдачей ответа, секунды (>= 0)
SCAN_EMULATOR_POLL_SECONDS 1 период просмотра «Входящих», секунды (> 0)

Коды ошибок

Итоговый конверт error содержит error.code и error.message. Нормализатор может вернуть любой код ErrorCode контракта, остальные коды ставит воркер (api/src/core/worker.py).

Код Когда Ack
SCHEMA_MISMATCH тело не JSON / не по схеме / MAJOR версии ≠ 1 / pipeline ≠ image. Конверт публикуется, только если в теле есть корректные job_id и file_id, иначе запрос пишется в лог и отбрасывается нет
FILE_READ_ERROR объект S3 не прочитан; s3.bucket не совпадает с бакетом сервиса да
RECOGNITION_FAILED не записан файл во «Входящие» (горячая папка недоступна); недопустимое original_name; «Исходящие» не читались ни разу до дедлайна да
TIMEOUT ИС не ответила до min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS); reply_deadline истёк до отправки файла да
INVALID_FORMAT все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, raw_s3_key указан) или корень ответа не объект; склейка нарушена или пуста да
код ошибки ИС все файлы ответа ошибочны (error.code непустой): код первого ошибочного файла по имени пробрасывается, если он входит в ErrorCode (например, INVALID_DOCUMENT); каждый ошибочный файл — строка metrics.warnings да
NOT_IMPLEMENTED есть хотя бы один успешный файл (error.code == ""); нормализатор Среза 0 — заглушка. Ошибочные файлы набора — в metrics.warnings да
UNKNOWN_ERROR сырой ответ или результат не записан в S3; нормализатор упал; неизвестный код ИС (в том числе error.code не строка: число, список, true); непредвиденная ошибка да

Файл ответа считается ошибочным, если его error.code непустой, файл не разобрался как JSON (parse_error) либо корень ответа не объект (INVALID_FORMAT). metrics.warnings итогового конверта содержит по строке <имя файла>: <код>: <сообщение> на каждый ошибочный файл (и при NOT_IMPLEMENTED, и когда ошибочны все файлы); если ошибочных файлов нет, поле metrics не передаётся.

Контракт NATS (кратко)

Схемы и примеры — ../docs/contracts/; их байт-в-байт копии лежат в api/contracts/ (идентичность проверяет unit-тест api/tests/unit/test_contract.py, входит в make check), pydantic-модели — api/src/contract/.

  • Запрос: subject recognition.image.request, подписка в queue group scan-gateway, Nats-Msg-Id = <job_id>:<attempt>.
  • Ответы — subject recognition.result: ack status=processing, Nats-Msg-Id = ack:<job_id>:<attempt>; итог status = done | needs_check | error, Nats-Msg-Id = result:<job_id>:<attempt>. service.name = "scan-gateway".
  • Ключи S3: исходник — из запроса (s3.key), сырой ответ (склейка файлов ИС) — raw/{job_id}.json, результат — results/{file_id}/{job_id}/result.json. Бакет один.
  • NATS без JetStream: переподключение и повторная подписка — на nats-py, ack и повторы публикации — на уровне приложения (api/src/core/nats.py).

Запуск

Требуются Docker с compose v2, uv, Python 3.11. Все команды make — из корня репозитория; backend-команды рецепты выполняют в api/. Рядом в workspace должны лежать ../docs (compose, контракт) и ../fixtures.

Разработка

make up                                  # NATS, MinIO, PostgreSQL: compose-проект arm-ka из ../docs/dev
make venv                                # api/.venv (Python 3.11), зависимости без raisa_*
cp api/.env.example api/.env             # значения совпадают с ../docs/dev (NATS 4222, MinIO 9000, бакет arm-ka)
mkdir -p api/.hotfolder                  # корень горячей папки: сам не создаётся
cd api && .venv/bin/python run_server.py # сервис на :8000
make emulator                            # в другом терминале: эмулятор ИС Сканирование
curl -s localhost:8000/healthcheck

Целей down/stop в Makefile нет намеренно: compose-проект arm-ka общий для сервисов workspace. Контейнера scan-emulator в compose пока нет (его добавит репозиторий docs позже), поэтому эмулятор запускается локально командой make emulator.

Полный круг против compose и эмулятора

Исполняемый пример — api/tests/integration/test_roundtrip.py (поднимает приложение и эмулятор в процессе теста, публикует запрос, проверяет ack, итоговый конверт, объекты S3). Вручную те же шаги:

  1. Выполнить «Разработку» выше: сервис и эмулятор работают, /healthcheck отдаёт nats = ok, hotfolder = ok.
  2. Положить файл в MinIO (консоль http://localhost:9001 или mc) в бакет arm-ka, например uploads/<file_id>/<job_id>/aep_3.pdf (имя файла должно совпадать с фикстурами эмулятора: aep_3.* -> набор aep_3.json + aep_3_ofr.json, aep_6.* -> aep_6.json, aep_5.* -> aep_5.json).
  3. Подписаться на recognition.result любым клиентом NATS.
  4. Опубликовать в recognition.image.request тело по образцу api/contracts/examples/request-image.json (свои job_id и file_id — UUID, original_name, s3.key, reply_deadline в будущем, attempt = 1) с заголовком Nats-Msg-Id: <job_id>:1.
  5. Ожидать: ack processing, через SCAN_EMULATOR_DELAY (+ до SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS после первого опроса, увидевшего набор) — конверт error:
    • aep_3 — NOT_IMPLEMENTED и metrics.warnings с одной строкой <job_id>_ofr.json: INVALID_DOCUMENT: ... (второй файл набора — ответ-ошибка);
    • aep_6 — NOT_IMPLEMENTED без metrics;
    • aep_5 (и aep_8) — INVALID_DOCUMENT с сообщением ИС (ответы-ошибки);
    • неизвестное имя — INVALID_DOCUMENT эмулятора. В бакете появится raw/<job_id>.json (склейка всех файлов набора), raw_s3_key указан в конверте.

Проверки

make check             # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration")
make test-integration  # pytest -m integration: нужны NATS и MinIO (make up)
make docker-build      # docker build -t scan-gateway:dev . (образ без Nexus)
make check-full        # check + test-integration + docker-build; перед PR

Тесты запускаются из api/: .env.test подключается через ENV_FILE=.env.test в api/pytest.ini и разрешается относительно текущего каталога (рецепты make это делают сами). Маркеры: integration — нужна инфраструктура, исключается из make test; fixtures — нужны реальные данные из ../fixtures (нет репозитория — тесты пропускаются). Тесты: api/tests/unit/, api/tests/integration/. Интеграционные тесты делят NATS с другими сервисами workspace: если в нём работает другой экземпляр scan-gateway, он может забрать запрос теста из queue group. Шаги make check-full идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются.

/healthcheck

GET /healthcheck всегда отвечает 200; состояние — в полях:

Поле Значения
uptime время старта процесса
version VERSION
nats ok — соединение установлено; down — подключение идёт, переподключение, сбой; disabled — NATS_ENABLED=false
hotfolder ok — HotFolder.healthy() (корень существует и доступен); down — нет, проверка не уложилась в 2 с или упала

Подключение к NATS идёт в фоне: HTTP отвечает сразу (nats = down), подписка появляется после подключения. Результат проверки горячей папки кэшируется на 5 с.

Docker-образ

Один образ (Dockerfile, python:3.11-slim, контекст сборки — корень репозитория, docker build -t scan-gateway .) для шлюза и для эмулятора. По умолчанию запускается шлюз (python run_server.py, порт 8000); эмулятор — тем же образом с command: python -m src.scan_emulator. Пакеты raisa_* в образ не ставятся. api/.env в образ не попадает (см. .dockerignore): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути SCAN_HOTFOLDER_PATH, эмулятору — каталог фикстур по пути SCAN_FIXTURES_DIR.

Ограничения Среза 0

  • NullNormalizer (api/src/normalizer/): если ошибочны все файлы ответа, код первого из них пробрасывается; если есть успешный файл (error.code == "") — NOT_IMPLEMENTED; ошибочные файлы — в metrics.warnings. Таблицы data_ocr (в одном json их может быть несколько, разных форм: баланс, ОФР) не интерпретируются; настоящая нормализация в statementDocument — Срез 3.
  • Реальные фикстуры: aep_3.json (баланс и ОФР), aep_6.json — успешные ответы, aep_3_ofr.json, aep_5.json, aep_8_form4.json — ответы-ошибки (INVALID_DOCUMENT; data_ocr — объект с пустыми полями и table_structure: []).
  • Режим smb покрыт unit-тестами с подменённым smbclient; на реальной шаре ИС Сканирование не проверялся. Фактические имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код.
  • Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до reply_deadline ядра.
  • Зависший вызов SMB (poll/put, до ~60 с по умолчанию smbclient) дедлайном воркера не прерывается: вызов идёт в потоке без собственного таймаута, поэтому TIMEOUT может прийти позже дедлайна на это время.
  • Недописанный набор ответа ИС: защита — settle (SCAN_SETTLE_SECONDS), а не разбор содержимого. Если ИС дописывает файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается: response = null, parse_error в raw/, ошибка этого файла (INVALID_FORMAT) — в metrics.warnings.
  • Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе он кладёт ответ INVALID_DOCUMENT с сообщением о повреждённой фикстуре и пишет ошибку в лог.
  • Контейнер scan-emulator в compose отсутствует.
Description
No description provided
Readme 2.7 MiB
Languages
Python 99.3%
Makefile 0.6%