diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b6a96b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.superpowers +.claude +.codegraph +openspec +docs +setup +**/__pycache__ +**/*.pyc +**/.mypy_cache +**/.ruff_cache +**/.pytest_cache +# локальное окружение и горячие папки dev/тестов: в контейнер окружение приходит из compose +api/.venv +api/.env +api/.env.test +api/.hotfolder +api/.hotfolder-test diff --git a/.gitignore b/.gitignore index c09eafc..e089f24 100644 --- a/.gitignore +++ b/.gitignore @@ -322,9 +322,14 @@ dist # Custom ignore .DS_Store -Dockerfile docker-compose.yml -.dockerignore .vscode my_project_env -.cursorignore \ No newline at end of file +.cursorignore +.codegraph/ +.superpowers/ +.bounded + +# scan-gateway: локальная горячая папка dev-режима +api/.hotfolder/ +api/.hotfolder-test/ diff --git a/CLAUDE.md b/CLAUDE.md index a16d0cf..40575a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,38 +3,62 @@ Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис. ## Что это -Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие», -забрать `Исходящие/UUID/UUID.json`, сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), +Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие» +(через `SCAN_TMP_DIR`, по умолчанию `Tmp`: `.part` пишется туда, на той же ФС/шаре, и переименовывается), забрать **все** +`Исходящие/UUID/*.json` (ИС кладёт по одному json на каждый распознанный документ), склеить и сохранить сырой ответ в S3 +`raw/{job_id}.json` (в БД не регистрируется), нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать -`recognition.result`. Таймаут 5 мин → `timeout`. **Нет доступа к БД.** +`recognition.result`. Таймаут → ошибка `TIMEOUT` по `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, старт — +запись файла во «Входящие». **Нет доступа к БД.** ## Правила - Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100, поля организации/даты — по тому, что реально приходит. -- Один PDF может содержать ББ и ОФР → два документа в результате. -- Dev-режим: эмулятор ИС Сканирование, возвращающий `aep_N.json` по имени файла (`SCAN_EMULATOR=1`). +- Набор ответа готов по settle: есть `*.json`, а имена и размеры не менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2; + отсчёт от первого опроса, увидевшего окончательный набор). Не ждать «один файл `.json`». +- Склеенный `raw/{job_id}.json`: `{"job_id", "files": [{"name", "response", "parse_error"}, ...]}`, все `*.json` + каталога по возрастанию имени; нормализатор получает ровно эту структуру. +- Успех файла ответа — `error.code == ""` (`error` при этом не `null`!), ошибка — непустой `error.code`, + `parse_error` или корень ответа не объект (`INVALID_FORMAT`). Проверка `error is None` для реального ответа неверна. Ошибочные файлы — в `metrics.warnings` + (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого, есть успешный → `NOT_IMPLEMENTED` (Срез 0). +- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы. Таблицы не + интерпретируются в Срезе 0 — разбор в Срезе 3. +- Dev-режим: эмулятор ИС Сканирование: по входящему `_.` кладёт в `Output//` набор фикстур + `.json` + `_*.json` как `.json` / `_<суффикс>.json` (атомарно, через `Tmp`): `make emulator` + (то же, что `python -m src.scan_emulator` из `api/`); в контейнере — тот же образ с `command: python -m src.scan_emulator`. +- Описание сервиса, протокол горячих папок, env, запуск и коды ошибок — `README.md`; требования и решения Среза 0 — + `openspec/changes/slice-0-skeleton/` (`design.md`). ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — -`../docs/context/03-architecture.md`, «Соглашения шаблона команды». Сейчас репозиторий — полная копия каркаса `arm-core`; -change `slice-0-skeleton` приводит его к сервису **без фронта и без БД**, но с SMB. Все команды — из `api/`. +`../docs/context/03-architecture.md`, «Соглашения шаблона команды». Репозиторий приведён к сервису **без фронта и без +БД**, но с SMB. Команды `make` — из корня репозитория (рецепты сами заходят в `api/`), `pytest` — из `api/`. -**Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan с -регистрацией SMB-сессии, `GET /healthcheck`, gzip/proxy middleware, обработчики ошибок), `core/config.py` (pydantic-settings, -`OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`, `DEBUG`), `core/clients.py` (S3), `core/nats_callback.py`, -`core/logger.py`, `core/exception_handlers.py`, `common/exceptions.py`, `pytest.ini`, `requirements*.txt` (включая -`smbprotocol`), `raisa_config.yml`, `setup/setup.sh` + `pip.conf` (без npm-шага). +**Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan: +SMB-сессия, S3, NATS, воркер; `GET /healthcheck`; gzip/proxy middleware), `core/config.py` (pydantic-settings, +`OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`, `DEBUG`), `core/exception_handlers.py` (подключается в +`create_app`), `api/pytest.ini`, `api/requirements*.txt` (включая `smbprotocol`), `raisa_config.yml`, +`setup/setup.sh` + `setup/pip.conf` (без npm-шага). Заглушки шаблона без кода сервиса: `src/api/` (пустой `router`, +пустые `dependencies/` и `endpoints/`), `src/schemas/` и `src/services/` (пустые пакеты), `common/exceptions.py` +(`NotFoundError`, `CouldntCreateError` нигде не используются), `core/logger.py` (`setup_logger` нигде не вызывается). -**Вырезано (Срез 0):** `web/` целиком и `setup/.npmrc`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`), -CORS, импорт `raisa_fastapi_protected_api`; `db/`, `models/`, `repositories/`, `alembic/`, `alembic.ini`; из настроек — -`OPENBAO.DATABASE`; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; -шаблонный `README.md` заменён описанием сервиса. +**Вырезано (Срез 0):** `web/`, `setup/.npmrc`; из `app.py` — mount SPA, CORS, `raisa_fastapi_protected_api`; `db/`, +`models/`, `repositories/`, `alembic/`; `OPENBAO.DATABASE`; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, +`raisa_fastapi_protected_api`; шаблонные `core/nats_callback.py` и `README.md` заменены своими. -**Добавлено:** `api/src/gateway/` (горячие папки: положить `UUID_имя.pdf` во «Входящие», опрашивать «Исходящие/UUID/UUID.json»; -режим `local` — обычный каталог для dev, `smb` — как в шаблоне через `smbclient`; референс поллинга — -`../reference-old-arm/api/src/core/PDFWorker`), `api/src/normalizer/` (ответ Content AI → `statementDocument`; Срез 3), -`api/src/contract/` (pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в `make check`), -`api/src/core/worker.py` (подписка queue group `recognition.image.request` → S3 → горячая папка → ожидание ≤ `reply_deadline` -→ S3 `raw/` и `results/` → `recognition.result`), `api/src/scan_emulator/` (эмулятор ИС Сканирование для compose: следит за -«Входящими», кладёт `../fixtures/ocr/aep_N.json` по имени файла в «Исходящие»; запускается тем же образом, `SCAN_EMULATOR=1`), -`Dockerfile`, `api/pyproject.toml` (ruff, mypy). Проверки: `pytest`, `ruff check . && ruff format --check .`, `mypy src`. +**Добавлено:** +- `api/src/gateway/` — горячие папки: протокол `HotFolder` (`hotfolder.py`: `LocalHotFolder`, `SettleTracker`, `inbox_name`), + `smb.py` (`SmbHotFolder`, импортируется лениво), фабрика `create_hotfolder`; `merge.py` — склейка файлов ответа в `raw/`; референс поллинга — `../reference-old-arm/api/src/core/PDFWorker`. +- `api/src/core/` — `worker.py` (задание: проверка → ack → S3 → горячая папка → опрос → `raw/` → нормализатор → + результат; ошибки по таблице D-7), `nats.py` (core NATS без JetStream, queue group `scan-gateway`, публикация с + повторами), `clients.py` (`S3Storage`), `health.py` (`nats`/`hotfolder` в `/healthcheck`). +- `api/src/normalizer/` — `Normalizer`, `NullNormalizer` (Срез 0: разбор по файлам склейки — все ошибочны → код + первого, есть успешный → `NOT_IMPLEMENTED`, ошибочные файлы → `metrics.warnings`); настоящая нормализация — Срез 3. +- `api/src/contract/` + `api/contracts/` — pydantic-модели и байт-в-байт копии схем `../docs/contracts`; идентичность + проверяет `api/tests/unit/test_contract.py` (входит в `make check`). +- `api/src/scan_emulator/` — эмулятор ИС Сканирование (`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, + `SCAN_EMULATOR_POLL_SECONDS`); в compose контейнера пока нет — `make emulator`. +- `Makefile` (`venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`, + `emulator`), `api/pyproject.toml` (ruff, mypy), `Dockerfile` + `.dockerignore` (один образ: шлюз и эмулятор), + `api/.env.example`, `api/.env.test`. +- Тесты: `api/tests/unit/`, `api/tests/integration/` (маркеры `integration`, `fixtures`); перед PR — `make check-full`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67e4968 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Образ scan-gateway для dev и `make check-full`. Контекст сборки — корень репозитория. +# Платформенные пакеты raisa_* (Nexus) в образ не ставятся — та же логика, что в `make venv`. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app/api + +COPY api/requirements.txt ./requirements.txt +RUN grep -v '^raisa_' requirements.txt > /tmp/req.txt \ + && pip install --no-cache-dir -r /tmp/req.txt \ + && rm /tmp/req.txt + +COPY api/ ./ + +EXPOSE 8000 + +# Шлюз. Эмулятор ИС Сканирование запускается из этого же образа командой `python -m src.scan_emulator`, +# которую compose задаёт через `command`. +CMD ["python", "run_server.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0367f9e --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +# Команды разработки scan-gateway. Запуск из корня репозитория; рецепты сами переходят в api/ (как и в arm-core), +# поэтому backend-команды работают с каталогом api/ и окружением api/.venv. +VENV := api/.venv +BIN := $(CURDIR)/$(VENV)/bin +# docs/dev — соседний репозиторий и в основном workspace, и в worktree .wt/<срез>/ +COMPOSE_FILE ?= ../docs/dev/docker-compose.yml +COMPOSE_PROJECT ?= arm-ka +IMAGE ?= scan-gateway:dev + +.PHONY: help venv lint test test-integration check docker-build check-full up emulator + +help: + @echo "venv - окружение api/.venv (Python 3.11) с зависимостями без raisa_*" + @echo "lint - ruff check, ruff format --check, mypy src" + @echo "test - pytest без интеграционных тестов" + @echo "test-integration - интеграционные тесты (нужны NATS и MinIO: make up)" + @echo "check - lint + test" + @echo "docker-build - собрать docker-образ $(IMAGE) (контекст — корень репозитория)" + @echo "check-full - check + test-integration + docker-build (по порядку, до первой ошибки)" + @echo "up - поднять NATS/MinIO/PostgreSQL общим compose-проектом arm-ka и создать бакет (minio-init)" + @echo "emulator - запустить эмулятор ИС Сканирование (python -m src.scan_emulator)" + +# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их +venv: + cd api && uv venv --python 3.11 --allow-existing .venv + cd api && grep -v '^raisa_' requirements.txt | uv pip install --python .venv/bin/python -r - + cd api && grep -v '^-r' requirements-dev.txt | uv pip install --python .venv/bin/python -r - + +lint: + cd api && $(BIN)/ruff check . + cd api && $(BIN)/ruff format --check . + cd api && $(BIN)/mypy src + +test: + cd api && $(BIN)/pytest -m "not integration" + +test-integration: + cd api && $(BIN)/pytest -m integration + +check: lint test + +# Образ собирается без Nexus (raisa_* в образ не ставятся, см. Dockerfile). Тесты в образе не запускаются: +# tests/ в нём есть, но api/.env.test исключён через .dockerignore, поэтому pytest внутри контейнера не пройдёт. +docker-build: + docker build -t $(IMAGE) . + +# Полная проверка перед PR. Шаги вызываются последовательно через $(MAKE), а не как пререквизиты: +# так порядок check -> test-integration -> docker-build и остановка на первой ошибке гарантированы и при make -j. +check-full: + $(MAKE) check + $(MAKE) test-integration + $(MAKE) docker-build + +# Общий compose-проект workspace (arm-ka): его используют и другие сервисы, поэтому целей down/stop/restart нет. +# --wait — только со списком долгоживущих сервисов: одноразовый minio-init завершается кодом 0, и compose считает это сбоем. +# Поэтому бакет создаётся отдельным шагом: `run --rm minio-init` (идемпотентно: `mc mb --ignore-existing`; на свежем +# томе без него интеграционные тесты падают на NoSuchBucket). Код выхода шага пробрасывается в make. +up: + docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) up -d --wait postgres nats minio + docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) run --rm minio-init + +# Эмулятор ИС Сканирование (параметры — SCAN_HOTFOLDER_PATH, SCAN_FIXTURES_DIR, SCAN_EMULATOR_DELAY из api/.env) +emulator: + cd api && $(BIN)/python -m src.scan_emulator diff --git a/README.md b/README.md index 6b8cbb1..440587f 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,283 @@ -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) -__________________________________________________________________________________________________________ -# dkb_armka, v 0.0.1 -____ +# scan-gateway -| | Исполнитель | Заказчик | -|-----------|:----------------------------------------:|:----------------------------------------------:| -| Сотрудник | raykov-mse | raykov-mse | -| Отдел | aurora | aurora | -| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru | ------ -# Шаблон FastAPI +Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений +(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его +и публикует результат в `recognition.result`. Контракт обмена — `../docs/contracts/` (v1.0), архитектура — +`../docs/context/03-architecture.md`. -## Описание +Границы сервиса (DECISION-4): -Данное шаблон предоставляет проект, в котором приложение состоит из API и UI. При использовании данного типа проекта, Вы можете разрабатывать, как отдельные API сервисы, так и WEB приложения с интерфейсом. Для построения WEB интерфейса используется React. Далее описаны основные иснтрументы предустановленные данным шаблоном: +- нет базы данных: единственный писатель в БД — `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/`. -Для удобной настройки проекта под разработку. Перейдите в каталог setup и выполните слудеющие команды: +## Поток обработки + +``` +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. горячая папка: /_ (через /.<имя>.part и rename) + 6. опрос // каждые 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__*` | платформа | + +- Входящий файл: `/_`. `original_name` приходит из запроса и не доверяется: берётся + последний компонент пути, символы, недопустимые в Windows/SMB (`: < > " | ? *` и управляющие), заменяются на `_`, + завершающие точки и пробелы отбрасываются; кириллица и пробелы сохраняются; длинное имя укорачивается так, чтобы + временный `.<имя>.part` умещался в 240 байт UTF-8 (расширение сохраняется). Если имени не осталось — + `RECOGNITION_FAILED`. +- Запись атомарна: сначала временный `.<имя>.part` в служебном каталоге `` (по умолчанию `Tmp`), затем + переименование в `Input`. `Tmp` лежит рядом с `Input` и `Output`, на той же ФС/шаре, чтобы `rename` оставался + атомарным, а ИС не забрала недописанный файл из `Input`. Каталоги `Input` и `Tmp` создаются при необходимости (без + родителей), корень горячей папки сам не создаётся: нет корня — `RECOGNITION_FAILED`. +- Ответ ИС — каталог `//`: ИС кладёт туда **все** распознанные документы задания одновременно, + по одному `*.json` на файл (`Output//*.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`-режиме: для файла `_<имя>` ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output//` весь + набор фикстур: `<имя>.json` -> `.json` и `<имя>_<суффикс>.json` -> `_<суффикс>.json` (`<имя>` — имя + входящего файла без расширения; именно `<имя>_*`, а не префикс `<имя>*`: `aep_3` не забирает `aep_30*`; в каждом + файле `file_uid` = ``). Набор собирается во временном каталоге `Tmp` и появляется в `Output//` + атомарно целиком (rename каталога). Затем входящий файл переименовывается в `….done`. Нет фикстуры — один + ответ-ошибка `INVALID_DOCUMENT`. Dot-файлы и `.done` игнорируются. + +## Склеенный ответ ИС (`raw/{job_id}.json`) + +В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (контракт v1.0 не меняется: `raw_s3_key` — +по-прежнему одна строка `raw/{job_id}.json`): + +```json +{ + "job_id": "", + "files": [ + {"name": ".json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null}, + {"name": "_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//` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 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//` | +| `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 = :`. +- Ответы — subject `recognition.result`: ack `status=processing`, `Nats-Msg-Id = ack::`; итог + `status = done | needs_check | error`, `Nats-Msg-Id = result::`. `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`. + +### Разработка ```bash -cd setup -chmod +x setup.sh -./setup.sh +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///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: :1`. +5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) — + конверт `error`: + - `aep_3` — `NOT_IMPLEMENTED` и `metrics.warnings` с одной строкой `_ofr.json: INVALID_DOCUMENT: ...` + (второй файл набора — ответ-ошибка); + - `aep_6` — `NOT_IMPLEMENTED` без `metrics`; + - `aep_5` (и `aep_8`) — `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки); + - неизвестное имя — `INVALID_DOCUMENT` эмулятора. + В бакете появится `raw/.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте. + +### Проверки ```bash -complete setup web application -To start api, cd to api directory, and use venv, start run_server.py -To start api, cd to web directory, and use command npm run start! -Happy Hacking! :) +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` идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются. -### Backend +## `/healthcheck` -Исходный код API находится в каталоге api. После клонирования репозитория требуется создать виртуальное окружение и установить зависимости, это достигается следующими командами: +`GET /healthcheck` всегда отвечает 200; состояние — в полях: -```bash -cd api -python -m venv ./.venv -cp ./../setup/pip.conf ./.venv/pip.conf -source ./.venv/bin/python -pip install -r requirements.txt -``` +| Поле | Значения | +|---|---| +| `uptime` | время старта процесса | +| `version` | `VERSION` | +| `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` | +| `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала | -Запуск приложения осуществляется через файл: run_server.py +Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения. +Результат проверки горячей папки кэшируется на 5 с. -В качестве фреймворка ля api используется FastAPI. (Как понятно из названия шаблона) Подробнее ознакомится с возможностями Fast API вы можете в официальной документации: [Fast API документация](https://fastapi.tiangolo.com/) +## Docker-образ -Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла requirements.txt +Один образ (`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`. -Базовый шаблон предоставляет минимальный функционал по работе с данными. Далее описаны основные моменты влияющие на работу приложения. - -#### Основная структура проекта - -```bash -api -├── requirements.txt -├── run_server.py -├── src -│ ├── app.py -│ ├── __init__.py -│ ├── settings -│ │ ├── __init__.py -│ │ ├── models.py -│ │ └── settings.py -└── web -``` - -Вы можете придерживатся любой структуры проекта, главное, чтобы у Вас присутствовал файл run_server.py. Который запускает web сервер uvicorn или gunicorn. Также обязательно Ваш сервер должен подниматся на определенном хосте и порте. Требуемый хост и порт передаются внутрь через переменные окружения SERVER_HOST, SERVER_PORT - -```python -uvicorn.run( - app, - host=settings.server.HOST, - port=settings.server.PORT, - lifespan="on" - ) -``` - -#### Отказ от ui (react) - -Если внутри вашего приложения не требуется добавлять визуальный интерфейс, а Вам требуется исключительно разработать API, то в таком случае не стоит удалять директорию web находящуюся на уровне с директорией api. ТРебуется внести небольшие правки в конфигурацию приложения, в частности в файл: /api/src/app.py, удалив следующее содержимое. В таком случае Fast API сервис не будет обрабатывать статические файлы, от web приложения. - -```python -#Подключение собранного web приложения -app.mount("/", StaticFiles(directory="web", html=True), name="web") -``` - -### Frontend - -Исходный код UI находится в каталоге web. После клонирования репозитория требуется установить зависимости, это достигается следующими командами: - -```bash -cd web -npm install -``` - -Запуск приложения осуществляется через файл команду: - -```bash -npm run start -``` - -В качестве фреймворка для ui используется React. Подробнее ознакомится с возможностями React вы можете в официальной документации: [React документация](https://react.dev/reference/react) - -Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла package.json - -Базовый шаблон предоставляет минимальный интерфейс по работе с данными. Далее описаны основные моменты влияющие на работу web приложения. - -#### Основная структура проекта - -```bash -web -├── biome.json -├── .npmrc -├── dist -├── package.json -├── postcss.config.cjs -├── public -│ └── index.html -├── README.md -├── rsbuild.config.ts -├── src -│ ├── App.tsx -│ ├── config.ts -│ ├── env.d.ts -│ ├── index.css -│ └── index.tsx -├── tailwind.config.js -└── tsconfig.json -``` - -Вы можете придерживатся любой структуры проекта, внутри src. Но на верхнем уровне находятся файлы конфигурации и сборки их требуется изменять только в крайнем случае. Для сборки приложения используется [RSBuild](https://rsbuild.dev/) - -Для создания UI настоятельно рекомендуется использовать предустановленную библиотеку компонентови стилей: - -- tailwindcss -- daisy-ui -- react-daisy-ui -- raisa-ui - -!!! Важно, если внутри web приложения Вы хотите обращатся к API данного проекта, используйте следующую конструкцию: - -```javascript - -fetch(`${Config.API_URL}/api`, { - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - method: 'GET' -}) - -``` - -Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо -запросов вида http://localhost:8000/api должно быть так: /api +## Ограничения Среза 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 отсутствует. diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..8605667 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,66 @@ +# Локальное окружение scan-gateway. Скопируйте в .env: cp .env.example .env (.env в git не попадает) +# Значения совпадают с ../docs/dev/docker-compose.yml и ../docs/dev/.env (compose: NATS 4222, MinIO 9000, бакет arm-ka); +# это только dev-умолчания. Файл окружения можно выбрать переменной ENV_FILE (по умолчанию .env, путь от каталога api/); +# переменные окружения приоритетнее файла. Тестовый набор — .env.test (его подключает pytest.ini). + +DEBUG=true + +# --- MinIO (S3): единственный бакет, в нём лежат входные файлы, raw/{job_id}.json и results/ --- +# Адрес без схемы (host:port) — так ждёт miniopy-async; MinIO из compose работает по http, поэтому MINIO_SECURE=false. +# На платформе MINIO_SECURE не задаётся (по умолчанию true — https). +OPENBAO__MINIO__MINIO_URL=localhost:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka +OPENBAO__MINIO__MINIO_SECURE=false + +# --- NATS (core, без JetStream) --- +# NATS_SEED / NATS_JWT пустые: подключение без nkeys (локальная разработка, тесты). На платформе их выдаёт OpenBAO. +OPENBAO__NATS__NATS_URL=nats://localhost:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +# Включает подписку на recognition.image.request. false — воркер не запускается (сервис поднимется без NATS). +NATS_ENABLED=true +# PUBLISH_RETRIES — число повторов публикации ack/результата в NATS после первой попытки (при 3 — до 4 попыток всего); +# DONE_CACHE_SIZE — размер LRU обработанных job_id (защита от повторной доставки запроса) +PUBLISH_RETRIES=3 +DONE_CACHE_SIZE=1000 + +# --- Горячие папки ИС Сканирование --- +# local — обычный каталог (dev, тесты, эмулятор); smb — SMB-шара платформы (нужен блок OPENBAO__SMB__* ниже). +SCAN_HOTFOLDER_MODE=local +# Корень горячей папки в режиме local: каталог должен существовать (mkdir -p .hotfolder). +# Путь считается от каталога api/; его же читает эмулятор. +SCAN_HOTFOLDER_PATH=./.hotfolder +# Подкаталоги корня: «Входящие» (сюда кладётся UUID_имя) и «Исходящие» (оттуда забираются все *.json из /) +SCAN_INPUT_DIR=Input +SCAN_OUTPUT_DIR=Output +# Служебный каталог корня: временный .part пишется сюда и переименовывается во «Входящие» (та же ФС/шара — rename +# атомарен, во «Входящих» не бывает недописанных файлов); создаётся при первой записи +SCAN_TMP_DIR=Tmp +# Период опроса «Исходящих», секунды +SCAN_POLL_SECONDS=5 +# Набор *.json в / считается готовым, когда имена и размеры файлов не менялись столько секунд (>= 0) +SCAN_SETTLE_SECONDS=2 +# Потолок ожидания ответа ИС, секунды (дополнительно ограничен reply_deadline запроса); повторной отправки нет +SCAN_TIMEOUT_SECONDS=300 +# Сколько заданий обрабатывается одновременно +SCAN_MAX_INFLIGHT=4 + +# --- SMB (только при SCAN_HOTFOLDER_MODE=smb; в режиме local весь блок не нужен) --- +# OPENBAO__SMB__SMB_HOST=smb.example.local +# OPENBAO__SMB__SMB_PATH=share/scan +# OPENBAO__SMB__SMB_LOGIN=user +# OPENBAO__SMB__SMB_PASSWORD=password + +# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) --- +# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги +# эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает +# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в // целиком. +# Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace) +SCAN_FIXTURES_DIR=../../fixtures/ocr +# Задержка перед выдачей ответа, секунды (имитация работы ИС) +SCAN_EMULATOR_DELAY=2 +# Период опроса входящего каталога эмулятором, секунды (> 0) +SCAN_EMULATOR_POLL_SECONDS=1 diff --git a/api/.env.test b/api/.env.test new file mode 100644 index 0000000..74b1d06 --- /dev/null +++ b/api/.env.test @@ -0,0 +1,29 @@ +# Окружение тестов (pytest.ini: ENV_FILE=.env.test, путь от каталога api/). Только dev-значения, секретов нет. +# Инфраструктура — compose-проект arm-ka на 127.0.0.1 (make up); модульные тесты в неё не ходят. +DEBUG=true + +OPENBAO__MINIO__MINIO_URL=127.0.0.1:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka +OPENBAO__MINIO__MINIO_SECURE=false + +OPENBAO__NATS__NATS_URL=nats://127.0.0.1:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +NATS_ENABLED=true + +# Корень горячей папки задаётся строкой и не обязан существовать: тесты, которым нужна папка, создают tmp-каталог +# и подставляют его сами +SCAN_HOTFOLDER_MODE=local +SCAN_HOTFOLDER_PATH=./.hotfolder-test +SCAN_INPUT_DIR=Input +SCAN_OUTPUT_DIR=Output +SCAN_TMP_DIR=Tmp +# Укороченные интервалы, чтобы тесты не ждали по 5 секунд +SCAN_POLL_SECONDS=0.1 +SCAN_TIMEOUT_SECONDS=5 +# R-23: набор ответа готов сразу (тесты воркера/таймаута/параллелизма не ждут стабилизации); settle проверяется +# отдельными тестами горячей папки с подменёнными часами +SCAN_SETTLE_SECONDS=0 diff --git a/api/alembic.ini b/api/alembic.ini deleted file mode 100644 index 807ded2..0000000 --- a/api/alembic.ini +++ /dev/null @@ -1,149 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts. -# this is typically a path given in POSIX (e.g. forward slashes) -# format, relative to the token %(here)s which refers to the location of this -# ini file -script_location = %(here)s/alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s -# Or organize into date-based subdirectories (requires recursive_version_locations = true) -# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. for multiple paths, the path separator -# is defined by "path_separator" below. -prepend_sys_path = . - - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the tzdata library which can be installed by adding -# `alembic[tz]` to the pip requirements. -# string value is passed to ZoneInfo() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to /versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "path_separator" -# below. -# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions - -# path_separator; This indicates what character is used to split lists of file -# paths, including version_locations and prepend_sys_path within configparser -# files such as alembic.ini. -# The default rendered in new alembic.ini files is "os", which uses os.pathsep -# to provide os-dependent path splitting. -# -# Note that in order to support legacy alembic.ini files, this default does NOT -# take place if path_separator is not present in alembic.ini. If this -# option is omitted entirely, fallback logic is as follows: -# -# 1. Parsing of the version_locations option falls back to using the legacy -# "version_path_separator" key, which if absent then falls back to the legacy -# behavior of splitting on spaces and/or commas. -# 2. Parsing of the prepend_sys_path option falls back to the legacy -# behavior of splitting on spaces, commas, or colons. -# -# Valid values for path_separator are: -# -# path_separator = : -# path_separator = ; -# path_separator = space -# path_separator = newline -# -# Use os.pathsep. Default configuration used for new projects. -path_separator = os - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -# hooks = black -# black.type = console_scripts -# black.entrypoint = black -# black.options = -l 79 REVISION_SCRIPT_FILENAME - -# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module -# hooks = ruff -# ruff.type = module -# ruff.module = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Alternatively, use the exec runner to execute a binary found on your PATH -# hooks = ruff -# ruff.type = exec -# ruff.executable = ruff -# ruff.options = check --fix REVISION_SCRIPT_FILENAME - -# Logging configuration. This is also consumed by the user-maintained -# env.py script only. -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARNING -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARNING -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/api/alembic/README b/api/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/api/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/api/alembic/env.py b/api/alembic/env.py deleted file mode 100644 index b89fbff..0000000 --- a/api/alembic/env.py +++ /dev/null @@ -1,102 +0,0 @@ -import asyncio -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool -from sqlalchemy.ext.asyncio import AsyncEngine -from alembic import context -from src.core.config import settings -from src.models import Base # noqa: F401 — модели регистрируются в src/models/__init__.py -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL) -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - -def include_name(name, type_, parent_names): - if type_ == 'schema': - return name == 'dkb' - return True - - -def do_run_migrations(connection) -> None: - context.configure( - connection=connection, - target_metadata=target_metadata, - compare_type=True, - version_table_schema="dkb", - include_schemas=True, - include_name=include_name - ) - - with context.begin_transaction(): - context.run_migrations() - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - version_table_schema="dkb", - include_schemas=True, - include_name=include_name - ) - - with context.begin_transaction(): - context.run_migrations() - - -async def run_migrations_online() -> None: - """Run migrations in 'online' mode. - - In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = AsyncEngine( - engine_from_config( - config.get_section(config.config_ini_section), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - future=True - ) - ) - - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - asyncio.run(run_migrations_online()) diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako deleted file mode 100644 index 1101630..0000000 --- a/api/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/api/alembic/versions/.gitkeep b/api/alembic/versions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/api/contracts/examples/request-image.json b/api/contracts/examples/request-image.json new file mode 100644 index 0000000..2de9cd5 --- /dev/null +++ b/api/contracts/examples/request-image.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "pipeline": "image", + "file_type": "PDF", + "original_name": "bb_2025_q3.pdf", + "s3": { + "bucket": "arm-ka", + "key": "uploads/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/bb_2025_q3.pdf" + }, + "reply_deadline": "2026-10-03T12:45:00Z", + "attempt": 1 +} diff --git a/api/contracts/examples/request-tabular.json b/api/contracts/examples/request-tabular.json new file mode 100644 index 0000000..e1a26c1 --- /dev/null +++ b/api/contracts/examples/request-tabular.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "pipeline": "tabular", + "file_type": "XLSX", + "original_name": "osv_60_2025_q1.xlsx", + "s3": { + "bucket": "arm-ka", + "key": "uploads/a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02/3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01/osv_60_2025_q1.xlsx" + }, + "reply_deadline": "2026-10-03T12:30:00Z", + "attempt": 1 +} diff --git a/api/contracts/examples/result-error.json b/api/contracts/examples/result-error.json new file mode 100644 index 0000000..54af7cb --- /dev/null +++ b/api/contracts/examples/result-error.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "error", + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + }, + "documents": [] +} diff --git a/api/contracts/examples/result-message-done.json b/api/contracts/examples/result-message-done.json new file mode 100644 index 0000000..6d56f17 --- /dev/null +++ b/api/contracts/examples/result-message-done.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "status": "done", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "result_s3": { + "bucket": "arm-ka", + "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" + }, + "result_schema_version": "1.0", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { + "duration_ms": 18400, + "pages": 2, + "fields_total": 4, + "fields_low_confidence": 0, + "fields_unrecognized": 0, + "warnings": [] + } +} diff --git a/api/contracts/examples/result-message-error.json b/api/contracts/examples/result-message-error.json new file mode 100644 index 0000000..f5ec77a --- /dev/null +++ b/api/contracts/examples/result-message-error.json @@ -0,0 +1,11 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "error", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + } +} diff --git a/api/contracts/examples/result-message-processing.json b/api/contracts/examples/result-message-processing.json new file mode 100644 index 0000000..2087150 --- /dev/null +++ b/api/contracts/examples/result-message-processing.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "processing", + "service": { "name": "tabular-parser", "version": "0.1.0" } +} diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json new file mode 100644 index 0000000..6b6d122 --- /dev/null +++ b/api/contracts/examples/result-osv-minimal.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "done", + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": "7700000000", + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "C", + "opening_credit": "D", + "turnover_debit": "E", + "turnover_credit": "F", + "closing_debit": "G", + "closing_credit": "H" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик, ИНН 7711111111", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/examples/result-statement-minimal.json b/api/contracts/examples/result-statement-minimal.json new file mode 100644 index 0000000..484f222 --- /dev/null +++ b/api/contracts/examples/result-statement-minimal.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "status": "done", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "documents": [ + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_confidence": 97.5, + "inn": "7700000000", + "inn_confidence": 99, + "report_date_text": "30 сентября 2025 г.", + "report_date": "2025-09-30", + "units": "тыс. руб." + }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { + "line_code": "1100", + "line_code_confidence": 99, + "line_name": "Итого по разделу I", + "line_name_confidence": 96, + "values": [ + { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, + { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } + ] + }, + { + "line_code": "1600", + "line_code_confidence": 99, + "line_name": "БАЛАНС (актив)", + "line_name_confidence": 98, + "values": [ + { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, + { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } + ] + } + ] + } + ] +} diff --git a/api/contracts/recognition-request.schema.json b/api/contracts/recognition-request.schema.json new file mode 100644 index 0000000..780573c --- /dev/null +++ b/api/contracts/recognition-request.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-request/v1", + "title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0", + "description": "Тело сообщения на subject recognition.tabular.request или recognition.image.request; subject должен соответствовать полю pipeline (tabular → recognition.tabular.request, image → recognition.image.request). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (stored_file) → один job.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "pipeline": { "enum": ["tabular", "image"], "description": "конвейер распознавания; определяет subject запроса" }, + "file_type": { "enum": ["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] }, + "original_name": { "type": "string", "minLength": 1, "description": "имя файла, как загрузил пользователь" }, + "s3": { + "type": "object", + "description": "исходный файл в роли uploads", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "reply_deadline": { "type": "string", "format": "date-time", "description": "срок ответа, UTC" }, + "attempt": { "type": "integer", "minimum": 1, "description": "номер попытки, с 1" } + } +} diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json new file mode 100644 index 0000000..1f20727 --- /dev/null +++ b/api/contracts/recognition-result-message.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result-message/v1", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "description": "Тело сообщения на subject recognition.result. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "status", "service"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "status": { "enum": ["processing", "done", "needs_check", "error"] }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "result_s3": { + "type": "object", + "description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { "required": ["result_s3", "result_schema_version"] } + }, + { + "if": { + "properties": { "status": { "const": "error" } }, + "required": ["status"] + }, + "then": { "required": ["error"] } + } + ] +} diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json new file mode 100644 index 0000000..c5eea87 --- /dev/null +++ b/api/contracts/recognition-result.schema.json @@ -0,0 +1,197 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result/v1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid" }, + "file_id": { "type": "string", "format": "uuid" }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "status": { "enum": ["done", "needs_check", "error"] }, + "error": { + "type": ["object", "null"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "documents": { + "type": "array", + "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } + } + }, + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + }, + "$defs": { + "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "osvDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "group_schema", "rows"], + "properties": { + "document_type": { "const": "OSV" }, + "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "header": { + "type": "object", + "required": ["organization_name", "account_code_raw", "period_text"], + "properties": { + "organization_name": { "type": "string", "description": "как в r1 файла" }, + "organization_inn": { "type": ["string", "null"] }, + "account_code_raw": { "type": "string", "examples": ["60", "58.03"] }, + "period_text": { "type": "string", "examples": ["1 квартал 2025 г.", "2024 г."] }, + "period_year": { "type": ["integer", "null"] }, + "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, + "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, + "header_variant": { "enum": [1, 2] } + } + }, + "group_schema": { + "type": "array", + "description": "порядок группировок из шапки, после первого ACCOUNT", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] }, + "name": { "type": "string", "description": "исходный текст из шапки" }, + "has_inn": { "type": "boolean" } + } + } + }, + "columns": { + "type": "object", + "description": "диагностика: какие колонки листа были приняты за шесть сумм и ИНН", + "additionalProperties": { "type": "string" } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["row_number", "level", "row_value", "group_type"], + "properties": { + "row_number": { "type": "integer" }, + "level": { "type": "integer" }, + "parent_row_number": { "type": ["integer", "null"] }, + "row_value": { "type": "string" }, + "group_type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] }, + "group_name": { "type": ["string", "null"] }, + "account_code_raw": { "type": ["string", "null"], "description": "код текущей ветки, как в файле (60.1)" }, + "counterparty_name": { "type": ["string", "null"] }, + "counterparty_inn": { "type": ["string", "null"] }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "opening_debit": { "$ref": "#/$defs/money" }, + "opening_credit": { "$ref": "#/$defs/money" }, + "turnover_debit": { "$ref": "#/$defs/money" }, + "turnover_credit": { "$ref": "#/$defs/money" }, + "closing_debit": { "$ref": "#/$defs/money" }, + "closing_credit": { "$ref": "#/$defs/money" } + } + } + }, + "checks": { + "type": "object", + "properties": { + "total_row_matches_root": { "type": "boolean" }, + "balance_identity_violations": { "type": "array", "items": { "type": "integer" }, "description": "row_number строк, где opening−turnover≠closing" }, + "parent_sum_violations": { "type": "array", "items": { "type": "integer" } } + } + } + } + }, + "statementDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "columns", "lines"], + "properties": { + "document_type": { "enum": ["BALANCE", "INCOME", "OTHER"] }, + "locator": { "type": "string", "examples": ["pages:1-2"] }, + "header": { + "type": "object", + "properties": { + "form_name": { "type": "string" }, + "organization_name": { "type": ["string", "null"] }, + "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "inn": { "type": ["string", "null"] }, + "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "report_date_text": { "type": ["string", "null"] }, + "report_date": { "type": ["string", "null"], "format": "date" }, + "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "okud": { "type": ["string", "null"] }, + "okpo": { "type": ["string", "null"] }, + "okopf": { "type": ["string", "null"] }, + "okfs": { "type": ["string", "null"] }, + "address": { "type": ["string", "null"] } + } + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index", "label"], + "properties": { + "column_index": { "type": "integer" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, + "period_date": { "type": ["string", "null"], "format": "date" } + } + } + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["line_code", "line_name", "values"], + "properties": { + "line_code": { "type": "string" }, + "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "line_name": { "type": "string" }, + "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index"], + "properties": { + "column_index": { "type": "integer" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "is_recognized": { "type": "boolean" } + } + } + } + } + } + } + } + } + } +} diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..f9543f0 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,50 @@ +# Настройки инструментов качества. Зависимости — в requirements*.txt, pytest (и его маркеры) — в pytest.ini. + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "PL"] + +# В тестах сравнение с конкретными числами (порты, тайм-ауты, размеры кэша) — и есть проверка; константы только ухудшили бы чтение +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["PLR2004"] + +[tool.ruff.lint.isort] +known-first-party = ["src"] + +[tool.mypy] +python_version = "3.11" +strict = false +check_untyped_defs = true +plugins = ["pydantic.mypy"] +mypy_path = "." +explicit_package_bases = true + +# Строгий режим для собственного кода сервиса (`src`). `strict = true` в overrides mypy (2.x) применяется глобально, +# а не к модулю, поэтому флаги семейства strict перечислены явно. +[[tool.mypy.overrides]] +module = ["src.contract.*", "src.core.clients", "src.core.health", "src.core.nats", "src.core.worker", "src.gateway.*", "src.normalizer.*", "src.scan_emulator.*"] +disallow_any_generics = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +warn_return_any = true +warn_unused_ignores = true +no_implicit_reexport = true +strict_equality = true +extra_checks = true + +# Сторонние пакеты без типов (smbprotocol/nkeys не поставляют py.typed; raisa_* недоступны вне банка) +[[tool.mypy.overrides]] +module = ["smbclient.*", "smbprotocol.*", "nkeys.*", "raisa_fastapi_protected_api", "raisa_fastapi_protected_api.*"] +ignore_missing_imports = true + +# miniopy_async: пакет типизирован с ошибкой (Minio не виден mypy при реэкспорте) — типы не подтягиваем, Minio = Any +[[tool.mypy.overrides]] +module = ["miniopy_async", "miniopy_async.*"] +follow_imports = "skip" +ignore_missing_imports = true diff --git a/api/pytest.ini b/api/pytest.ini index 2dc434a..59d52bd 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -18,4 +18,7 @@ addopts = env = APP_ENV=test ENV_FILE=.env.test +markers = + integration: требует запущенной инфраструктуры (NATS, MinIO из docs/dev); исключается из `make test` + fixtures: использует реальные данные из ../fixtures (репозиторий arm-ka-fixtures, внутренний доступ) norecursedirs = .git __pycache__ .venv env build dist diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index e4d6841..0214af3 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -1,50 +1,11 @@ -aiosqlite -alembic -annotated-types==0.7.0 -anyio==4.8.0 -argon2-cffi -asyncpg -certifi==2024.12.14 -click==8.1.8 -colorama==0.4.6 -dnspython==2.7.0 -email_validator==2.2.0 -et_xmlfile==2.0.0 -fastapi==0.115.7 -fastapi-cli==0.0.7 -h11==0.14.0 -httpcore==1.0.7 -httptools==0.6.4 -httpx==0.27.2 -idna==3.10 -itsdangerous==2.2.0 -Jinja2==3.1.5 -markdown-it-py==3.0.0 -MarkupSafe==3.0.2 -mdurl==0.1.2 -openpyxl==3.1.5 -orjson==3.10.15 -pydantic==2.10.6 -pydantic-extra-types==2.10.2 -pydantic-settings==2.7.1 -pydantic_core==2.27.2 -Pygments==2.19.1 -python-dotenv==1.0.1 -python-jose -python-multipart==0.0.20 -PyYAML==6.0.2 -rich==13.9.4 -rich-toolkit==0.13.2 -shellingham==1.5.4 -sniffio==1.3.1 -sqlalchemy -starlette==0.45.3 -typer==0.15.1 -typing_extensions==4.12.2 -ujson==5.10.0 -uvicorn==0.34.0 -watchfiles==1.0.4 -websockets==14.2 -pytest-asyncio -pytest-env -cosmic-ray \ No newline at end of file +# Инструменты разработки и проверок. Платформенные зависимости — в requirements.txt (не меняем). +# httpx (клиент для TestClient) уже закреплён в requirements.txt — здесь не дублируется. +-r requirements.txt +ruff==0.16.10 +mypy==2.4.0 +pytest==9.1.1 +pytest-asyncio==1.4.0 +pytest-env==1.7.1 +jsonschema==4.26.0 +# заглушки типов для mypy (yaml используется эмулятором и загрузкой схем) +types-PyYAML==6.0.12.20260906 diff --git a/api/requirements.txt b/api/requirements.txt index f26f6d9..4729a5d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,13 +2,12 @@ aiohappyeyeballs==2.7.1 aiohttp==3.14.3 aiohttp-retry==2.9.1 aiosignal==1.4.0 -alembic==1.14.0 annotated-doc==0.0.4 annotated-types==0.8.0 anyio==4.14.2 +# транзитивные зависимости miniopy-async argon2-cffi==23.1.0 argon2-cffi-bindings==25.1.0 -asyncpg==0.30.0 attrs==26.1.0 certifi==2025.11.12 cffi==2.1.0 @@ -20,7 +19,6 @@ fastapi-cli==0.0.24 fastapi-cloud-cli==0.14.1 fastar==0.8.0 frozenlist==1.8.0 -greenlet==3.4.0 h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 @@ -28,7 +26,6 @@ httpx==0.28.1 idna==3.18 Jinja2==3.1.6 loguru==0.7.3 -Mako==1.3.10 markdown-it-py==4.2.0 MarkupSafe==3.0.3 mdurl==0.1.2 @@ -48,13 +45,11 @@ PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 PyYAML==6.0.3 -raisa_fastapi_protected_api==1.0.0 rich==15.0.0 rich-toolkit==0.19.7 rignore==0.7.6 sentry-sdk==2.66.1 shellingham==1.5.4 -SQLAlchemy==2.0.36 smbprotocol==1.15.0 starlette==1.3.1 typer==0.27.0 diff --git a/api/run_server.py b/api/run_server.py index f2449e1..7715863 100644 --- a/api/run_server.py +++ b/api/run_server.py @@ -1,5 +1,7 @@ import uvicorn -from src import app, get_settings + +from src.app import app +from src.core.config import get_settings if __name__ == "__main__": settings = get_settings() @@ -9,4 +11,3 @@ if __name__ == "__main__": port=settings.server.PORT, lifespan="on", ) - diff --git a/api/src/__init__.py b/api/src/__init__.py index 61c3faa..9cc1d91 100644 --- a/api/src/__init__.py +++ b/api/src/__init__.py @@ -1,7 +1,5 @@ -from .app import app -from .core.config import get_settings +"""Пакет сервиса scan-gateway. -__all__ = [ - 'app', - 'get_settings', -] \ No newline at end of file +Импорт пакета не создаёт приложение и не читает настройки шлюза: эмулятор (`python -m src.scan_emulator`) запускается +без переменных `OPENBAO__*`. Приложение — `src.app:app` (точка входа — `run_server.py`). +""" diff --git a/api/src/app.py b/api/src/app.py index 845e7c0..a34d826 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,189 +1,114 @@ -"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. +"""Точка входа FastAPI сервиса scan-gateway (адаптер ИС Сканирование). Без БД и без фронта. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), -middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. -Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result -(docs/context/03-architecture.md), зависимость require(permission). +- `create_app(settings)` строит горячую папку (`create_hotfolder`, без обращения к ФС/шаре) и `HealthProvider`; + `GET /healthcheck` отдаёт `uptime`, `version`, `nats` (`ok`/`down`/`disabled`), `hotfolder` (`ok`/`down`). +- lifespan: регистрация SMB-сессии (только `SCAN_HOTFOLDER_MODE=smb`), S3 (`S3Storage`), NATS (`NatsGateway`), воркер + (`Worker` с `NullNormalizer`). Подключение к NATS блокирует до доступности сервера, поэтому идёт фоновой задачей: + HTTP отвечает сразу (`nats = down`), подписка `recognition.image.request` появляется после подключения. Сбой этой + задачи (например, `NatsConfigError`) — лог error с трассировкой и `nats = down`, HTTP-сервер жив. + Остановка: отмена подключения, остановка воркера (задания в работе отменяются), закрытие NATS и S3. +- Объекты lifespan доступны в `app.state`: `worker`, `nats`, `storage`, `health` (тесты, задачи 13–14). +- `NATS_ENABLED=false` — подключения нет (`nats = disabled`), воркер запросов не получает (dev без NATS). """ -import base64 -import os -from contextlib import asynccontextmanager -from datetime import datetime, timezone -import nkeys +import asyncio +from contextlib import asynccontextmanager, suppress +from datetime import UTC, datetime + import smbclient from fastapi import FastAPI -from fastapi.exceptions import HTTPException, StarletteHTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from loguru import logger -from miniopy_async import Minio -from nats.aio.client import Client as NATS from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import router from src.core.clients import S3Storage -from src.core.config import get_settings +from src.core.config import Settings, get_settings from src.core.exception_handlers import register_exception_handlers - -settings = get_settings() - -VERSION = settings.VERSION -UP_TIME = datetime.now(timezone.utc) +from src.core.health import HealthProvider +from src.core.nats import NatsGateway +from src.core.worker import Worker +from src.gateway import create_hotfolder +from src.normalizer import NullNormalizer -# --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) +async def run_nats(gateway: NatsGateway, worker: Worker) -> None: + """Фоновая задача: подключение к NATS (ждёт сервер сколь угодно долго) и подписка воркера на запросы. - -def jwt_cb(): - return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8") - - -def sig_cb(nonce): - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(kp.sign(nonce)) - - -# --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- -protected_middleware_available = False -if not settings.DEBUG: + Ошибка не роняет приложение: лог error с трассировкой; если соединение установлено, а подписка не удалась, + соединение закрывается, чтобы `/healthcheck.nats = down` честно показывал, что запросы не принимаются. + """ try: - from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports] - AuthorizationMiddleware, - OpenEndpoint, - ProtectedOAuthSettings, - ProtectedRolesSettings, - ProtectedSettings, - SearchType, - ) - - protected_middleware_available = True - except ImportError: - protected_middleware_available = False - - -def _normalize_root_path(value: str) -> str: - if not value: - return "" - value = value.strip() - if value == "/": - return "" - return "/" + value.strip("/") - - -_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - app.state.nats_subs = [] - app.state.storage = None - app.state.nats = None - - try: - smbclient.register_session( - settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD - ) - except Exception: - logger.exception("Ошибка при регистрации сессии SMB") - - s3_client = Minio( - settings.OPENBAO.MINIO.MINIO_URL, - access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, - secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, - cert_check=False, - ) - try: - bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) - except Exception as exc: - logger.exception( - "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) - - nc = NATS() - if not settings.DEBUG: - try: - await nc.connect( - settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2 - ) - logger.info("Подключение к NATS произошло") - except Exception as exc: - logger.exception( - "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) + if not await gateway.connect(): return - app.state.nats = nc - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - + except asyncio.CancelledError: + raise + except Exception: + logger.exception("NATS: подключение не выполнено, запросы не принимаются (nats=down) до перезапуска") + return try: - logger.info("Приложение запущено") - yield - finally: - logger.info("Приложение закрывается") - smbclient.reset_connection_cache() - for sub in app.state.nats_subs: - await sub.unsubscribe() - if app.state.nats and not settings.DEBUG: - await app.state.nats.close() - if app.state.storage: - await app.state.storage.client.close_session() + await gateway.subscribe_requests(worker.on_request) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("NATS: подписка на запросы не оформлена, соединение закрывается (nats=down)") + await gateway.close() -app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or get_settings() + version = settings.VERSION + up_time = datetime.now(UTC) + hotfolder = create_hotfolder(settings) + health = HealthProvider(nats_enabled=settings.NATS_ENABLED, hotfolder=hotfolder) - -@app.get("/healthcheck", status_code=200) -async def healthcheck(): - return {"uptime": UP_TIME, "version": VERSION} - - -app.include_router(router) - -register_exception_handlers(app) - -app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -# --- Раздача собранного SPA (web/) --------------------------------------------------------------- -os.makedirs("web", exist_ok=True) - - -def sep_static(path: str, stat_dir: str) -> str: - sep_path = path.split("/") - index = sep_path.index(stat_dir) - return "/".join(sep_path[index:]) - - -class SPAStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): + @asynccontextmanager + async def lifespan(app: FastAPI): + smb_session = False + if settings.SCAN_HOTFOLDER_MODE == "smb": + smb = settings.OPENBAO.SMB + try: + smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) + smb_session = True + except Exception: + # учётные данные передаются в каждый вызов smbclient (SmbHotFolder), сессия восстановится при + # доступной шаре; пока шара недоступна, /healthcheck.hotfolder = down + logger.exception("Ошибка при регистрации сессии SMB") + storage = S3Storage.from_settings(settings.OPENBAO.MINIO) + gateway = NatsGateway(settings) + worker = Worker(settings, storage=storage, publisher=gateway, hotfolder=hotfolder, normalizer=NullNormalizer()) + health.nats = gateway + app.state.storage = storage + app.state.nats = gateway + app.state.worker = worker + worker.start() + nats_task = asyncio.create_task(run_nats(gateway, worker), name="nats-connect") try: - return await super().get_response(path, scope) - except (HTTPException, StarletteHTTPException) as ex: - if ex.status_code == 404: - if "images" in path: - return await super().get_response(sep_static(path, "images"), scope) - if "static" in path: - return await super().get_response(sep_static(path, "static"), scope) - return await super().get_response("index.html", scope) - raise + logger.info("Приложение запущено") + yield + finally: + logger.info("Приложение закрывается") + nats_task.cancel() + with suppress(asyncio.CancelledError): + await nats_task + await worker.stop() + await gateway.close() + await storage.close() + if smb_session: + smbclient.reset_connection_cache() + + app = FastAPI(title="scan-gateway", version=version, root_path=settings.ROOT_PATH, lifespan=lifespan) + app.state.health = health + + @app.get("/healthcheck", status_code=200) + async def healthcheck(): + return {"uptime": up_time, "version": version, **await health.snapshot()} + + app.include_router(router) + register_exception_handlers(app) + app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) + app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) + return app -app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") +app = create_app() diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 2eca689..3eaa18e 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -1,5 +1,6 @@ class NotFoundError(Exception): pass + class CouldntCreateError(Exception): - pass \ No newline at end of file + pass diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py new file mode 100644 index 0000000..98864aa --- /dev/null +++ b/api/src/contract/__init__.py @@ -0,0 +1,78 @@ +"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). + +Публичный API: pydantic-модели запроса, конверта и файла результата, конструкторы конвертов, subject'ы NATS, +идентификаторы сообщений (`Nats-Msg-Id`), проверка MAJOR версии. +""" + +from src.contract.common import ( + CONTRACT_VERSION, + SUPPORTED_MAJOR, + ContractModel, + ErrorCode, + Metrics, + S3Ref, + ServiceInfo, + ServiceName, + is_supported_major, +) +from src.contract.message import ( + MessageError, + MessageStatus, + RecognitionResultMessage, + build_ack_message, + build_done_message, + build_error_message, +) +from src.contract.request import FileType, RecognitionRequest +from src.contract.result import ( + OsvDocument, + RecognitionResult, + ResultStatus, + StatementDocument, +) +from src.contract.subjects import ( + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + REQUEST_SUBJECTS, + RESULT_SUBJECT, + TABULAR_REQUEST_SUBJECT, + Pipeline, + ack_msg_id, + request_msg_id, + request_subject, + result_msg_id, +) + +__all__ = [ + "CONTRACT_VERSION", + "IMAGE_REQUEST_SUBJECT", + "NATS_MSG_ID_HEADER", + "REQUEST_SUBJECTS", + "RESULT_SUBJECT", + "SUPPORTED_MAJOR", + "TABULAR_REQUEST_SUBJECT", + "ContractModel", + "ErrorCode", + "FileType", + "MessageError", + "MessageStatus", + "Metrics", + "OsvDocument", + "Pipeline", + "RecognitionRequest", + "RecognitionResult", + "RecognitionResultMessage", + "ResultStatus", + "S3Ref", + "ServiceInfo", + "ServiceName", + "StatementDocument", + "ack_msg_id", + "build_ack_message", + "build_done_message", + "build_error_message", + "is_supported_major", + "request_msg_id", + "request_subject", + "result_msg_id", +] diff --git a/api/src/contract/common.py b/api/src/contract/common.py new file mode 100644 index 0000000..75dce31 --- /dev/null +++ b/api/src/contract/common.py @@ -0,0 +1,247 @@ +"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. + +Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` +(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — +так обратно совместимый MINOR-релиз (необязательные поля) не ломает потребителя. Типы строгие (`strict=True`): +JSON Schema не приводит `"1"` к числу, а число — к строке; поля со строковой записью (uuid, date-time, date, деньги) +разбирают строки явно. +""" + +import re +import uuid +from datetime import UTC, date, datetime +from decimal import Decimal +from enum import StrEnum +from typing import Annotated, Any, Literal, TypeVar + +from pydantic import ( + AfterValidator, + AwareDatetime, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PlainSerializer, + PlainValidator, + WithJsonSchema, + model_validator, +) + +CONTRACT_VERSION = "1.0" +SUPPORTED_MAJOR = 1 # сервис принимает запросы только этого MAJOR; любой MINOR + +# Шаблоны версий — буквально из схем +SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version + +T = TypeVar("T") + +_SUPPORTED_VERSION_RE = re.compile(SCHEMA_VERSION_PATTERN, re.ASCII) # ровно шаблон схемы, без int() + + +def is_supported_major(schema_version: object) -> bool: + """MAJOR версии `MAJOR.MINOR` равен `SUPPORTED_MAJOR` (запрос неизвестного MAJOR → `SCHEMA_MISMATCH`). + + Принимает любой объект (версия берётся из ещё не проверенного тела): не строка и не `MAJOR.MINOR` — `False`. + """ + if not isinstance(schema_version, str): + return False + # `$` Python допускает перевод строки в конце, `fullmatch` — нет; шаблон — тот же, что у схемы и модели + return _SUPPORTED_VERSION_RE.fullmatch(schema_version) is not None + + +class _NotNull: + """Метка поля, необязательного в схеме, но не допускающего явный `null` (в схеме тип без `null`).""" + + +NOT_NULL = _NotNull() +# Необязательное поле без `null`: отсутствует (None в модели) или значение типа T; явный null во входе — ошибка +OptionalNotNull = Annotated[T | None, NOT_NULL] + + +class ContractModel(BaseModel): + """База моделей контракта: неизменяемые, строгие, незнакомые поля отбрасываются.""" + + model_config = ConfigDict(extra="ignore", strict=True, frozen=True) + + @model_validator(mode="before") + @classmethod + def _reject_explicit_null(cls, data: Any) -> Any: + """Схема не допускает `null`, где тип без `null`: `{"result_s3": null}` — ошибка, а не «нет поля».""" + if isinstance(data, dict): + for name, field in cls.model_fields.items(): + key = field.alias or name + if NOT_NULL in field.metadata and key in data and data[key] is None: + raise ValueError(f"поле {key!r} не допускает null (в схеме контракта нет null в типе)") + return data + + def to_wire(self) -> dict[str, Any]: + """Тело для NATS / файла S3: JSON-словарь только из заданных полей (uuid, время, деньги — строки).""" + return self.model_dump(mode="json", exclude_unset=True) + + +# --- UUID ----------------------------------------------------------------------------------------------------------- + +_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", re.ASCII) + + +def _require_canonical_uuid(value: object) -> object: + """Строка канонического вида 8-4-4-4-12 (как `format: uuid` схемы) либо готовый `uuid.UUID`.""" + if isinstance(value, uuid.UUID) or (isinstance(value, str) and _UUID_RE.fullmatch(value)): + return value + raise ValueError("ожидается uuid строкой вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + + +ContractUUID = Annotated[uuid.UUID, BeforeValidator(_require_canonical_uuid), Field(strict=False)] + +# --- время ---------------------------------------------------------------------------------------------------------- + +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII) + + +def _require_text_or_datetime(value: object) -> object: + if isinstance(value, str | datetime): + return value + raise ValueError("ожидается строка date-time (RFC 3339) с часовым поясом") + + +def _to_utc(value: datetime) -> datetime: + return value.astimezone(UTC) + + +# `reply_deadline`: срок ответа, UTC. Часовой пояс обязателен (aware); смещение приводится к UTC +UtcDatetime = Annotated[ + AwareDatetime, + BeforeValidator(_require_text_or_datetime), + AfterValidator(_to_utc), + Field(strict=False), +] + + +def _require_iso_date(value: object) -> object: + if isinstance(value, date) and not isinstance(value, datetime): + return value + if isinstance(value, str) and _DATE_RE.fullmatch(value): + return value + raise ValueError("ожидается дата строкой YYYY-MM-DD") + + +ContractDate = Annotated[date, BeforeValidator(_require_iso_date), Field(strict=False)] + +# --- деньги --------------------------------------------------------------------------------------------------------- + +MONEY_PATTERN = r"^-?\d+(\.\d{1,2})?$" # буквально из `$defs.money` +_MONEY_RE = re.compile(r"-?\d+(\.\d{1,2})?", re.ASCII) + + +def _parse_money(value: object) -> Decimal | None: + """Сумма — строка по шаблону схемы (копейки не теряются) или `null`; `float` / `int` — ошибка.""" + if value is None: + return None + if isinstance(value, Decimal): + text = format(value, "f") if value.is_finite() else "" + elif isinstance(value, str): + text = value + else: + raise ValueError( + "сумма должна быть строкой вида '106311116.46' (float и int недопустимы, чтобы не терять копейки)" + ) + if not _MONEY_RE.fullmatch(text): + raise ValueError(f"сумма {text!r} не соответствует шаблону {MONEY_PATTERN} (до двух знаков после точки)") + return Decimal(text) + + +def _dump_money(value: Decimal | None) -> str | None: + return None if value is None else format(value, "f") + + +# В модели — `Decimal`; в JSON — строка ровно в той записи, как пришла («1100», «-12.30») +Money = Annotated[ + Decimal | None, + PlainValidator(_parse_money), + PlainSerializer(_dump_money, return_type=str | None, when_used="json"), + WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}), +] + +# --- целые ----------------------------------------------------------------------------------------------------------- + + +def int_like(value: object) -> object: + """JSON Schema 2020-12 считает `1.0` целым: `float` с целым значением приводится к `int` (pandas пишет `5.0`). + + `bool` (в JSON это не число) отвергается явно; нецелые `float` (в том числе NaN и inf) и строки остаются как есть + и отвергаются строгой проверкой `int`. + """ + if isinstance(value, bool): + raise ValueError("ожидается целое число, а не булево значение") + if isinstance(value, float) and value.is_integer(): + return int(value) + return value + + +# Целое по схеме (`type: integer`): `int` или целочисленный `float`; bool, строка и `1.5` — ошибка +IntLike = Annotated[int, BeforeValidator(int_like)] +# `header_variant`: enum [1, 2] схемы; `True` и `1.5` отвергаются, `1.0` равно 1 +HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] + +# --- скаляры -------------------------------------------------------------------------------------------------------- + +NonEmptyStr = Annotated[str, Field(min_length=1)] +SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] +ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] +# Уверенность распознавания, проценты (число 0–100) +Confidence = Annotated[float, Field(ge=0, le=100)] | None + +# --- общие объекты -------------------------------------------------------------------------------------------------- + + +class ServiceName(StrEnum): + TABULAR_PARSER = "tabular-parser" + SCAN_GATEWAY = "scan-gateway" + + +class ErrorCode(StrEnum): + UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT" + UNREADABLE = "UNREADABLE" + TIMEOUT = "TIMEOUT" + SCHEMA_MISMATCH = "SCHEMA_MISMATCH" + NOT_IMPLEMENTED = "NOT_IMPLEMENTED" + FILE_READ_ERROR = "FILE_READ_ERROR" + INVALID_FORMAT = "INVALID_FORMAT" + INVALID_DOCUMENT = "INVALID_DOCUMENT" + RECOGNITION_FAILED = "RECOGNITION_FAILED" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + + +# Строгий режим модели не принимает значение enum строкой: поле разбирает строку само (как JSON Schema `enum`) +LaxServiceName = Annotated[ServiceName, Field(strict=False)] +LaxErrorCode = Annotated[ErrorCode, Field(strict=False)] + + +class ServiceInfo(ContractModel): + """Кто ответил: `service {name, version}`.""" + + name: LaxServiceName + version: str + + +class S3Ref(ContractModel): + """Указатель на объект S3: `{bucket, key}`.""" + + bucket: NonEmptyStr + key: NonEmptyStr + + +class Metrics(ContractModel): + """Метрики обработки; все поля необязательны.""" + + duration_ms: OptionalNotNull[IntLike] = None + pages: OptionalNotNull[IntLike] = None + rows: OptionalNotNull[IntLike] = None + fields_total: OptionalNotNull[IntLike] = None + fields_low_confidence: OptionalNotNull[IntLike] = None + fields_unrecognized: OptionalNotNull[IntLike] = None + warnings: OptionalNotNull[list[str]] = None + + +DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"] diff --git a/api/src/contract/message.py b/api/src/contract/message.py new file mode 100644 index 0000000..18b1303 --- /dev/null +++ b/api/src/contract/message.py @@ -0,0 +1,141 @@ +"""Конверт сообщения на `recognition.result`: `recognition-result-message.schema.json` v1.0. + +`processing` — ack «взял в работу» (до чтения файла из S3); `done | needs_check | error` — итог. +Условная обязательность — буквально из `allOf` схемы: для `done | needs_check` обязательны `result_s3` и +`result_schema_version`; для `error` — `error`. Других правил нет (например, `error` при `done` схема не запрещает). +""" + +import uuid +from enum import StrEnum +from typing import Annotated, Any, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + CONTRACT_VERSION, + ContractModel, + ContractUUID, + ErrorCode, + LaxErrorCode, + Metrics, + OptionalNotNull, + ResultSchemaVersion, + S3Ref, + SchemaVersion, + ServiceInfo, +) + + +class MessageStatus(StrEnum): + PROCESSING = "processing" + DONE = "done" + NEEDS_CHECK = "needs_check" + ERROR = "error" + + +LaxMessageStatus = Annotated[MessageStatus, Field(strict=False)] + + +class MessageError(ContractModel): + """`error {code, message}` конверта: оба поля обязательны.""" + + code: LaxErrorCode + message: str + + +class RecognitionResultMessage(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID # recognition_job.id + file_id: ContractUUID # stored_file.id + status: LaxMessageStatus + service: ServiceInfo + result_s3: OptionalNotNull[S3Ref] = None # results/{file_id}/{job_id}/result.json + result_schema_version: OptionalNotNull[ResultSchemaVersion] = None # schema_version файла результата + error: OptionalNotNull[MessageError] = None + raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json; в БД не регистрируется + metrics: OptionalNotNull[Metrics] = None + + @model_validator(mode="after") + def _check_status_requirements(self) -> Self: + if self.status in (MessageStatus.DONE, MessageStatus.NEEDS_CHECK): + missing = [name for name in ("result_s3", "result_schema_version") if getattr(self, name) is None] + if missing: + raise ValueError(f"при status = {self.status.value} обязательны поля: {', '.join(missing)}") + elif self.status is MessageStatus.ERROR and self.error is None: + raise ValueError("при status = error обязательно поле error") + return self + + +# --- конструкторы конвертов (ack, ошибка, успех) ------------------------------------------------------------------ +# Собирают только заданные поля: `to_wire()` не пишет `null` в необязательные ключи. Имя сервиса передаёт вызывающий +# (`ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION)`): пакет контракта не зависит от настроек. + + +def _envelope( + job_id: uuid.UUID | str, file_id: uuid.UUID | str, service: ServiceInfo, status: MessageStatus, **optional: Any +) -> RecognitionResultMessage: + fields = {key: value for key, value in optional.items() if value is not None} + # model_validate (а не конструктор): `job_id` / `file_id` принимаются и строкой, и `uuid.UUID` + return RecognitionResultMessage.model_validate( + { + "schema_version": CONTRACT_VERSION, + "job_id": job_id, + "file_id": file_id, + "status": status, + "service": service, + **fields, + } + ) + + +def build_ack_message( + job_id: uuid.UUID | str, file_id: uuid.UUID | str, service: ServiceInfo +) -> RecognitionResultMessage: + """Ack «взял в работу» (`status = processing`); `Nats-Msg-Id` — `ack_msg_id(job_id, attempt)`.""" + return _envelope(job_id, file_id, service, MessageStatus.PROCESSING) + + +def build_error_message( # noqa: PLR0913 - идентификаторы + код + текст; необязательное — только keyword + job_id: uuid.UUID | str, + file_id: uuid.UUID | str, + service: ServiceInfo, + code: ErrorCode, + message: str, + *, + raw_s3_key: str | None = None, + metrics: Metrics | None = None, +) -> RecognitionResultMessage: + """Итог с ошибкой (`status = error`, `error {code, message}`); `Nats-Msg-Id` — `result_msg_id(job_id, attempt)`.""" + return _envelope( + job_id, + file_id, + service, + MessageStatus.ERROR, + error=MessageError(code=code, message=message), + raw_s3_key=raw_s3_key, + metrics=metrics, + ) + + +def build_done_message( # noqa: PLR0913 - идентификаторы + результат; необязательное — только keyword + job_id: uuid.UUID | str, + file_id: uuid.UUID | str, + service: ServiceInfo, + result_s3: S3Ref, + *, + needs_check: bool = False, + result_schema_version: str = CONTRACT_VERSION, + raw_s3_key: str | None = None, + metrics: Metrics | None = None, +) -> RecognitionResultMessage: + """Успешный итог (`done`, при `needs_check = True` — `needs_check`); файл результата уже лежит в `result_s3`.""" + return _envelope( + job_id, + file_id, + service, + MessageStatus.NEEDS_CHECK if needs_check else MessageStatus.DONE, + result_s3=result_s3, + result_schema_version=result_schema_version, + raw_s3_key=raw_s3_key, + metrics=metrics, + ) diff --git a/api/src/contract/request.py b/api/src/contract/request.py new file mode 100644 index 0000000..5165080 --- /dev/null +++ b/api/src/contract/request.py @@ -0,0 +1,27 @@ +"""Запрос распознавания файла (arm-core → tabular-parser / scan-gateway): `recognition-request.schema.json` v1.0. + +Формируется из `recognition_job ⋈ stored_file ⋈ s3_object` (spec recognition-job-exchange); один файл — один job. +Subject — по `pipeline` (`request_subject`); заголовок `Nats-Msg-Id = :` +(`request_msg_id`) — не поле тела. +""" + +from typing import Annotated, Literal + +from pydantic import Field + +from src.contract.common import ContractModel, ContractUUID, IntLike, NonEmptyStr, S3Ref, SchemaVersion, UtcDatetime +from src.contract.subjects import Pipeline + +FileType = Literal["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] + + +class RecognitionRequest(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID # recognition_job.id + file_id: ContractUUID # stored_file.id + pipeline: Pipeline + file_type: FileType + original_name: NonEmptyStr # имя файла, как загрузил пользователь + s3: S3Ref # исходный файл в роли uploads + reply_deadline: UtcDatetime # срок ответа, UTC + attempt: Annotated[IntLike, Field(ge=1)] # номер попытки, с 1 diff --git a/api/src/contract/result.py b/api/src/contract/result.py new file mode 100644 index 0000000..d591872 --- /dev/null +++ b/api/src/contract/result.py @@ -0,0 +1,189 @@ +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. + +Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров; +маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). +Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь. +""" + +from enum import StrEnum +from typing import Annotated, Literal, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + Confidence, + ContractDate, + ContractModel, + ContractUUID, + DataType, + HeaderVariant, + IntLike, + LaxErrorCode, + Metrics, + Money, + OptionalNotNull, + SchemaVersion, + ServiceInfo, +) + +__all__ = [ + "OsvChecks", + "OsvDocument", + "OsvGroup", + "OsvHeader", + "OsvRow", + "RecognitionResult", + "ResultError", + "ResultStatus", + "StatementColumn", + "StatementDocument", + "StatementHeader", + "StatementLine", + "StatementValue", +] + + +class ResultStatus(StrEnum): + DONE = "done" + NEEDS_CHECK = "needs_check" + ERROR = "error" + + +LaxResultStatus = Annotated[ResultStatus, Field(strict=False)] + + +class ResultError(ContractModel): + """`error` файла результата: в отличие от конверта, `code` и `message` необязательны.""" + + code: OptionalNotNull[LaxErrorCode] = None + message: OptionalNotNull[str] = None + + +# --- ОСВ ------------------------------------------------------------------------------------------------------------ + + +class OsvHeader(ContractModel): + organization_name: str # как в r1 файла + organization_inn: str | None = None + account_code_raw: str # «60», «58.03» + period_text: str # «1 квартал 2025 г.» + period_year: IntLike | None = None + period_quarter: Literal["Q1", "Q2", "Q3", "Q4"] | None = None + is_cumulative: bool | None = None # по тексту; ядро может переопределить по данным (OQ-02) + data_type: DataType | None = None + currency: str | None = None + header_variant: OptionalNotNull[HeaderVariant] = None + + +class OsvGroup(ContractModel): + """Группировка из шапки (порядок — после первого ACCOUNT).""" + + type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] + name: OptionalNotNull[str] = None # исходный текст из шапки + has_inn: OptionalNotNull[bool] = None + + +class OsvRow(ContractModel): + row_number: IntLike + level: IntLike + parent_row_number: IntLike | None = None + row_value: str + group_type: Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] + group_name: str | None = None + account_code_raw: str | None = None # код текущей ветки, как в файле (60.1) + counterparty_name: str | None = None + counterparty_inn: str | None = None + data_type: DataType | None = None + opening_debit: Money = None + opening_credit: Money = None + turnover_debit: Money = None + turnover_credit: Money = None + closing_debit: Money = None + closing_credit: Money = None + + +class OsvChecks(ContractModel): + total_row_matches_root: OptionalNotNull[bool] = None + balance_identity_violations: OptionalNotNull[list[IntLike]] = None # row_number, где opening−turnover≠closing + parent_sum_violations: OptionalNotNull[list[IntLike]] = None + + +class OsvDocument(ContractModel): + document_type: Literal["OSV"] + locator: str # «sheet:Лист_1» + header: OsvHeader + group_schema: list[OsvGroup] + columns: OptionalNotNull[dict[str, str]] = None # диагностика: какие колонки приняты за шесть сумм и ИНН + rows: list[OsvRow] + checks: OptionalNotNull[OsvChecks] = None + + +# --- ББ / ОФР / прочее ---------------------------------------------------------------------------------------------- + + +class StatementHeader(ContractModel): + form_name: OptionalNotNull[str] = None + organization_name: str | None = None + organization_name_confidence: Confidence = None + inn: str | None = None + inn_confidence: Confidence = None + report_date_text: str | None = None + report_date: ContractDate | None = None + units: str | None = None # «тыс. руб.» + okud: str | None = None + okpo: str | None = None + okopf: str | None = None + okfs: str | None = None + address: str | None = None + + +class StatementColumn(ContractModel): + column_index: IntLike + label: str # «На 30 сентября 2025 г.» + period_date: ContractDate | None = None + + +class StatementValue(ContractModel): + column_index: IntLike + amount: Money = None + string_value: str | None = None + confidence: Confidence = None + is_recognized: OptionalNotNull[bool] = None + + +class StatementLine(ContractModel): + line_code: str + line_code_confidence: Confidence = None + line_name: str + line_name_confidence: Confidence = None + values: list[StatementValue] + + +class StatementDocument(ContractModel): + document_type: Literal["BALANCE", "INCOME", "OTHER"] + locator: str # «pages:1-2» + header: StatementHeader + columns: list[StatementColumn] + lines: list[StatementLine] + + +ResultDocument = Annotated[OsvDocument | StatementDocument, Field(discriminator="document_type")] + + +class RecognitionResult(ContractModel): + schema_version: SchemaVersion + job_id: ContractUUID + file_id: ContractUUID + service: ServiceInfo + status: LaxResultStatus + error: ResultError | None = None + metrics: OptionalNotNull[Metrics] = None + raw_s3_key: str | None = None # scan-gateway: raw/{job_id}.json + documents: list[ResultDocument] + + @model_validator(mode="after") + def _check_documents_for_success(self) -> Self: + """`if status in (done, needs_check) then documents.minItems = 1` — буквально из схемы.""" + if self.status in (ResultStatus.DONE, ResultStatus.NEEDS_CHECK) and not self.documents: + raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") + return self diff --git a/api/src/contract/subjects.py b/api/src/contract/subjects.py new file mode 100644 index 0000000..be58d01 --- /dev/null +++ b/api/src/contract/subjects.py @@ -0,0 +1,45 @@ +"""Subject'ы NATS, заголовок дедупликации и идентификаторы сообщений контракта v1.0. + +Источник: `docs/context/03-architecture.md`, «Контракт обмена через NATS»; схема recognition-request +(`Nats-Msg-Id = :`) и recognition-result-message +(`ack::` / `result::`). +""" + +import uuid +from collections.abc import Mapping +from typing import Literal, get_args + +Pipeline = Literal["tabular", "image"] + +NATS_MSG_ID_HEADER = "Nats-Msg-Id" +RESULT_SUBJECT = "recognition.result" +TABULAR_REQUEST_SUBJECT = "recognition.tabular.request" +IMAGE_REQUEST_SUBJECT = "recognition.image.request" + +REQUEST_SUBJECTS: Mapping[str, str] = { + "tabular": TABULAR_REQUEST_SUBJECT, + "image": IMAGE_REQUEST_SUBJECT, +} + + +def request_subject(pipeline: str) -> str: + """Subject запроса по pipeline: `tabular` → `recognition.tabular.request`, `image` → `recognition.image.request`.""" + try: + return REQUEST_SUBJECTS[pipeline] + except KeyError: + raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None + + +def request_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` запроса: `:`.""" + return f"{job_id}:{attempt}" + + +def ack_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` ack (`status = processing`) на `recognition.result`: `ack::`.""" + return f"ack:{job_id}:{attempt}" + + +def result_msg_id(job_id: uuid.UUID | str, attempt: int) -> str: + """`Nats-Msg-Id` результата (`done | needs_check | error`) на `recognition.result`: `result::`.""" + return f"result:{job_id}:{attempt}" diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 8f07d24..0035b6a 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -1,27 +1,105 @@ -"""Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0 -по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). +"""S3-клиент сервиса (MinIO через `miniopy-async`): чтение исходника, запись сырого ответа ИС и файла результата. + +Ошибки не проглатываются и не превращаются в `None`: каждая операция бросает исключение из иерархии +`S3StorageError` с исходной причиной в `__cause__`, чтобы воркер мог классифицировать сбой (design D-7): + +- `S3ObjectNotFoundError` (подкласс `S3ReadError`) — объекта нет (`NoSuchKey`); +- `S3ReadError` — любая другая ошибка чтения (нет доступа, нет бакета, сеть) → воркер отвечает `FILE_READ_ERROR`; +- `S3WriteError` — ошибка записи (`put_bytes` / `put_json`). + +`asyncio.CancelledError` не оборачивается — отмена задачи проходит как есть. """ + +import json from io import BytesIO +from typing import Any from miniopy_async import Minio +from miniopy_async.error import S3Error + +from src.core.config import MinioSettings + +OCTET_STREAM = "application/octet-stream" +JSON_CONTENT_TYPE = "application/json" +NOT_FOUND_CODES = frozenset({"NoSuchKey"}) + + +class S3StorageError(Exception): + """Ошибка операции с S3; `bucket` и `key` — объект, на котором она случилась.""" + + def __init__(self, message: str, *, bucket: str, key: str) -> None: + super().__init__(message) + self.bucket = bucket + self.key = key + + +class S3ReadError(S3StorageError): + """Объект не прочитан (нет доступа, нет бакета, сбой сети, ...).""" + + +class S3ObjectNotFoundError(S3ReadError): + """Объекта с таким ключом в бакете нет.""" + + +class S3WriteError(S3StorageError): + """Объект не записан.""" class S3Storage: - def __init__(self, client: Minio, bucket_name: str): - self.client = client - self.bucket = bucket_name - - async def put_object(self, obj_name, file_data): - try: - stream = BytesIO(file_data) - await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data)) - except: - raise + """Операции сервиса над одним бакетом (`OPENBAO.MINIO.MINIO_BUCKET`); чтение может явно указать другой бакет.""" - async def get_object(self, obj_name): + def __init__(self, client: Minio, bucket: str) -> None: + self.client = client + self.bucket = bucket + + @classmethod + def from_settings(cls, settings: MinioSettings) -> "S3Storage": + """Клиент MinIO из настроек: `MINIO_SECURE=false` — http (compose), `true` — https (платформа).""" + client = Minio( + settings.MINIO_URL, + access_key=settings.MINIO_ADMIN_LOGIN, + secret_key=settings.MINIO_ADMIN_PASSWORD, + secure=settings.MINIO_SECURE, + ) + return cls(client, settings.MINIO_BUCKET) + + async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes: + """Содержимое объекта целиком. `bucket` — из запроса (`s3.bucket`), по умолчанию — бакет сервиса. + + Нет объекта → `S3ObjectNotFoundError`; иная ошибка → `S3ReadError`. + """ + bucket_name = bucket or self.bucket try: - response = await self.client.get_object(self.bucket, obj_name) - # file_bytes = await response.content.read() - return response - except: - raise + response = await self.client.get_object(bucket_name, key) + try: + data: bytes = await response.read() + finally: + response.release() + except S3Error as exc: + error_class = S3ObjectNotFoundError if exc.code in NOT_FOUND_CODES else S3ReadError + raise error_class( + f"S3: объект {bucket_name}/{key} не прочитан ({exc.code})", bucket=bucket_name, key=key + ) from exc + except Exception as exc: + raise S3ReadError( + f"S3: объект {bucket_name}/{key} не прочитан ({type(exc).__name__})", bucket=bucket_name, key=key + ) from exc + return data + + async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None: + """Записывает `data` в бакет сервиса; ошибка → `S3WriteError`.""" + try: + await self.client.put_object(self.bucket, key, BytesIO(data), length=len(data), content_type=content_type) + except Exception as exc: + raise S3WriteError( + f"S3: объект {self.bucket}/{key} не записан ({type(exc).__name__})", bucket=self.bucket, key=key + ) from exc + + async def put_json(self, key: str, obj: Any) -> None: + """Записывает `obj` как JSON в UTF-8 без `\\u`-экранирования, `Content-Type: application/json`.""" + data = json.dumps(obj, ensure_ascii=False).encode("utf-8") + await self.put_bytes(key, data, content_type=JSON_CONTENT_TYPE) + + async def close(self) -> None: + """Закрывает HTTP-сессию клиента (при остановке приложения).""" + await self.client.close_session() diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..00a326e 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,50 +1,107 @@ +"""Настройки сервиса (pydantic-settings). + +Вложенные модели — тоже BaseSettings (как в шаблоне): имена полей в ENV регистронезависимы только для них. +Секреты платформы приходят вложенными переменными `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__*`; +прикладные параметры — обычными именами верхнего уровня (`SCAN_HOTFOLDER_MODE`, `SCAN_POLL_SECONDS`, ...). +Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env`, путь считается от текущего каталога — `api/`); +переменные окружения приоритетнее файла. Настройки читаются при первом вызове `get_settings()`, не при импорте. +""" + +import os +from functools import lru_cache +from typing import Literal + +from pydantic import NonNegativeFloat, PositiveFloat, PositiveInt, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -class DBSettings(BaseSettings): - DATABASE_URL: str - class MinioSettings(BaseSettings): MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str MINIO_BUCKET: str + MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose) + class NATSSettings(BaseSettings): NATS_URL: str - NATS_SEED: str - NATS_JWT: str + NATS_SEED: str = "" # пусто — подключение к NATS без nkeys/JWT (локальная разработка, тесты) + NATS_JWT: str = "" + class SMBSettings(BaseSettings): - SMB_HOST: str - SMB_PATH: str - SMB_LOGIN: str - SMB_PASSWORD: str + """SMB-шара горячих папок ИС Сканирование. Нужна только при `SCAN_HOTFOLDER_MODE=smb` (там все поля обязательны, + проверяет `Settings`); в режиме local весь блок можно не задавать.""" + + SMB_HOST: str = "" + SMB_PATH: str = "" + SMB_LOGIN: str = "" + SMB_PASSWORD: str = "" + class OpenBAOSettings(BaseSettings): - DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings - SMB: SMBSettings + SMB: SMBSettings = SMBSettings() # весь блок необязателен: в режиме local не требуется + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" PORT: int = 8000 + class Settings(BaseSettings): OPENBAO: OpenBAOSettings DEBUG: bool = False server: ServerSettings = ServerSettings() - ROOT_PATH: str = '/' - VERSION: str = '1.0.0' + ROOT_PATH: str = "/" + VERSION: str = "1.0.0" + SERVICE_NAME: str = "scan-gateway" # service.name в конверте recognition.result - class Config: - extra = "ignore" - env_file = ".env" - env_nested_delimiter = "__" - nested_model_default_partial_update = False + # NATS: подписка на recognition.image.request и публикация. Выключено — воркер не запускается (dev без NATS) + NATS_ENABLED: bool = True + PUBLISH_RETRIES: PositiveInt = 3 # повторы публикации результата/ack после первой попытки (всего до 4) + DONE_CACHE_SIZE: PositiveInt = 1000 # размер LRU обработанных job_id (дедупликация повторов) + # Горячие папки ИС Сканирование: local — обычный каталог (dev, тесты), smb — SMB-шара платформы + SCAN_HOTFOLDER_MODE: Literal["local", "smb"] = "local" + SCAN_HOTFOLDER_PATH: str = "" # корень горячей папки в режиме local (каталог должен существовать) + SCAN_INPUT_DIR: str = "Input" + SCAN_OUTPUT_DIR: str = "Output" + SCAN_TMP_DIR: str = "Tmp" # служебный каталог временных файлов записи (рядом с Input, на той же ФС/шаре) + SCAN_POLL_SECONDS: PositiveFloat = 5 # период опроса «Исходящих» + # набор *.json ответа готов, когда имена и размеры не менялись столько секунд; 0 — готов сразу (тесты) + SCAN_SETTLE_SECONDS: NonNegativeFloat = 2 + SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса) + SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий + + model_config = SettingsConfigDict( + extra="ignore", + env_file_encoding="utf-8", + env_nested_delimiter="__", + nested_model_default_partial_update=False, + ) + + @model_validator(mode="after") + def _check_hotfolder(self) -> "Settings": + if self.SCAN_HOTFOLDER_MODE == "local" and not self.SCAN_HOTFOLDER_PATH.strip(): + raise ValueError("SCAN_HOTFOLDER_MODE=local требует SCAN_HOTFOLDER_PATH (корень горячей папки)") + if self.SCAN_HOTFOLDER_MODE == "smb": + missing = [name for name, value in self.OPENBAO.SMB.model_dump().items() if not value.strip()] + if missing: + raise ValueError( + "SCAN_HOTFOLDER_MODE=smb требует OPENBAO__SMB__SMB_HOST/SMB_PATH/SMB_LOGIN/SMB_PASSWORD; " + f"не заданы: {', '.join(missing)}" + ) + if self.SCAN_SETTLE_SECONDS >= self.SCAN_TIMEOUT_SECONDS: + raise ValueError( + f"SCAN_SETTLE_SECONDS ({self.SCAN_SETTLE_SECONDS}) должен быть меньше SCAN_TIMEOUT_SECONDS " + f"({self.SCAN_TIMEOUT_SECONDS}): иначе набор ответа не успеет настояться и каждое задание завершится " + "TIMEOUT" + ) + return self + + +@lru_cache def get_settings() -> Settings: - return Settings() - -settings = get_settings() \ No newline at end of file + return Settings(_env_file=os.environ.get("ENV_FILE", ".env")) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index f7643c2..2f7b412 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,7 +1,6 @@ from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from sqlalchemy.exc import IntegrityError, SQLAlchemyError def register_exception_handlers(app: FastAPI) -> None: @@ -12,20 +11,6 @@ def register_exception_handlers(app: FastAPI) -> None: content={"detail": exc.errors()}, ) - @app.exception_handler(IntegrityError) - async def integrity_error_handler(_: Request, exc: IntegrityError): - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={"detail": "нарушение целостности данных в БД"}, - ) - - @app.exception_handler(SQLAlchemyError) - async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError): - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "ошибка базы данных"}, - ) - @app.exception_handler(HTTPException) async def http_exception_handler(_: Request, exc: HTTPException): # Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail. diff --git a/api/src/core/health.py b/api/src/core/health.py new file mode 100644 index 0000000..20f4c06 --- /dev/null +++ b/api/src/core/health.py @@ -0,0 +1,96 @@ +"""Состояние сервиса для `GET /healthcheck`: поля `nats` и `hotfolder` (D-8; spec image-recognition-gateway). + +- `nats`: `disabled` при `NATS_ENABLED=false`; `ok`, если соединение установлено (`is_connected`), иначе `down` — в том + числе пока идёт первое подключение, во время переподключения и после сбоя фоновой задачи подключения. +- `hotfolder`: `HotFolder.healthy()` в потоке (`asyncio.to_thread`) под `wait_for` с коротким таймаутом и с кэшем на + короткое время (T8.4: подключение к недоступной SMB-шаре ждёт до 60 с, проба не должна висеть и плодить потоки). + Пока прежняя проверка не вернулась, новая не запускается — ответ `down`. + В режиме smb сбой регистрации сессии при старте виден естественно: `SmbHotFolder.healthy()` обращается к шаре с теми + же учётными данными и при недоступной шаре или неверном логине возвращает `False` (T2.3). +""" + +import asyncio +import time +from collections.abc import Callable +from typing import Literal, Protocol + +from loguru import logger + +from src.gateway.hotfolder import HotFolder + +__all__ = ["HOTFOLDER_CACHE_TTL", "HOTFOLDER_CHECK_TIMEOUT", "ConnectionState", "HealthProvider"] + +HOTFOLDER_CHECK_TIMEOUT = 2.0 # секунд на `healthy()`; дольше — `down` +HOTFOLDER_CACHE_TTL = 5.0 # секунд, в течение которых повторная проба отдаёт прежний результат + +Status = Literal["ok", "down"] +NatsStatus = Literal["ok", "down", "disabled"] + + +class ConnectionState(Protocol): + """Источник состояния NATS (`NatsGateway`).""" + + @property + def is_connected(self) -> bool: ... + + +class HealthProvider: + """Поля `nats`/`hotfolder` для `/healthcheck`. `nats` присваивается, когда соединение создано (lifespan).""" + + def __init__( + self, + *, + nats_enabled: bool, + hotfolder: HotFolder | None, + check_timeout: float = HOTFOLDER_CHECK_TIMEOUT, + cache_ttl: float = HOTFOLDER_CACHE_TTL, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self.nats: ConnectionState | None = None + self._nats_enabled = nats_enabled + self._hotfolder = hotfolder + self._check_timeout = check_timeout + self._cache_ttl = cache_ttl + self._clock = clock + self._cached: Status | None = None + self._cached_at = 0.0 + self._inflight: asyncio.Task[bool] | None = None + + def nats_status(self) -> NatsStatus: + if not self._nats_enabled: + return "disabled" + if self.nats is not None and self.nats.is_connected: + return "ok" + return "down" + + async def hotfolder_status(self) -> Status: + if self._hotfolder is None: + return "down" + if self._cached is not None and self._clock() - self._cached_at < self._cache_ttl: + return self._cached + if self._inflight is None or self._inflight.done(): + self._inflight = asyncio.create_task(asyncio.to_thread(self._hotfolder.healthy)) + self._inflight.add_done_callback(_consume_exception) + status: Status + try: + healthy = await asyncio.wait_for(asyncio.shield(self._inflight), self._check_timeout) + status = "ok" if healthy is True else "down" + except TimeoutError: + logger.warning( + "Проверка горячей папки не уложилась в {timeout} с: hotfolder=down", timeout=self._check_timeout + ) + status = "down" + except Exception as exc: + logger.warning("Проверка горячей папки упала: hotfolder=down. error_type={type}", type=type(exc).__name__) + status = "down" + self._cached, self._cached_at = status, self._clock() + return status + + async def snapshot(self) -> dict[str, str]: + return {"nats": self.nats_status(), "hotfolder": await self.hotfolder_status()} + + +def _consume_exception(task: "asyncio.Task[bool]") -> None: + """Исключение пробы, которую никто не дождался (таймаут), не должно попадать в лог asyncio как «не извлечённое».""" + if not task.cancelled(): + task.exception() diff --git a/api/src/core/logger.py b/api/src/core/logger.py index 2f99033..4d1ae4c 100644 --- a/api/src/core/logger.py +++ b/api/src/core/logger.py @@ -5,7 +5,6 @@ import sys from loguru import logger - # Периодические пробы мониторинга/оркестратора и служебные doc-эндпоинты. NOISE_PATHS: tuple[str, ...] = ( "/healthcheck", @@ -22,8 +21,20 @@ _NOISE_MARKERS: tuple[str, ...] = ("/static/", "/static-swagger/") # Расширения статических ассетов (картинки, стили, скрипты, шрифты). _STATIC_SUFFIXES: tuple[str, ...] = ( - ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", - ".css", ".js", ".map", ".woff", ".woff2", ".ttf", ".eot", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".svg", + ".ico", + ".webp", + ".css", + ".js", + ".map", + ".woff", + ".woff2", + ".ttf", + ".eot", ) @@ -73,6 +84,7 @@ def setup_logger( class InterceptHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: + lvl: str | int try: lvl = logger.level(record.levelname).name except ValueError: diff --git a/api/src/core/nats.py b/api/src/core/nats.py new file mode 100644 index 0000000..af5507d --- /dev/null +++ b/api/src/core/nats.py @@ -0,0 +1,317 @@ +"""Соединение scan-gateway с NATS: core NATS без JetStream (design D-2, D-7, D-8; spec image-recognition-gateway, +«Соединение с NATS без JetStream»; docs/context/03-architecture.md, «Контракт обмена через NATS»). + +- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных; задан + только один или seed не читается — `NatsConfigError` (подключиться «как-нибудь» без учётных данных нельзя). +- `NATS_ENABLED=false` — `connect()` ничего не делает и возвращает `False` (dev без NATS). +- Переподключение — встроенное в nats-py (`max_reconnect_attempts=-1`): клиент сам повторяет и первое подключение, + поэтому `connect()` возвращается только при успехе (вызывающий оборачивает его в задачу или `wait_for`). + После переподключения nats-py сам отправляет SUB для всех подписок с прежней queue group — подписку повторно + оформлять не нужно. +- `subscribe_requests(cb)` — подписка на `recognition.image.request` в queue group `scan-gateway`: из нескольких + инстансов сообщение получает ровно один. `cb` получает исходное сообщение nats-py (`msg.data`, `msg.headers`); + исключения `cb` логируются, подписка живёт. +- `publish(subject, payload, msg_id)` — публикация с заголовком `Nats-Msg-Id` и `flush` (подтверждение сервером) с + повторами на временных ошибках (первая попытка + до `PUBLISH_RETRIES` повторов); исчерпаны — `PublishFailed`. + Воркер логирует error и идёт дальше: ядро закроет задание по `reply_deadline` (D-7). +- Stream'ов и consumer'ов JetStream нет: `jetstream()` не вызывается. +""" + +import asyncio +import base64 +import json +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any + +import nkeys +from loguru import logger +from nats import errors as nats_errors +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract import ( + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + MessageStatus, + RecognitionResultMessage, + ack_msg_id, + result_msg_id, +) +from src.core.config import NATSSettings, Settings + +REQUEST_QUEUE_GROUP = "scan-gateway" +MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно +RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py +CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения +FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG) +RETRY_DELAY = 0.5 # секунд между попытками публикации + +MessageHandler = Callable[[Msg], Awaitable[None]] +SleepFn = Callable[[float], Awaitable[None]] + +# Временные ошибки публикации: соединение закрыто/переподключается/переполнен буфер на время переподключения, +# сервер не подтвердил flush. Повтор имеет смысл. Остальное (MaxPayloadError, BadSubjectError, ошибки программы) +# повтором не лечится — `PublishFailed` сразу. +TRANSIENT_PUBLISH_ERRORS: tuple[type[BaseException], ...] = ( + nats_errors.ConnectionClosedError, + nats_errors.ConnectionReconnectingError, + nats_errors.ConnectionDrainingError, + nats_errors.OutboundBufferLimitError, + nats_errors.StaleConnectionError, + nats_errors.NoServersError, + TimeoutError, # nats.errors.TimeoutError / FlushTimeoutError — его подклассы + ConnectionError, +) + + +class NatsError(Exception): + """Базовая ошибка слоя NATS сервиса.""" + + +class NatsConfigError(NatsError, RuntimeError): + """Учётные данные NATS заданы некорректно: подключение не выполняется.""" + + +class NatsNotConnectedError(NatsError): + """Операция требует соединения с NATS.""" + + +class PublishFailed(NatsError): + """Публикация не подтверждена: повторы исчерпаны или ошибка не временная. Причина последней попытки — в + `__cause__`; `attempts` — сколько попыток фактически сделано (0 — соединения не было, при исчерпанных повторах — + `retries + 1`).""" + + def __init__(self, message: str, *, subject: str, msg_id: str, attempts: int) -> None: + super().__init__(message) + self.subject = subject + self.msg_id = msg_id + self.attempts = attempts + + +def build_nats_auth(settings: NATSSettings) -> dict[str, Any]: + """Параметры аутентификации для `Client.connect`: `{}` без учётных данных или колбэки nkeys/JWT шаблона.""" + seed = settings.NATS_SEED.strip() + jwt = settings.NATS_JWT.strip() + if not seed and not jwt: + return {} + if not seed or not jwt: + raise NatsConfigError( + "Неполные учётные данные NATS: NATS_SEED и NATS_JWT задаются вместе или не задаются вовсе" + ) + try: + keypair = nkeys.from_seed(seed.encode("utf-8")) + except Exception as exc: + raise NatsConfigError(f"Некорректный NATS_SEED: {type(exc).__name__}") from None + + def user_jwt_cb() -> bytes: + return jwt.encode("utf-8") + + def signature_cb(nonce: str | bytes) -> bytes: + raw = nonce.encode("utf-8") if isinstance(nonce, str) else nonce + signature: bytes = keypair.sign(raw) + return base64.b64encode(signature) + + return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} + + +def message_msg_id(message: RecognitionResultMessage, attempt: int) -> str: + """`Nats-Msg-Id` конверта: `ack::` для `processing`, иначе `result::`.""" + if message.status is MessageStatus.PROCESSING: + return ack_msg_id(message.job_id, attempt) + return result_msg_id(message.job_id, attempt) + + +class NatsGateway: + """Соединение сервиса с NATS: подключение, подписка на запросы, публикация с `Nats-Msg-Id` и повторами. + + `client_factory`, `sleep`, `retry_delay`, `flush_timeout` — подмены для тестов и тайминги. + """ + + def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги + self, + settings: Settings, + *, + client_factory: Callable[[], Any] = NATS, + sleep: SleepFn = asyncio.sleep, + retry_delay: float = RETRY_DELAY, + flush_timeout: int = FLUSH_TIMEOUT, + ) -> None: + self._settings = settings + self._client_factory = client_factory + self._sleep = sleep + self._retry_delay = retry_delay + self._flush_timeout = flush_timeout + self._client: Any = None + self._closing = False + + # --- состояние и жизненный цикл --------------------------------------------------------------------------- + @property + def is_connected(self) -> bool: + """Фактическое состояние: `False` до подключения, при `NATS_ENABLED=false`, во время переподключения и после + `close()`. Источник поля `nats` в `/healthcheck`.""" + return self._client is not None and bool(self._client.is_connected) + + async def connect(self) -> bool: + """Подключается к `NATS_URL`; `False` без подключения, если `NATS_ENABLED=false`. + + Ждёт сервер сколь угодно долго (nats-py повторяет и первое подключение). Ошибка учётных данных — + `NatsConfigError` до попытки подключения. + """ + if not self._settings.NATS_ENABLED: + logger.info("NATS выключен (NATS_ENABLED=false): подключение не выполняется") + return False + nats_settings = self._settings.OPENBAO.NATS + auth = build_nats_auth(nats_settings) + client = self._client_factory() + self._closing = False + try: + await client.connect( + nats_settings.NATS_URL, + name=self._settings.SERVICE_NAME, + error_cb=self._on_error, + disconnected_cb=self._on_disconnected, + reconnected_cb=self._on_reconnected, + closed_cb=self._on_closed, + max_reconnect_attempts=MAX_RECONNECT_ATTEMPTS, + reconnect_time_wait=RECONNECT_TIME_WAIT, + connect_timeout=CONNECT_TIMEOUT, + **auth, + ) + except BaseException: + with suppress(Exception): + await client.close() + raise + self._client = client + logger.info("Подключение к NATS установлено. url={url}", url=nats_settings.NATS_URL) + return True + + async def close(self) -> None: + """Подключённый клиент — `drain` (дообработать полученное и отправить буфер), иначе `close`; без исключений.""" + client = self._client + if client is None or client.is_closed: + return + self._closing = True + try: + if client.is_connected: + await client.drain() + else: + await client.close() + except Exception as exc: + logger.warning( + "Ошибка при закрытии соединения NATS. error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + with suppress(Exception): + await client.close() + + # --- подписка --------------------------------------------------------------------------------------------- + async def subscribe_requests(self, cb: MessageHandler, queue: str = REQUEST_QUEUE_GROUP) -> Any: + """Подписка на `recognition.image.request` в queue group `queue`; возвращает подписку nats-py после + подтверждения сервером (`flush`). + + `cb` получает исходное `Msg` (`data`, `headers`); его исключения логируются, подписка не падает. + `cb` должен возвращаться быстро: nats-py вызывает колбэки одной подписки последовательно, долгая обработка + (ожидание ИС) задерживает следующие запросы — её выносят в отдельную задачу. + """ + if self._client is None: + raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {IMAGE_REQUEST_SUBJECT} невозможна") + + async def safe_cb(msg: Msg) -> None: + try: + await cb(msg) + except Exception as exc: + logger.exception( + "Ошибка обработки сообщения NATS. subject={subject}, error_type={error_type}, error={error}", + subject=msg.subject, + error_type=type(exc).__name__, + error=repr(exc), + ) + + subscription = await self._client.subscribe(IMAGE_REQUEST_SUBJECT, queue=queue, cb=safe_cb) + await self._client.flush(timeout=self._flush_timeout) # SUB принят сервером: подписка действует по возврату + logger.info( + "Подписка NATS активна. subject={subject}, queue={queue}", subject=IMAGE_REQUEST_SUBJECT, queue=queue + ) + return subscription + + # --- публикация ------------------------------------------------------------------------------------------- + async def publish(self, subject: str, payload: bytes, msg_id: str, *, retries: int | None = None) -> None: + """Публикует `payload` с заголовком `Nats-Msg-Id: ` и ждёт подтверждения сервера (`flush`). + + `retries` — число **повторов** после первой попытки (спека: «повторить до трёх раз»; по умолчанию + `PUBLISH_RETRIES` = 3, т.е. не более 4 попыток; `retries = 0` — ровно одна попытка), перед каждым повтором — + пауза `retry_delay`. Повторяются только временные ошибки (`TRANSIENT_PUBLISH_ERRORS`). Повторы исчерпаны, + ошибка не временная или соединения не было вовсе (`attempts = 0`) — `PublishFailed`. + Дубликаты при повторе безопасны: ядро дедуплицирует по `Nats-Msg-Id`. + `PublishFailed` не означает «не доставлено»: сообщения, буферизованные nats-py во время переподключения, могут + уйти после восстановления соединения; ядро отбросит копии по `Nats-Msg-Id`. + """ + retry_count = self._settings.PUBLISH_RETRIES if retries is None else retries + if retry_count < 0: + raise ValueError(f"retries должно быть ≥ 0, получено {retry_count}") + attempts_total = retry_count + 1 + if self._client is None: + raise PublishFailed( + f"Нет соединения с NATS: {msg_id} в {subject} не опубликовано", + subject=subject, + msg_id=msg_id, + attempts=0, + ) + headers = {NATS_MSG_ID_HEADER: msg_id} + for attempt in range(1, attempts_total + 1): + try: + await self._client.publish(subject, payload, headers=headers) + await self._client.flush(timeout=self._flush_timeout) + return + except TRANSIENT_PUBLISH_ERRORS as exc: + if attempt == attempts_total: + raise PublishFailed( + f"Публикация {msg_id} в {subject} не удалась, попыток: {attempt} ({type(exc).__name__})", + subject=subject, + msg_id=msg_id, + attempts=attempt, + ) from exc + logger.warning( + "Публикация в NATS не удалась, повтор. subject={subject}, msg_id={msg_id}, attempt={attempt}, " + "error_type={error_type}", + subject=subject, + msg_id=msg_id, + attempt=attempt, + error_type=type(exc).__name__, + ) + await self._sleep(self._retry_delay) + except Exception as exc: + raise PublishFailed( + f"Публикация {msg_id} в {subject} не удалась, ошибка не временная ({type(exc).__name__})", + subject=subject, + msg_id=msg_id, + attempts=attempt, + ) from exc + + async def publish_result(self, message: RecognitionResultMessage, attempt: int) -> None: + """Публикует конверт в `recognition.result`: тело — `message.to_wire()` (JSON, UTF-8), `Nats-Msg-Id` — + `ack:…` для `processing`, `result:…` для итога; `attempt` — из запроса. Ошибки — как у `publish`.""" + payload = json.dumps(message.to_wire(), ensure_ascii=False).encode("utf-8") + await self.publish(RESULT_SUBJECT, payload, message_msg_id(message, attempt)) + + # --- колбэки nats-py -------------------------------------------------------------------------------------- + async def _on_error(self, exc: Exception) -> None: + logger.warning( + "Ошибка соединения NATS. error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + + async def _on_disconnected(self) -> None: + if self._closing: + return + logger.warning("Соединение с NATS потеряно, nats-py переподключается") + + async def _on_reconnected(self) -> None: + logger.info("Соединение с NATS восстановлено, подписки восстановлены клиентом") + + async def _on_closed(self) -> None: + logger.info("Соединение с NATS закрыто") diff --git a/api/src/core/nats_callback.py b/api/src/core/nats_callback.py deleted file mode 100644 index 2f2e05b..0000000 --- a/api/src/core/nats_callback.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Обработчики сообщений NATS. - -Каркас: доменный обработчик удалён. Consumer `recognition.result` (дедуп по recognition_job.result_msg_id, -маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по -docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py. -""" -from loguru import logger -from nats.aio.client import Client as NATS - -from src.core.clients import S3Storage - - -def build_result_handler(nc: NATS, storage: S3Storage): - async def handler(msg): - logger.info("NATS message received. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data)) - await msg.ack() - - return handler diff --git a/api/src/core/worker.py b/api/src/core/worker.py new file mode 100644 index 0000000..aaad8f4 --- /dev/null +++ b/api/src/core/worker.py @@ -0,0 +1,662 @@ +"""Воркер scan-gateway: запрос NATS → ack → S3 → горячая папка → набор ответов ИС → склейка → S3 `raw/` → +нормализатор → конверт. + +Spec image-recognition-gateway; design D-2, D-3, D-4, D-5, D-7; решения R-14..R-20, R-24, R-25, R-30. + +- `on_request(msg)` — колбэк подписки NATS. Только разбирает и проверяет тело, регистрирует `job_id` и запускает + задачу задания; ничего не ждёт (nats-py вызывает колбэки одной подписки последовательно, T11.4). + Тело не по схеме / MAJOR ≠ 1 / `pipeline` ≠ `image` → `SCHEMA_MISMATCH` без ack; конверт публикуется, только если + из тела извлекаются `job_id` и `file_id` (R-16), иначе запрос записывается в лог и отбрасывается. +- Задача задания: (1) ack `processing` — до чтения S3 и до ожидания слота; (2) слот семафора `SCAN_MAX_INFLIGHT`; + (3) `handle(request)` — обработка и публикация итогового конверта (R-20). +- Повторный запрос с `job_id`, который в работе или в LRU обработанных (`DONE_CACHE_SIZE`), получает повторный ack с + `attempt` повторного запроса; файл в ИС второй раз не отправляется (D-2, R-17). +- Ошибки — результат, не молчание (таблица D-7): каждое задание заканчивается опубликованным конвертом; исключение + задания наружу не выходит (кроме отмены при остановке); неудачная публикация — лог error (ядро закроет задание по + `reply_deadline`). +- Методы горячей папки синхронные (R-9) — вызываются через `asyncio.to_thread`; нормализатор — тоже в потоке. +""" + +import asyncio +import json +import re +import uuid +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Protocol + +from loguru import logger +from pydantic import ValidationError + +from src.contract import ( + CONTRACT_VERSION, + ErrorCode, + Metrics, + RecognitionRequest, + RecognitionResult, + RecognitionResultMessage, + ResultStatus, + S3Ref, + ServiceInfo, + build_ack_message, + build_done_message, + build_error_message, + is_supported_major, +) +from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM +from src.core.config import Settings +from src.core.nats import PublishFailed +from src.gateway.hotfolder import AnswerFile, HotFolder, inbox_name +from src.gateway.merge import dump_merged, merge_answers +from src.normalizer import NormalizeOutcome, Normalizer + +__all__ = [ + "MAX_ERROR_MESSAGE_CHARS", + "MAX_WARNINGS", + "MAX_WARNING_CHARS", + "IncomingMessage", + "ObjectStorage", + "ResultPublisher", + "Worker", + "raw_key", + "result_key", + "utc_now", +] + +# Предел текста `error.message` в конверте: конверт остаётся далеко меньше лимита NATS (1 МБ) даже при 4 байтах на +# символ (T10.1). Длинное сообщение обрезается с многоточием. +MAX_ERROR_MESSAGE_CHARS = 4000 +# Предел `metrics.warnings` конверта (R-30): не больше MAX_WARNINGS строк по MAX_WARNING_CHARS символов — конверт +# остаётся далеко меньше лимита NATS (1 МБ) при любом числе ошибочных файлов; лишние строки сводятся в последнюю +MAX_WARNINGS = 100 +MAX_WARNING_CHARS = 1000 +# Предел фрагментов `repr()` (исключения, недоверенные значения) внутри сообщений и логов +MAX_FRAGMENT_CHARS = 300 +_ELLIPSIS = "…" +_MAX_ATTEMPT = 2**31 - 1 # attempt из тела, отвергнутого схемой, берётся только из разумного диапазона +_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", re.ASCII) +_IMAGE_PIPELINE = "image" + +SleepFn = Callable[[float], Awaitable[None]] +ClockFn = Callable[[], datetime] + + +class IncomingMessage(Protocol): + """Сообщение NATS в колбэке подписки: воркеру нужно только тело (`nats.aio.msg.Msg` подходит).""" + + data: bytes + + +class ObjectStorage(Protocol): + """Часть `S3Storage`, нужная воркеру; ошибки — классы `src.core.clients`.""" + + bucket: str + + async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes: ... + + async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None: ... + + async def put_json(self, key: str, obj: Any) -> None: ... + + +class ResultPublisher(Protocol): + """Часть `NatsGateway`: публикация конверта в `recognition.result` с `Nats-Msg-Id` и повторами.""" + + async def publish_result(self, message: RecognitionResultMessage, attempt: int) -> None: ... + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def raw_key(job_id: object) -> str: + """Ключ сырого ответа ИС в S3 (D-4).""" + return f"raw/{job_id}.json" + + +def result_key(file_id: object, job_id: object) -> str: + """Ключ файла результата в S3 (контракт, «Схема ключей S3»).""" + return f"results/{file_id}/{job_id}/result.json" + + +def clean_text(text: str, limit: int = MAX_ERROR_MESSAGE_CHARS) -> str: + """Текст, безопасный для конверта: одиночные суррогаты (из `\\udXXX` в JSON) заменены на `?` — иначе публикация + падает на кодировании в UTF-8 (T10.2); длина ограничена `limit` с многоточием в конце (T10.1).""" + safe = text.encode("utf-8", "replace").decode("utf-8") + if len(safe) > limit: + safe = safe[: limit - len(_ELLIPSIS)] + _ELLIPSIS + return safe + + +def _fragment(value: object) -> str: + """Короткое безопасное представление недоверенного значения для сообщения или лога.""" + try: + text = repr(value) + except Exception: # repr не должен ронять обработку ошибки + text = f"<{type(value).__name__}>" + return clean_text(text, MAX_FRAGMENT_CHARS) + + +def _describe(exc: BaseException) -> str: + return f"{type(exc).__name__}: {clean_text(str(exc), MAX_FRAGMENT_CHARS)}" + + +def _parse_json(data: bytes) -> tuple[object, str | None]: + """(разобранный JSON, None) или (None, описание ошибки разбора).""" + try: + return json.loads(data), None + except (ValueError, RecursionError) as exc: # UnicodeDecodeError — подкласс ValueError + return None, _describe(exc) + + +@dataclass(frozen=True) +class _Rejection: + """Тело запроса, не прошедшее проверку: причина и (если тело — JSON-объект) исходный словарь.""" + + reason: str + raw: dict[str, Any] | None + + +@dataclass +class _JobContext: + """Состояние задания, нужное для конверта при непредвиденной ошибке: сырой ответ уже в S3.""" + + raw_s3_key: str | None = None + + +@dataclass(frozen=True) +class _WaitResult: + answer: list[AnswerFile] | None + polls: int + failures: int + last_error: BaseException | None + + +class Worker: + """Обработка запросов `recognition.image.request` (см. докстринг модуля). + + Зависимости внедряются: `storage` (S3), `publisher` (NATS), `hotfolder`, `normalizer`; `clock` — часы UTC для + дедлайна (R-20), `sleep` — пауза между опросами (подмены для тестов). + Запросы принимаются между `start()` и `stop()`. + """ + + def __init__( # noqa: PLR0913 - зависимости воркера; всё, кроме настроек, — только именованные + self, + settings: Settings, + *, + storage: ObjectStorage, + publisher: ResultPublisher, + hotfolder: HotFolder, + normalizer: Normalizer, + clock: ClockFn = utc_now, + sleep: SleepFn = asyncio.sleep, + ) -> None: + self._settings = settings + self._storage = storage + self._publisher = publisher + self._hotfolder = hotfolder + self._normalizer = normalizer + self._clock = clock + self._sleep = sleep + self.service = ServiceInfo.model_validate({"name": settings.SERVICE_NAME, "version": settings.VERSION}) + self._semaphore = asyncio.Semaphore(settings.SCAN_MAX_INFLIGHT) + self._active: set[str] = set() # job_id в работе (ack, ожидание слота, обработка) — не вытесняются + self._done: OrderedDict[str, None] = OrderedDict() # LRU обработанных job_id + self._tasks: set[asyncio.Task[None]] = set() + self._accepting = False + + # --- жизненный цикл --------------------------------------------------------------------------------------- + @property + def active_jobs(self) -> int: + """Задания в работе (включая ожидающие слот).""" + return len(self._active) + + def start(self) -> None: + """Начать приём запросов (колбэк `on_request` подписывается после этого).""" + self._accepting = True + logger.info( + "Воркер scan-gateway запущен. max_inflight={max_inflight}, poll={poll}, timeout={timeout}", + max_inflight=self._settings.SCAN_MAX_INFLIGHT, + poll=self._settings.SCAN_POLL_SECONDS, + timeout=self._settings.SCAN_TIMEOUT_SECONDS, + ) + + async def stop(self) -> None: + """Прекратить приём, отменить задания в работе и дождаться их завершения. Повторный вызов безопасен. + + Отменённые задания конверта не публикуют: ядро закроет их по `reply_deadline` (D-2). + """ + self._accepting = False + tasks = list(self._tasks) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("Воркер остановлен, отменено заданий: {count}", count=len(tasks)) + + async def wait_idle(self) -> None: + """Дождаться завершения всех запущенных задач (ack, задания, публикации отказов) — для тестов и остановки.""" + while self._tasks: + await asyncio.gather(*list(self._tasks), return_exceptions=True) + + # --- приём запроса ---------------------------------------------------------------------------------------- + async def on_request(self, msg: IncomingMessage) -> None: + """Колбэк подписки: проверка тела, дедупликация по `job_id`, запуск задачи. Не ждёт ни сети, ни ИС.""" + if not self._accepting: + logger.warning("Воркер не принимает запросы (остановлен): сообщение отброшено, ядро повторит запрос") + return + parsed = self._parse_request(msg.data) + if isinstance(parsed, _Rejection): + self._reject(parsed) + return + request = parsed + job_id = str(request.job_id) + if self._seen(job_id): + logger.info( + "Повторный запрос: повторный ack без второй отправки в ИС. job_id={job_id}, attempt={attempt}", + job_id=job_id, + attempt=request.attempt, + ) + self._spawn(self._publish(self._ack(request), request.attempt), f"ack-repeat:{job_id}") + return + self._active.add(job_id) + logger.info( + "Запрос принят. job_id={job_id}, file_id={file_id}, attempt={attempt}, file_type={file_type}", + job_id=job_id, + file_id=request.file_id, + attempt=request.attempt, + file_type=request.file_type, + ) + self._spawn(self._run_job(request), f"job:{job_id}") + + def _parse_request(self, data: bytes) -> RecognitionRequest | _Rejection: + raw, error = _parse_json(data) + if error is not None: + return _Rejection(f"тело запроса не разбирается как JSON ({error})", None) + if not isinstance(raw, dict): + return _Rejection(f"тело запроса — не JSON-объект ({type(raw).__name__})", None) + version = raw.get("schema_version") + if not is_supported_major(version): + return _Rejection(f"неподдерживаемая версия контракта {_fragment(version)}: ожидается MAJOR 1", raw) + pipeline = raw.get("pipeline") + if pipeline != _IMAGE_PIPELINE: + return _Rejection(f"pipeline {_fragment(pipeline)} не обслуживается scan-gateway (ожидается image)", raw) + try: + return RecognitionRequest.model_validate(raw) + except ValidationError as exc: + details = "; ".join( + f"{'.'.join(str(part) for part in item['loc']) or '<тело>'}: {item['msg']}" + for item in exc.errors(include_url=False, include_input=False)[:5] + ) + return _Rejection(f"запрос не соответствует схеме recognition-request: {details}", raw) + + def _reject(self, rejection: _Rejection) -> None: + """`SCHEMA_MISMATCH` без ack (R-16): конверт — только если в теле есть корректные `job_id` и `file_id`. + + Для `job_id` в работе или уже обработанного конверт не публикуется (T12.1): его `Nats-Msg-Id` + `result::` совпал бы с настоящим результатом, и ядро отбросило бы настоящий как дубль. + """ + raw = rejection.raw or {} + job_id, file_id = raw.get("job_id"), raw.get("file_id") + if not (_is_uuid(job_id) and _is_uuid(file_id)): + logger.warning( + "SCHEMA_MISMATCH: запрос отброшен без ответа (нет корректных job_id/file_id). reason={reason}", + reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS), + ) + return + # `_active`/`_done` хранят канонический вид (нижний регистр), а UUID в теле может прийти в верхнем + key = str(uuid.UUID(str(job_id))) + if self._seen(key): + logger.warning( + "SCHEMA_MISMATCH для задания в работе/обработанного: конверт не публикуется. job_id={job_id}, " + "reason={reason}", + job_id=key, + reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS), + ) + return + attempt = raw.get("attempt") + if not (type(attempt) is int and 1 <= attempt <= _MAX_ATTEMPT): + attempt = 1 + logger.warning( + "SCHEMA_MISMATCH: запрос отвергнут, ack не публикуется. job_id={job_id}, reason={reason}", + job_id=job_id, + reason=clean_text(rejection.reason, MAX_FRAGMENT_CHARS), + ) + message = build_error_message( + str(job_id), str(file_id), self.service, ErrorCode.SCHEMA_MISMATCH, clean_text(rejection.reason) + ) + self._spawn(self._publish(message, attempt), f"schema-mismatch:{job_id}") + + # --- дедупликация (D-2, R-17) ----------------------------------------------------------------------------- + def _seen(self, job_id: str) -> bool: + if job_id in self._active: + return True + if job_id in self._done: + self._done.move_to_end(job_id) + return True + return False + + def _finish(self, job_id: str) -> None: + self._active.discard(job_id) + self._done[job_id] = None + self._done.move_to_end(job_id) + while len(self._done) > self._settings.DONE_CACHE_SIZE: + self._done.popitem(last=False) + + # --- задачи ----------------------------------------------------------------------------------------------- + def _spawn(self, coro: Coroutine[Any, Any, None], name: str) -> None: + task = asyncio.create_task(coro, name=name) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _run_job(self, request: RecognitionRequest) -> None: + job_id = str(request.job_id) + try: + await self._publish(self._ack(request), request.attempt) # до чтения S3 и до ожидания слота + async with self._semaphore: + await self.handle(request) + except asyncio.CancelledError: + logger.warning("Задание отменено при остановке воркера. job_id={job_id}", job_id=job_id) + raise + except Exception: # handle не бросает; страховка, чтобы исключение не осталось в задаче + logger.exception("Непредвиденная ошибка задания. job_id={job_id}", job_id=job_id) + finally: + self._finish(job_id) + + def _ack(self, request: RecognitionRequest) -> RecognitionResultMessage: + return build_ack_message(request.job_id, request.file_id, self.service) + + async def _publish(self, message: RecognitionResultMessage, attempt: int) -> None: + """Публикация с повторами `NatsGateway`; неудача — лог error, исключение не выходит (D-7).""" + try: + await self._publisher.publish_result(message, attempt) + except PublishFailed as exc: + logger.error( + "Конверт не опубликован, ядро закроет задание по reply_deadline. job_id={job_id}, status={status}, " + "msg_id={msg_id}, attempts={attempts}, cause={cause}", + job_id=message.job_id, + status=message.status.value, + msg_id=exc.msg_id, + attempts=exc.attempts, + cause=_fragment(exc.__cause__), + ) + except Exception as exc: + logger.error( + "Конверт не опубликован: непредвиденная ошибка. job_id={job_id}, status={status}, error={error}", + job_id=message.job_id, + status=message.status.value, + error=_describe(exc), + ) + + # --- обработка задания ------------------------------------------------------------------------------------ + async def handle(self, request: RecognitionRequest) -> RecognitionResultMessage: + """Обработать задание и опубликовать итоговый конверт; вернуть его. + + Не бросает исключений (кроме `asyncio.CancelledError`): любой сбой становится конвертом `error` по D-7. + Ack и семафор — забота вызывающего (`on_request`); здесь — S3 → горячая папка → ответ → `raw/` → + нормализатор → `results/` → конверт. + """ + context = _JobContext() + try: + message = await self._process(request, context) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.exception("Непредвиденная ошибка обработки. job_id={job_id}", job_id=request.job_id) + message = self._error( + request, + ErrorCode.UNKNOWN_ERROR, + f"непредвиденная ошибка scan-gateway: {_describe(exc)}", + raw_s3_key=context.raw_s3_key, + ) + logger.info( + "Задание обработано. job_id={job_id}, status={status}, error_code={code}", + job_id=request.job_id, + status=message.status.value, + code=message.error.code.value if message.error else None, + ) + await self._publish(message, request.attempt) + return message + + def _error( + self, + request: RecognitionRequest, + code: ErrorCode, + text: str, + *, + raw_s3_key: str | None = None, + metrics: Metrics | None = None, + ) -> RecognitionResultMessage: + return build_error_message( + request.job_id, + request.file_id, + self.service, + code, + clean_text(text), + raw_s3_key=raw_s3_key, + metrics=metrics, + ) + + async def _process(self, request: RecognitionRequest, context: _JobContext) -> RecognitionResultMessage: + """Шаги задания; каждая неудача — конверт `error` по строке таблицы D-7.""" + sent = await self._send(request) + if isinstance(sent, RecognitionResultMessage): + return sent + name, started = sent + job_id = str(request.job_id) + # 3. Опрос исходящего каталога до min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS) (D-3, R-20) + deadline = min(request.reply_deadline, started + timedelta(seconds=self._settings.SCAN_TIMEOUT_SECONDS)) + waited = await self._wait_answer(job_id, deadline) + if waited.answer is None: + if waited.failures == waited.polls and waited.last_error is not None: + return self._error( + request, + ErrorCode.RECOGNITION_FAILED, + f"горячая папка недоступна: исходящий каталог не прочитан ни разу за {waited.polls} опросов " + f"({_describe(waited.last_error)})", + ) + logger.warning( + "ИС Сканирование не ответила до дедлайна. job_id={job_id}, deadline={deadline}, polls={polls}", + job_id=job_id, + deadline=deadline.isoformat(), + polls=waited.polls, + ) + # R-39: при settle > 0 устойчивая ошибка чтения чередуется с пустыми опросами — код остаётся TIMEOUT, + # но ошибку опроса видно в тексте, чтобы конверт не вёл к ИС, когда не читается горячая папка + poll_failure = ( + f"; ошибки опроса исходящего каталога: {waited.failures} из {waited.polls}, " + f"последняя: {_describe(waited.last_error)}" + if waited.failures > 0 and waited.last_error is not None + else "" + ) + return self._error( + request, + ErrorCode.TIMEOUT, + f"ИС Сканирование не ответила до {deadline.isoformat()} (опросов: {waited.polls}{poll_failure}); " + f"входящий файл {name} остаётся в каталоге, повторной отправки нет", + ) + return await self._accept_answer(request, waited.answer, context) + + async def _send(self, request: RecognitionRequest) -> tuple[str, datetime] | RecognitionResultMessage: # noqa: PLR0911 - каждая ветка — строка таблицы D-7 + """Срок, исходник из S3, запись во входящий каталог: (имя файла, момент записи) или конверт `error`.""" + job_id = str(request.job_id) + if request.reply_deadline <= self._clock(): + # задание дождалось слота после срока ответа: ядро уже закрывает его, файл в ИС не отправляется + return self._error( + request, + ErrorCode.TIMEOUT, + f"срок ответа {request.reply_deadline.isoformat()} истёк до отправки файла в ИС Сканирование", + ) + + # 1. Исходник из S3 (один бакет сервиса, R-19) + source = request.s3 + if source.bucket != self._storage.bucket: + return self._error( + request, + ErrorCode.FILE_READ_ERROR, + f"бакет {_fragment(source.bucket)} не обслуживается: scan-gateway работает с одним бакетом " + f"{_fragment(self._storage.bucket)}", + ) + try: + data = await self._storage.get_bytes(source.key, bucket=source.bucket) + except Exception as exc: + logger.warning( + "Исходник не прочитан из S3. job_id={job_id}, error={error}", job_id=job_id, error=_describe(exc) + ) + return self._error( + request, + ErrorCode.FILE_READ_ERROR, + f"исходный файл {source.bucket}/{source.key} не прочитан из S3: {_describe(exc)}", + ) + + # 2. Во входящий каталог как _ (R-5, R-15) + try: + name = inbox_name(job_id, request.original_name) + except ValueError: + return self._error( + request, + ErrorCode.RECOGNITION_FAILED, + f"недопустимое имя файла {_fragment(request.original_name)}: файл не передан в ИС Сканирование", + ) + try: + await asyncio.to_thread(self._hotfolder.put, name, data) + except Exception as exc: + logger.error( + "Горячая папка недоступна: файл не записан. job_id={job_id}, error={error}", + job_id=job_id, + error=_describe(exc), + ) + return self._error( + request, + ErrorCode.RECOGNITION_FAILED, + f"горячая папка недоступна: файл не записан во входящий каталог ({_describe(exc)})", + ) + return name, self._clock() + + async def _accept_answer( + self, request: RecognitionRequest, answer: list[AnswerFile], context: _JobContext + ) -> RecognitionResultMessage: + """Набор ответов ИС: склейка, сырой в S3, нормализатор, файл результата; конверт итога.""" + job_id = str(request.job_id) + + # 4. Склейка всех файлов набора (не JSON — parse_error файла, R-24) и сырой ответ в S3 до нормализации (D-4) + # (склейка и сериализация — CPU-работа над произвольным JSON: в потоке, как нормализатор; сбой сериализации, + # например RecursionError на очень глубокой вложенности, — такой же сбой записи сырого ответа) + merged = await asyncio.to_thread(merge_answers, job_id, answer) + key = raw_key(job_id) + try: + payload = await asyncio.to_thread(dump_merged, merged) + await self._storage.put_bytes(key, payload, JSON_CONTENT_TYPE) + except Exception as exc: + logger.error( + "Сырой ответ ИС не сохранён. job_id={job_id}, error={error}", job_id=job_id, error=_describe(exc) + ) + return self._error( + request, ErrorCode.UNKNOWN_ERROR, f"сырой ответ ИС не сохранён в S3 ({key}): {_describe(exc)}" + ) + context.raw_s3_key = key + + # 5. Нормализатор получает склеенный ответ (D-5); его исключение — UNKNOWN_ERROR с сохранённым сырым ответом + try: + outcome = await asyncio.to_thread(self._normalizer.normalize, merged) + except Exception as exc: + logger.exception("Нормализатор ответа ИС упал. job_id={job_id}", job_id=job_id) + return self._error( + request, ErrorCode.UNKNOWN_ERROR, f"нормализатор ответа ИС упал: {_describe(exc)}", raw_s3_key=key + ) + metrics = _outcome_metrics(outcome, key) + if outcome.status is ResultStatus.ERROR: + error = outcome.error + if error is None: # исключено инвариантом NormalizeOutcome + raise ValueError("NormalizeOutcome со status=error без error") + return self._error(request, error.code, error.message, raw_s3_key=key, metrics=metrics) + + # 6. Успех: файл результата в S3, затем конверт с result_s3 + result = RecognitionResult.model_validate( + { + "schema_version": CONTRACT_VERSION, + "job_id": request.job_id, + "file_id": request.file_id, + "service": self.service, + "status": outcome.status, + "raw_s3_key": key, + "documents": list(outcome.documents), + **({"metrics": metrics} if metrics is not None else {}), + } + ) + target = result_key(request.file_id, request.job_id) + try: + await self._storage.put_json(target, result.to_wire()) + except Exception as exc: + logger.error( + "Файл результата не записан. job_id={job_id}, error={error}", job_id=job_id, error=_describe(exc) + ) + return self._error( + request, + ErrorCode.UNKNOWN_ERROR, + f"файл результата не записан в S3 ({target}): {_describe(exc)}", + raw_s3_key=key, + ) + return build_done_message( + request.job_id, + request.file_id, + self.service, + S3Ref(bucket=self._storage.bucket, key=target), + needs_check=outcome.status is ResultStatus.NEEDS_CHECK, + raw_s3_key=key, + metrics=metrics, + ) + + async def _wait_answer(self, job_id: str, deadline: datetime) -> _WaitResult: + """Опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` до дедлайна (последний опрос — в момент дедлайна). + + Исключения `poll` (SMB и прочие) — warning, опрос продолжается (R-14). Первый готовый набор (`poll` отдаёт его + только после настаивания, D-1) принимается сразу: файл, не разобранный как JSON, не перечитывается — он + станет `parse_error` в склейке (R-24). Ненастоявшийся к дедлайну набор — `None` (таймаут). + """ + polls = failures = 0 + last_error: BaseException | None = None + while True: + polls += 1 + answer: list[AnswerFile] | None + try: + answer = await asyncio.to_thread(self._hotfolder.poll, job_id) + except Exception as exc: + failures += 1 + last_error = exc + answer = None + logger.warning( + "Опрос исходящего каталога не удался, опрос продолжается. job_id={job_id}, error={error}", + job_id=job_id, + error=_describe(exc), + ) + if answer: + return _WaitResult(answer, polls, failures, last_error) + now = self._clock() + if now >= deadline: + return _WaitResult(None, polls, failures, last_error) + await self._sleep(min(self._settings.SCAN_POLL_SECONDS, (deadline - now).total_seconds())) + + +def _outcome_metrics(outcome: NormalizeOutcome, raw_s3_key: str) -> Metrics | None: + """`metrics` конверта: метрики исхода + `warnings` исхода (R-25) в конце `metrics.warnings`. + + Пустые `warnings` опускаются. Строки безопасны для конверта (`clean_text`), их не больше `MAX_WARNINGS` по + `MAX_WARNING_CHARS` символов; лишние сводятся в последнюю строку со ссылкой на сырой ответ (R-30). + """ + metrics = outcome.metrics + lines = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] + if not lines: + return metrics + if len(lines) > MAX_WARNINGS: + hidden = len(lines) - (MAX_WARNINGS - 1) + lines = [*lines[: MAX_WARNINGS - 1], f"ещё предупреждений: {hidden}; все ошибки файлов — в {raw_s3_key}"] + warnings = [clean_text(line, MAX_WARNING_CHARS) for line in lines] + base = metrics.model_dump(exclude_none=True) if metrics is not None else {} + return Metrics.model_validate({**base, "warnings": warnings}) + + +def _is_uuid(value: object) -> bool: + return isinstance(value, str) and _UUID_RE.fullmatch(value) is not None diff --git a/api/src/db/__init__.py b/api/src/db/__init__.py deleted file mode 100644 index 49ce0c1..0000000 --- a/api/src/db/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .db import get_async_session \ No newline at end of file diff --git a/api/src/db/db.py b/api/src/db/db.py deleted file mode 100644 index be73f6b..0000000 --- a/api/src/db/db.py +++ /dev/null @@ -1,16 +0,0 @@ -from contextlib import asynccontextmanager - -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession -from fastapi import Depends -# from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID -from src.core.config import settings - -engine = create_async_engine(settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": 'dkb'}}) -async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - -# @asynccontextmanager -async def get_async_session(): - async with async_session_maker() as session: - yield session - diff --git a/api/src/gateway/__init__.py b/api/src/gateway/__init__.py new file mode 100644 index 0000000..4902baa --- /dev/null +++ b/api/src/gateway/__init__.py @@ -0,0 +1,38 @@ +"""Горячие папки ИС Сканирование: фабрика по настройкам (R-6). + +`SCAN_HOTFOLDER_MODE=local` — `LocalHotFolder` (модуль `src.gateway.smb` не импортируется), `smb` — `SmbHotFolder` +из `OPENBAO.SMB` (импорт модуля — только здесь, лениво). +""" + +from src.core.config import Settings +from src.gateway.hotfolder import HotFolder, LocalHotFolder + +__all__ = ["create_hotfolder"] + + +def create_hotfolder(settings: Settings) -> HotFolder: + """Горячая папка для режима `SCAN_HOTFOLDER_MODE`. К файловой системе и шаре не обращается.""" + mode = settings.SCAN_HOTFOLDER_MODE + if mode == "local": + return LocalHotFolder( + settings.SCAN_HOTFOLDER_PATH, + settings.SCAN_INPUT_DIR, + settings.SCAN_OUTPUT_DIR, + settings.SCAN_TMP_DIR, + settle_seconds=settings.SCAN_SETTLE_SECONDS, + ) + if mode == "smb": + from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — ленивый импорт: в режиме local smb не нужен (R-6) + + smb = settings.OPENBAO.SMB + return SmbHotFolder( + smb.SMB_HOST, + smb.SMB_PATH, + settings.SCAN_INPUT_DIR, + settings.SCAN_OUTPUT_DIR, + settings.SCAN_TMP_DIR, + login=smb.SMB_LOGIN, + password=smb.SMB_PASSWORD, + settle_seconds=settings.SCAN_SETTLE_SECONDS, + ) + raise ValueError(f"неизвестный SCAN_HOTFOLDER_MODE: {mode!r}") diff --git a/api/src/gateway/hotfolder.py b/api/src/gateway/hotfolder.py new file mode 100644 index 0000000..f16e64d --- /dev/null +++ b/api/src/gateway/hotfolder.py @@ -0,0 +1,305 @@ +"""Горячие папки ИС Сканирование: протокол `HotFolder` и реализация в обычном каталоге (D-1, R-4, R-5). + +Файл кладётся во входящий каталог как `_`: сначала временный `.<имя>.part` в служебном каталоге +`Tmp` (на той же ФС/шаре), затем атомарный rename во входящий (OQ-79). Ответ ИС — набор файлов каталога +`//`: ИС кладёт туда все распознанные документы задания, по одному `*.json` на документ (OQ-78). +`poll` отдаёт все `*.json` по возрастанию имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров +не менялся `settle_seconds` (`SCAN_SETTLE_SECONDS`, `SettleTracker`). Имена каталогов (`Input`/`Output`/`Tmp`) +настраиваются, а не зашиты в код. +""" + +import contextlib +import os +import threading +import time +from collections import OrderedDict +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +_PATH_SEPARATORS = ("/", "\\") +# Недопустимые в именах файлов Windows/SMB символы (кроме разделителей пути) и управляющие 0x00-0x1F (с NUL) +_ILLEGAL_CHARS = frozenset(':<>"|?*') | frozenset(chr(code) for code in range(0x20)) +_FORBIDDEN_CHARS = _ILLEGAL_CHARS | frozenset(_PATH_SEPARATORS) +# Предел длины временного имени `._<имя>.part` в байтах UTF-8: ext4 — 255 байт, SMB/NTFS — 255 единиц UTF-16 +# (их не больше, чем байт UTF-8); 15 байт — запас +MAX_PART_NAME_BYTES = 240 +# Суффикс после последней точки считается расширением и сохраняется при укорачивании, если он не длиннее этого +_MAX_EXTENSION_CHARS = 16 + + +def validate_name(name: str) -> None: + """Проверка имени входящего файла для `put` (общая для `LocalHotFolder` и `SmbHotFolder`). + + Имя — один компонент пути: не пустое, не начинается с `.` (dot-файлы — служебные `.part`, их игнорирует эмулятор), + без разделителей `/` и `\\`, без символов, недопустимых в Windows/SMB (`: < > " | ? *`), и без управляющих + символов. Пробелы и кириллица допустимы. Нарушение — `ValueError`. + """ + if name == "" or name.startswith(".") or any(char in _FORBIDDEN_CHARS for char in name): + raise ValueError(f"недопустимое имя файла: {name!r}") + + +def validate_job_id(job_id: str) -> None: + """Проверка `job_id` перед построением пути ответа: защита от обхода каталога. UUID проходит; пусто, `.`, `..`, + разделители, недопустимые в Windows/SMB и управляющие символы — `ValueError`.""" + if job_id in ("", ".", "..") or any(char in _FORBIDDEN_CHARS for char in job_id): + raise ValueError(f"недопустимый job_id: {job_id!r}") + + +def _split_basename(name: str) -> str: + """Последний компонент пути с любыми разделителями (и POSIX, и Windows); пустая строка, если его нет.""" + return name.replace("\\", "/").rsplit("/", 1)[-1] + + +def inbox_name(job_id: str, original_name: str) -> str: + """Имя входящего файла `_`. + + `original_name` приходит из недоверенного запроса («как загрузил пользователь», контракт требует только + непустую строку): берётся последний компонент пути (без разделителей и `..`), символы, недопустимые в имени файла + Windows/SMB, и управляющие символы заменяются на `_`, завершающие точки и пробелы (их отбрасывает Windows) + убираются. Пробелы внутри имени и кириллица сохраняются. Результат всегда проходит `validate_name`, если `job_id` + допустим. `ValueError` — только если имени не осталось (пусто, `.`, `..`, одни точки/пробелы). + + Длинное имя укорачивается (T7.3): основа обрезается по символам с конца, пока `.<результат>.part` не уложится + в `MAX_PART_NAME_BYTES` байт UTF-8; расширение (суффикс после последней точки до `_MAX_EXTENSION_CHARS` символов) + сохраняется. Короткое имя не меняется. + """ + base = _split_basename(original_name) + if base in (".", ".."): + raise ValueError(f"недопустимое имя файла: {original_name!r}") + base = "".join("_" if char in _ILLEGAL_CHARS else char for char in base).rstrip(". ") + if not base: + raise ValueError(f"недопустимое имя файла: {original_name!r}") + return f"{job_id}_{_fit_name(job_id, base)}" + + +def _utf8_len(text: str) -> int: + # surrogatepass: одиночный суррогат (схема запроса их не пропускает) не роняет подсчёт длины + return len(text.encode("utf-8", "surrogatepass")) + + +def _fit_name(job_id: str, base: str) -> str: + """Основа имени, при которой `._<основа>.part` занимает не больше `MAX_PART_NAME_BYTES` байт UTF-8.""" + budget = MAX_PART_NAME_BYTES - _utf8_len(f".{job_id}_.part") + if _utf8_len(base) <= budget: + return base + dot = base.rfind(".") + extension = base[dot:] if 0 < dot and len(base) - dot - 1 <= _MAX_EXTENSION_CHARS else "" + stem = base[: len(base) - len(extension)] + limit = budget - _utf8_len(extension) + used = 0 + kept = 0 + for char in stem: # по code points: многобайтный символ не разрезается + used += _utf8_len(char) + if used > limit: + break + kept += 1 + stem = stem[:kept] + if not extension: + # без расширения имя заканчивается основой: завершающие точки и пробелы отбрасывает Windows + stem = stem.rstrip(". ") + return (stem or "_") + extension + + +@dataclass(frozen=True) +class AnswerFile: + """Один файл ответа ИС: имя в каталоге `//` и байты как есть.""" + + name: str + data: bytes + + +# Снимок набора ответа: (имя, размер) всех `*.json` каталога задания по возрастанию имени +Snapshot = tuple[tuple[str, int], ...] +MonotonicFn = Callable[[], float] +# Предел числа заданий, для которых помнится ненастоявшийся набор (задание ушло по таймауту — состояние вытесняется) +MAX_PENDING_JOBS = 1024 + + +def is_answer_name(name: str) -> bool: + """Файл ответа ИС — `*.json`; расширение без учёта регистра (маски Windows/SMB регистронезависимы).""" + return name.lower().endswith(".json") + + +class SettleTracker: + """Готовность набора ответа по стабилизации (settle): набор считается готовым, когда он не пуст и одинаковый + снимок (имена и размеры) наблюдается не меньше `settle_seconds` по часам `clock`. + + Состояние — по `job_id`: снимок и момент, с которого он не менялся. Оно сбрасывается, когда набор признан готовым + или опустел (каталога/json нет), а число заданий ограничено `max_jobs` (самые давние вытесняются: у задания, + ушедшего по таймауту, состояние иначе осталось бы навсегда; вытесненное активное задание настаивается заново). + `settle_seconds == 0` — готов любой непустой набор, состояние не хранится. Потокобезопасно: `poll` вызывается + через `asyncio.to_thread`. + """ + + def __init__( + self, settle_seconds: float, clock: MonotonicFn = time.monotonic, *, max_jobs: int = MAX_PENDING_JOBS + ) -> None: + if settle_seconds < 0: + raise ValueError(f"settle_seconds не может быть отрицательным: {settle_seconds!r}") + self.settle_seconds = settle_seconds + self._clock = clock + self._max_jobs = max_jobs + self._lock = threading.Lock() + self._seen: OrderedDict[str, tuple[Snapshot, float]] = OrderedDict() + + def ready(self, job_id: str, snapshot: Snapshot) -> bool: + """Набор `snapshot` задания готов. Готовый набор забывается: следующий вызов настаивает набор заново.""" + if not snapshot or self.settle_seconds == 0: + self.forget(job_id) + return bool(snapshot) + now = self._clock() + with self._lock: + seen = self._seen.get(job_id) + if seen is not None and seen[0] == snapshot: + if now - seen[1] >= self.settle_seconds: + del self._seen[job_id] + return True + return False + self._seen[job_id] = (snapshot, now) + self._seen.move_to_end(job_id) + while len(self._seen) > self._max_jobs: + self._seen.popitem(last=False) + return False + + def forget(self, job_id: str) -> None: + with self._lock: + self._seen.pop(job_id, None) + + def __len__(self) -> int: + with self._lock: + return len(self._seen) + + +class HotFolder(Protocol): + """Обмен файлами с ИС Сканирование через пару каталогов. + + Методы синхронные: файловые операции и SMB блокирующие, воркер (asyncio) вызывает их через `asyncio.to_thread`. + """ + + def put(self, name: str, data: bytes) -> None: + """Атомарно положить файл `name` (простое имя, без пути) во входящий каталог: временный файл — в служебном + каталоге `Tmp`, затем rename. Имя собирает вызывающий (`inbox_name`). Если горячая папка недоступна — + исключение (воркер превращает его в `RECOGNITION_FAILED`).""" + ... + + def poll(self, job_id: str) -> list[AnswerFile] | None: + """Все `*.json` каталога `//` по возрастанию имени, когда набор готов (не пуст и не менялся + `SCAN_SETTLE_SECONDS`); `None`, пока ответа нет или набор ещё пишется. Вызывается повторно с тем же `job_id`: + реализация помнит снимок набора между вызовами.""" + ... + + def healthy(self) -> bool: + """Горячая папка доступна. Не бросает исключений.""" + ... + + +class LocalHotFolder: + """Горячая папка в обычном каталоге: dev, compose-том, unit-тесты. + + `root`, `input_dir`, `output_dir`, `tmp_dir`, `answer_dir` и `answer_path` открыты намеренно (R-4): эмулятор + ИС Сканирование работает с ними обычными операциями файловой системы (листинг входящих, `.done`, запись ответа). + `settle_seconds` и `clock` (монотонные часы) — готовность набора ответа (`SettleTracker`). + """ + + def __init__( # noqa: PLR0913 — имена трёх каталогов + параметры готовности набора + self, + root: Path | str, + input_dir: str = "Input", + output_dir: str = "Output", + tmp_dir: str = "Tmp", + *, + settle_seconds: float = 2, + clock: MonotonicFn = time.monotonic, + ) -> None: + self.root: Path = Path(root) + self.input_dir: Path = self.root / input_dir + self.output_dir: Path = self.root / output_dir + self.tmp_dir: Path = self.root / tmp_dir + self._settle = SettleTracker(settle_seconds, clock) + + def answer_dir(self, job_id: str) -> Path: + """Каталог ответа ИС: `/`.""" + validate_job_id(job_id) + return self.output_dir / job_id + + def answer_path(self, job_id: str) -> Path: + """Путь основного файла ответа `//.json` (так его называет ИС и эмулятор).""" + return self.answer_dir(job_id) / f"{job_id}.json" + + def put(self, name: str, data: bytes) -> None: + """Атомарная запись: `..part` в служебном каталоге `tmp_dir`, затем `os.replace` в `input_dir/` + (существующий файл заменяется; оба каталога на одной ФС — rename атомарен). + + Корень не создаётся (нет корня — `FileNotFoundError`), служебный и входящий каталоги создаются без родителей. + При сбое временный файл удаляется (ошибка удаления не маскирует исходную). + """ + validate_name(name) + self.tmp_dir.mkdir(exist_ok=True) + self.input_dir.mkdir(exist_ok=True) + part = self.tmp_dir / f".{name}.part" + try: + part.write_bytes(data) + os.replace(part, self.input_dir / name) + except BaseException: + # ошибка удаления временного файла не должна маскировать исходную (T7.5) + with contextlib.suppress(OSError): + part.unlink(missing_ok=True) + raise + + def poll(self, job_id: str) -> list[AnswerFile] | None: + """Готовый набор `*.json` каталога ответа или `None` (нет корня/каталога/json-файлов, набор ещё + настаивается, файл исчез или изменился между листингом и чтением, набор изменился за время чтения). + Другие ошибки ФС поднимаются.""" + folder = self.answer_dir(job_id) + snapshot = self._snapshot(folder) + if not self._settle.ready(job_id, snapshot): + return None + files: list[AnswerFile] = [] + for name, size in snapshot: + try: + data = (folder / name).read_bytes() + except FileNotFoundError: + return None + if len(data) != size: # файл изменился после листинга — набор настаивается заново + return None + files.append(AnswerFile(name, data)) + # T2.1: за время чтения мог появиться или пропасть файл — такой набор не отдаётся, он настаивается заново + # (состояние settle уже сброшено в момент готовности) + if self._snapshot(folder) != snapshot: + return None + return files + + @property + def settle_seconds(self) -> float: + """`SCAN_SETTLE_SECONDS`: сколько набор ответа должен не меняться, чтобы считаться готовым.""" + return self._settle.settle_seconds + + def pending_jobs(self) -> int: + """Число заданий с ненастоявшимся набором (для тестов и диагностики).""" + return len(self._settle) + + def healthy(self) -> bool: + """Корень существует и является каталогом.""" + try: + return self.root.is_dir() + except (OSError, ValueError): + return False + + @staticmethod + def _snapshot(folder: Path) -> Snapshot: + try: + with os.scandir(folder) as entries: + found = [] + for entry in entries: + if not is_answer_name(entry.name): + continue + try: + if entry.is_file(): + found.append((entry.name, entry.stat().st_size)) + except FileNotFoundError: + continue + except (FileNotFoundError, NotADirectoryError): + return () + return tuple(sorted(found)) diff --git a/api/src/gateway/merge.py b/api/src/gateway/merge.py new file mode 100644 index 0000000..c4c92be --- /dev/null +++ b/api/src/gateway/merge.py @@ -0,0 +1,70 @@ +"""Склейка набора файлов ответа ИС Сканирование в один ответ (D-4, OQ-78, R-24, решения №55, №60). + +ИС кладёт в `//` по одному `*.json` на распознанный документ; `HotFolder.poll` отдаёт готовый набор. +`merge_answers` разбирает каждый файл `json.loads` независимо и собирает +`{"job_id": str, "files": [{"name": str, "response": <разобранный json> | None, "parse_error": str | None}, ...]}` +по возрастанию имени. Файл, не разобранный как JSON, получает `response = None` и `parse_error` и не перечитывается: +к моменту склейки набор уже настоялся (R-24). Корень ответа не интерпретируется (не-объект — забота нормализатора). + +Склейка живёт на стороне транспорта (`src/gateway/`): её вход — файлы горячей папки, выход — сырой объект +`raw/{job_id}.json` и вход нормализатора; реализация нормализатора (Срез 3) подменяется, склейка — нет. +""" + +import json +from collections.abc import Iterable +from typing import TypedDict + +from src.gateway.hotfolder import AnswerFile + +__all__ = ["MergedFile", "MergedResponse", "dump_merged", "merge_answers"] + +# Предел текста `parse_error`: описание ошибки разбора, а не данные +_MAX_PARSE_ERROR_CHARS = 300 + + +class MergedFile(TypedDict): + """Один файл ответа в склейке: имя в каталоге ответа и разобранный JSON либо описание ошибки разбора.""" + + name: str + response: object + parse_error: str | None + + +class MergedResponse(TypedDict): + """Склеенный ответ ИС: объект `raw/{job_id}.json` и вход `Normalizer.normalize`.""" + + job_id: str + files: list[MergedFile] + + +def _parse(data: bytes) -> tuple[object, str | None]: + """(разобранный JSON, None) или (None, описание ошибки разбора).""" + try: + return json.loads(data), None + except (ValueError, RecursionError) as exc: # UnicodeDecodeError — подкласс ValueError + text = f"{type(exc).__name__}: {exc}" + if len(text) > _MAX_PARSE_ERROR_CHARS: + text = text[: _MAX_PARSE_ERROR_CHARS - 1] + "…" + return None, text + + +def merge_answers(job_id: str, files: Iterable[AnswerFile]) -> MergedResponse: + """Склеить набор файлов ответа: каждый разобран независимо, порядок — по возрастанию имени. Не бросает на + содержимом файлов.""" + merged: list[MergedFile] = [] + for answer in sorted(files, key=lambda item: item.name): + response, parse_error = _parse(answer.data) + merged.append({"name": answer.name, "response": response, "parse_error": parse_error}) + return {"job_id": job_id, "files": merged} + + +def dump_merged(merged: MergedResponse) -> bytes: + """Байты объекта `raw/{job_id}.json`: UTF-8 JSON с читаемой кириллицей. + + Одиночный суррогат (из `\\udXXX` в ответе ИС) в UTF-8 не кодируется — тогда объект пишется с экранированием + `\\uXXXX` (ASCII): он остаётся валидным JSON и разбирается в то же значение. + """ + try: + return json.dumps(merged, ensure_ascii=False).encode("utf-8") + except UnicodeEncodeError: + return json.dumps(merged, ensure_ascii=True).encode("ascii") diff --git a/api/src/gateway/smb.py b/api/src/gateway/smb.py new file mode 100644 index 0000000..1db65c9 --- /dev/null +++ b/api/src/gateway/smb.py @@ -0,0 +1,194 @@ +r"""Горячая папка на SMB-шаре платформы (`SCAN_HOTFOLDER_MODE=smb`, D-1, R-5): `SmbHotFolder` поверх `smbclient`. + +Модуль импортируется лениво — только фабрикой `create_hotfolder` в режиме `smb` (R-6). SMB-сессия регистрируется +в lifespan приложения (`app.py`); логин/пароль, если заданы, передаются ещё и в каждый вызов `smbclient`, чтобы +сессия поднималась заново по требованию (после сбоя регистрации при старте или сброса соединения). + +Пути — UNC: временный файл `\\\\\.<имя>.part` переименовывается во входящий +`\\\\\<имя>` (одна шара — rename атомарен, OQ-79); ответ — все `*.json` каталога +`\\\\\\` (OQ-78), готовность — по стабилизации набора (`SettleTracker`). +Ошибки smbprotocol — `SMBOSError` (подкласс `OSError`, но НЕ `FileNotFoundError`): «нет файла/каталога» распознаётся +по `errno == ENOENT`. +""" + +import contextlib +import errno +import ntpath +import time +from typing import Any + +import smbclient +from smbprotocol.header import NtStatus + +from src.gateway.hotfolder import ( + AnswerFile, + MonotonicFn, + SettleTracker, + Snapshot, + is_answer_name, + validate_job_id, + validate_name, +) + +# Ответ есть, но пока не читается: ИС держит файл открытым на запись или файл удаляется — опрос повторится позже +_NOT_READY = frozenset({NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING}) + + +def _has_errno(err: OSError, code: int, exc_type: type[OSError]) -> bool: + """`SMBOSError` несёт `errno`, но не превращается в подкласс `OSError` по нему — проверяем и то, и другое.""" + return isinstance(err, exc_type) or err.errno == code + + +def _is_no_answer(err: OSError) -> bool: + """Ответа нет: каталога/файла нет (ENOENT) или на месте каталога `` — файл (ENOTDIR, T2.2).""" + return _has_errno(err, errno.ENOENT, FileNotFoundError) or _has_errno(err, errno.ENOTDIR, NotADirectoryError) + + +def build_unc_root(host: str, path: str) -> str: + r"""UNC-корень горячей папки. + + `path` — путь на сервере `host` (`share/scan`, разделители `/` или `\`) или уже полный UNC-путь + (`\\server\share\dir` или `//server/share/dir`, как `SMB_PATH` в старом коде) — тогда `host` не используется. + Нужны хотя бы сервер и имя шары, иначе `ValueError`. + """ + if path.startswith(("\\\\", "//")): + unc = ntpath.normpath(path) + else: + server, share_path = host.strip("\\/ "), path.strip("\\/ ") + if not server or not share_path: + raise ValueError(f"неполный адрес SMB: host={host!r}, path={path!r}") + unc = ntpath.normpath(f"\\\\{server}\\{share_path}") + if len([part for part in unc.split("\\") if part]) < 2: # noqa: PLR2004 — сервер и шара + raise ValueError(f"в пути SMB нет сервера и шары: {path!r}") + return unc.rstrip("\\") + + +class SmbHotFolder: + """Горячая папка ИС Сканирование на SMB-шаре. Методы синхронные (блокирующий SMB), семантика как у + `LocalHotFolder`: те же правила имён, атомарная запись `Tmp\\..part` + replace в `Input`, `Tmp` и `Input` + создаются без родителей, корень не создаётся, готовность набора ответа — по стабилизации (`SettleTracker`).""" + + def __init__( # noqa: PLR0913 — адрес и учётные данные D-1 + имена каталогов и готовность, как у LocalHotFolder + self, + host: str, + path: str, + input_dir: str = "Input", + output_dir: str = "Output", + tmp_dir: str = "Tmp", + *, + login: str = "", + password: str = "", + settle_seconds: float = 2, + clock: MonotonicFn = time.monotonic, + ) -> None: + self.root: str = build_unc_root(host, path) + self.input_dir: str = ntpath.join(self.root, input_dir) + self.output_dir: str = ntpath.join(self.root, output_dir) + self.tmp_dir: str = ntpath.join(self.root, tmp_dir) + self._credentials: dict[str, Any] = {"username": login, "password": password} if login else {} + self._settle = SettleTracker(settle_seconds, clock) + + def answer_dir(self, job_id: str) -> str: + r"""UNC-путь каталога ответа ИС: `\`.""" + validate_job_id(job_id) + return ntpath.join(self.output_dir, job_id) + + def answer_path(self, job_id: str) -> str: + r"""UNC-путь основного файла ответа: `\\.json`.""" + return ntpath.join(self.answer_dir(job_id), f"{job_id}.json") + + def put(self, name: str, data: bytes) -> None: + """Записать `..part` в служебный каталог и переименовать в `\\` (`smbclient.replace`: + существующий файл заменяется). Имя проверяется до любого обращения к шаре. При сбое временный файл удаляется + (ошибка удаления не маскирует исходную). Нет корня — `SMBOSError` (ENOENT) от первого `mkdir`. + """ + validate_name(name) + self._ensure_dir(self.tmp_dir) + self._ensure_dir(self.input_dir) + part = ntpath.join(self.tmp_dir, f".{name}.part") + try: + with smbclient.open_file(part, mode="wb", **self._credentials) as fd: + fd.write(data) + smbclient.replace(part, ntpath.join(self.input_dir, name), **self._credentials) + except BaseException: + with contextlib.suppress(Exception): + smbclient.remove(part, **self._credentials) + raise + + def poll(self, job_id: str) -> list[AnswerFile] | None: + r"""Готовый набор `*.json` каталога `\\` по возрастанию имени или `None`: каталога/`Output`/ + json-файлов нет, на месте каталога — файл, набор ещё настаивается, файл исчез или изменился между листингом и + чтением, набор изменился за время чтения, ИС ещё держит файл (`STATUS_SHARING_VIOLATION`, удаляется — + `STATUS_DELETE_PENDING`). Остальные ошибки SMB поднимаются.""" + folder = self.answer_dir(job_id) + try: + snapshot = self._snapshot(folder) + if not self._settle.ready(job_id, snapshot): + return None + files: list[AnswerFile] = [] + for name, size in snapshot: + # Монопольное открытие (share_access по умолчанию 0): пока ИС пишет файл, оно не проходит, и + # недописанный JSON не читается + with smbclient.open_file(ntpath.join(folder, name), mode="rb", **self._credentials) as fd: + data: bytes = fd.read() + if len(data) != size: # файл изменился после листинга — набор настаивается заново + return None + files.append(AnswerFile(name, data)) + # T2.1: за время чтения мог появиться или пропасть файл — такой набор не отдаётся, он настаивается + # заново (состояние settle уже сброшено в момент готовности) + if self._snapshot(folder) != snapshot: + return None + except OSError as err: + if _is_no_answer(err) or getattr(err, "ntstatus", None) in _NOT_READY: + self._settle.forget(job_id) + return None + raise + return files + + @property + def settle_seconds(self) -> float: + """`SCAN_SETTLE_SECONDS`: сколько набор ответа должен не меняться, чтобы считаться готовым.""" + return self._settle.settle_seconds + + def pending_jobs(self) -> int: + """Число заданий с ненастоявшимся набором (для тестов и диагностики).""" + return len(self._settle) + + def healthy(self) -> bool: + """Корень доступен и является каталогом (`smbclient.listdir` проходит). Не бросает исключений.""" + try: + smbclient.listdir(self.root, **self._credentials) + except Exception: + return False + return True + + def _snapshot(self, folder: str) -> Snapshot: + """(имя, размер) `*.json`-файлов каталога по возрастанию имени; размер — из листинга (`end_of_file`), без + отдельного stat. Нет каталога — ENOENT, на его месте файл — ENOTDIR из `scandir` (их обрабатывает `poll`).""" + found = [ + (entry.name, int(entry.smb_info.end_of_file)) + for entry in smbclient.scandir(folder, **self._credentials) + if is_answer_name(entry.name) and entry.is_file() + ] + return tuple(sorted(found)) + + def _ensure_dir(self, path: str) -> None: + """`mkdir` служебного или входящего каталога, как `os.makedirs(exist_ok=True)` без родителей. + + «Уже существует» (EEXIST) — не ошибка. Другая ошибка (например, `STATUS_ACCESS_DENIED`: учётке нельзя + создавать подкаталоги в корне, хотя писать в каталог можно) — тоже не ошибка, если каталог уже есть + (`smbclient.path.isdir`); иначе поднимается исходная ошибка `mkdir`. ENOENT (нет корня) — сразу наружу. + """ + try: + smbclient.mkdir(path, **self._credentials) + except OSError as err: + if _has_errno(err, errno.EEXIST, FileExistsError): + return + if _has_errno(err, errno.ENOENT, FileNotFoundError) or not self._dir_exists(path): + raise + + def _dir_exists(self, path: str) -> bool: + try: + return bool(smbclient.path.isdir(path, **self._credentials)) + except Exception: + return False diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py deleted file mode 100644 index 36fbcbd..0000000 --- a/api/src/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0.""" -from .base import Base, UUIDPrimaryMixin - -__all__ = ["Base", "UUIDPrimaryMixin"] diff --git a/api/src/models/base.py b/api/src/models/base.py deleted file mode 100644 index 325b5bb..0000000 --- a/api/src/models/base.py +++ /dev/null @@ -1,14 +0,0 @@ -import uuid - -from sqlalchemy import MetaData -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column - - -class Base(DeclarativeBase): - metadata = MetaData(schema="dkb") - - -@declarative_mixin -class UUIDPrimaryMixin: - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py new file mode 100644 index 0000000..2b2f180 --- /dev/null +++ b/api/src/normalizer/__init__.py @@ -0,0 +1,166 @@ +"""Нормализатор ответа ИС Сканирование (D-5, R-12, R-25, решения №57, №58, №60; slice-0-skeleton). + +Нормализатор получает склеенный ответ ИС (`raw: object`, D-4): `{"job_id", "files": [{"name", "response", +"parse_error"}, ...]}` по возрастанию имени — `json.loads` каждого файла уже выполнен склейкой +(`src.gateway.merge.merge_answers`). Результат — `NormalizeOutcome`, из которого воркер собирает конверт +(`warnings` -> `metrics.warnings`) и, для done/needs_check, файл `results/{file_id}/{job_id}/result.json`. + +В Срезе 0 единственная реализация — `NullNormalizer`: сводит ошибки по файлам (успех файла — `error.code == ""`), +при хотя бы одном успешном файле отвечает `NOT_IMPLEMENTED`. Настоящий разбор `data_ocr` (§11 `05-domain-rules.md`) +появится в Срезе 3 новой реализацией протокола `Normalizer`. +""" + +from dataclasses import dataclass +from typing import Protocol, Self, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument + +__all__ = ["NOT_IMPLEMENTED_MESSAGE", "NOT_OBJECT_MESSAGE", "NormalizeOutcome", "Normalizer", "NullNormalizer"] + +NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3" +NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом" +NOT_JSON_MESSAGE = "ответ ИС не разбирается как JSON" +NOT_MERGED_MESSAGE = "склеенный ответ ИС не соответствует структуре {job_id, files: [...]}" +NO_FILES_MESSAGE = "в ответе ИС нет ни одного файла" +BAD_ENTRY_MESSAGE = "элемент files не соответствует структуре {name, response, parse_error}" + + +class NormalizeOutcome(BaseModel): + """Итог нормализации: статус, документы, ошибка, метрики, предупреждения. + + `warnings` — строки для `metrics.warnings` конверта (R-25; внутренний тип, контракт не меняется): воркер + опускает поле, если список пуст. Инварианты: `status == error` <=> `error` задана; для done/needs_check + `documents` непусты (как в схеме файла результата), для error — пусты. Статус `processing` исключён: он не бывает + итогом. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + status: ResultStatus + documents: list[StatementDocument] = Field(default_factory=list) + error: MessageError | None = None + metrics: Metrics | None = None + warnings: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _check_invariants(self) -> Self: + if self.status is ResultStatus.ERROR: + if self.error is None: + raise ValueError("status=error требует error") + if self.documents: + raise ValueError("status=error не может содержать documents") + else: + if self.error is not None: + raise ValueError(f"status={self.status.value} не может содержать error") + if not self.documents: + raise ValueError(f"status={self.status.value} требует непустой documents") + return self + + +@runtime_checkable +class Normalizer(Protocol): + """Превращает склеенный ответ ИС (D-4) в `NormalizeOutcome`; не бросает исключений на любом входе.""" + + def normalize(self, raw: object) -> NormalizeOutcome: ... + + +@dataclass(frozen=True) +class _FileError: + """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`.""" + + name: str + code: ErrorCode # error.code сводки, если этот файл — первый ошибочный + source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT) + message: str # сообщение файла: строка warnings + summary: str # error.message сводки, если этот файл — первый ошибочный + + @property + def warning(self) -> str: + return f"{self.name}: {self.source_code}: {self.message}" + + +def _invalid_file(name: str, message: str) -> _FileError: + code = ErrorCode.INVALID_FORMAT + return _FileError(name, code, code.value, message, message) + + +def _file_error(index: int, entry: object) -> _FileError | None: + """Ошибка файла склейки или `None`, если файл успешен. + + Успех — `response.error.code == ""` (реальный успех ИС: `error = {"code": "", "message": ""}`); отсутствующий, + `null` или не-объектный `error`, а также `error` без кода (`code` нет или `null`) ошибкой не считаются (№57). + Ошибочен файл с непустым `error.code`, с `parse_error`, с корнем ответа не-объектом (`INVALID_FORMAT`). + """ + if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not entry["name"]: + return _invalid_file(f"files[{index}]", BAD_ENTRY_MESSAGE) + name: str = entry["name"] + parse_error = entry.get("parse_error") + if parse_error is not None: + return _invalid_file(name, f"{NOT_JSON_MESSAGE}: {parse_error}") + response = entry.get("response") + if not isinstance(response, dict): + return _invalid_file(name, NOT_OBJECT_MESSAGE) + raw_error = response.get("error") + if not isinstance(raw_error, dict) or raw_error.get("code") in (None, ""): + return None + return _response_error(name, raw_error) + + +def _response_error(name: str, raw_error: dict[str, object]) -> _FileError: + """Ошибка ИС с непустым `error.code`: код контракта либо `UNKNOWN_ERROR` с исходным кодом в сообщении.""" + raw_code = raw_error.get("code") + source_code = raw_code if isinstance(raw_code, str) else repr(raw_code) + raw_message = raw_error.get("message") + if isinstance(raw_message, str) and raw_message: + message = raw_message + else: + # пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть + note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})" + message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}" + + try: + code = ErrorCode(raw_code) if isinstance(raw_code, str) else None + except ValueError: + code = None + if code is not None: + return _FileError(name, code, source_code, message, message) + # код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12) + summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}" + return _FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary) + + +class NullNormalizer: + """Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет. + + Все файлы ошибочны — `status = error` с кодом и сообщением первого ошибочного файла (порядок `files` — по + возрастанию имени, D-4); есть хотя бы один успешный — `NOT_IMPLEMENTED`. В обоих случаях каждый ошибочный файл — + строка `warnings` `<имя>: <код ИС>: <сообщение>`. Структура склейки нарушена — `INVALID_FORMAT`. + """ + + def normalize(self, raw: object) -> NormalizeOutcome: + if not isinstance(raw, dict) or not isinstance(raw.get("files"), list): + return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE) + files: list[object] = raw["files"] + if not files: + return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE) + errors: list[_FileError] = [] + succeeded = 0 + for index, entry in enumerate(files): + error = _file_error(index, entry) + if error is None: + succeeded += 1 + else: + errors.append(error) + warnings = [error.warning for error in errors] + if succeeded: + return _error_outcome(ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE, warnings) + first = errors[0] + return _error_outcome(first.code, first.summary, warnings) + + +def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = None) -> NormalizeOutcome: + return NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code=code, message=message), warnings=warnings or [] + ) diff --git a/api/src/repositories/__init__.py b/api/src/repositories/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api/src/scan_emulator/__init__.py b/api/src/scan_emulator/__init__.py new file mode 100644 index 0000000..e1fadfe --- /dev/null +++ b/api/src/scan_emulator/__init__.py @@ -0,0 +1,277 @@ +"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator). + +Воспроизводит протокол горячих папок: для нового файла `_.` во входящем каталоге через задержку кладёт +каталог `//` с набором эталонных ответов из `SCAN_FIXTURES_DIR` (все `.json` -> `.json` и +`_.json` -> `_.json`) и переименовывает входящий файл в `….done`. Набор собирается во +временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и ``) и появляется в `` одним rename +каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не +настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, +тем самым тестируя и его. +""" + +import json +import math +import os +import re +import shutil +import threading +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values +from loguru import logger + +from src.gateway.hotfolder import LocalHotFolder + +__all__ = ["EmulatorSettings", "process_once", "run"] + +# `_`: канонический UUID (36 символов) и непустое имя после `_` +_INBOX_NAME = re.compile( + r"^(?P[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_(?P.+)$" +) +_DONE_SUFFIX = ".done" +_ERROR_CODE = "INVALID_DOCUMENT" + + +def _read_float(env: Mapping[str, str], name: str, default: float, *, positive: bool) -> float: + raw = env.get(name) + if raw is None or raw.strip() == "": + return default + try: + value = float(raw) + except ValueError: + raise ValueError(f"{name}: ожидается число, получено {raw!r}") from None + if not math.isfinite(value) or value < 0 or (positive and value == 0): + raise ValueError( + f"{name}: ожидается {'положительное' if positive else 'неотрицательное'} число, получено {raw!r}" + ) + return value + + +@dataclass(frozen=True) +class EmulatorSettings: + """Настройки эмулятора из переменных окружения (имена совпадают с `.env.example`).""" + + hotfolder_path: Path + input_dir: str = "Input" + output_dir: str = "Output" + tmp_dir: str = "Tmp" + fixtures_dir: Path = Path("../../fixtures/ocr") + delay: float = 2.0 + poll_interval: float = 1.0 + + @classmethod + def from_env(cls, environ: Mapping[str, str] | None = None) -> "EmulatorSettings": + """Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть). + + `SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output, + `SCAN_TMP_DIR`=Tmp (служебный каталог для сборки набора ответа), `SCAN_FIXTURES_DIR`=../../fixtures/ocr, + `SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0). + """ + if environ is None: + env_file = os.environ.get("ENV_FILE", ".env") + file_values = {k: v for k, v in dotenv_values(env_file).items() if v is not None} + env: Mapping[str, str] = {**file_values, **os.environ} + else: + env = environ + path = env.get("SCAN_HOTFOLDER_PATH", "").strip() + if not path: + raise ValueError("SCAN_HOTFOLDER_PATH не задан: укажите корень горячей папки (каталог с Input/ и Output/)") + return cls( + hotfolder_path=Path(path), + input_dir=env.get("SCAN_INPUT_DIR") or "Input", + output_dir=env.get("SCAN_OUTPUT_DIR") or "Output", + tmp_dir=env.get("SCAN_TMP_DIR") or "Tmp", + fixtures_dir=Path(env.get("SCAN_FIXTURES_DIR") or "../../fixtures/ocr"), + delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False), + poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True), + ) + + +_EMPTY_FIELD: dict[str, Any] = {"form_name": "", "confidence": 0} + + +def _error_answer(job_id: str, message: str) -> dict[str, Any]: + """Ответ-ошибка в форме реальных ответов Content AI (`aep_5.json`): `data_ocr` — объект с пустыми полями (R-28).""" + data_ocr: dict[str, Any] = { + field: dict(_EMPTY_FIELD) for field in ("label", "organization", "inn", "units", "date") + } + data_ocr["table_structure"] = [] + return {"file_uid": job_id, "error": {"code": _ERROR_CODE, "message": message}, "data_ocr": data_ocr} + + +def _select_fixtures(fixtures: Path, stem: str) -> list[tuple[str, Path]]: + """Фикстуры задания: `.json` и `_*.json` -> `(suffix, путь)`, `suffix` пуст для `.json`. + + Имя сравнивается буквально, без glob; расширение `.json` — без учёта регистра. Не префикс `*`: `aep_30.json` + не входит в набор `aep_3`. Результат отсортирован по имени файла; при совпадении `suffix` берётся первый файл. + """ + if not stem: + return [] + try: + names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file()) + except OSError: + return [] + selected: dict[str, Path] = {} + for fixture_name in names: + base = fixture_name[: -len(".json")] + if base == stem: + suffix = "" + elif base.startswith(f"{stem}_") and len(base) > len(stem) + 1: + suffix = base[len(stem) + 1 :] + else: + continue + selected.setdefault(suffix, fixtures / fixture_name) + return list(selected.items()) + + +def _answer_file_name(job_id: str, suffix: str) -> str: + return f"{job_id}_{suffix}.json" if suffix else f"{job_id}.json" + + +def _build_answers(job_id: str, name: str, fixtures: Path) -> dict[str, dict[str, Any]]: + """Набор ответов ИС на файл `name` (без префикса uuid): `{имя файла ответа: ответ}`. + + Каждый ответ — фикстура с подставленным `file_uid`; повреждённая фикстура даёт на своём месте ответ-ошибку; если + фикстур нет совсем — один ответ `.json` с `INVALID_DOCUMENT`. + """ + selected = _select_fixtures(fixtures, Path(name).stem) + if not selected: + return {_answer_file_name(job_id, ""): _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах")} + answers: dict[str, dict[str, Any]] = {} + for suffix, fixture in selected: + try: + data = json.loads(fixture.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("корень не объект JSON") + except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError + logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE) + answers[_answer_file_name(job_id, suffix)] = _error_answer( + job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран" + ) + continue + answers[_answer_file_name(job_id, suffix)] = { + "file_uid": job_id, + **{key: value for key, value in data.items() if key != "file_uid"}, + } + return answers + + +def _write_json(path: Path, answer: dict[str, Any]) -> None: + path.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _publish_answers(hotfolder: LocalHotFolder, job_id: str, answers: dict[str, dict[str, Any]]) -> None: + """Атомарная публикация набора (R-26): файлы пишутся в `/..answer`, затем каталог переименовывается в + `//`. Служебный и исходящий каталоги лежат в одном корне (одна ФС), поэтому rename атомарен, а + каталог ответа либо отсутствует, либо содержит весь набор. Каталог ответа, оставшийся от прошлого запуска, + заменяется (единственный момент, когда его на время нет). При сбое временный каталог удаляется.""" + target = hotfolder.answer_dir(job_id) + hotfolder.tmp_dir.mkdir(exist_ok=True) + hotfolder.output_dir.mkdir(parents=True, exist_ok=True) + staging = hotfolder.tmp_dir / f".{job_id}.answer" + shutil.rmtree(staging, ignore_errors=True) # остаток прерванной сборки + try: + staging.mkdir() + for file_name, answer in answers.items(): + _write_json(staging / file_name, answer) + if target.exists(): + shutil.rmtree(target) + os.replace(staging, target) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def process_once( # noqa: PLR0913 — корень, фикстуры, задержка + имена трёх служебных каталогов + root: Path | str, + fixtures: Path | str, + delay: float, + *, + input_dir: str = "Input", + output_dir: str = "Output", + tmp_dir: str = "Tmp", +) -> list[str]: + """Один проход по входящему каталогу; возвращает uuid обработанных файлов. + + Берёт новые `_.` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход + ждёт `delay` секунд, для каждого файла публикует набор ответов `//` (атомарно, через `tmp_dir`) и + только затем переименовывает входящий файл в `….done`. Имена не по шаблону `_` игнорируются (одно + предупреждение за проход). Нет входящего каталога — пустой результат. + """ + hotfolder = LocalHotFolder(root, input_dir, output_dir, tmp_dir) + fixtures_dir = Path(fixtures) + try: + entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name) + except FileNotFoundError: + return [] + pending: list[tuple[Path, str, str]] = [] + foreign: list[str] = [] + for entry in entries: + if entry.name.startswith(".") or entry.name.endswith(_DONE_SUFFIX) or not entry.is_file(): + continue + match = _INBOX_NAME.match(entry.name) + if match is None: + foreign.append(entry.name) + continue + pending.append((entry, match["uuid"], match["name"])) + if foreign: + logger.warning("Эмулятор: файлы не по шаблону _<имя> пропущены: {}", ", ".join(foreign)) + if not pending: + return [] + if delay > 0: + time.sleep(delay) + processed: list[str] = [] + for entry, job_id, name in pending: + try: + _publish_answers(hotfolder, job_id, _build_answers(job_id, name, fixtures_dir)) + os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX)) + except FileNotFoundError: + logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name) + continue + processed.append(job_id) + logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_dir(job_id)) + return processed + + +def _count_fixtures(fixtures: Path) -> int: + return sum(1 for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file()) + + +def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None: + """Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено + `max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл.""" + hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir) + logger.info( + "Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с", + hotfolder.root, + hotfolder.input_dir, + hotfolder.output_dir, + settings.delay, + settings.poll_interval, + ) + if settings.fixtures_dir.is_dir(): + logger.info("Фикстуры: {}, фикстур: {}", settings.fixtures_dir, _count_fixtures(settings.fixtures_dir)) + else: + logger.warning( + "Каталог фикстур {} не найден: на все файлы будет отвечать {}", settings.fixtures_dir, _ERROR_CODE + ) + iterations = 0 + while not stop.is_set() and (max_iterations is None or iterations < max_iterations): + try: + process_once( + settings.hotfolder_path, + settings.fixtures_dir, + settings.delay, + input_dir=settings.input_dir, + output_dir=settings.output_dir, + tmp_dir=settings.tmp_dir, + ) + except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят + logger.exception("Эмулятор: сбой прохода по входящему каталогу") + iterations += 1 + stop.wait(settings.poll_interval) + logger.info("Эмулятор остановлен") diff --git a/api/src/scan_emulator/__main__.py b/api/src/scan_emulator/__main__.py new file mode 100644 index 0000000..e829fb1 --- /dev/null +++ b/api/src/scan_emulator/__main__.py @@ -0,0 +1,46 @@ +"""Точка входа эмулятора: `python -m src.scan_emulator` (make emulator).""" + +import signal +import sys +import threading +from types import FrameType + +from loguru import logger + +from src.scan_emulator import EmulatorSettings, run + + +def main(stop: threading.Event | None = None) -> int: + """Запуск цикла эмулятора; код возврата процесса. SIGINT/SIGTERM — штатная остановка (0). + + `stop` — для тестов: событие остановки вместо сигналов. Нет `SCAN_HOTFOLDER_PATH` или корня горячей папки — ошибка + и ненулевой код (эмулятор не создаёт корень сам: его создаёт и монтирует окружение). + """ + try: + settings = EmulatorSettings.from_env() + except ValueError as exc: + logger.error("Эмулятор не запущен: {}", exc) + return 2 + if not settings.hotfolder_path.is_dir(): + logger.error( + "Эмулятор не запущен: корень горячей папки {} не существует или не каталог", settings.hotfolder_path + ) + return 2 + + event = stop if stop is not None else threading.Event() + + def _on_signal(signum: int, _frame: FrameType | None) -> None: + logger.info("Получен сигнал {}, останавливаюсь", signal.Signals(signum).name) + event.set() + + previous = {sig: signal.signal(sig, _on_signal) for sig in (signal.SIGINT, signal.SIGTERM)} + try: + run(settings, event) + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e69de29..94f60e8 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -0,0 +1,54 @@ +"""Общие фикстуры. Окружение тестов — `api/.env.test`: его подключает pytest.ini (`ENV_FILE=.env.test`, путь от +каталога api/), поэтому `import src.app` (он создаёт приложение) работает без своей настройки окружения +и без инфраструктуры.""" + +import sys +from collections.abc import Iterator + +import pytest +from loguru import logger + + +@pytest.fixture(autouse=True) +def _fresh_settings(): + """get_settings кэшируется (lru_cache): сбрасываем кэш до и после каждого теста.""" + # Импорт отложен намеренно: модули тестов импортируют `src.app`, а он создаёт приложение и читает окружение + from src.core.config import get_settings # noqa: PLC0415 + + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture +def minimal_env_file(tmp_path, monkeypatch): + """Env-файл только с обязательными настройками (без `.env.test`): для проверки значений по умолчанию.""" + env_file = tmp_path / "minimal.env" + env_file.write_text( + "\n".join( + [ + "OPENBAO__MINIO__MINIO_URL=localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin", + "OPENBAO__MINIO__MINIO_BUCKET=arm-ka", + "OPENBAO__NATS__NATS_URL=nats://localhost:4222", + "SCAN_HOTFOLDER_PATH=/tmp/scan-gateway-test-hotfolder", + ] + ) + ) + monkeypatch.setenv("ENV_FILE", str(env_file)) + return env_file + + +@pytest.fixture +def log_lines() -> Iterator[list[str]]: + """Перехват loguru: строки `LEVEL|сообщение` вместо stderr (pytest запускается с --capture=no, вывод чистый). + + Обработчик stderr по умолчанию восстанавливается после теста. + """ + lines: list[str] = [] + logger.remove() + logger.add(lambda message: lines.append(str(message)), level="DEBUG", format="{level}|{message}") + yield lines + logger.remove() + logger.add(sys.stderr) diff --git a/api/tests/integration/test_nats_minio_integration.py b/api/tests/integration/test_nats_minio_integration.py new file mode 100644 index 0000000..d1f0810 --- /dev/null +++ b/api/tests/integration/test_nats_minio_integration.py @@ -0,0 +1,213 @@ +"""Интеграция `src.core.nats` и `src.core.clients` с compose-инфраструктурой (`make up`): NATS без JetStream на +127.0.0.1:4222, MinIO на 127.0.0.1:9000 (бакет `arm-ka`); параметры — `api/.env.test`. + +Недоступная инфраструктура — падение с понятным сообщением, не skip. Общий контейнер NATS не перезапускается: +сценарий переподключения покрыт unit-тестом колбэков (tests/unit/test_nats.py). Чужой трафик на тех же subject'ах +отсекается уникальным `job_id` теста; ключи MinIO — под уникальным префиксом `itest//` и удаляются. +""" + +import asyncio +import json +import socket +import uuid +from collections.abc import AsyncIterator +from typing import Any +from urllib.parse import urlparse + +import pytest +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract import ( + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ServiceInfo, + build_ack_message, +) +from src.core.clients import S3ObjectNotFoundError, S3Storage +from src.core.config import Settings, get_settings +from src.core.nats import REQUEST_QUEUE_GROUP, NatsGateway + +pytestmark = pytest.mark.integration + +CONNECT_WAIT = 5.0 # секунд на подключение к NATS (nats-py ждёт сервер бесконечно — ограничиваем снаружи) +DELIVERY_WAIT = 5.0 + + +def _require_port(host: str, port: int, what: str) -> None: + try: + with socket.create_connection((host, port), timeout=2): + return + except OSError as exc: + pytest.fail(f"{what} недоступен на {host}:{port} ({exc!r}): поднимите инфраструктуру `make up`") + + +@pytest.fixture +def settings() -> Settings: + settings = get_settings() + nats_url = urlparse(settings.OPENBAO.NATS.NATS_URL) + _require_port(nats_url.hostname or "127.0.0.1", nats_url.port or 4222, "NATS") + host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":") + _require_port(host, int(port or 9000), "MinIO") + return settings + + +def _no_jetstream_client() -> NATS: + """Настоящий клиент nats-py, у которого вызов `jetstream()` — ошибка теста (D-8: JetStream не используется).""" + client = NATS() + + def forbidden(**_: Any) -> None: + raise AssertionError("JetStream не используется (D-8)") + + client.jetstream = forbidden # type: ignore[method-assign] + return client + + +async def _connected_gateway(settings: Settings) -> NatsGateway: + gateway = NatsGateway(settings, client_factory=_no_jetstream_client, retry_delay=0.1) + try: + connected = await asyncio.wait_for(gateway.connect(), CONNECT_WAIT) + except TimeoutError: + await gateway.close() + pytest.fail(f"Нет подключения к NATS {settings.OPENBAO.NATS.NATS_URL} за {CONNECT_WAIT} с: `make up`") + assert connected is True + assert gateway.is_connected is True + return gateway + + +@pytest.fixture +async def gateways(settings: Settings) -> AsyncIterator[list[NatsGateway]]: + created: list[NatsGateway] = [] + yield created + for gateway in created: + await gateway.close() + + +async def _wait_until(predicate: Any, timeout: float = DELIVERY_WAIT) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate() and loop.time() < deadline: + await asyncio.sleep(0.05) + + +# --- NATS --------------------------------------------------------------------------------------------------------- + + +async def test_subscribe_and_publish_with_msg_id_without_jetstream(settings: Settings, gateways: list[NatsGateway]): + subscriber = await _connected_gateway(settings) + publisher = await _connected_gateway(settings) + gateways += [subscriber, publisher] + job_id = str(uuid.uuid4()) + received: list[Msg] = [] + + async def on_request(msg: Msg) -> None: + if json.loads(msg.data).get("job_id") == job_id: # чужие сообщения на общем subject пропускаем + received.append(msg) + + await subscriber.subscribe_requests(on_request) + await publisher.publish(IMAGE_REQUEST_SUBJECT, json.dumps({"job_id": job_id}).encode(), f"{job_id}:1") + await _wait_until(lambda: len(received) >= 1) + + assert len(received) == 1 + [msg] = received + assert msg.subject == IMAGE_REQUEST_SUBJECT + assert msg.headers is not None + assert msg.headers[NATS_MSG_ID_HEADER] == f"{job_id}:1" + + +async def test_queue_group_delivers_each_message_to_exactly_one_member(settings: Settings, gateways: list[NatsGateway]): + members = [await _connected_gateway(settings), await _connected_gateway(settings)] + publisher = await _connected_gateway(settings) + gateways += [*members, publisher] + run_id = str(uuid.uuid4()) + deliveries: list[tuple[int, str]] = [] + + def collector(member: int) -> Any: + async def on_request(msg: Msg) -> None: + body = json.loads(msg.data) + if body.get("run_id") == run_id: + assert msg.headers is not None + deliveries.append((member, msg.headers[NATS_MSG_ID_HEADER])) + + return on_request + + for index, member in enumerate(members): + await member.subscribe_requests(collector(index), queue=REQUEST_QUEUE_GROUP) + + total = 40 + sent = {f"{run_id}-{n}:1" for n in range(total)} + for msg_id in sorted(sent): + await publisher.publish(IMAGE_REQUEST_SUBJECT, json.dumps({"run_id": run_id}).encode(), msg_id) + await _wait_until(lambda: len(deliveries) >= total) + await asyncio.sleep(0.3) # лишние (дублирующие) доставки успели бы прийти + + delivered_ids = [msg_id for _, msg_id in deliveries] + assert len(deliveries) == total + assert set(delivered_ids) == sent # каждое сообщение — ровно одному участнику группы, без дублей + assert {member for member, _ in deliveries} == {0, 1} # сообщения распределены между обоими участниками + + +async def test_publish_result_reaches_result_subject_with_ack_msg_id(settings: Settings, gateways: list[NatsGateway]): + gateway = await _connected_gateway(settings) + gateways.append(gateway) + listener = NATS() + await asyncio.wait_for(listener.connect(settings.OPENBAO.NATS.NATS_URL), CONNECT_WAIT) + job_id, file_id = uuid.uuid4(), uuid.uuid4() + received: list[Msg] = [] + + async def on_result(msg: Msg) -> None: + if json.loads(msg.data).get("job_id") == str(job_id): + received.append(msg) + + try: + await listener.subscribe(RESULT_SUBJECT, cb=on_result) + await listener.flush() + message = build_ack_message(job_id, file_id, ServiceInfo(name="scan-gateway", version="1.0.0")) + await gateway.publish_result(message, attempt=1) + await _wait_until(lambda: len(received) >= 1) + finally: + await listener.drain() + + assert len(received) == 1 + assert received[0].headers is not None + assert received[0].headers[NATS_MSG_ID_HEADER] == f"ack:{job_id}:1" + assert json.loads(received[0].data) == message.to_wire() + + +# --- S3 ----------------------------------------------------------------------------------------------------------- + + +@pytest.fixture +async def storage(settings: Settings) -> AsyncIterator[tuple[S3Storage, str, list[str]]]: + storage = S3Storage.from_settings(settings.OPENBAO.MINIO) + prefix = f"itest/{uuid.uuid4()}" + written: list[str] = [] + yield storage, prefix, written + for key in written: + await storage.client.remove_object(storage.bucket, key) + await storage.close() + + +async def test_s3_put_and_get_roundtrip(storage: tuple[S3Storage, str, list[str]]): + s3, prefix, written = storage + json_key, bytes_key = f"{prefix}/raw.json", f"{prefix}/file.pdf" + written += [json_key, bytes_key] + payload = {"error": None, "message": "Бухгалтерский баланс", "n": 1} + + await s3.put_json(json_key, payload) + await s3.put_bytes(bytes_key, b"%PDF-1.7 \x00\xff", content_type="application/pdf") + + raw = await s3.get_bytes(json_key) + assert "Бухгалтерский баланс".encode() in raw # UTF-8 без экранирования + assert json.loads(raw) == payload + assert await s3.get_bytes(bytes_key, bucket=s3.bucket) == b"%PDF-1.7 \x00\xff" + stat = await s3.client.stat_object(s3.bucket, json_key) + assert stat.content_type == "application/json" + + +async def test_s3_get_missing_key_raises_not_found(storage: tuple[S3Storage, str, list[str]]): + s3, prefix, _ = storage + with pytest.raises(S3ObjectNotFoundError) as exc_info: + await s3.get_bytes(f"{prefix}/missing.pdf") + assert exc_info.value.key == f"{prefix}/missing.pdf" diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py new file mode 100644 index 0000000..79ed4e8 --- /dev/null +++ b/api/tests/integration/test_roundtrip.py @@ -0,0 +1,490 @@ +"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10). + +Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `NullNormalizer`) и эмулятор ИС Сканирование +(`src.scan_emulator.run` в потоке) работают в процессе теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из +`api/.env.test` (`make up`), горячая папка — tmp-каталог. + +Сценарии (реальные фикстуры `fixtures/ocr`: OQ-76/77/78; эмулятор кладёт в `Output//` весь набор ответа): +1. `aep_5.PDF` (ответ-ошибка `INVALID_DOCUMENT`) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и + `raw_s3_key`; повтор запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет; +2. `aep_3.pdf` (набор из двух файлов: `.json` — успех, `_ofr.json` — `INVALID_DOCUMENT`) → ack, затем + `NOT_IMPLEMENTED` с `metrics.warnings` про `_ofr.json`; `raw/{job_id}.json` — оба файла (склейка, D-4); +3. `aep_6.pdf` (один успешный файл) → `NOT_IMPLEMENTED` без `metrics`; +4. `ok_doc.pdf` (синтетический успешный ответ) → ack, затем `NOT_IMPLEMENTED` с `raw_s3_key`; +5. `unknown.pdf` (нет фикстуры) → ack, затем `INVALID_DOCUMENT` эмулятора. +Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; склеенный сырой ответ в +MinIO содержит ровно те файлы, что лежат в каталоге ответа, в порядке возрастания имени, с телами байт в байт; +входящий файл совпадает с объектом S3. Готовность набора — по settle (`SETTLE_SECONDS`, 0.2 с). + +Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `recognition.result` +отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-/…`, `raw/.json`) и удаляются. +Подписка шлюза идёт в queue group `scan-gateway`: если в этом NATS живёт другой экземпляр scan-gateway, он может +забрать запрос теста (тест тогда упадёт по таймауту ожидания ack). Перед запуском занятость группы можно проверить +мониторингом NATS: `curl -s 'http://127.0.0.1:8222/subsz?subs=1'` — подписчиков `recognition.image.request` быть +не должно. Автоматической защиты в тесте нет намеренно (она была бы гонкой). Недоступная инфраструктура — падение +теста с понятным сообщением, не skip; нет репозитория fixtures — skip сценариев на реальных данных (маркер `fixtures`). +""" + +import asyncio +import json +import shutil +import socket +import threading +import uuid +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, suppress +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx +import pytest +from fastapi import FastAPI +from jsonschema import Draft202012Validator, FormatChecker +from miniopy_async.error import S3Error +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.app import create_app +from src.contract import IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, request_msg_id +from src.core.clients import JSON_CONTENT_TYPE, S3Storage +from src.core.config import Settings, get_settings +from src.normalizer import NOT_IMPLEMENTED_MESSAGE +from src.scan_emulator import EmulatorSettings, run + +pytestmark = [ + pytest.mark.integration, + pytest.mark.usefixtures("log_lines"), + # miniopy-async отдаёт aiohttp тело объекта байтами; на теле > 1 МиБ (реальный скан aep_5.PDF) aiohttp пишет + # ResourceWarning «Sending a large body directly…». Предупреждение библиотеки, по умолчанию Python его не + # показывает; здесь отключено точечно, чтобы прогон с `-W error` проверял остальные предупреждения + pytest.mark.filterwarnings("ignore:Sending a large body directly with raw bytes:ResourceWarning"), +] + +API_DIR = Path(__file__).resolve().parents[2] +CONTRACTS_DIR = API_DIR / "contracts" +# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в unit-тестах +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" + +CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения) +SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария +QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно +EMULATOR_DELAY = 0.5 # секунд «распознавания»: окно, в котором повторный запрос приходит во время ожидания ИС +POLL_SECONDS = 0.1 # SCAN_POLL_SECONDS шлюза +SETTLE_SECONDS = 0.2 # SCAN_SETTLE_SECONDS шлюза: набор ответа готов, когда не менялся столько секунд +TIMEOUT_SECONDS = 15 # SCAN_TIMEOUT_SECONDS: зависший эмулятор даёт TIMEOUT раньше, чем истечёт SCENARIO_WAIT + +MESSAGE_VALIDATOR = Draft202012Validator( + json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")), + format_checker=FormatChecker(), +) +REQUEST_EXAMPLE: dict[str, Any] = json.loads( + (CONTRACTS_DIR / "examples" / "request-image.json").read_text(encoding="utf-8") +) + +# Синтетический успешный ответ ИС (R-10): копия `aep_5.json` с `error = null` и непустым `data_ocr` по форме +# docs/context/05-domain-rules.md §11 (документы с label/organization/inn/units/date/table_structure.rows) +SUCCESS_DATA_OCR: list[dict[str, Any]] = [ + { + "pages": [1], + "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.4}, + "organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.1}, + "inn": {"inn": "7701234567", "confidence": 99.2}, + "units": {"measurement": "тыс. руб.", "confidence": 96.0}, + "date": {"date": "30.09.2025", "confidence": 95.5}, + "table_structure": { + "rows": [ + { + "row_index": 0, + "cells": [ + {"column_index": 0, "value": "Наименование показателя", "confidence": 99.0}, + {"column_index": 1, "value": "Код", "confidence": 99.0}, + {"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 97.0}, + {"column_index": 3, "value": "На 31 декабря 2024 г.", "confidence": 97.0}, + ], + }, + { + "row_index": 1, + "cells": [ + {"column_index": 0, "value": "БАЛАНС", "confidence": 98.0}, + {"column_index": 1, "value": "1600", "confidence": 99.0}, + {"column_index": 2, "value": "1 234 567", "confidence": 96.5}, + {"column_index": 3, "value": "(12 345)", "confidence": 95.0}, + ], + }, + ] + }, + } +] + + +def _require_port(host: str, port: int, what: str) -> None: + try: + with socket.create_connection((host, port), timeout=2): + return + except OSError as exc: + pytest.fail(f"{what} недоступен на {host}:{port} ({exc!r}): поднимите инфраструктуру `make up`") + + +def _real_fixture(stem: str, suffix: str) -> Path: + """Файл реальной фикстуры без учёта регистра расширения (`aep_5.PDF`); нет репозитория fixtures — skip.""" + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + for path in sorted(REAL_FIXTURES.iterdir()): + if path.stem == stem and path.suffix.lower() == suffix: + return path + pytest.fail(f"в {REAL_FIXTURES} нет {stem}{suffix} (без учёта регистра расширения)") + + +@dataclass +class Envelope: + msg_id: str + body: dict[str, Any] + + +@dataclass +class Roundtrip: + """Запущенный шлюз (lifespan приложения), эмулятор и слушатель `recognition.result`.""" + + settings: Settings + app: FastAPI + root: Path + s3: S3Storage + publisher: NATS + received: list[Msg] = field(default_factory=list) + jobs: set[str] = field(default_factory=set) + keys: list[str] = field(default_factory=list) + + @property + def input_dir(self) -> Path: + return self.root / self.settings.SCAN_INPUT_DIR + + def answer_dir(self, job_id: str) -> Path: + return self.root / self.settings.SCAN_OUTPUT_DIR / job_id + + def answer_names(self, job_id: str) -> list[str]: + """Имена всех `*.json` каталога ответа по возрастанию (каталог шлюз не удаляет).""" + return sorted(p.name for p in self.answer_dir(job_id).glob("*.json")) + + # --- запрос --------------------------------------------------------------------------------------------------- + async def request(self, original_name: str, data: bytes) -> dict[str, Any]: + """Кладёт исходник в MinIO под уникальным ключом и строит запрос по `examples/request-image.json`.""" + job_id, file_id = str(uuid.uuid4()), str(uuid.uuid4()) + key = f"uploads/itest-{uuid.uuid4()}/{file_id}/{job_id}/{original_name}" + self.jobs.add(job_id) + self.keys += [key, f"raw/{job_id}.json"] + await self.s3.put_bytes(key, data, content_type="application/pdf") + deadline = datetime.now(UTC) + timedelta(seconds=60) + return { + **REQUEST_EXAMPLE, + "job_id": job_id, + "file_id": file_id, + "original_name": original_name, + "s3": {"bucket": self.s3.bucket, "key": key}, + "reply_deadline": deadline.isoformat().replace("+00:00", "Z"), + "attempt": 1, + } + + async def publish(self, body: dict[str, Any]) -> None: + """Публикация как в ядре: `recognition.image.request`, `Nats-Msg-Id = :`.""" + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers = {NATS_MSG_ID_HEADER: request_msg_id(body["job_id"], body["attempt"])} + await self.publisher.publish(IMAGE_REQUEST_SUBJECT, payload, headers=headers) + await self.publisher.flush() + + # --- конверты ------------------------------------------------------------------------------------------------- + def envelopes(self, job_id: str) -> list[Envelope]: + """Конверты задания в порядке получения; каждый проверен схемой и `service.name`.""" + out = [] + for msg in self.received: + body = json.loads(msg.data) + if body.get("job_id") != job_id: + continue + MESSAGE_VALIDATOR.validate(body) + assert body["service"]["name"] == "scan-gateway", body + assert body["schema_version"] == "1.0", body + assert msg.headers is not None, body + out.append(Envelope(msg.headers[NATS_MSG_ID_HEADER], body)) + return out + + def acks(self, job_id: str) -> list[str]: + return [e.msg_id for e in self.envelopes(job_id) if e.body["status"] == "processing"] + + def results(self, job_id: str) -> list[Envelope]: + return [e for e in self.envelopes(job_id) if e.body["status"] != "processing"] + + async def wait_for(self, what: str, predicate: Any, timeout: float = SCENARIO_WAIT) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + if loop.time() > deadline: + raise AssertionError(f"за {timeout} с не дождались: {what}") + await asyncio.sleep(0.01) + + async def health(self) -> dict[str, Any]: + transport = httpx.ASGITransport(app=self.app) + async with httpx.AsyncClient(transport=transport, base_url="http://scan-gateway") as client: + response = await client.get("/healthcheck") + assert response.status_code == 200 + body: dict[str, Any] = response.json() + return body + + # --- проверки ------------------------------------------------------------------------------------------------- + async def raw(self, job_id: str) -> dict[str, Any]: + raw: dict[str, Any] = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json")) + return raw + + def inbox(self, job_id: str) -> list[str]: + return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id)) + + async def run(self, original_name: str, data: bytes) -> tuple[dict[str, Any], Envelope]: + """Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог.""" + body = await self.request(original_name, data) + job_id = body["job_id"] + await self.publish(body) + await self.wait_for(f"итоговый конверт {job_id}", lambda: bool(self.results(job_id))) + envelopes = self.envelopes(job_id) + assert [e.msg_id for e in envelopes] == [f"ack:{job_id}:1", f"result:{job_id}:1"] # ack раньше итога + assert envelopes[0].body["status"] == "processing" + assert envelopes[0].body["file_id"] == body["file_id"] + await self.check_files(body, envelopes[1], data) + return body, envelopes[1] + + async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes) -> None: + """Итог `error` с `raw_s3_key`; сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий = объект S3.""" + job_id = body["job_id"] + assert result.body["status"] == "error" + assert result.body["file_id"] == body["file_id"] + assert result.body["raw_s3_key"] == f"raw/{job_id}.json" + raw = await self.raw(job_id) + assert raw["job_id"] == job_id + names = self.answer_names(job_id) + assert names, "эмулятор не положил ни одного json" + # склейка (D-4): все json каталога ответа, по возрастанию имени, тела совпадают с файлами + assert [entry["name"] for entry in raw["files"]] == names + for entry in raw["files"]: + assert entry["parse_error"] is None + assert entry["response"] == json.loads((self.answer_dir(job_id) / entry["name"]).read_bytes()) + assert entry["response"]["file_uid"] == job_id + stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json") + assert stat.content_type == JSON_CONTENT_TYPE + # результат-файл пишется только для done/needs_check + with pytest.raises(S3Error): + await self.s3.client.stat_object(self.s3.bucket, f"results/{body['file_id']}/{job_id}/result.json") + name = f"{job_id}_{body['original_name']}.done" + assert self.inbox(job_id) == [name] + assert (self.input_dir / name).read_bytes() == await self.s3.get_bytes(body["s3"]["key"]) == data + + +def _write_fixtures(fixtures: Path) -> None: + """Каталог фикстур эмулятора: копии реальных ответов (`aep_3.json` + `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`) + и синтетическая успешная `ok_doc.json` (R-10). Без репозитория fixtures — только `ok_doc.json`.""" + fixtures.mkdir() + template: dict[str, Any] = {"file_uid": "aep_5"} + if REAL_FIXTURES.is_dir(): + for name in ("aep_3.json", "aep_3_ofr.json", "aep_5.json", "aep_6.json"): + shutil.copy(REAL_FIXTURES / name, fixtures / name) + template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8")) + # успех — в форме реальных ответов ИС: `error = {"code": "", "message": ""}`, а не `null` (OQ-77) + success = {**template, "file_uid": "ok_doc", "error": {"code": "", "message": ""}, "data_ocr": SUCCESS_DATA_OCR} + (fixtures / "ok_doc.json").write_text(json.dumps(success, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _settings(monkeypatch: pytest.MonkeyPatch, root: Path) -> Settings: + """Настройки шлюза из `.env.test` и окружения (как в проде) с tmp-корнем; инфраструктура недоступна — fail.""" + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local") + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(root)) + monkeypatch.setenv("SCAN_POLL_SECONDS", str(POLL_SECONDS)) + monkeypatch.setenv("SCAN_SETTLE_SECONDS", str(SETTLE_SECONDS)) + monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS)) + monkeypatch.setenv("NATS_ENABLED", "true") + settings = get_settings() + parsed = urlparse(settings.OPENBAO.NATS.NATS_URL) + _require_port(parsed.hostname or "127.0.0.1", parsed.port or 4222, "NATS") + host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":") + _require_port(host, int(port or 9000), "MinIO") + return settings + + +async def _start_gateway(stack: AsyncExitStack, app: FastAPI) -> None: + """Lifespan приложения; возврат — когда шлюз подписан на `recognition.image.request`.""" + await stack.enter_async_context(app.router.lifespan_context(app)) + [connect_task] = [t for t in asyncio.all_tasks() if t.get_name() == "nats-connect"] + # задача завершается после SUB с flush: дальше подписка шлюза действует на сервере + await asyncio.wait_for(asyncio.shield(connect_task), CONNECT_WAIT) + assert app.state.nats.is_connected, "шлюз не подписался на запросы (см. лог ERROR)" + + +def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtures: Path) -> None: + """Эмулятор ИС (`src.scan_emulator.run`) в потоке; при закрытии стека — штатная остановка и ожидание потока.""" + stop = threading.Event() + emulator_settings = EmulatorSettings( + hotfolder_path=root, + input_dir=settings.SCAN_INPUT_DIR, + output_dir=settings.SCAN_OUTPUT_DIR, + tmp_dir=settings.SCAN_TMP_DIR, + fixtures_dir=fixtures, + delay=EMULATOR_DELAY, + poll_interval=0.05, + ) + emulator = asyncio.create_task(asyncio.to_thread(run, emulator_settings, stop), name="scan-emulator") + + async def stop_emulator() -> None: + stop.set() + await emulator + + stack.push_async_callback(stop_emulator) + + +@pytest.fixture +async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]) -> AsyncIterator[Roundtrip]: + root = tmp_path / "hotfolder" + root.mkdir() + _write_fixtures(tmp_path / "fixtures") + settings = _settings(monkeypatch, root) + nats_url = settings.OPENBAO.NATS.NATS_URL + + async with AsyncExitStack() as stack: + s3 = S3Storage.from_settings(settings.OPENBAO.MINIO) + stack.push_async_callback(s3.close) + client = NATS() + try: + await asyncio.wait_for(client.connect(nats_url, max_reconnect_attempts=0), CONNECT_WAIT) + except Exception as exc: + pytest.fail(f"нет подключения к NATS {nats_url} ({exc!r}): `make up`") + stack.push_async_callback(client.drain) + rt = Roundtrip(settings=settings, app=create_app(settings), root=root, s3=s3, publisher=client) + + async def remove_objects() -> None: + for key in rt.keys: + with suppress(S3Error): + await s3.client.remove_object(s3.bucket, key) + + stack.push_async_callback(remove_objects) + + async def on_result(msg: Msg) -> None: + with suppress(ValueError): # чужие сообщения на общем subject могут быть чем угодно + body = json.loads(msg.data) + if isinstance(body, dict) and body.get("job_id") in rt.jobs: + rt.received.append(msg) + + # core NATS без JetStream не хранит сообщения: слушатель подписан до первой публикации запроса + await client.subscribe(RESULT_SUBJECT, cb=on_result) + await client.flush() + await _start_gateway(stack, rt.app) + _start_emulator(stack, settings, root, tmp_path / "fixtures") + yield rt + + # lifespan остановлен: заданий и фоновых задач приложения не осталось, ошибок в логе нет + assert rt.app.state.worker.active_jobs == 0 + leaked = [t.get_name() for t in asyncio.all_tasks() if t is not asyncio.current_task() and not t.done()] + assert leaked == [], f"незавершённые задачи после остановки: {leaked}" + errors = [line for line in log_lines if line.startswith(("ERROR|", "CRITICAL|"))] + assert errors == [], errors + + +@pytest.mark.fixtures +async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(roundtrip: Roundtrip) -> None: + pdf = _real_fixture("aep_5", ".pdf") # реальное имя `aep_5.PDF`: расширение в верхнем регистре + data = pdf.read_bytes() + expected_error = json.loads(_real_fixture("aep_5", ".json").read_text(encoding="utf-8"))["error"] + health = await roundtrip.health() + assert (health["nats"], health["hotfolder"]) == ("ok", "ok"), health + + body = await roundtrip.request(pdf.name, data) + job_id = body["job_id"] + await roundtrip.publish(body) + inbox_file = roundtrip.input_dir / f"{job_id}_{pdf.name}" + await roundtrip.wait_for(f"файл {inbox_file.name} во входящем каталоге", inbox_file.exists) + # повтор того же запроса во время ожидания ответа ИС (эмулятор «распознаёт» EMULATOR_DELAY с) + await roundtrip.publish(body) + await roundtrip.wait_for(f"итоговый конверт {job_id}", lambda: bool(roundtrip.results(job_id))) + await asyncio.sleep(QUIET_PERIOD) # второй (лишний) результат или ack успел бы прийти + + envelopes = roundtrip.envelopes(job_id) + assert [e.msg_id for e in envelopes] == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"result:{job_id}:1"] + [result] = roundtrip.results(job_id) + assert result.body["error"] == {"code": "INVALID_DOCUMENT", "message": expected_error["message"]} + assert result.body["error"]["message"] == "Не удалось определить тип документа." + await roundtrip.check_files(body, result, data) + raw = await roundtrip.raw(job_id) + assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error] + # все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5) + assert result.body["metrics"]["warnings"] == [f"{job_id}.json: INVALID_DOCUMENT: {expected_error['message']}"] + + # повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата + await roundtrip.publish({**body, "attempt": 2}) + await roundtrip.wait_for(f"ack:{job_id}:2", lambda: f"ack:{job_id}:2" in roundtrip.acks(job_id)) + await asyncio.sleep(QUIET_PERIOD) + assert roundtrip.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"ack:{job_id}:2"] + assert [e.msg_id for e in roundtrip.results(job_id)] == [f"result:{job_id}:1"] + assert roundtrip.inbox(job_id) == [f"{job_id}_{pdf.name}.done"] + + +@pytest.mark.fixtures +async def test_real_fixture_aep_3_two_files_merged_with_warning(roundtrip: Roundtrip) -> None: + """Набор из двух файлов: успешный `.json` и `_ofr.json` с `INVALID_DOCUMENT` (OQ-76, OQ-78).""" + data = _real_fixture("aep_3", ".pdf").read_bytes() + ofr_error = json.loads(_real_fixture("aep_3_ofr", ".json").read_text(encoding="utf-8"))["error"] + assert ofr_error["code"] == "INVALID_DOCUMENT" + + body, result = await roundtrip.run("aep_3.pdf", data) + job_id = body["job_id"] + assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} + # ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>`; успешный файл в них не попадает + assert result.body["metrics"]["warnings"] == [f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"] + + raw = await roundtrip.raw(job_id) + assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"] + first, second = raw["files"] + assert first["response"]["error"] == {"code": "", "message": ""} # успех — не `null` + assert [t["label"]["form_name"] for t in first["response"]["data_ocr"]["table_structure"]] == [ + "Бухгалтерский баланс", + "Отчет о финансовых результатах", + ] + assert second["response"]["error"] == ofr_error + assert (first["parse_error"], second["parse_error"]) == (None, None) + + +@pytest.mark.fixtures +async def test_real_fixture_aep_6_success_without_warnings(roundtrip: Roundtrip) -> None: + data = _real_fixture("aep_6", ".pdf").read_bytes() + body, result = await roundtrip.run("aep_6.pdf", data) + job_id = body["job_id"] + assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} + assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено + raw = await roundtrip.raw(job_id) + [entry] = raw["files"] + assert entry["name"] == f"{job_id}.json" + assert entry["response"]["error"] == {"code": "", "message": ""} + + +@pytest.mark.fixtures +async def test_success_answer_gives_not_implemented(roundtrip: Roundtrip) -> None: + data = _real_fixture("aep_5", ".pdf").read_bytes() # байты реального скана под именем ok_doc.pdf + body, result = await roundtrip.run("ok_doc.pdf", data) + job_id = body["job_id"] + assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} + assert "metrics" not in result.body # ошибочных файлов нет — warnings опущено + raw = await roundtrip.raw(job_id) + [entry] = raw["files"] + assert entry["response"]["error"] == {"code": "", "message": ""} + assert entry["response"]["data_ocr"] == SUCCESS_DATA_OCR + + +async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None: + data = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02" + body, result = await roundtrip.run("unknown.pdf", data) + job_id = body["job_id"] + error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"} + assert result.body["error"] == error + raw = await roundtrip.raw(job_id) + [entry] = raw["files"] + assert entry["name"] == f"{job_id}.json" + assert entry["parse_error"] is None + # ответ-заглушка эмулятора в форме реальных ответов ИС (R-28): `data_ocr` — объект без таблиц + assert entry["response"]["file_uid"] == job_id + assert entry["response"]["error"] == error + assert entry["response"]["data_ocr"]["table_structure"] == [] diff --git a/api/tests/unit/conftest.py b/api/tests/unit/conftest.py new file mode 100644 index 0000000..32697eb --- /dev/null +++ b/api/tests/unit/conftest.py @@ -0,0 +1,84 @@ +"""Фикстуры unit-тестов: окружение воркера шлюза `make_env` (обвязка — `tests/worker_helpers.py`).""" + +import json +from collections.abc import AsyncIterator, Awaitable, Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest + +from src.core.config import get_settings +from src.core.nats import NatsGateway +from src.core.worker import Worker +from src.gateway.hotfolder import LocalHotFolder +from src.normalizer import NullNormalizer +from tests.worker_helpers import SUCCESS_ANSWER, Env, EnvFactory, FakeNatsClient, FakeStorage, SpyHotFolder + + +@pytest.fixture +async def make_env(tmp_path: Path) -> AsyncIterator[EnvFactory]: + """Фабрика окружений воркера на `tmp_path`: настройки — `get_settings()` с короткими опросом и тайм-аутом + (`**overrides` — поверх), подмены `normalizer`, `clock`, `sleep` (пауза между опросами горячей папки). + Воркер запущен (`start()`); после теста все созданные воркеры останавливаются. + """ + created: list[Env] = [] + + async def build( + *, + normalizer: Any = None, + root_exists: bool = True, + clock: Callable[[], datetime] | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + **overrides: Any, + ) -> Env: + root = tmp_path / f"hotfolder-{len(created)}" + if root_exists: + root.mkdir() + fixtures = tmp_path / "fixtures" + fixtures.mkdir(exist_ok=True) + (fixtures / "ok_doc.json").write_text(json.dumps(SUCCESS_ANSWER, ensure_ascii=False), encoding="utf-8") + settings = get_settings().model_copy( + update={ + "SCAN_HOTFOLDER_PATH": str(root), + "SCAN_POLL_SECONDS": 0.01, + "SCAN_TIMEOUT_SECONDS": 5, + "SCAN_SETTLE_SECONDS": 0, # R-23: набор ответа готов сразу, тесты не ждут стабилизации + "PUBLISH_RETRIES": 1, + **overrides, + } + ) + events: list[tuple[str, str]] = [] + storage = FakeStorage(events) + client = FakeNatsClient(events) + gateway = NatsGateway(settings, client_factory=lambda: client, retry_delay=0) + await gateway.connect() + local = LocalHotFolder( + root, + settings.SCAN_INPUT_DIR, + settings.SCAN_OUTPUT_DIR, + settings.SCAN_TMP_DIR, + settle_seconds=settings.SCAN_SETTLE_SECONDS, + ) + hotfolder = SpyHotFolder(local, events) + kwargs: dict[str, Any] = {} + if clock is not None: + kwargs["clock"] = clock + if sleep is not None: + kwargs["sleep"] = sleep + worker = Worker( + settings, + storage=storage, + publisher=gateway, + hotfolder=hotfolder, + normalizer=normalizer or NullNormalizer(), + **kwargs, + ) + worker.start() + env = Env(settings, root, fixtures, events, storage, client, gateway, hotfolder, worker) + created.append(env) + return env + + yield build + for env in created: + await env.worker.stop() diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py new file mode 100644 index 0000000..41587c9 --- /dev/null +++ b/api/tests/unit/test_app.py @@ -0,0 +1,217 @@ +"""Приложение: `/healthcheck` (`uptime`, `version`, `nats`, `hotfolder`), lifespan (SMB-сессия, S3, NATS в фоне, +воркер). NATS и S3 не нужны: в тестах lifespan `NATS_ENABLED=false` либо методы `NatsGateway` подменены.""" + +import asyncio +from datetime import datetime +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest +import smbclient + +from src.app import create_app +from src.core.config import get_settings +from src.core.nats import NatsGateway +from src.core.worker import Worker + +pytestmark = pytest.mark.usefixtures("log_lines") + + +def _client(app) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +@pytest.fixture +def local_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Режим local с существующим корнем и без NATS: lifespan не ходит в сеть.""" + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local") + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path)) + monkeypatch.setenv("NATS_ENABLED", "false") + return tmp_path + + +async def eventually(predicate, timeout: float = 2.0) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + assert loop.time() < deadline, "условие не выполнилось вовремя" + await asyncio.sleep(0.005) + + +async def test_healthcheck_without_lifespan_has_all_fields(local_env: Path) -> None: + app = create_app(get_settings()) + + # lifespan не запускается: подключений к NATS/S3 нет + async with _client(app) as client: + response = await client.get("/healthcheck") + + assert response.status_code == 200 + body = response.json() + assert set(body) == {"uptime", "version", "nats", "hotfolder"} + assert body["version"] == get_settings().VERSION + datetime.fromisoformat(body["uptime"]) + + +async def test_start_in_local_mode(local_env: Path) -> None: + """Spec «Старт в режиме локальных папок»: 200 с uptime, version, nats, hotfolder = ok; OPENBAO__SMB__* не нужны.""" + app = create_app(get_settings()) + async with app.router.lifespan_context(app), _client(app) as client: + response = await client.get("/healthcheck") + assert response.status_code == 200 + body = response.json() + assert body["hotfolder"] == "ok" + assert body["nats"] == "disabled" + assert body["version"] == get_settings().VERSION + + +async def test_missing_hotfolder_root_is_down(local_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(local_env / "missing")) + app = create_app(get_settings()) + async with app.router.lifespan_context(app), _client(app) as client: + assert (await client.get("/healthcheck")).json()["hotfolder"] == "down" + + +async def test_lifespan_starts_and_stops_worker(local_env: Path) -> None: + app = create_app(get_settings()) + async with app.router.lifespan_context(app): + worker = app.state.worker + assert isinstance(worker, Worker) + assert isinstance(app.state.nats, NatsGateway) + # после остановки воркер запросов не принимает + await worker.on_request(type("Msg", (), {"data": b"{}"})()) + assert worker.active_jobs == 0 + + +async def test_lifespan_does_not_register_smb_session_in_local_mode(local_env: Path, monkeypatch) -> None: + register = MagicMock() + monkeypatch.setattr(smbclient, "register_session", register) + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + assert (await client.get("/healthcheck")).status_code == 200 + + register.assert_not_called() + + +def _smb_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb") + monkeypatch.setenv("OPENBAO__SMB__SMB_HOST", "smb.example") + monkeypatch.setenv("OPENBAO__SMB__SMB_PATH", "share") + monkeypatch.setenv("OPENBAO__SMB__SMB_LOGIN", "user") + monkeypatch.setenv("OPENBAO__SMB__SMB_PASSWORD", "secret") + monkeypatch.setenv("NATS_ENABLED", "false") + + +async def test_lifespan_registers_smb_session_in_smb_mode(monkeypatch) -> None: + _smb_env(monkeypatch) + register = MagicMock() + reset = MagicMock() + monkeypatch.setattr(smbclient, "register_session", register) + monkeypatch.setattr(smbclient, "reset_connection_cache", reset) + monkeypatch.setattr(smbclient, "listdir", MagicMock(return_value=["Input", "Output"])) + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + response = await client.get("/healthcheck") + assert response.status_code == 200 + assert response.json()["hotfolder"] == "ok" + + register.assert_called_once_with("smb.example", "user", "secret") + reset.assert_called_once() + + +async def test_smb_session_failure_shows_hotfolder_down(monkeypatch, log_lines: list[str]) -> None: + """T2.3: регистрация SMB-сессии при старте не удалась (шара недоступна) — `hotfolder = down`, сервис жив.""" + _smb_env(monkeypatch) + monkeypatch.setattr(smbclient, "register_session", MagicMock(side_effect=OSError("сервер недоступен"))) + monkeypatch.setattr(smbclient, "listdir", MagicMock(side_effect=OSError("сервер недоступен"))) + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + response = await client.get("/healthcheck") + + assert response.status_code == 200 + assert response.json()["hotfolder"] == "down" + assert any("SMB" in line for line in log_lines if line.startswith("ERROR|")) + + +async def test_nats_connects_in_background_and_subscribes_worker(local_env: Path, monkeypatch) -> None: + monkeypatch.setenv("NATS_ENABLED", "true") + subscribed: list[Any] = [] + connected = asyncio.Event() + + async def fake_connect(self: NatsGateway) -> bool: + await connected.wait() # подключение «висит», а HTTP уже отвечает + return True + + async def fake_subscribe(self: NatsGateway, cb: Any, queue: str = "scan-gateway") -> None: + subscribed.append(cb) + + async def fake_close(self: NatsGateway) -> None: + return None + + monkeypatch.setattr(NatsGateway, "connect", fake_connect) + monkeypatch.setattr(NatsGateway, "subscribe_requests", fake_subscribe) + monkeypatch.setattr(NatsGateway, "close", fake_close) + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + assert (await client.get("/healthcheck")).json()["nats"] == "down" + connected.set() + await eventually(lambda: bool(subscribed)) + assert subscribed == [app.state.worker.on_request] + + +async def test_nats_connect_hanging_is_cancelled_on_shutdown(local_env: Path, monkeypatch) -> None: + monkeypatch.setenv("NATS_ENABLED", "true") + cancelled = asyncio.Event() + + async def fake_connect(self: NatsGateway) -> bool: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise + return True + + async def fake_close(self: NatsGateway) -> None: + return None + + monkeypatch.setattr(NatsGateway, "connect", fake_connect) + monkeypatch.setattr(NatsGateway, "close", fake_close) + app = create_app(get_settings()) + async with app.router.lifespan_context(app): + await asyncio.sleep(0.01) + assert cancelled.is_set() + + +async def test_nats_config_error_is_loud_and_shows_down(local_env: Path, monkeypatch, log_lines: list[str]) -> None: + """Неполные учётные данные NATS (`NatsConfigError`): лог error с трассировкой, `nats = down`, HTTP жив.""" + monkeypatch.setenv("NATS_ENABLED", "true") + monkeypatch.setenv("OPENBAO__NATS__NATS_SEED", "SUAINCOMPLETESEED") + monkeypatch.setenv("OPENBAO__NATS__NATS_JWT", "") + app = create_app(get_settings()) + + async with app.router.lifespan_context(app), _client(app) as client: + await eventually(lambda: any(line.startswith("ERROR|") and "NATS" in line for line in log_lines)) + response = await client.get("/healthcheck") + + assert response.status_code == 200 + assert response.json()["nats"] == "down" + + +async def test_unhandled_error_has_json_500(local_env: Path) -> None: + app = create_app(get_settings()) + + @app.get("/boom") + async def boom(): + raise RuntimeError("x") + + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/boom") + + assert response.status_code == 500 + assert response.json() == {"detail": "внутренняя ошибка сервера"} diff --git a/api/tests/unit/test_clients.py b/api/tests/unit/test_clients.py new file mode 100644 index 0000000..df784de --- /dev/null +++ b/api/tests/unit/test_clients.py @@ -0,0 +1,201 @@ +"""S3-клиент сервиса (`src.core.clients.S3Storage`) поверх фейкового `Minio`: аргументы вызовов, JSON в UTF-8, +классификация ошибок чтения/записи. Настоящий MinIO — в tests/integration/test_nats_minio_integration.py. +""" + +import json +from typing import Any + +import pytest + +from src.core import clients +from src.core.clients import ( + S3ObjectNotFoundError, + S3ReadError, + S3Storage, + S3StorageError, + S3WriteError, +) +from src.core.config import get_settings + + +class FakeResponse: + def __init__(self, data: bytes) -> None: + self._data = data + self.released = False + + async def read(self) -> bytes: + return self._data + + def release(self) -> None: + self.released = True + + +class FakeS3Error(Exception): + """Подмена `miniopy_async.error.S3Error`: у настоящего — атрибут `code` (`NoSuchKey`, `AccessDenied`, ...).""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +class FakeMinio: + def __init__(self) -> None: + self.objects: dict[tuple[str, str], tuple[bytes, str]] = {} + self.get_calls: list[tuple[str, str]] = [] + self.put_calls: list[dict[str, Any]] = [] + self.responses: list[FakeResponse] = [] + self.get_error: BaseException | None = None + self.put_error: BaseException | None = None + self.session_closed = False + + async def get_object(self, bucket_name: str, object_name: str) -> FakeResponse: + self.get_calls.append((bucket_name, object_name)) + if self.get_error is not None: + raise self.get_error + response = FakeResponse(self.objects[(bucket_name, object_name)][0]) + self.responses.append(response) + return response + + async def put_object( + self, bucket_name: str, object_name: str, data: Any, length: int, content_type: str = "" + ) -> None: + body = data.read() + self.put_calls.append( + {"bucket": bucket_name, "key": object_name, "data": body, "length": length, "content_type": content_type} + ) + if self.put_error is not None: + raise self.put_error + self.objects[(bucket_name, object_name)] = (body, content_type) + + async def close_session(self) -> None: + self.session_closed = True + + +@pytest.fixture +def minio() -> FakeMinio: + return FakeMinio() + + +@pytest.fixture +def storage(minio: FakeMinio) -> S3Storage: + return S3Storage(minio, "arm-ka") + + +async def test_put_bytes_passes_bucket_key_length_and_content_type(storage: S3Storage, minio: FakeMinio): + await storage.put_bytes("uploads/a.pdf", b"%PDF-1.7", content_type="application/pdf") + + assert minio.put_calls == [ + { + "bucket": "arm-ka", + "key": "uploads/a.pdf", + "data": b"%PDF-1.7", + "length": 8, + "content_type": "application/pdf", + } + ] + + +async def test_put_bytes_default_content_type_is_octet_stream(storage: S3Storage, minio: FakeMinio): + await storage.put_bytes("k", b"x") + assert minio.put_calls[0]["content_type"] == "application/octet-stream" + + +async def test_put_json_writes_utf8_without_ascii_escaping(storage: S3Storage, minio: FakeMinio): + payload = {"error": {"code": "INVALID_DOCUMENT", "message": "Документ не распознан"}, "n": 1} + + await storage.put_json("raw/job.json", payload) + + [call] = minio.put_calls + assert call["key"] == "raw/job.json" + assert call["content_type"] == "application/json" + assert "Документ не распознан".encode() in call["data"] + assert call["length"] == len(call["data"]) + assert json.loads(call["data"].decode("utf-8")) == payload + + +async def test_get_bytes_reads_and_releases_response(storage: S3Storage, minio: FakeMinio): + minio.objects[("arm-ka", "uploads/a.pdf")] = (b"%PDF", "application/pdf") + + assert await storage.get_bytes("uploads/a.pdf") == b"%PDF" + assert minio.get_calls == [("arm-ka", "uploads/a.pdf")] + assert minio.responses[0].released is True + + +async def test_get_bytes_from_explicit_bucket(storage: S3Storage, minio: FakeMinio): + minio.objects[("other", "k")] = (b"data", "") + assert await storage.get_bytes("k", bucket="other") == b"data" + assert minio.get_calls == [("other", "k")] + + +async def test_get_bytes_missing_key_raises_not_found( + storage: S3Storage, minio: FakeMinio, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(clients, "S3Error", FakeS3Error) + minio.get_error = FakeS3Error("NoSuchKey") + + with pytest.raises(S3ObjectNotFoundError) as exc_info: + await storage.get_bytes("uploads/missing.pdf") + + assert isinstance(exc_info.value, S3ReadError) + assert exc_info.value.key == "uploads/missing.pdf" + assert exc_info.value.bucket == "arm-ka" + assert exc_info.value.__cause__ is minio.get_error + + +@pytest.mark.parametrize("error", [FakeS3Error("AccessDenied"), ConnectionRefusedError(), RuntimeError("x")]) +async def test_get_bytes_other_errors_raise_read_error( + storage: S3Storage, minio: FakeMinio, monkeypatch: pytest.MonkeyPatch, error: BaseException +): + monkeypatch.setattr(clients, "S3Error", FakeS3Error) + minio.get_error = error + + with pytest.raises(S3ReadError) as exc_info: + await storage.get_bytes("uploads/a.pdf") + + assert not isinstance(exc_info.value, S3ObjectNotFoundError) + assert exc_info.value.__cause__ is error + + +async def test_put_errors_raise_write_error(storage: S3Storage, minio: FakeMinio): + minio.put_error = ConnectionRefusedError() + + with pytest.raises(S3WriteError) as exc_info: + await storage.put_json("raw/job.json", {"a": 1}) + + assert isinstance(exc_info.value, S3StorageError) + assert exc_info.value.key == "raw/job.json" + assert exc_info.value.__cause__ is minio.put_error + + +async def test_close_closes_http_session(storage: S3Storage, minio: FakeMinio): + await storage.close() + assert minio.session_closed is True + + +def test_from_settings_builds_minio_with_secure_flag(monkeypatch: pytest.MonkeyPatch): + created: list[dict[str, Any]] = [] + + def fake_minio(endpoint: str, **kwargs: Any) -> FakeMinio: + created.append({"endpoint": endpoint, **kwargs}) + return FakeMinio() + + monkeypatch.setattr(clients, "Minio", fake_minio) + settings = get_settings() # .env.test: 127.0.0.1:9000, minioadmin, бакет arm-ka, MINIO_SECURE=false + + storage = S3Storage.from_settings(settings.OPENBAO.MINIO) + + assert created == [ + {"endpoint": "127.0.0.1:9000", "access_key": "minioadmin", "secret_key": "minioadmin", "secure": False} + ] + assert storage.bucket == "arm-ka" + + +def test_from_settings_secure_true(monkeypatch: pytest.MonkeyPatch): + created: list[dict[str, Any]] = [] + monkeypatch.setattr(clients, "Minio", lambda endpoint, **kwargs: created.append(kwargs) or FakeMinio()) + monkeypatch.setenv("OPENBAO__MINIO__MINIO_SECURE", "true") + get_settings.cache_clear() + + S3Storage.from_settings(get_settings().OPENBAO.MINIO) + + assert created[0]["secure"] is True diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py new file mode 100644 index 0000000..1f29787 --- /dev/null +++ b/api/tests/unit/test_config.py @@ -0,0 +1,178 @@ +import pytest +from pydantic import ValidationError + +from src.core.config import Settings, get_settings + +SMB_ENV = { + "OPENBAO__SMB__SMB_HOST": "smb.example", + "OPENBAO__SMB__SMB_PATH": "share", + "OPENBAO__SMB__SMB_LOGIN": "user", + "OPENBAO__SMB__SMB_PASSWORD": "secret", +} + + +def _clean_env(monkeypatch): + """Убрать из окружения всё, что относится к SMB/NATS-учётным данным и режиму горячей папки.""" + for key in list(SMB_ENV) + ["OPENBAO__NATS__NATS_SEED", "OPENBAO__NATS__NATS_JWT", "SCAN_HOTFOLDER_MODE"]: + monkeypatch.delenv(key, raising=False) + + +def test_defaults_local_mode_without_smb_and_nats_credentials(monkeypatch, minimal_env_file): + _clean_env(monkeypatch) + + s = get_settings() + + assert s.SERVICE_NAME == "scan-gateway" + assert s.NATS_ENABLED is True + assert s.PUBLISH_RETRIES == 3 + assert s.DONE_CACHE_SIZE == 1000 + assert s.SCAN_HOTFOLDER_MODE == "local" + assert s.SCAN_INPUT_DIR == "Input" + assert s.SCAN_OUTPUT_DIR == "Output" + assert s.SCAN_TMP_DIR == "Tmp" + assert s.SCAN_SETTLE_SECONDS == 2 + assert s.SCAN_POLL_SECONDS == 5 + assert s.SCAN_TIMEOUT_SECONDS == 300 + assert s.SCAN_MAX_INFLIGHT == 4 + assert s.OPENBAO.SMB.SMB_HOST == "" + assert s.OPENBAO.NATS.NATS_SEED == "" + assert s.OPENBAO.NATS.NATS_JWT == "" + assert not hasattr(s.OPENBAO, "DATABASE") + + +def test_local_mode_requires_hotfolder_path(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", "") + + with pytest.raises(ValidationError, match="SCAN_HOTFOLDER_PATH"): + get_settings() + + +def test_smb_mode_without_smb_block_fails_with_clear_error(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb") + + with pytest.raises(ValidationError, match="OPENBAO__SMB"): + get_settings() + + +def test_smb_mode_with_smb_block_ok(monkeypatch): + _clean_env(monkeypatch) + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "smb") + for key, value in SMB_ENV.items(): + monkeypatch.setenv(key, value) + + s = get_settings() + + assert s.OPENBAO.SMB.SMB_HOST == "smb.example" + + +def test_settle_seconds_zero_is_allowed(monkeypatch): + monkeypatch.setenv("SCAN_SETTLE_SECONDS", "0") + + assert get_settings().SCAN_SETTLE_SECONDS == 0 + + +def test_negative_settle_seconds_rejected(monkeypatch): + monkeypatch.setenv("SCAN_SETTLE_SECONDS", "-1") + + with pytest.raises(ValidationError, match="SCAN_SETTLE_SECONDS"): + get_settings() + + +@pytest.mark.parametrize("settle", ["10", "11"]) +def test_settle_not_less_than_timeout_rejected(monkeypatch, settle): + """M-4: набор не может настояться за срок, который короче или равен settle — каждое задание давало бы TIMEOUT.""" + monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", "10") + monkeypatch.setenv("SCAN_SETTLE_SECONDS", settle) + + with pytest.raises(ValidationError, match="SCAN_SETTLE_SECONDS .* меньше SCAN_TIMEOUT_SECONDS"): + get_settings() + + +def test_settle_less_than_timeout_is_allowed(monkeypatch): + monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", "10") + monkeypatch.setenv("SCAN_SETTLE_SECONDS", "9.9") + + assert get_settings().SCAN_SETTLE_SECONDS == 9.9 + + +def test_unknown_hotfolder_mode_rejected(monkeypatch): + monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "ftp") + + with pytest.raises(ValidationError): + get_settings() + + +def test_env_file_selected_by_env_file_variable(monkeypatch, tmp_path): + _clean_env(monkeypatch) + for key in ( + "SCAN_HOTFOLDER_PATH", + "SCAN_POLL_SECONDS", + "SERVER__PORT", + "OPENBAO__NATS__NATS_URL", + "OPENBAO__MINIO__MINIO_URL", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD", + "OPENBAO__MINIO__MINIO_BUCKET", + ): + monkeypatch.delenv(key, raising=False) + env_file = tmp_path / "custom.env" + env_file.write_text( + "\n".join( + [ + "OPENBAO__MINIO__MINIO_URL=minio.test:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN=a", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=b", + "OPENBAO__MINIO__MINIO_BUCKET=bucket", + "OPENBAO__NATS__NATS_URL=nats://nats.test:4222", + "SCAN_HOTFOLDER_PATH=/data/hot", + "SCAN_POLL_SECONDS=1", + "SERVER__PORT=8123", + ] + ) + ) + monkeypatch.setenv("ENV_FILE", str(env_file)) + + s = get_settings() + + assert s.OPENBAO.MINIO.MINIO_URL == "minio.test:9000" + assert s.OPENBAO.NATS.NATS_URL == "nats://nats.test:4222" + assert s.SCAN_HOTFOLDER_PATH == "/data/hot" + assert s.SCAN_POLL_SECONDS == 1 + assert s.server.PORT == 8123 + + +def test_get_settings_is_cached(monkeypatch): + assert get_settings() is get_settings() + assert isinstance(get_settings(), Settings) + + +def test_env_test_file_loads_for_tests(): + """pytest.ini выбирает `.env.test`: он должен давать рабочие настройки local-режима с укороченными интервалами.""" + s = get_settings() + + assert s.SCAN_HOTFOLDER_MODE == "local" + assert s.SCAN_HOTFOLDER_PATH == "./.hotfolder-test" + assert s.SCAN_POLL_SECONDS == 0.1 + assert s.SCAN_TIMEOUT_SECONDS == 5 + assert s.SCAN_TMP_DIR == "Tmp" + assert s.SCAN_SETTLE_SECONDS == 0 # R-23: тесты воркера не ждут стабилизации набора + assert s.OPENBAO.MINIO.MINIO_URL == "127.0.0.1:9000" + assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.OPENBAO.NATS.NATS_URL == "nats://127.0.0.1:4222" + assert s.OPENBAO.NATS.NATS_SEED == "" + + +def test_env_example_loads(monkeypatch): + """`.env.example` — рабочий dev-набор: Settings читает его без ошибок.""" + monkeypatch.setenv("ENV_FILE", ".env.example") + + s = get_settings() + + assert s.SCAN_HOTFOLDER_MODE == "local" + assert s.SCAN_INPUT_DIR == "Input" + assert s.SCAN_TMP_DIR == "Tmp" + assert s.SCAN_SETTLE_SECONDS == 2 + assert s.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" + assert s.OPENBAO.NATS.NATS_JWT == "" diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py new file mode 100644 index 0000000..7799b78 --- /dev/null +++ b/api/tests/unit/test_contract.py @@ -0,0 +1,823 @@ +"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-contract, slice-0-skeleton). + +Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и +JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. +""" + +import copy +import json +import uuid +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker +from pydantic import ValidationError +from referencing import Registry, Resource + +from src.contract import ( + CONTRACT_VERSION, + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + SUPPORTED_MAJOR, + ContractModel, + ErrorCode, + MessageStatus, + Metrics, + RecognitionRequest, + RecognitionResult, + RecognitionResultMessage, + S3Ref, + ServiceInfo, + ServiceName, + ack_msg_id, + build_ack_message, + build_done_message, + build_error_message, + is_supported_major, + request_msg_id, + request_subject, + result_msg_id, +) + +API_DIR = Path(__file__).resolve().parents[2] +CONTRACTS_DIR = API_DIR / "contracts" +EXAMPLES_DIR = CONTRACTS_DIR / "examples" +SCHEMA_FILES = { + "request": "recognition-request.schema.json", + "message": "recognition-result-message.schema.json", + "result": "recognition-result.schema.json", +} +MODELS: dict[str, type[ContractModel]] = { + "request": RecognitionRequest, + "message": RecognitionResultMessage, + "result": RecognitionResult, +} +EXAMPLE_KIND = { + "request-image.json": "request", + "request-tabular.json": "request", + "result-message-done.json": "message", + "result-message-error.json": "message", + "result-message-processing.json": "message", + "result-error.json": "result", + "result-osv-minimal.json": "result", + "result-statement-minimal.json": "result", +} +EXAMPLES_COUNT = 8 + + +def _load(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _example(name: str) -> dict[str, Any]: + data = _load(EXAMPLES_DIR / name) + assert isinstance(data, dict) + return data + + +def _build_validators() -> dict[str, Draft202012Validator]: + """Валидаторы по трём схемам; `$ref` между схемами разрешаются локальным реестром (без сети).""" + schemas = {kind: _load(CONTRACTS_DIR / file_name) for kind, file_name in SCHEMA_FILES.items()} + registry: Registry = Registry() + for schema in schemas.values(): + registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) + return { + kind: Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()) + for kind, schema in schemas.items() + } + + +VALIDATORS = _build_validators() + + +def _schema_ok(kind: str, payload: dict[str, Any]) -> bool: + return VALIDATORS[kind].is_valid(payload) + + +def _model_ok(kind: str, payload: dict[str, Any]) -> bool: + try: + MODELS[kind].model_validate(payload) + except ValidationError: + return False + return True + + +# --- идентичность вендоренных копий ------------------------------------------------------------------------------- + + +def _docs_contracts_dir() -> Path: + """`docs/contracts` соседнего репозитория: `<корень scan-gateway>/../docs` (workspace и worktree `.wt/<срез>/`).""" + return API_DIR.parent.parent / "docs" / "contracts" + + +def test_vendored_contracts_are_identical_to_docs() -> None: + docs_dir = _docs_contracts_dir() + if not docs_dir.is_dir(): + pytest.skip("docs недоступен: тест идентичности контракта пропущен (положите репозиторий docs рядом)") + docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")} + own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")} + assert own_files == docs_files, ( + f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, " + f"только в docs/contracts: {sorted(docs_files - own_files)}" + ) + for name in sorted(docs_files): + assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), ( + f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками" + ) + + +def test_example_table_covers_all_example_files() -> None: + files = {p.name for p in EXAMPLES_DIR.glob("*.json")} + assert files == set(EXAMPLE_KIND) + assert len(files) == EXAMPLES_COUNT + + +# --- примеры: JSON Schema, модель, round-trip --------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND)) +def test_example_passes_json_schema(name: str) -> None: + VALIDATORS[EXAMPLE_KIND[name]].validate(_example(name)) + + +@pytest.mark.parametrize("name", sorted(EXAMPLE_KIND)) +def test_example_passes_model_and_round_trips(name: str) -> None: + kind = EXAMPLE_KIND[name] + original = _example(name) + model = MODELS[kind].model_validate(original) + dumped = model.model_dump(mode="json", exclude_unset=True) + # Ничего не потеряно и не изменено (в том числе суммы-строки: «1100», «-12.30», «0.00») + assert dumped == original + VALIDATORS[kind].validate(dumped) + # to_wire() — то, что уйдёт в NATS/S3: тоже проходит схему и совпадает с исходным примером + wire = model.to_wire() + assert wire == original + VALIDATORS[kind].validate(wire) + # повторный разбор даёт равную модель + assert MODELS[kind].model_validate(wire) == model + + +def test_result_amounts_are_decimal_in_model_and_strings_on_the_wire() -> None: + result = RecognitionResult.model_validate(_example("result-statement-minimal.json")) + values = result.documents[0].lines[1].values # type: ignore[union-attr] + assert values[0].amount == Decimal("5678.00") + assert values[1].amount == Decimal("-12.30") + assert isinstance(values[1].amount, Decimal) + wire = result.to_wire() + assert wire["documents"][0]["lines"][1]["values"][1]["amount"] == "-12.30" + assert wire["documents"][0]["lines"][0]["values"][1]["amount"] == "1100" # без «.00» — как пришло + + +def test_osv_amount_keeps_kopecks() -> None: + payload = _example("result-osv-minimal.json") + payload["documents"][0]["rows"][0]["closing_credit"] = "106311116.46" + result = RecognitionResult.model_validate(payload) + assert result.documents[0].rows[0].closing_credit == Decimal("106311116.46") # type: ignore[union-attr] + wire = result.to_wire() + assert wire["documents"][0]["rows"][0]["closing_credit"] == "106311116.46" + VALIDATORS["result"].validate(wire) + + +def test_models_expose_json_schema() -> None: + for model in MODELS.values(): + assert model.model_json_schema()["type"] == "object" + + +# --- согласованность моделей и схем ------------------------------------------------------------------------------- + + +def _set(path: str, value: Any) -> Callable[[dict[str, Any]], None]: + """Мутация: положить `value` по пути `a.b.0.c` (числа — индексы списков).""" + + def apply(payload: dict[str, Any]) -> None: + *parents, last = path.split(".") + node: Any = payload + for part in parents: + node = node[int(part)] if part.isdigit() else node[part] + if isinstance(node, list): + node[int(last)] = value + else: + node[last] = value + + return apply + + +def _drop(path: str) -> Callable[[dict[str, Any]], None]: + def apply(payload: dict[str, Any]) -> None: + *parents, last = path.split(".") + node: Any = payload + for part in parents: + node = node[int(part)] if part.isdigit() else node[part] + del node[last] + + return apply + + +# (идентификатор, пример-основа, мутация, ожидаемый вердикт «валидно») +CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ + # --- запрос --- + ("request/base", "request-tabular.json", lambda p: None, True), + ("request/no-job_id", "request-tabular.json", _drop("job_id"), False), + ("request/no-attempt", "request-tabular.json", _drop("attempt"), False), + ("request/attempt-0", "request-tabular.json", _set("attempt", 0), False), + ("request/attempt-2", "request-tabular.json", _set("attempt", 2), True), + ("request/attempt-string", "request-tabular.json", _set("attempt", "1"), False), + ("request/attempt-float", "request-tabular.json", _set("attempt", 1.5), False), + ("request/attempt-1.0", "request-tabular.json", _set("attempt", 1.0), True), + ("request/attempt-0.0", "request-tabular.json", _set("attempt", 0.0), False), + ("request/attempt-nan", "request-tabular.json", _set("attempt", float("nan")), False), + ("request/attempt-bool", "request-tabular.json", _set("attempt", True), False), + ("request/pipeline-unknown", "request-tabular.json", _set("pipeline", "ocr"), False), + ("request/file_type-unknown", "request-tabular.json", _set("file_type", "DOCX"), False), + ("request/schema_version-2.0", "request-tabular.json", _set("schema_version", "2.0"), False), + ("request/schema_version-1.7", "request-tabular.json", _set("schema_version", "1.7"), True), + ("request/schema_version-1", "request-tabular.json", _set("schema_version", "1"), False), + ("request/schema_version-1.0.1", "request-tabular.json", _set("schema_version", "1.0.1"), False), + ("request/job_id-not-uuid", "request-tabular.json", _set("job_id", "not-a-uuid"), False), + ( + "request/job_id-uuid-without-dashes", + "request-tabular.json", + _set("job_id", "3f2a9c1e7b4d4e0a9d512c8e6a1b0f01"), + False, + ), + ("request/file_id-number", "request-tabular.json", _set("file_id", 12345), False), + ("request/original_name-empty", "request-tabular.json", _set("original_name", ""), False), + ("request/s3-no-key", "request-tabular.json", _drop("s3.key"), False), + ("request/s3-empty-bucket", "request-tabular.json", _set("s3.bucket", ""), False), + ("request/s3-null", "request-tabular.json", _set("s3", None), False), + ("request/extra-field", "request-tabular.json", _set("trace_id", "abc"), True), + ("request/extra-field-in-s3", "request-tabular.json", _set("s3.version_id", "v1"), True), + # --- конверт --- + ("message/processing", "result-message-processing.json", lambda p: None, True), + ("message/done", "result-message-done.json", lambda p: None, True), + ("message/status-unknown", "result-message-done.json", _set("status", "queued"), False), + ("message/no-service", "result-message-processing.json", _drop("service"), False), + ("message/service-unknown", "result-message-processing.json", _set("service.name", "other-service"), False), + ("message/schema_version-2.0", "result-message-processing.json", _set("schema_version", "2.0"), False), + ("message/schema_version-1.7", "result-message-processing.json", _set("schema_version", "1.7"), True), + ("message/done-no-result_s3", "result-message-done.json", _drop("result_s3"), False), + ("message/done-no-result_schema_version", "result-message-done.json", _drop("result_schema_version"), False), + ("message/needs_check-ok", "result-message-done.json", _set("status", "needs_check"), True), + ( + "message/needs_check-no-result_s3", + "result-message-done.json", + lambda p: (p.pop("result_s3"), p.update(status="needs_check")), + False, + ), + ("message/result_s3-no-key", "result-message-done.json", _drop("result_s3.key"), False), + ("message/result_s3-null", "result-message-done.json", _set("result_s3", None), False), + ("message/result_schema_version-2.0", "result-message-done.json", _set("result_schema_version", "2.0"), True), + ("message/result_schema_version-bad", "result-message-done.json", _set("result_schema_version", "1"), False), + ("message/error-ok", "result-message-error.json", lambda p: None, True), + ("message/error-no-error", "result-message-error.json", _drop("error"), False), + ("message/error-null", "result-message-error.json", _set("error", None), False), + ("message/error-unknown-code", "result-message-error.json", _set("error.code", "BOOM"), False), + ("message/error-no-message", "result-message-error.json", _drop("error.message"), False), + ("message/processing-no-result_s3", "result-message-processing.json", lambda p: None, True), + ( + "message/done-with-error-block", + "result-message-done.json", + _set("error", {"code": "TIMEOUT", "message": "x"}), + True, + ), + ("message/raw_s3_key-null", "result-message-done.json", _set("raw_s3_key", None), True), + ("message/metrics-warnings-not-strings", "result-message-done.json", _set("metrics.warnings", [1]), False), + ("message/metrics-duration-float", "result-message-done.json", _set("metrics.duration_ms", 1.5), False), + ("message/metrics-duration-1.0", "result-message-done.json", _set("metrics.duration_ms", 18400.0), True), + ("message/metrics-pages-bool", "result-message-done.json", _set("metrics.pages", True), False), + ("message/metrics-extra-field", "result-message-done.json", _set("metrics.cpu", 3), True), + ("message/metrics-rows", "result-message-done.json", _set("metrics.rows", 10), True), + ("message/extra-field", "result-message-done.json", _set("trace_id", "abc"), True), + # --- файл результата --- + ("result/osv-base", "result-osv-minimal.json", lambda p: None, True), + ("result/statement-base", "result-statement-minimal.json", lambda p: None, True), + ("result/error-base", "result-error.json", lambda p: None, True), + ( + "result/amount-float", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", 1234.5), + False, + ), + ("result/amount-int", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", 1234), False), + ("result/amount-null", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", None), True), + ( + "result/amount-3-decimals", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "1.234"), + False, + ), + ("result/amount-comma", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", "1,5"), False), + ( + "result/amount-negative", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "-0.5"), + True, + ), + ( + "result/amount-exponent", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.amount", "1e3"), + False, + ), + ("result/amount-empty", "result-statement-minimal.json", _set("documents.0.lines.0.values.0.amount", ""), False), + ("result/osv-amount-float", "result-osv-minimal.json", _set("documents.0.rows.0.opening_credit", 1000.0), False), + ("result/osv-amount-missing", "result-osv-minimal.json", _drop("documents.0.rows.0.opening_credit"), True), + ("result/done-empty-documents", "result-osv-minimal.json", _set("documents", []), False), + ( + "result/needs_check-empty-documents", + "result-osv-minimal.json", + lambda p: p.update(status="needs_check", documents=[]), + False, + ), + ("result/error-empty-documents", "result-error.json", lambda p: None, True), + ("result/no-documents", "result-error.json", _drop("documents"), False), + ("result/status-processing", "result-osv-minimal.json", _set("status", "processing"), False), + ("result/error-null", "result-error.json", _set("error", None), True), + ("result/error-empty-object", "result-error.json", _set("error", {}), True), + ("result/error-bad-code", "result-error.json", _set("error.code", "BOOM"), False), + ("result/schema_version-2.0", "result-osv-minimal.json", _set("schema_version", "2.0"), False), + ("result/schema_version-1.3", "result-osv-minimal.json", _set("schema_version", "1.3"), True), + ("result/document_type-unknown", "result-osv-minimal.json", _set("documents.0.document_type", "MEMO"), False), + ("result/document-no-document_type", "result-osv-minimal.json", _drop("documents.0.document_type"), False), + ("result/osv-no-rows", "result-osv-minimal.json", _drop("documents.0.rows"), False), + ("result/osv-no-group_schema", "result-osv-minimal.json", _drop("documents.0.group_schema"), False), + ("result/osv-no-header-period_text", "result-osv-minimal.json", _drop("documents.0.header.period_text"), False), + ("result/osv-header_variant-3", "result-osv-minimal.json", _set("documents.0.header.header_variant", 3), False), + ("result/osv-period_quarter-Q5", "result-osv-minimal.json", _set("documents.0.header.period_quarter", "Q5"), False), + ( + "result/osv-period_quarter-null", + "result-osv-minimal.json", + _set("documents.0.header.period_quarter", None), + True, + ), + ( + "result/osv-header-null-for-non-nullable", + "result-osv-minimal.json", + _set("documents.0.header.header_variant", None), + False, + ), + ( + "result/osv-row-group_type-unknown", + "result-osv-minimal.json", + _set("documents.0.rows.0.group_type", "FOLDER"), + False, + ), + ( + "result/osv-row-group_type-TOTAL", + "result-osv-minimal.json", + _set("documents.0.rows.0.group_type", "TOTAL"), + True, + ), + ("result/osv-row-no-row_number", "result-osv-minimal.json", _drop("documents.0.rows.0.row_number"), False), + ("result/osv-level-1.0", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.0), True), + ("result/osv-level-1.5", "result-osv-minimal.json", _set("documents.0.rows.0.level", 1.5), False), + ("result/osv-level-bool", "result-osv-minimal.json", _set("documents.0.rows.0.level", True), False), + ("result/osv-level-string", "result-osv-minimal.json", _set("documents.0.rows.0.level", "1"), False), + ("result/osv-row_number-2.0", "result-osv-minimal.json", _set("documents.0.rows.0.row_number", 2.0), True), + ( + "result/osv-parent_row_number-1.0", + "result-osv-minimal.json", + _set("documents.0.rows.1.parent_row_number", 1.0), + True, + ), + ( + "result/osv-parent_row_number-1.5", + "result-osv-minimal.json", + _set("documents.0.rows.1.parent_row_number", 1.5), + False, + ), + ("result/osv-period_year-2025.0", "result-osv-minimal.json", _set("documents.0.header.period_year", 2025.0), True), + ( + "result/osv-period_year-nan", + "result-osv-minimal.json", + _set("documents.0.header.period_year", float("nan")), + False, + ), + ("result/osv-header_variant-2.0", "result-osv-minimal.json", _set("documents.0.header.header_variant", 2.0), True), + ( + "result/osv-header_variant-true", + "result-osv-minimal.json", + _set("documents.0.header.header_variant", True), + False, + ), + ("result/osv-header_variant-1.5", "result-osv-minimal.json", _set("documents.0.header.header_variant", 1.5), False), + ( + "result/osv-violations-1.0", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", [3.0]), + True, + ), + ( + "result/osv-violations-1.5", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", [3.5]), + False, + ), + ( + "result/statement-column_index-1.0", + "result-statement-minimal.json", + _set("documents.0.columns.0.column_index", 1.0), + True, + ), + ( + "result/statement-column_index-1.5", + "result-statement-minimal.json", + _set("documents.0.columns.0.column_index", 1.5), + False, + ), + ( + "result/statement-value-column_index-0.0", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.column_index", 0.0), + True, + ), + ( + "result/statement-value-column_index-bool", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.column_index", False), + False, + ), + ("result/osv-row-level-float", "result-osv-minimal.json", _set("documents.0.rows.0.level", 0.5), False), + ("result/osv-columns-non-string", "result-osv-minimal.json", _set("documents.0.columns.opening_debit", 3), False), + ("result/osv-columns-extra-key", "result-osv-minimal.json", _set("documents.0.columns.inn", "B"), True), + ( + "result/osv-group_schema-bad-type", + "result-osv-minimal.json", + _set("documents.0.group_schema.0.type", "FOLDER"), + False, + ), + ( + "result/osv-checks-violations-not-ints", + "result-osv-minimal.json", + _set("documents.0.checks.parent_sum_violations", ["a"]), + False, + ), + ("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True), + ("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False), + ("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False), + ( + "result/statement-type-INCOME", + "result-statement-minimal.json", + _set("documents.0.document_type", "INCOME"), + True, + ), + ( + "result/statement-type-OSV-with-statement-body", + "result-statement-minimal.json", + _set("documents.0.document_type", "OSV"), + False, + ), + ( + "result/statement-confidence-101", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", 101), + False, + ), + ( + "result/statement-confidence-negative", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_code_confidence", -1), + False, + ), + ( + "result/statement-confidence-null", + "result-statement-minimal.json", + _set("documents.0.lines.0.line_code_confidence", None), + True, + ), + ( + "result/statement-confidence-bool", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", True), + False, + ), + ( + "result/statement-confidence-100.0", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.confidence", 100.0), + True, + ), + ( + "result/statement-report_date-bad", + "result-statement-minimal.json", + _set("documents.0.header.report_date", "2025-13-45"), + False, + ), + ( + "result/statement-report_date-null", + "result-statement-minimal.json", + _set("documents.0.header.report_date", None), + True, + ), + ( + "result/statement-value-no-column_index", + "result-statement-minimal.json", + _drop("documents.0.lines.0.values.0.column_index"), + False, + ), + ( + "result/statement-is_recognized-string", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.is_recognized", "yes"), + False, + ), + ( + "result/statement-string_value-null", + "result-statement-minimal.json", + _set("documents.0.lines.0.values.0.string_value", None), + True, + ), + ( + "result/statement-period_date-bad", + "result-statement-minimal.json", + _set("documents.0.columns.0.period_date", "30.09.2025"), + False, + ), + ("result/statement-extra-field", "result-statement-minimal.json", _set("documents.0.header.fax", "1"), True), +] + + +@pytest.mark.parametrize(("case_id", "example", "mutate", "expected_valid"), CASES, ids=[c[0] for c in CASES]) +def test_model_and_schema_agree( + case_id: str, example: str, mutate: Callable[[dict[str, Any]], None], expected_valid: bool +) -> None: + kind = EXAMPLE_KIND[example] + payload = copy.deepcopy(_example(example)) + mutate(payload) + schema_verdict = _schema_ok(kind, payload) + model_verdict = _model_ok(kind, payload) + assert schema_verdict == expected_valid, ( + f"{case_id}: JSON Schema вынесла вердикт {schema_verdict}, ожидали {expected_valid}" + ) + assert model_verdict == schema_verdict, ( + f"{case_id}: модель {'приняла' if model_verdict else 'отвергла'}, схема " + f"{'приняла' if schema_verdict else 'отвергла'} — расхождение модели и схемы" + ) + + +def test_consistency_table_covers_every_kind() -> None: + assert {EXAMPLE_KIND[c[1]] for c in CASES} == set(MODELS) + + +# --- то, что модель проверяет строже валидатора схемы без дополнительных библиотек --------------------------------- + + +def test_reply_deadline_must_be_aware_and_is_normalized_to_utc() -> None: + naive = _example("request-tabular.json") + naive["reply_deadline"] = "2026-10-03T12:30:00" + with pytest.raises(ValidationError): + RecognitionRequest.model_validate(naive) + shifted = _example("request-tabular.json") + shifted["reply_deadline"] = "2026-10-03T15:30:00+03:00" + request = RecognitionRequest.model_validate(shifted) + assert request.reply_deadline == datetime(2026, 10, 3, 12, 30, tzinfo=UTC) + assert request.reply_deadline.utcoffset() == UTC.utcoffset(None) + assert request.to_wire()["reply_deadline"] == "2026-10-03T12:30:00Z" + + +def test_uuid_fields_are_uuid_objects() -> None: + request = RecognitionRequest.model_validate(_example("request-tabular.json")) + assert request.job_id == uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert isinstance(request.file_id, uuid.UUID) + + +def test_message_status_enum_values() -> None: + assert [s.value for s in MessageStatus] == ["processing", "done", "needs_check", "error"] + message = RecognitionResultMessage.model_validate(_example("result-message-done.json")) + assert message.status is MessageStatus.DONE + + +def test_contract_version_constant() -> None: + assert CONTRACT_VERSION == "1.0" + + +def test_python_float_amount_is_rejected_with_clear_error() -> None: + payload = _example("result-statement-minimal.json") + payload["documents"][0]["lines"][0]["values"][0]["amount"] = 1234.5 + with pytest.raises(ValidationError, match="строк"): + RecognitionResult.model_validate(payload) + + +def test_decimal_object_input_is_accepted_and_dumped_as_string() -> None: + """Для кода, собирающего результат из `Decimal` (тесты, будущие производители): объект `Decimal` принимается.""" + payload = _example("result-statement-minimal.json") + payload["documents"][0]["lines"][0]["values"][0]["amount"] = Decimal("1234.50") + result = RecognitionResult.model_validate(payload) + assert result.to_wire()["documents"][0]["lines"][0]["values"][0]["amount"] == "1234.50" + + +# --- subject'ы, заголовок и идентификаторы сообщений --------------------------------------------------------------- + + +def test_request_msg_id() -> None: + job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert request_msg_id(job_id, 1) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1" + assert request_msg_id(str(job_id), 3) == "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:3" + + +def test_ack_and_result_msg_ids() -> None: + job_id = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") + assert ack_msg_id(job_id, 1) == "ack:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:1" + assert result_msg_id(job_id, 2) == "result:3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01:2" + + +def test_request_subject_by_pipeline() -> None: + assert request_subject("tabular") == "recognition.tabular.request" + assert request_subject("image") == "recognition.image.request" + assert RESULT_SUBJECT == "recognition.result" + assert NATS_MSG_ID_HEADER == "Nats-Msg-Id" + + +def test_request_subject_rejects_unknown_pipeline() -> None: + with pytest.raises(ValueError, match="pipeline"): + request_subject("ocr") + + +def test_request_subject_matches_example_pipeline() -> None: + for name in ("request-tabular.json", "request-image.json"): + request = RecognitionRequest.model_validate(_example(name)) + assert request_subject(request.pipeline) == f"recognition.{request.pipeline}.request" + + +def test_integer_valued_float_is_normalized_to_int() -> None: + """`1.0` (pandas) принимается как целое и уходит дальше целым числом: `1`.""" + payload = _example("result-osv-minimal.json") + payload["documents"][0]["rows"][0]["level"] = 1.0 + payload["documents"][0]["header"]["header_variant"] = 2.0 + result = RecognitionResult.model_validate(payload) + wire = result.to_wire() + assert wire["documents"][0]["rows"][0]["level"] == 1 + assert type(wire["documents"][0]["rows"][0]["level"]) is int + assert type(wire["documents"][0]["header"]["header_variant"]) is int + VALIDATORS["result"].validate(wire) + + +# --- проверка MAJOR версии -------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("version", "supported"), + [ + ("1.0", True), + ("1.7", True), + ("1.12", True), + ("2.0", False), + ("0.9", False), + ("10.0", False), + ("01.0", False), # шаблон схемы `^1\\.\\d+$`: ведущий ноль в MAJOR недопустим + ("9" * 5000 + ".0", False), # длиннее лимита int() (4300 цифр): без исключения + ("11.0", False), + ("1", False), + ("1.", False), + ("1.0.1", False), + ("", False), + ("v1.0", False), + (" 1.0", False), + ("1.0\n", False), + ("١.٠", False), # арабско-индийские цифры: \d схемы в Python их бы принял, контракт — нет + (1, False), + (1.0, False), + (None, False), + ], +) +def test_is_supported_major(version: object, supported: bool) -> None: + assert is_supported_major(version) is supported + + +def test_supported_major_is_one() -> None: + assert SUPPORTED_MAJOR == 1 + + +@pytest.mark.parametrize("version", ["1.0", "1.7", "2.0", "1", "1.0.1", "0.9", "01.0", "9" * 5000 + ".0"]) +def test_is_supported_major_agrees_with_schema_pattern(version: str) -> None: + """Проверка MAJOR совпадает с `schema_version` запроса по схеме (pattern `^1\\.\\d+$`).""" + payload = _example("request-image.json") + payload["schema_version"] = version + assert is_supported_major(version) is _schema_ok("request", payload) + + +def test_image_request_subject() -> None: + assert IMAGE_REQUEST_SUBJECT == "recognition.image.request" + + +# --- конструкторы конвертов ----------------------------------------------------------------------------------------- + +JOB_ID = uuid.UUID("8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03") +FILE_ID = uuid.UUID("c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04") +SERVICE = ServiceInfo(name=ServiceName.SCAN_GATEWAY, version="0.1.0") + + +def test_service_name_comes_from_settings() -> None: + from src.core.config import get_settings # noqa: PLC0415 + + settings = get_settings() + service = ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION) + assert service.name is ServiceName.SCAN_GATEWAY + assert build_ack_message(JOB_ID, FILE_ID, service).to_wire()["service"]["name"] == "scan-gateway" + + +def test_build_ack_message() -> None: + message = build_ack_message(JOB_ID, FILE_ID, SERVICE) + wire = message.to_wire() + assert wire == { + "schema_version": "1.0", + "job_id": str(JOB_ID), + "file_id": str(FILE_ID), + "status": "processing", + "service": {"name": "scan-gateway", "version": "0.1.0"}, + } + VALIDATORS["message"].validate(wire) + assert message.status is MessageStatus.PROCESSING + assert RecognitionResultMessage.model_validate(wire) == message + + +def test_build_ack_message_accepts_string_ids() -> None: + assert build_ack_message(str(JOB_ID), str(FILE_ID), SERVICE) == build_ack_message(JOB_ID, FILE_ID, SERVICE) + + +def test_build_error_message_with_raw_s3_key_and_metrics() -> None: + message = build_error_message( + JOB_ID, + FILE_ID, + SERVICE, + ErrorCode.TIMEOUT, + "ИС Сканирование не ответила за 300 с", + raw_s3_key=f"raw/{JOB_ID}.json", + metrics=Metrics(duration_ms=300000, warnings=["нет ответа"]), + ) + wire = message.to_wire() + assert wire["status"] == "error" + assert wire["error"] == {"code": "TIMEOUT", "message": "ИС Сканирование не ответила за 300 с"} + assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" + assert wire["metrics"] == {"duration_ms": 300000, "warnings": ["нет ответа"]} + assert "result_s3" not in wire + VALIDATORS["message"].validate(wire) + assert RecognitionResultMessage.model_validate(wire) == message + + +def test_build_error_message_minimal_has_no_optional_keys() -> None: + wire = build_error_message(JOB_ID, FILE_ID, SERVICE, ErrorCode.SCHEMA_MISMATCH, "нет s3.key").to_wire() + assert set(wire) == {"schema_version", "job_id", "file_id", "status", "service", "error"} + VALIDATORS["message"].validate(wire) + + +@pytest.mark.parametrize("code", list(ErrorCode)) +def test_build_error_message_every_contract_code(code: ErrorCode) -> None: + wire = build_error_message(JOB_ID, FILE_ID, SERVICE, code, "x").to_wire() + assert wire["error"]["code"] == code.value + VALIDATORS["message"].validate(wire) + + +def test_error_codes_match_schema_enum() -> None: + schema = _load(CONTRACTS_DIR / SCHEMA_FILES["message"]) + assert {c.value for c in ErrorCode} == set(schema["properties"]["error"]["properties"]["code"]["enum"]) + + +def test_build_done_message() -> None: + result_s3 = S3Ref(bucket="arm-ka", key=f"results/{FILE_ID}/{JOB_ID}/result.json") + message = build_done_message(JOB_ID, FILE_ID, SERVICE, result_s3, raw_s3_key=f"raw/{JOB_ID}.json") + wire = message.to_wire() + assert wire["status"] == "done" + assert wire["result_s3"] == {"bucket": "arm-ka", "key": f"results/{FILE_ID}/{JOB_ID}/result.json"} + assert wire["result_schema_version"] == "1.0" + assert wire["raw_s3_key"] == f"raw/{JOB_ID}.json" + VALIDATORS["message"].validate(wire) + assert RecognitionResultMessage.model_validate(wire) == message + + +def test_build_done_message_needs_check() -> None: + result_s3 = S3Ref(bucket="arm-ka", key="results/x/result.json") + message = build_done_message(JOB_ID, FILE_ID, SERVICE, result_s3, needs_check=True) + assert message.status is MessageStatus.NEEDS_CHECK + VALIDATORS["message"].validate(message.to_wire()) + + +def test_constructed_envelopes_match_example_files() -> None: + """Конструкторы дают то же, что эталонные примеры контракта.""" + done = _example("result-message-done.json") + built = build_done_message( + done["job_id"], + done["file_id"], + ServiceInfo(name=ServiceName.SCAN_GATEWAY, version=done["service"]["version"]), + S3Ref(**done["result_s3"]), + raw_s3_key=done["raw_s3_key"], + metrics=Metrics(**done["metrics"]), + ) + assert built.to_wire() == done + + +def test_envelope_is_immutable() -> None: + message = build_ack_message(JOB_ID, FILE_ID, SERVICE) + with pytest.raises(ValidationError): + message.status = MessageStatus.DONE # type: ignore[misc] + + +def test_message_msg_ids_for_built_envelopes() -> None: + assert ack_msg_id(JOB_ID, 2) == f"ack:{JOB_ID}:2" + assert result_msg_id(JOB_ID, 2) == f"result:{JOB_ID}:2" + assert request_msg_id(JOB_ID, 2) == f"{JOB_ID}:2" diff --git a/api/tests/unit/test_emulator.py b/api/tests/unit/test_emulator.py new file mode 100644 index 0000000..73a5e9f --- /dev/null +++ b/api/tests/unit/test_emulator.py @@ -0,0 +1,623 @@ +"""Эмулятор ИС Сканирование (spec scan-emulator, D-6, R-11, slice-0-skeleton). + +Сценарии spec «Точное имя», «Суффикс», «Несколько файлов на одно задание», «Нет пары» — на копиях реальных +`../fixtures/ocr/*.json` (маркер `fixtures`); остальное — на синтетических фикстурах в `tmp_path`, без внешних +репозиториев и без Docker. +""" + +import json +import os +import shutil +import subprocess +import sys +import threading +import uuid +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from loguru import logger + +import src.scan_emulator as emulator +from src.gateway.hotfolder import LocalHotFolder, inbox_name +from src.gateway.merge import merge_answers +from src.normalizer import NullNormalizer +from src.scan_emulator import EmulatorSettings, process_once, run +from src.scan_emulator.__main__ import main + +API_DIR = Path(__file__).resolve().parents[2] +# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у теста идентичности контракта +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" + + +def _uid() -> str: + return str(uuid.uuid4()) + + +def _hotfolder(tmp_path: Path) -> LocalHotFolder: + root = tmp_path / "hotfolder" + root.mkdir() + return LocalHotFolder(root) + + +def _fixtures_dir(tmp_path: Path, files: dict[str, Any] | None = None) -> Path: + path = tmp_path / "fixtures" + path.mkdir(exist_ok=True) + for name, content in (files or {}).items(): + text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) + (path / name).write_text(text, encoding="utf-8") + return path + + +def _drop(hf: LocalHotFolder, name: str, data: bytes = b"%PDF") -> str: + """Положить `_` во входящий каталог, вернуть uuid.""" + job_id = _uid() + hf.put(inbox_name(job_id, name), data) + return job_id + + +def _answer(hf: LocalHotFolder, job_id: str, name: str | None = None) -> dict[str, Any]: + """Файл ответа каталога `Output//` (по умолчанию основной `.json`).""" + path = hf.answer_path(job_id) if name is None else hf.answer_dir(job_id) / name + result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) + return result + + +def _names(hf: LocalHotFolder, job_id: str) -> list[str]: + return sorted(p.name for p in hf.answer_dir(job_id).iterdir()) + + +# форма `data_ocr` реального ответа-ошибки ИС (aep_5.json); R-28 +EMPTY_DATA_OCR: dict[str, Any] = { + "label": {"form_name": "", "confidence": 0}, + "organization": {"form_name": "", "confidence": 0}, + "inn": {"form_name": "", "confidence": 0}, + "units": {"form_name": "", "confidence": 0}, + "date": {"form_name": "", "confidence": 0}, + "table_structure": [], +} + + +@pytest.fixture +def log_messages() -> Iterator[list[str]]: + messages: list[str] = [] + handler_id = logger.add(lambda message: messages.append(str(message)), level="DEBUG", format="{level}|{message}") + yield messages + logger.remove(handler_id) + + +@pytest.fixture +def real_fixtures(tmp_path: Path) -> Path: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + target = tmp_path / "real-fixtures" + target.mkdir() + for source in REAL_FIXTURES.glob("*.json"): + shutil.copy(source, target / source.name) + return target + + +@pytest.mark.fixtures +class TestRealFixtures: + @pytest.mark.parametrize("name", ["aep_5.pdf", "aep_5.PDF"]) + def test_exact_name(self, tmp_path: Path, real_fixtures: Path, name: str) -> None: + hf = _hotfolder(tmp_path) + job_id = _drop(hf, name) + assert process_once(hf.root, real_fixtures, 0) == [job_id] + expected = json.loads((real_fixtures / "aep_5.json").read_text(encoding="utf-8")) + expected["file_uid"] = job_id + assert _answer(hf, job_id) == expected + assert (hf.input_dir / f"{job_id}_{name}.done").is_file() + assert not (hf.input_dir / f"{job_id}_{name}").exists() + + def test_suffix(self, tmp_path: Path, real_fixtures: Path) -> None: + """`aep_8.pdf` -> `aep_8_form4.json` как `_form4.json` (раньше — `.json`, правило `*`).""" + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "aep_8.pdf") + process_once(hf.root, real_fixtures, 0) + expected = json.loads((real_fixtures / "aep_8_form4.json").read_text(encoding="utf-8")) + expected["file_uid"] = job_id + assert _names(hf, job_id) == [f"{job_id}_form4.json"] + assert _answer(hf, job_id, f"{job_id}_form4.json") == expected + assert (hf.input_dir / f"{job_id}_aep_8.pdf.done").is_file() + + def test_two_files_for_one_job(self, tmp_path: Path, real_fixtures: Path) -> None: + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "aep_3.pdf") + assert process_once(hf.root, real_fixtures, 0) == [job_id] + assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"] + main = json.loads((real_fixtures / "aep_3.json").read_text(encoding="utf-8")) + ofr = json.loads((real_fixtures / "aep_3_ofr.json").read_text(encoding="utf-8")) + assert main["error"] == {"code": "", "message": ""} + assert ofr["error"]["code"] == "INVALID_DOCUMENT" + assert _answer(hf, job_id) == {**main, "file_uid": job_id} + assert _answer(hf, job_id, f"{job_id}_ofr.json") == {**ofr, "file_uid": job_id} + assert (hf.input_dir / f"{job_id}_aep_3.pdf.done").is_file() + + def test_single_success_file(self, tmp_path: Path, real_fixtures: Path) -> None: + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "aep_6.pdf") + process_once(hf.root, real_fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json"] + expected = json.loads((real_fixtures / "aep_6.json").read_text(encoding="utf-8")) + assert _answer(hf, job_id) == {**expected, "file_uid": job_id} + + def test_no_pair(self, tmp_path: Path, real_fixtures: Path) -> None: + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "unknown.pdf") + process_once(hf.root, real_fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json"] + assert _answer(hf, job_id) == { + "file_uid": job_id, + "error": {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"}, + "data_ocr": EMPTY_DATA_OCR, + } + assert (hf.input_dir / f"{job_id}_unknown.pdf.done").is_file() + + def test_no_pair_has_the_shape_of_real_error_answers(self, tmp_path: Path, real_fixtures: Path) -> None: + """R-28: форма `data_ocr` совпадает с реальным ответом-ошибкой `aep_5.json` (кроме `0` вместо `0.0`).""" + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "unknown.pdf") + process_once(hf.root, real_fixtures, 0) + real = json.loads((real_fixtures / "aep_5.json").read_text(encoding="utf-8")) + assert _answer(hf, job_id)["data_ocr"] == real["data_ocr"] + assert list(_answer(hf, job_id)["data_ocr"]) == list(real["data_ocr"]) + + +class TestFixtureLookup: + def test_name_with_other_characters_is_not_taken(self, tmp_path: Path) -> None: + """В набор входят `.json` и `_*.json`; `a_b-1.json` (нет `_` после имени) — нет.""" + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a_b-1.json": {"v": "dash"}, "a_b.json": {"file_uid": "x", "v": "exact"}}) + job_id = _drop(hf, "a_b.pdf") + process_once(hf.root, fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json"] + assert _answer(hf, job_id) == {"file_uid": job_id, "v": "exact"} + + def test_all_suffix_fixtures_are_taken(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a_zz.json": {"v": "zz"}, "a_b.json": {"v": "b"}, "a_c.json": {"v": "c"}}) + job_id = _drop(hf, "a.pdf") + process_once(hf.root, fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}_b.json", f"{job_id}_c.json", f"{job_id}_zz.json"] + assert [_answer(hf, job_id, f"{job_id}_{s}.json")["v"] for s in ("b", "c", "zz")] == ["b", "c", "zz"] + + def test_foreign_prefix_is_not_taken(self, tmp_path: Path) -> None: + """`aep_3` не забирает `aep_30*`: правило `.json` и `_*.json`, а не `*`.""" + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir( + tmp_path, + { + "aep_3.json": {"v": "main"}, + "aep_3_ofr.json": {"v": "ofr"}, + "aep_30.json": {"v": 30}, + "aep_30_x.json": {"v": 31}, + }, + ) + job_id = _drop(hf, "aep_3.pdf") + process_once(hf.root, fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"] + assert _answer(hf, job_id)["v"] == "main" + assert _answer(hf, job_id, f"{job_id}_ofr.json")["v"] == "ofr" + + def test_underscore_without_suffix_is_not_taken(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a_.json": {"v": 1}}) + job_id = _drop(hf, "a.pdf") + process_once(hf.root, fixtures, 0) + assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT" + + def test_file_uid_added_when_absent_and_replaced_when_present(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"data_ocr": [1]}, "b.json": {"file_uid": "old", "data_ocr": []}}) + first, second = _drop(hf, "a.pdf"), _drop(hf, "b.pdf") + process_once(hf.root, fixtures, 0) + assert _answer(hf, first) == {"file_uid": first, "data_ocr": [1]} + assert _answer(hf, second) == {"file_uid": second, "data_ocr": []} + + def test_name_without_extension(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"scan.json": {"v": 1}}) + job_id = _drop(hf, "scan") + process_once(hf.root, fixtures, 0) + assert _answer(hf, job_id) == {"file_uid": job_id, "v": 1} + + def test_name_with_glob_characters_is_literal(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + job_id = _drop(hf, "[a]*.pdf") + process_once(hf.root, fixtures, 0) + assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT" + + def test_non_ascii_not_escaped(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"text": "Баланс"}}) + job_id = _drop(hf, "a.pdf") + process_once(hf.root, fixtures, 0) + assert "Баланс" in hf.answer_path(job_id).read_text(encoding="utf-8") + + def test_missing_fixtures_dir_gives_error_answer(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + job_id = _drop(hf, "a.pdf") + process_once(hf.root, tmp_path / "нет-такого", 0) + assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT" + + @pytest.mark.parametrize("content", ["{не json", "[1, 2]", ""]) + def test_broken_fixture_gives_error_answer(self, tmp_path: Path, log_messages: list[str], content: str) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": content}) + job_id = _drop(hf, "a.pdf") + assert process_once(hf.root, fixtures, 0) == [job_id] + answer = _answer(hf, job_id) + assert answer["file_uid"] == job_id + assert answer["data_ocr"] == EMPTY_DATA_OCR + assert answer["error"]["code"] == "INVALID_DOCUMENT" + assert "a.pdf" in answer["error"]["message"] + assert "a.json" in answer["error"]["message"] + assert any(m.startswith("ERROR|") and "a.json" in m for m in log_messages) + assert (hf.input_dir / f"{job_id}_a.pdf.done").is_file() + + +class TestProcessOnce: + def test_returns_processed_uuids(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + ids = {_drop(hf, "a.pdf"), _drop(hf, "a.pdf")} + assert set(process_once(hf.root, fixtures, 0)) == ids + + def test_nothing_to_do(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + assert process_once(hf.root, _fixtures_dir(tmp_path), 0) == [] + + def test_missing_input_dir_is_not_an_error(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + assert not hf.input_dir.exists() + assert process_once(hf.root, _fixtures_dir(tmp_path), 0) == [] + + def test_second_pass_does_not_reprocess(self, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + job_id = _drop(hf, "a.pdf") + assert process_once(hf.root, fixtures, 0) == [job_id] + mtime = hf.answer_path(job_id).stat().st_mtime_ns + assert process_once(hf.root, fixtures, 0) == [] + assert hf.answer_path(job_id).stat().st_mtime_ns == mtime + + def test_ignores_done_dotfiles_and_non_uuid(self, tmp_path: Path, log_messages: list[str]) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + hf.input_dir.mkdir() + job_id = _uid() + ignored = [ + f"{job_id}_a.pdf.done", + f".{job_id}_a.pdf.part", + "a.pdf", + "not-a-uuid_a.pdf", + f"{job_id}a.pdf", + f"{job_id}_", + f"{job_id[:-1]}_a.pdf", + ] + for name in ignored: + (hf.input_dir / name).write_bytes(b"x") + (hf.input_dir / f"{_uid()}_dir.pdf").mkdir() + assert process_once(hf.root, fixtures, 0) == [] + assert sorted(p.name for p in hf.input_dir.iterdir() if p.is_file()) == sorted(ignored) + assert not hf.output_dir.exists() + warnings = [m for m in log_messages if m.startswith("WARNING|")] + assert len(warnings) == 1 + assert "not-a-uuid_a.pdf" in warnings[0] + + def test_delay_once_per_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + sleeps: list[float] = [] + monkeypatch.setattr("src.scan_emulator.time.sleep", sleeps.append) + assert process_once(hf.root, fixtures, 2) == [] + assert sleeps == [] # нет работы — нет задержки + _drop(hf, "a.pdf") + _drop(hf, "a.pdf") + process_once(hf.root, fixtures, 2) + assert sleeps == [2] + + def test_answer_exists_before_done_and_is_atomic(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + job_id = _drop(hf, "a.pdf") + seen: dict[str, bool] = {} + original_replace = __import__("os").replace + + def spy(src: Path, dst: Path) -> None: + if str(dst).endswith(".done"): + seen["answer_before_done"] = hf.answer_path(job_id).is_file() + original_replace(src, dst) + + monkeypatch.setattr("src.scan_emulator.os.replace", spy) + process_once(hf.root, fixtures, 0) + assert seen == {"answer_before_done": True} + assert [p.name for p in hf.answer_path(job_id).parent.iterdir()] == [f"{job_id}.json"] + + def test_custom_dir_names(self, tmp_path: Path) -> None: + root = tmp_path / "hf" + root.mkdir() + hf = LocalHotFolder(root, "In", "Out") + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + job_id = _drop(hf, "a.pdf") + assert process_once(root, fixtures, 0, input_dir="In", output_dir="Out") == [job_id] + assert hf.answer_path(job_id).is_file() + + def test_interop_put_emulator_poll(self, tmp_path: Path) -> None: + """Сквозной «put -> эмулятор -> poll» без Docker (D-9); готовность набора без ожидания (settle 0).""" + root = tmp_path / "hotfolder" + root.mkdir() + hf = LocalHotFolder(root, settle_seconds=0) + fixtures = _fixtures_dir(tmp_path, {"report.json": {"file_uid": "x", "data_ocr": [{"k": "v"}]}}) + job_id = _uid() + assert hf.poll(job_id) is None + hf.put(inbox_name(job_id, "report.pdf"), b"%PDF-1.4") + assert hf.poll(job_id) is None + process_once(hf.root, fixtures, 0) + files = hf.poll(job_id) + assert files is not None + assert [f.name for f in files] == [f"{job_id}.json"] + assert json.loads(files[0].data) == {"file_uid": job_id, "data_ocr": [{"k": "v"}]} + + +class TestAnswerSet: + def _two_file_job(self, tmp_path: Path) -> tuple[LocalHotFolder, Path, str]: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": "main"}, "a_ofr.json": {"v": "ofr"}}) + return hf, fixtures, _drop(hf, "a.pdf") + + def test_directory_appears_only_by_rename_of_complete_set( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """R-26: до rename каталога `Output//` нет вовсе, набор уже собран во временном каталоге `Tmp`; + шлюз (`poll`) в это время ничего не видит, после rename — видит оба файла сразу.""" + hf, fixtures, job_id = self._two_file_job(tmp_path) + gateway_side = LocalHotFolder(hf.root, settle_seconds=0) + seen: dict[str, Any] = {} + original_replace = os.replace + + def spy(src: Path, dst: Path) -> None: + if Path(dst) == hf.answer_dir(job_id): + seen["output_entries_before"] = sorted(p.name for p in hf.output_dir.iterdir()) + seen["poll_before"] = gateway_side.poll(job_id) + seen["staging_parent"] = Path(src).parent + seen["staging_files"] = sorted(p.name for p in Path(src).iterdir()) + original_replace(src, dst) + + monkeypatch.setattr("src.scan_emulator.os.replace", spy) + process_once(hf.root, fixtures, 0) + assert seen["output_entries_before"] == [] + assert seen["poll_before"] is None + assert seen["staging_parent"] == hf.tmp_dir + assert seen["staging_files"] == [f"{job_id}.json", f"{job_id}_ofr.json"] + after = gateway_side.poll(job_id) + assert after is not None + assert [f.name for f in after] == [f"{job_id}.json", f"{job_id}_ofr.json"] + assert list(hf.tmp_dir.iterdir()) == [] # временный каталог переименован, не скопирован + + def test_failure_while_writing_leaves_no_answer_dir_and_no_done( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf, fixtures, job_id = self._two_file_job(tmp_path) + calls: list[Path] = [] + original = emulator._write_json + + def flaky(path: Path, answer: dict[str, Any]) -> None: + calls.append(path) + if len(calls) == 2: + raise OSError("диск переполнен") + original(path, answer) + + monkeypatch.setattr(emulator, "_write_json", flaky) + with pytest.raises(OSError, match="диск"): + process_once(hf.root, fixtures, 0) + assert len(calls) == 2 + assert not hf.answer_dir(job_id).exists() + assert list(hf.tmp_dir.iterdir()) == [] + assert (hf.input_dir / f"{job_id}_a.pdf").is_file() + assert not (hf.input_dir / f"{job_id}_a.pdf.done").exists() + monkeypatch.undo() + assert process_once(hf.root, fixtures, 0) == [job_id] # следующий проход обрабатывает задание целиком + assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"] + + def test_stale_staging_dir_is_discarded(self, tmp_path: Path) -> None: + hf, fixtures, job_id = self._two_file_job(tmp_path) + stale = hf.tmp_dir / f".{job_id}.answer" + stale.mkdir(parents=True) + (stale / "junk.json").write_text("{}") + process_once(hf.root, fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"] + assert list(hf.tmp_dir.iterdir()) == [] + + def test_existing_answer_dir_is_replaced(self, tmp_path: Path) -> None: + hf, fixtures, job_id = self._two_file_job(tmp_path) + hf.answer_dir(job_id).mkdir(parents=True) + (hf.answer_dir(job_id) / "old.json").write_text("{}") + process_once(hf.root, fixtures, 0) + assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"] + + def test_custom_tmp_dir_name(self, tmp_path: Path) -> None: + root = tmp_path / "hf" + root.mkdir() + hf = LocalHotFolder(root, tmp_dir="Work") + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + job_id = _drop(hf, "a.pdf") + assert process_once(root, fixtures, 0, tmp_dir="Work") == [job_id] + assert (root / "Work").is_dir() + assert not (root / "Tmp").exists() or list((root / "Tmp").iterdir()) == [] + + @pytest.mark.fixtures + def test_gateway_poll_waits_for_settle_then_returns_whole_set(self, tmp_path: Path) -> None: + """Сквозной «put -> эмулятор -> poll (settle, ложные часы) -> merge_answers -> NullNormalizer» на `aep_3`.""" + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + root = tmp_path / "hotfolder" + root.mkdir() + now = [100.0] + hf = LocalHotFolder(root, settle_seconds=2, clock=lambda: now[0]) + job_id = _uid() + assert hf.poll(job_id) is None + hf.put(inbox_name(job_id, "aep_3.pdf"), b"%PDF-1.4") + assert process_once(root, REAL_FIXTURES, 0) == [job_id] + assert hf.poll(job_id) is None # набор впервые замечен: settle отсчитывается с этого опроса + now[0] += 1.9 + assert hf.poll(job_id) is None + now[0] += 0.2 + files = hf.poll(job_id) + assert files is not None + assert [f.name for f in files] == [f"{job_id}.json", f"{job_id}_ofr.json"] + merged = merge_answers(job_id, files) + outcome = NullNormalizer().normalize(merged) + assert outcome.error is not None + assert outcome.error.code == "NOT_IMPLEMENTED" + assert outcome.warnings is not None + assert len(outcome.warnings) == 1 + assert outcome.warnings[0].startswith(f"{job_id}_ofr.json: INVALID_DOCUMENT: ") + + +class TestSettings: + def test_defaults(self, tmp_path: Path) -> None: + settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path)}) + assert settings.hotfolder_path == tmp_path + assert (settings.input_dir, settings.output_dir) == ("Input", "Output") + assert settings.delay == 2 + assert settings.poll_interval == 1 + assert settings.tmp_dir == "Tmp" + assert settings.fixtures_dir == Path("../../fixtures/ocr") + + def test_overrides(self, tmp_path: Path) -> None: + settings = EmulatorSettings.from_env( + { + "SCAN_HOTFOLDER_PATH": str(tmp_path), + "SCAN_INPUT_DIR": "In", + "SCAN_OUTPUT_DIR": "Out", + "SCAN_TMP_DIR": "Work", + "SCAN_FIXTURES_DIR": "fx", + "SCAN_EMULATOR_DELAY": "0.5", + "SCAN_EMULATOR_POLL_SECONDS": "0.1", + } + ) + assert (settings.input_dir, settings.output_dir) == ("In", "Out") + assert settings.tmp_dir == "Work" + assert settings.fixtures_dir == Path("fx") + assert (settings.delay, settings.poll_interval) == (0.5, 0.1) + + def test_path_is_required(self) -> None: + with pytest.raises(ValueError, match="SCAN_HOTFOLDER_PATH"): + EmulatorSettings.from_env({}) + + @pytest.mark.parametrize( + ("name", "value"), + [("SCAN_EMULATOR_DELAY", "-1"), ("SCAN_EMULATOR_DELAY", "abc"), ("SCAN_EMULATOR_POLL_SECONDS", "0")], + ) + def test_invalid_numbers(self, tmp_path: Path, name: str, value: str) -> None: + with pytest.raises(ValueError, match=name): + EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path), name: value}) + + +def _settings(tmp_path: Path, fixtures: Path, *, poll: float = 0.01) -> EmulatorSettings: + return EmulatorSettings( + hotfolder_path=tmp_path / "hotfolder", + input_dir="Input", + output_dir="Output", + fixtures_dir=fixtures, + delay=0, + poll_interval=poll, + ) + + +class TestRun: + def test_startup_log_and_processing(self, tmp_path: Path, log_messages: list[str]) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}, "b.json": {"v": 2}}) + (fixtures / "readme.txt").write_text("не фикстура") + job_id = _drop(hf, "a.pdf") + run(_settings(tmp_path, fixtures), threading.Event(), max_iterations=2) + assert hf.answer_path(job_id).is_file() + startup = "\n".join(log_messages) + assert str(hf.root) in startup + assert str(hf.input_dir) in startup + assert str(hf.output_dir) in startup + assert str(fixtures) in startup + assert any("фикстур: 2" in m for m in log_messages) + + def test_missing_fixtures_dir_warns_and_continues(self, tmp_path: Path, log_messages: list[str]) -> None: + hf = _hotfolder(tmp_path) + missing = tmp_path / "нет" + job_id = _drop(hf, "a.pdf") + run(_settings(tmp_path, missing), threading.Event(), max_iterations=1) + assert any(m.startswith("WARNING|") and str(missing) in m for m in log_messages) + assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT" + + def test_stop_event_ends_loop_without_waiting(self, tmp_path: Path) -> None: + _hotfolder(tmp_path) + stop = threading.Event() + stop.set() + run(_settings(tmp_path, _fixtures_dir(tmp_path), poll=60), stop) # вернётся сразу, а не через 60 с + + def test_loop_survives_pass_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_messages: list[str] + ) -> None: + _hotfolder(tmp_path) + calls: list[int] = [] + + def boom(*args: object, **kwargs: object) -> list[str]: + calls.append(1) + raise OSError("сбой") + + monkeypatch.setattr("src.scan_emulator.process_once", boom) + run(_settings(tmp_path, _fixtures_dir(tmp_path)), threading.Event(), max_iterations=2) + assert len(calls) == 2 + assert any(m.startswith("ERROR|") for m in log_messages) + + +class TestMain: + def test_no_hotfolder_path_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("SCAN_HOTFOLDER_PATH", raising=False) + monkeypatch.setenv("ENV_FILE", str(tmp_path / "нет.env")) + assert main() != 0 + + def test_hotfolder_root_missing_exits_nonzero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(tmp_path / "нет-корня")) + assert main() != 0 + + def test_clean_exit_on_stop(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + hf = _hotfolder(tmp_path) + fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}}) + monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(hf.root)) + monkeypatch.setenv("SCAN_FIXTURES_DIR", str(fixtures)) + monkeypatch.setenv("SCAN_EMULATOR_DELAY", "0") + monkeypatch.setenv("SCAN_EMULATOR_POLL_SECONDS", "0.01") + job_id = _drop(hf, "a.pdf") + stop = threading.Event() + threading.Timer(0.3, stop.set).start() + assert main(stop) == 0 + assert hf.answer_path(job_id).is_file() + + def test_module_starts_without_gateway_settings(self, tmp_path: Path) -> None: + """C-1: `python -m src.scan_emulator` не читает настройки шлюза — импорт пакета `src` не создаёт приложение. + + Окружение — только PATH, пустой ENV_FILE и несуществующий корень: ожидается штатный отказ эмулятора (код 2, + сообщение про корень горячей папки), а не `ValidationError` настроек шлюза (`OPENBAO`). + """ + env = { + "PATH": os.environ.get("PATH", ""), + "ENV_FILE": str(tmp_path / "none.env"), + "SCAN_HOTFOLDER_PATH": str(tmp_path / "missing"), + } + proc = subprocess.run( + [sys.executable, "-m", "src.scan_emulator"], + cwd=API_DIR, + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + assert proc.returncode == 2, proc.stderr + assert "корень горячей папки" in proc.stderr + assert "ValidationError" not in proc.stderr + assert "OPENBAO" not in proc.stderr diff --git a/api/tests/unit/test_health.py b/api/tests/unit/test_health.py new file mode 100644 index 0000000..2517573 --- /dev/null +++ b/api/tests/unit/test_health.py @@ -0,0 +1,120 @@ +"""Состояние для `/healthcheck` (`src.core.health`): поля `nats` и `hotfolder` (spec «Старт в режиме локальных папок», +«Горячая папка недоступна», «Соединение с NATS без JetStream»; D-8; T8.4 — проверка не зависает, T2.3 — сбой SMB).""" + +import threading +from pathlib import Path + +import pytest + +from src.core.health import HealthProvider +from src.gateway.hotfolder import AnswerFile, LocalHotFolder + +pytestmark = pytest.mark.usefixtures("log_lines") + + +class Connection: + def __init__(self, connected: bool) -> None: + self.is_connected = connected + + +class CountingHotFolder: + """`healthy()` считает вызовы; может зависнуть до события или бросить исключение.""" + + def __init__(self, result: bool = True) -> None: + self.result = result + self.calls = 0 + self.release: threading.Event | None = None + self.error: BaseException | None = None + + def put(self, name: str, data: bytes) -> None: + raise NotImplementedError + + def poll(self, job_id: str) -> list[AnswerFile] | None: + raise NotImplementedError + + def healthy(self) -> bool: + self.calls += 1 + if self.release is not None: + self.release.wait(5) + if self.error is not None: + raise self.error + return self.result + + +class Clock: + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +async def test_local_root_exists_is_ok(tmp_path: Path) -> None: + health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path)) + assert await health.snapshot() == {"nats": "disabled", "hotfolder": "ok"} + + +async def test_missing_root_is_down(tmp_path: Path) -> None: + health = HealthProvider(nats_enabled=False, hotfolder=LocalHotFolder(tmp_path / "missing")) + assert await health.hotfolder_status() == "down" + + +async def test_no_hotfolder_is_down() -> None: + assert await HealthProvider(nats_enabled=False, hotfolder=None).hotfolder_status() == "down" + + +@pytest.mark.parametrize( + ("enabled", "connection", "expected"), + [ + (False, None, "disabled"), + (False, Connection(True), "disabled"), + (True, None, "down"), + (True, Connection(False), "down"), + (True, Connection(True), "ok"), + ], +) +def test_nats_status(enabled: bool, connection: Connection | None, expected: str) -> None: + health = HealthProvider(nats_enabled=enabled, hotfolder=None) + health.nats = connection + assert health.nats_status() == expected + + +async def test_hotfolder_result_is_cached_for_ttl() -> None: + hotfolder = CountingHotFolder(result=True) + clock = Clock() + health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, cache_ttl=5, clock=clock) + assert await health.hotfolder_status() == "ok" + hotfolder.result = False + clock.now += 4.9 + assert await health.hotfolder_status() == "ok" + assert hotfolder.calls == 1 + clock.now += 0.2 + assert await health.hotfolder_status() == "down" + assert hotfolder.calls == 2 + + +async def test_hanging_check_is_down_within_timeout_and_not_repeated() -> None: + hotfolder = CountingHotFolder() + hotfolder.release = threading.Event() + clock = Clock() + health = HealthProvider(nats_enabled=False, hotfolder=hotfolder, check_timeout=0.05, cache_ttl=5, clock=clock) + try: + assert await health.hotfolder_status() == "down" + clock.now += 10 # кэш истёк, но прежняя проверка ещё висит — новый поток не запускается + assert await health.hotfolder_status() == "down" + assert hotfolder.calls == 1 + finally: + hotfolder.release.set() + + +async def test_check_raising_is_down() -> None: + hotfolder = CountingHotFolder() + hotfolder.error = OSError("smb") + health = HealthProvider(nats_enabled=False, hotfolder=hotfolder) + assert await health.hotfolder_status() == "down" + + +async def test_snapshot_with_connected_nats(tmp_path: Path) -> None: + health = HealthProvider(nats_enabled=True, hotfolder=LocalHotFolder(tmp_path)) + health.nats = Connection(True) + assert await health.snapshot() == {"nats": "ok", "hotfolder": "ok"} diff --git a/api/tests/unit/test_hotfolder_factory.py b/api/tests/unit/test_hotfolder_factory.py new file mode 100644 index 0000000..d88c7f5 --- /dev/null +++ b/api/tests/unit/test_hotfolder_factory.py @@ -0,0 +1,126 @@ +"""Фабрика горячей папки `create_hotfolder(settings)` (R-6, D-1, slice-0-skeleton): режим из `SCAN_HOTFOLDER_MODE`, +модуль `src.gateway.smb` импортируется только в режиме `smb`.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from src.core.config import Settings, SMBSettings, get_settings +from src.gateway import create_hotfolder +from src.gateway.hotfolder import LocalHotFolder + +API_DIR = Path(__file__).resolve().parents[2] + + +def _local_settings(root: Path) -> Settings: + return get_settings().model_copy( + update={ + "SCAN_HOTFOLDER_MODE": "local", + "SCAN_HOTFOLDER_PATH": str(root), + "SCAN_INPUT_DIR": "In", + "SCAN_TMP_DIR": "Служебный", + "SCAN_SETTLE_SECONDS": 1.5, + } + ) + + +def _smb_settings() -> Settings: + settings = get_settings() + smb = SMBSettings(SMB_HOST="smb.example.local", SMB_PATH="share/scan", SMB_LOGIN="user", SMB_PASSWORD="secret") + return settings.model_copy( + update={ + "SCAN_HOTFOLDER_MODE": "smb", + "SCAN_OUTPUT_DIR": "Out", + "SCAN_TMP_DIR": "T", + "SCAN_SETTLE_SECONDS": 3, + "OPENBAO": settings.OPENBAO.model_copy(update={"SMB": smb}), + } + ) + + +def test_local_mode_builds_local_hotfolder(tmp_path: Path) -> None: + hf = create_hotfolder(_local_settings(tmp_path)) + assert isinstance(hf, LocalHotFolder) + assert hf.root == tmp_path + assert hf.input_dir == tmp_path / "In" + assert hf.output_dir == tmp_path / "Output" + assert hf.tmp_dir == tmp_path / "Служебный" + assert hf.settle_seconds == 1.5 + assert hf.healthy() is True + + +def test_smb_mode_builds_smb_hotfolder() -> None: + from src.gateway.smb import SmbHotFolder # noqa: PLC0415 — импорт смб-модуля только в этом тесте + + hf = create_hotfolder(_smb_settings()) + assert isinstance(hf, SmbHotFolder) + assert hf.root == "\\\\smb.example.local\\share\\scan" + assert hf.input_dir == "\\\\smb.example.local\\share\\scan\\Input" + assert hf.output_dir == "\\\\smb.example.local\\share\\scan\\Out" + assert hf.tmp_dir == "\\\\smb.example.local\\share\\scan\\T" + assert hf.settle_seconds == 3 + + +def test_smb_mode_does_not_touch_share(monkeypatch: pytest.MonkeyPatch) -> None: + from src.gateway import smb # noqa: PLC0415 + + def forbidden(*args: object, **kwargs: object) -> None: + raise AssertionError("фабрика не должна обращаться к SMB") + + for name in ("open_file", "listdir", "scandir", "mkdir", "replace", "remove", "register_session"): + monkeypatch.setattr(smb.smbclient, name, forbidden) + create_hotfolder(_smb_settings()) + + +def test_unknown_mode_is_rejected(tmp_path: Path) -> None: + settings = _local_settings(tmp_path).model_copy(update={"SCAN_HOTFOLDER_MODE": "ftp"}) + with pytest.raises(ValueError, match="SCAN_HOTFOLDER_MODE"): + create_hotfolder(settings) + + +def test_local_mode_does_not_import_smb_module(tmp_path: Path) -> None: + """Отдельный интерпретатор: в процессе pytest модуль мог быть импортирован другими тестами.""" + code = "\n".join( + [ + "import sys", + "from src.core.config import get_settings", + "from src.gateway import create_hotfolder", + f"settings = get_settings().model_copy(update={{'SCAN_HOTFOLDER_PATH': {str(tmp_path)!r}}})", + "hf = create_hotfolder(settings)", + "assert settings.SCAN_HOTFOLDER_MODE == 'local'", + "assert type(hf).__name__ == 'LocalHotFolder', type(hf)", + "loaded = sorted(m for m in sys.modules if m.startswith('src.gateway'))", + "assert 'src.gateway.smb' not in sys.modules, loaded", + "print(loaded)", + ] + ) + result = subprocess.run( + [sys.executable, "-c", code], cwd=API_DIR, capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr + assert "src.gateway.hotfolder" in result.stdout + + +def test_smb_mode_imports_smb_module_lazily() -> None: + """Контроль предыдущего теста: в режиме smb тот же сценарий импортирует `src.gateway.smb`.""" + code = "\n".join( + [ + "import sys", + "from src.core.config import SMBSettings, get_settings", + "from src.gateway import create_hotfolder", + "assert 'src.gateway.smb' not in sys.modules", + "s = get_settings()", + "smb = SMBSettings(SMB_HOST='h', SMB_PATH='share', SMB_LOGIN='u', SMB_PASSWORD='p')", + "openbao = s.OPENBAO.model_copy(update={'SMB': smb})", + "s = s.model_copy(update={'SCAN_HOTFOLDER_MODE': 'smb', 'OPENBAO': openbao})", + "hf = create_hotfolder(s)", + "assert type(hf).__name__ == 'SmbHotFolder', type(hf)", + "assert 'src.gateway.smb' in sys.modules", + ] + ) + result = subprocess.run( + [sys.executable, "-c", code], cwd=API_DIR, capture_output=True, text=True, check=False, timeout=60 + ) + assert result.returncode == 0, result.stderr diff --git a/api/tests/unit/test_local_hotfolder.py b/api/tests/unit/test_local_hotfolder.py new file mode 100644 index 0000000..9c319c3 --- /dev/null +++ b/api/tests/unit/test_local_hotfolder.py @@ -0,0 +1,632 @@ +"""Горячая папка в обычном каталоге (spec image-recognition-gateway, D-1, R-4/R-5, slice-0-skeleton). + +Быстрые тесты на `tmp_path`: атомарная запись входящего файла через служебный каталог `Tmp`, опрос набора ответов ИС +с готовностью по стабилизации (settle, часы подменены), проверка здоровья, валидация имён. +""" + +import os +import uuid +from pathlib import Path +from typing import Any + +import pytest + +from src.gateway.hotfolder import AnswerFile, HotFolder, LocalHotFolder, SettleTracker, inbox_name, validate_name + + +def _job_id() -> str: + return str(uuid.uuid4()) + + +class FakeClock: + """Монотонные часы теста: время двигается только вручную.""" + + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + +def _write_answer(hf: LocalHotFolder, job_id: str, data: bytes, name: str | None = None) -> Path: + path = hf.answer_dir(job_id) / (name or f"{job_id}.json") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _settled(tmp_path: Path, settle: float = 2) -> tuple[LocalHotFolder, FakeClock]: + clock = FakeClock() + return LocalHotFolder(tmp_path, settle_seconds=settle, clock=clock), clock + + +class TestInboxName: + def test_joins_job_id_and_name(self) -> None: + job_id = _job_id() + assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}_aep_5.PDF" + + @pytest.mark.parametrize( + ("original", "expected"), + [ + ("../../etc/passwd", "passwd"), + ("dir/sub/report.pdf", "report.pdf"), + ("C:\\scans\\report.pdf", "report.pdf"), + ("/abs/report.pdf", "report.pdf"), + ("..\\..\\report.pdf", "report.pdf"), + ], + ) + def test_sanitizes_to_basename(self, original: str, expected: str) -> None: + assert inbox_name("job", original) == f"job_{expected}" + + @pytest.mark.parametrize("original", ["", "/", "..", ".", "a/..", "dir/", "\\", "...", ". .", " ", "dir/. "]) + def test_rejects_unusable_names(self, original: str) -> None: + with pytest.raises(ValueError, match="имя файла"): + inbox_name("job", original) + + @pytest.mark.parametrize( + ("original", "expected"), + [ + ("a:b?.pdf", "a_b_.pdf"), + ("Отчёт: 2024.pdf", "Отчёт_ 2024.pdf"), + ('Баланс "итог".pdf', "Баланс _итог_.pdf"), + ("форма?.pdf", "форма_.pdf"), + ("a|b*.pdf", "a_b_c__.pdf"), + ("C:report.pdf", "C_report.pdf"), + ("a\x01b.pdf", "a_b.pdf"), + ("bad\x00name.pdf", "bad_name.pdf"), + ("tab\tname.pdf", "tab_name.pdf"), + ], + ) + def test_replaces_chars_illegal_on_windows(self, original: str, expected: str) -> None: + """Имя от пользователя законно на Linux/S3/macOS: заменяем, а не отвергаем (контракт: только minLength 1).""" + assert inbox_name("job", original) == f"job_{expected}" + + @pytest.mark.parametrize( + ("original", "expected"), + [("report.pdf.", "report.pdf"), ("report.pdf . ", "report.pdf"), ("report .pdf", "report .pdf")], + ) + def test_strips_trailing_dots_and_spaces(self, original: str, expected: str) -> None: + assert inbox_name("job", original) == f"job_{expected}" + + def test_keeps_spaces_and_cyrillic(self) -> None: + job_id = _job_id() + assert inbox_name(job_id, "Отчёт за 2024 год (копия).PDF") == f"{job_id}_Отчёт за 2024 год (копия).PDF" + + @pytest.mark.parametrize( + "original", + [ + "a:b?.pdf", + 'Баланс "итог".pdf', + "Отчёт за 2024 год (копия).PDF", + ".hidden.pdf", + "..pdf", + "x\x00\x1f<>|*?.pdf. ", + "C:\\dir\\..\\скан.png", + " leading.pdf", + ], + ) + def test_result_always_passes_validate_name_and_put(self, tmp_path: Path, original: str) -> None: + hf = LocalHotFolder(tmp_path) + name = inbox_name(_job_id(), original) + validate_name(name) + hf.put(name, b"x") + assert (hf.input_dir / name).read_bytes() == b"x" + + def test_result_is_accepted_by_put(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + name = inbox_name(_job_id(), "../../scan.pdf") + hf.put(name, b"x") + assert (hf.input_dir / name).read_bytes() == b"x" + + +class TestInboxNameLength: + """T7.3: длинное имя укорачивается так, чтобы `._<имя>.part` занимал не больше 240 байт UTF-8 (ext4 — + 255 байт, SMB/NTFS — 255 единиц UTF-16), расширение (до 16 символов после последней точки) сохраняется.""" + + @staticmethod + def _part_bytes(name: str) -> int: + return len(f".{name}.part".encode()) + + def test_long_cyrillic_name_is_trimmed_and_put(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + job_id = _job_id() + name = inbox_name(job_id, "я" * 200 + ".pdf") + assert self._part_bytes(name) <= 240 + assert name.startswith(f"{job_id}_яя") + assert name.endswith("я.pdf") + hf.put(name, b"x") + assert (hf.input_dir / name).read_bytes() == b"x" + + def test_short_name_is_unchanged(self) -> None: + job_id = _job_id() + original = "Отчёт за 2024 год (копия).PDF" + assert inbox_name(job_id, original) == f"{job_id}_{original}" + + def test_name_at_limit_is_unchanged(self) -> None: + job_id = _job_id() + original = "a" * (240 - len(f".{job_id}_.part") - 4) + ".pdf" + name = inbox_name(job_id, original) + assert name == f"{job_id}_{original}" + assert self._part_bytes(name) == 240 + + def test_long_name_without_extension(self, tmp_path: Path) -> None: + name = inbox_name(_job_id(), "б" * 300) + assert self._part_bytes(name) <= 240 + assert name.endswith("б") + LocalHotFolder(tmp_path).put(name, b"x") + + def test_long_suffix_is_not_treated_as_extension(self) -> None: + name = inbox_name(_job_id(), "ж" * 150 + "." + "x" * 17) + assert self._part_bytes(name) <= 240 + assert not name.endswith("x" * 17) + + def test_four_byte_chars_are_not_split(self) -> None: + name = inbox_name(_job_id(), "\U0001f600" * 100 + ".png") + assert self._part_bytes(name) <= 240 + assert name.endswith("\U0001f600.png") + + +class TestPaths: + def test_default_layout(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + job_id = _job_id() + assert hf.root == tmp_path + assert hf.input_dir == tmp_path / "Input" + assert hf.output_dir == tmp_path / "Output" + assert hf.tmp_dir == tmp_path / "Tmp" + assert hf.answer_dir(job_id) == tmp_path / "Output" / job_id + assert hf.answer_path(job_id) == tmp_path / "Output" / job_id / f"{job_id}.json" + + def test_custom_dir_names_and_str_root(self, tmp_path: Path) -> None: + hf = LocalHotFolder(str(tmp_path), input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный") + assert hf.root == tmp_path + assert hf.input_dir == tmp_path / "Входящие" + assert hf.output_dir == tmp_path / "Исходящие" + assert hf.tmp_dir == tmp_path / "Служебный" + assert hf.answer_path("abc") == tmp_path / "Исходящие" / "abc" / "abc.json" + + @pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a?b", "a*b", "a\x00b", "a\nb"]) + def test_answer_path_rejects_unsafe_job_id(self, tmp_path: Path, job_id: str) -> None: + hf = LocalHotFolder(tmp_path) + with pytest.raises(ValueError, match="job_id"): + hf.answer_path(job_id) + with pytest.raises(ValueError, match="job_id"): + hf.answer_dir(job_id) + with pytest.raises(ValueError, match="job_id"): + hf.poll(job_id) + + def test_negative_settle_is_rejected(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="settle"): + LocalHotFolder(tmp_path, settle_seconds=-1) + + def test_implements_protocol(self, tmp_path: Path) -> None: + hf: HotFolder = LocalHotFolder(tmp_path) + assert hf.healthy() is True + + +class TestPut: + def test_writes_bytes_under_inbox_name(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + name = inbox_name(_job_id(), "aep_5.pdf") + data = bytes(range(256)) * 10 + hf.put(name, data) + assert (tmp_path / "Input" / name).read_bytes() == data + + def test_leaves_no_part_files(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + name = inbox_name(_job_id(), "aep_5.pdf") + hf.put(name, b"data") + assert [p.name for p in hf.input_dir.iterdir()] == [name] + assert list(hf.tmp_dir.iterdir()) == [] + + def test_part_is_written_in_tmp_not_in_input(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """OQ-79: во время записи во входящем каталоге нет временного файла — он в `Tmp/`, затем rename в `Input`.""" + hf = LocalHotFolder(tmp_path) + name = inbox_name(_job_id(), "aep_5.pdf") + seen: dict[str, Any] = {} + real_replace = os.replace + + def spy(src: Any, dst: Any) -> None: + seen["src"], seen["dst"] = Path(src), Path(dst) + seen["input"] = sorted(p.name for p in hf.input_dir.iterdir()) + seen["tmp"] = sorted(p.name for p in hf.tmp_dir.iterdir()) + seen["part"] = Path(src).read_bytes() + real_replace(src, dst) + + monkeypatch.setattr("src.gateway.hotfolder.os.replace", spy) + hf.put(name, b"data") + assert seen == { + "src": hf.tmp_dir / f".{name}.part", + "dst": hf.input_dir / name, + "input": [], + "tmp": [f".{name}.part"], + "part": b"data", + } + assert [p.name for p in hf.input_dir.iterdir()] == [name] + assert list(hf.tmp_dir.iterdir()) == [] + + def test_creates_input_and_tmp_dirs_only(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + assert not hf.input_dir.exists() + assert not hf.tmp_dir.exists() + hf.put("a.pdf", b"1") + assert hf.input_dir.is_dir() + assert hf.tmp_dir.is_dir() + assert not hf.output_dir.exists() + + def test_existing_tmp_dir_is_fine(self, tmp_path: Path) -> None: + (tmp_path / "Tmp").mkdir() + LocalHotFolder(tmp_path).put("a.pdf", b"1") + assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1" + + def test_custom_tmp_dir_name(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, tmp_dir="Служебный") + hf.put("a.pdf", b"1") + assert (tmp_path / "Служебный").is_dir() + assert not (tmp_path / "Tmp").exists() + + def test_existing_input_dir_is_fine(self, tmp_path: Path) -> None: + (tmp_path / "Input").mkdir() + LocalHotFolder(tmp_path).put("a.pdf", b"1") + assert (tmp_path / "Input" / "a.pdf").read_bytes() == b"1" + + def test_missing_root_raises_and_creates_nothing(self, tmp_path: Path) -> None: + root = tmp_path / "absent" + with pytest.raises(FileNotFoundError): + LocalHotFolder(root).put("a.pdf", b"1") + assert not root.exists() + assert list(tmp_path.iterdir()) == [] + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + hf.put("a.pdf", b"old") + hf.put("a.pdf", b"new") + assert (hf.input_dir / "a.pdf").read_bytes() == b"new" + assert [p.name for p in hf.input_dir.iterdir()] == ["a.pdf"] + + def test_empty_data_is_written(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + hf.put("empty.pdf", b"") + assert (hf.input_dir / "empty.pdf").read_bytes() == b"" + + @pytest.mark.parametrize( + "name", + ["", "../escape.pdf", "sub/a.pdf", "sub\\a.pdf", "/abs.pdf", ".hidden.pdf", ".a.pdf.part", "..", ".", "a\x00b"], + ) + def test_rejects_invalid_names(self, tmp_path: Path, name: str) -> None: + hf = LocalHotFolder(tmp_path) + with pytest.raises(ValueError, match="имя файла"): + hf.put(name, b"x") + assert not hf.input_dir.exists() + assert not (tmp_path.parent / "escape.pdf").exists() + + @pytest.mark.parametrize("name", ["a:b.pdf", "ab.pdf", 'a"b.pdf', "a|b.pdf", "a?b.pdf", "a*b.pdf"]) + def test_rejects_names_illegal_on_windows(self, tmp_path: Path, name: str) -> None: + hf = LocalHotFolder(tmp_path) + with pytest.raises(ValueError, match="имя файла"): + hf.put(name, b"x") + assert not hf.input_dir.exists() + + @pytest.mark.parametrize("name", ["a\x01b.pdf", "a\tb.pdf", "a\nb.pdf", "a\x1fb.pdf"]) + def test_rejects_control_chars(self, tmp_path: Path, name: str) -> None: + with pytest.raises(ValueError, match="имя файла"): + LocalHotFolder(tmp_path).put(name, b"x") + + def test_accepts_spaces_cyrillic_and_inbox_names(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path) + names = [inbox_name(_job_id(), "Отчёт за 2024 год (копия).PDF"), inbox_name(_job_id(), "aep_8.pdf"), "a b.pdf"] + for name in names: + hf.put(name, b"x") + assert sorted(p.name for p in hf.input_dir.iterdir()) == sorted(names) + + def test_cleans_part_file_on_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + hf = LocalHotFolder(tmp_path) + + def boom(src: object, dst: object) -> None: + raise OSError("диск недоступен") + + monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom) + with pytest.raises(OSError, match="диск недоступен"): + hf.put("a.pdf", b"data") + assert list(hf.input_dir.iterdir()) == [] + assert list(hf.tmp_dir.iterdir()) == [] + + def test_cleanup_failure_does_not_mask_original_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """T7.5: ошибка удаления `.part` подавляется, наружу выходит исходное исключение записи.""" + hf = LocalHotFolder(tmp_path) + + def boom(src: object, dst: object) -> None: + raise OSError("диск недоступен") + + def unlink_fails(self: Path, missing_ok: bool = False) -> None: + raise PermissionError("удаление .part запрещено") + + monkeypatch.setattr("src.gateway.hotfolder.os.replace", boom) + monkeypatch.setattr(Path, "unlink", unlink_fails) + with pytest.raises(OSError, match="диск недоступен"): + hf.put("a.pdf", b"data") + + +class TestPoll: + """`poll` — все `*.json` каталога `//` по имени, когда набор настоялся `settle_seconds`.""" + + def test_none_until_answer_dir_has_json(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + assert hf.poll(job_id) is None + (tmp_path / "Output").mkdir() + assert hf.poll(job_id) is None + (tmp_path / "Output" / job_id).mkdir() + assert hf.poll(job_id) is None + + def test_missing_root_is_none(self, tmp_path: Path) -> None: + assert LocalHotFolder(tmp_path / "absent", settle_seconds=0).poll(_job_id()) is None + + def test_answer_dir_is_a_file_is_none(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + (tmp_path / "Output").mkdir() + (tmp_path / "Output" / job_id).write_bytes(b"{}") + assert hf.poll(job_id) is None + + def test_settle_zero_returns_at_once(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + payload = b'{"file_uid": "x", "data_ocr": {}}' + _write_answer(hf, job_id, payload) + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", payload)] + + def test_one_json_after_settle(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None # набор впервые увиден + clock.now += 1.9 + assert hf.poll(job_id) is None + clock.now += 0.1 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + + def test_two_jsons_ascending_by_name(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b'{"n": 2}', f"{job_id}_ofr.json") + _write_answer(hf, job_id, b'{"n": 1}', f"{job_id}.json") + assert hf.poll(job_id) is None + clock.now += 2 + assert hf.poll(job_id) == [ + AnswerFile(f"{job_id}.json", b'{"n": 1}'), + AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'), + ] + + def test_non_json_files_and_subdirs_are_ignored(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"text", "readme.txt") + _write_answer(hf, job_id, b"{}", f".{job_id}.json.part") + (hf.answer_dir(job_id) / "nested.json").mkdir() + assert hf.poll(job_id) is None + clock.now += 10 + assert hf.poll(job_id) is None # json-файлов нет — ответа нет, сколько ни жди + _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 2 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + + def test_json_extension_is_case_insensitive(self, tmp_path: Path) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + _write_answer(hf, job_id, b"{}", "B.JSON") + _write_answer(hf, job_id, b"[]", "a.Json") + assert hf.poll(job_id) == [AnswerFile("B.JSON", b"{}"), AnswerFile("a.Json", b"[]")] + + def test_growing_file_is_not_ready_until_stable(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + path = _write_answer(hf, job_id, b'{"part": ') + assert hf.poll(job_id) is None + clock.now += 1.5 + path.write_bytes(b'{"part": 1, "more": ') # размер вырос — таймер заново + assert hf.poll(job_id) is None + clock.now += 1.5 + path.write_bytes(b'{"part": 1, "more": 2}') + assert hf.poll(job_id) is None + clock.now += 1.9 + assert hf.poll(job_id) is None + clock.now += 0.1 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b'{"part": 1, "more": 2}')] + + def test_new_file_resets_timer(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 1.5 + _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") + assert hf.poll(job_id) is None + clock.now += 1.5 + assert hf.poll(job_id) is None # с момента появления второго файла прошло 1,5 с + clock.now += 0.5 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}"), AnswerFile(f"{job_id}_ofr.json", b"[]")] + + def test_removed_file_resets_timer(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + extra = _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") + assert hf.poll(job_id) is None + clock.now += 1.5 + extra.unlink() + assert hf.poll(job_id) is None + clock.now += 1.5 + assert hf.poll(job_id) is None + clock.now += 0.5 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + + def test_removal_of_all_json_is_none_and_restarts(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + path = _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 1 + path.unlink() + assert hf.poll(job_id) is None + clock.now += 5 + path.write_bytes(b"{}") # тот же набор снова: настаивается заново, а не «с прошлого раза» + assert hf.poll(job_id) is None + clock.now += 2 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + + def test_state_is_dropped_after_answer(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 2 + assert hf.poll(job_id) is not None + assert hf.pending_jobs() == 0 + assert hf.poll(job_id) is None # состояние сброшено: повторный опрос настаивает набор заново + + def test_other_job_is_unaffected(self, tmp_path: Path) -> None: + hf, clock = _settled(tmp_path) + job_id, other = _job_id(), _job_id() + _write_answer(hf, other, b"{}") + assert hf.poll(other) is None + clock.now += 1 + _write_answer(hf, job_id, b"[]") + assert hf.poll(job_id) is None + clock.now += 1 + assert hf.poll(job_id) is None + assert hf.poll(other) == [AnswerFile(f"{other}.json", b"{}")] + clock.now += 1 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"[]")] + + def test_file_changed_between_listing_and_read_is_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + monkeypatch.setattr(Path, "read_bytes", lambda self: b'{"longer": true}') + assert hf.poll(job_id) is None + + def test_file_removed_between_listing_and_read_is_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + path = _write_answer(hf, job_id, b"{}") + + def vanished(self: Path) -> bytes: + path.unlink() + raise FileNotFoundError(str(self)) + + monkeypatch.setattr(Path, "read_bytes", vanished) + assert hf.poll(job_id) is None + + def test_new_file_appeared_while_reading_is_none_and_set_resettles( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """T2.1: после чтения каталог листается заново; набор изменился (появился файл) — `None`, набор настаивается + заново и отдаётся целиком.""" + hf, clock = _settled(tmp_path) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 2 + real_read = Path.read_bytes + + def read_then_add(self: Path) -> bytes: + data = real_read(self) + _write_answer(hf, job_id, b'{"n": 2}', f"{job_id}_ofr.json") + return data + + monkeypatch.setattr(Path, "read_bytes", read_then_add) + assert hf.poll(job_id) is None + monkeypatch.setattr(Path, "read_bytes", real_read) + assert hf.poll(job_id) is None # состояние сброшено: новый набор настаивается с нуля + clock.now += 2 + assert hf.poll(job_id) == [ + AnswerFile(f"{job_id}.json", b"{}"), + AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'), + ] + + def test_file_removed_after_read_is_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """T2.1: файл прочитан, но пропал до повторного листинга — набор изменился, `None`.""" + hf = LocalHotFolder(tmp_path, settle_seconds=0) + job_id = _job_id() + _write_answer(hf, job_id, b"{}") + second = _write_answer(hf, job_id, b"[]", f"{job_id}_ofr.json") + real_read = Path.read_bytes + + def read_then_remove(self: Path) -> bytes: + data = real_read(self) + if self == second: + second.unlink() + return data + + monkeypatch.setattr(Path, "read_bytes", read_then_remove) + assert hf.poll(job_id) is None + + +class TestSettleTracker: + def test_snapshot_must_be_stable_for_settle_seconds(self) -> None: + clock = FakeClock() + tracker = SettleTracker(2, clock) + snap = (("a.json", 2),) + assert tracker.ready("job", snap) is False + clock.now += 2 + assert tracker.ready("job", snap) is True + assert len(tracker) == 0 + + def test_empty_snapshot_is_never_ready(self) -> None: + assert SettleTracker(0).ready("job", ()) is False + + def test_zero_settle_keeps_no_state(self) -> None: + tracker = SettleTracker(0) + assert tracker.ready("job", (("a.json", 2),)) is True + assert len(tracker) == 0 + + def test_state_is_bounded(self) -> None: + """Задания, ушедшие по таймауту с ненастоявшимся набором, не копятся: старые вытесняются.""" + clock = FakeClock() + tracker = SettleTracker(2, clock, max_jobs=3) + for job in ("j1", "j2", "j3", "j4"): + assert tracker.ready(job, (("a.json", 1),)) is False + assert len(tracker) == 3 + clock.now += 2 + assert tracker.ready("j1", (("a.json", 1),)) is False # вытеснено — настаивается заново + assert tracker.ready("j4", (("a.json", 1),)) is True + + def test_forget(self) -> None: + clock = FakeClock() + tracker = SettleTracker(2, clock) + tracker.ready("job", (("a.json", 1),)) + tracker.forget("job") + tracker.forget("unknown") + assert len(tracker) == 0 + + def test_negative_settle_is_rejected(self) -> None: + with pytest.raises(ValueError, match="settle"): + SettleTracker(-0.1) + + +class TestHealthy: + def test_true_for_existing_dir(self, tmp_path: Path) -> None: + assert LocalHotFolder(tmp_path).healthy() is True + + def test_false_for_missing_root(self, tmp_path: Path) -> None: + assert LocalHotFolder(tmp_path / "absent").healthy() is False + + def test_false_for_file_root(self, tmp_path: Path) -> None: + file_root = tmp_path / "file" + file_root.write_bytes(b"x") + assert LocalHotFolder(file_root).healthy() is False + + def test_never_raises(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def boom(self: Path) -> bool: + raise PermissionError("нет доступа") + + monkeypatch.setattr(Path, "is_dir", boom) + assert LocalHotFolder(tmp_path).healthy() is False diff --git a/api/tests/unit/test_merge.py b/api/tests/unit/test_merge.py new file mode 100644 index 0000000..f61575d --- /dev/null +++ b/api/tests/unit/test_merge.py @@ -0,0 +1,101 @@ +"""Склейка набора файлов ответа ИС в один ответ (spec image-recognition-gateway «Сырой ответ…», D-4, R-24). + +`merge_answers(job_id, files)` разбирает каждый файл `json.loads` независимо: не JSON — `response = null` и +`parse_error`, задание не падает; файлы — по возрастанию имени. `dump_merged` — байты объекта `raw/{job_id}.json`. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import dump_merged, merge_answers + +API_DIR = Path(__file__).resolve().parents[2] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +JOB = "3f1c2b7a-0000-4000-8000-000000000001" + + +def test_structure_for_one_file() -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"file_uid": "x", "error": {"code": "", "message": ""}}')]) + assert merged == { + "job_id": JOB, + "files": [ + { + "name": f"{JOB}.json", + "response": {"file_uid": "x", "error": {"code": "", "message": ""}}, + "parse_error": None, + } + ], + } + + +def test_files_are_ordered_by_name() -> None: + files = [ + AnswerFile(f"{JOB}_ofr.json", b'{"n": 2}'), + AnswerFile("0.json", b'{"n": 0}'), + AnswerFile(f"{JOB}.json", b'{"n": 1}'), + ] + merged = merge_answers(JOB, files) + assert [f["name"] for f in merged["files"]] == ["0.json", f"{JOB}.json", f"{JOB}_ofr.json"] + assert [f["response"] for f in merged["files"]] == [{"n": 0}, {"n": 1}, {"n": 2}] + + +@pytest.mark.parametrize( + "data", + [b"", b"not json", b'{"a": ', b"\xff\xfe", b"[" * 100_000], + ids=["empty", "text", "truncated", "bad_utf8", "deep"], +) +def test_invalid_json_becomes_parse_error_and_others_are_parsed(data: bytes) -> None: + files = [AnswerFile(f"{JOB}.json", b'{"ok": true}'), AnswerFile(f"{JOB}_ofr.json", data)] + merged = merge_answers(JOB, files) + first, second = merged["files"] + assert first == {"name": f"{JOB}.json", "response": {"ok": True}, "parse_error": None} + assert second["name"] == f"{JOB}_ofr.json" + assert second["response"] is None + assert isinstance(second["parse_error"], str) + assert second["parse_error"] + + +@pytest.mark.parametrize("data", [b"[]", b'"text"', b"42", b"null"]) +def test_valid_json_of_any_root_is_kept_as_is(data: bytes) -> None: + """Корень не объект — это забота нормализатора (`INVALID_FORMAT` файла), склейка хранит разобранное как есть.""" + [entry] = merge_answers(JOB, [AnswerFile(f"{JOB}.json", data)])["files"] + assert entry["response"] == json.loads(data) + assert entry["parse_error"] is None + + +def test_empty_set_gives_empty_files() -> None: + assert merge_answers(JOB, []) == {"job_id": JOB, "files": []} + + +def test_dump_is_utf8_json_that_round_trips() -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", '{"m": "Не удалось"}'.encode())]) + data = dump_merged(merged) + assert "Не удалось".encode() in data # кириллица читаема в S3, не \\uXXXX + assert json.loads(data) == merged + + +def test_dump_with_lone_surrogate_is_valid_utf8() -> None: + """`\\ud800` в ответе ИС разбирается в одиночный суррогат: объект всё равно записывается валидным UTF-8.""" + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", b'{"m": "bad \\ud800 tail"}')]) + data = dump_merged(merged) + data.decode("utf-8") + assert json.loads(data) == merged + + +@pytest.mark.fixtures +def test_real_fixtures_are_merged(tmp_path: Path) -> None: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + for name in ("aep_3.json", "aep_3_ofr.json"): + shutil.copy(REAL_FIXTURES / name, tmp_path / name) + files = [AnswerFile(p.name, p.read_bytes()) for p in sorted(tmp_path.iterdir())] + merged = merge_answers(JOB, files) + assert [f["name"] for f in merged["files"]] == ["aep_3.json", "aep_3_ofr.json"] + assert all(f["parse_error"] is None for f in merged["files"]) + assert merged["files"][0]["response"]["error"] == {"code": "", "message": ""} + assert merged["files"][1]["response"]["error"]["code"] == "INVALID_DOCUMENT" + assert json.loads(dump_merged(merged)) == merged diff --git a/api/tests/unit/test_nats.py b/api/tests/unit/test_nats.py new file mode 100644 index 0000000..06e3baf --- /dev/null +++ b/api/tests/unit/test_nats.py @@ -0,0 +1,431 @@ +"""Слой NATS (`src.core.nats`): подключение с/без nkeys, колбэки переподключения, подписка queue group, публикация +с `Nats-Msg-Id` и повторами. Клиент nats-py подменяется фейком — сеть не нужна. Переподключение к живому серверу +не проверяется (общий контейнер NATS не перезапускаем): здесь — только колбэки и восстановление подписки клиентом. +""" + +import base64 +import json +import uuid +from typing import Any + +import nkeys +import pytest +from nats import errors as nats_errors + +from src.contract import ( + IMAGE_REQUEST_SUBJECT, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ErrorCode, + ServiceInfo, + build_ack_message, + build_error_message, +) +from src.core.config import Settings, get_settings +from src.core.nats import ( + REQUEST_QUEUE_GROUP, + NatsConfigError, + NatsGateway, + NatsNotConnectedError, + PublishFailed, +) + + +class FakeMsg: + def __init__(self, data: bytes = b"{}", headers: dict[str, str] | None = None) -> None: + self.subject = IMAGE_REQUEST_SUBJECT + self.data = data + self.headers = headers + + +class FakeNatsClient: + """Минимальная подмена `nats.aio.client.Client`: запоминает вызовы, ошибки публикации задаются очередью.""" + + def __init__(self) -> None: + self.connect_args: tuple[Any, ...] = () + self.connect_kwargs: dict[str, Any] = {} + self.is_connected = False + self.is_closed = False + self.published: list[tuple[str, bytes, dict[str, str] | None]] = [] + self.publish_errors: list[BaseException] = [] + self.flush_calls = 0 + self.subscriptions: list[dict[str, Any]] = [] + self.drained = False + self.closed = False + self.jetstream_calls = 0 + + async def connect(self, *args: Any, **kwargs: Any) -> None: + self.connect_args = args + self.connect_kwargs = kwargs + self.is_connected = True + + async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: + self.published.append((subject, payload, headers)) + if self.publish_errors: + raise self.publish_errors.pop(0) + + async def flush(self, timeout: float = 10) -> None: + self.flush_calls += 1 + + async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> str: + self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb}) + return f"sub-{len(self.subscriptions)}" + + async def drain(self) -> None: + self.drained = True + self.is_connected = False + self.is_closed = True + + async def close(self) -> None: + self.closed = True + self.is_connected = False + self.is_closed = True + + def jetstream(self, **opts: Any) -> None: # pragma: no cover - вызов и есть ошибка + self.jetstream_calls += 1 + raise AssertionError("JetStream не используется (D-8)") + + +def make_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings: + for name, value in env.items(): + monkeypatch.setenv(name, value) + get_settings.cache_clear() + return get_settings() + + +@pytest.fixture +def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: + return make_settings(monkeypatch) + + +@pytest.fixture +def fake() -> FakeNatsClient: + return FakeNatsClient() + + +@pytest.fixture +def sleeps() -> list[float]: + return [] + + +@pytest.fixture +def gateway(settings: Settings, fake: FakeNatsClient, sleeps: list[float]) -> NatsGateway: + async def fake_sleep(delay: float) -> None: + sleeps.append(delay) + + return NatsGateway(settings, client_factory=lambda: fake, retry_delay=0, sleep=fake_sleep) + + +# --- подключение ------------------------------------------------------------------------------------------------ + + +async def test_connect_without_credentials_when_seed_and_jwt_empty(gateway: NatsGateway, fake: FakeNatsClient): + assert await gateway.connect() is True + + assert fake.connect_args == ("nats://127.0.0.1:4222",) + kwargs = fake.connect_kwargs + assert kwargs["max_reconnect_attempts"] == -1 + assert kwargs["name"] == "scan-gateway" + for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"): + assert callable(kwargs[callback]) + for credential in ("user_jwt_cb", "signature_cb", "nkeys_seed", "nkeys_seed_str", "user_credentials", "token"): + assert credential not in kwargs + assert fake.jetstream_calls == 0 + assert gateway.is_connected is True + + +async def test_connect_with_nkeys_and_jwt_when_both_set(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): + # Фиктивная пара ключей создаётся в тесте: настоящих секретов в репозитории нет + keypair = nkeys.from_seed(nkeys.encode_seed(b"\x01" * 32, nkeys.PREFIX_BYTE_USER)) + seed = keypair.seed.decode() + settings = make_settings(monkeypatch, OPENBAO__NATS__NATS_SEED=seed, OPENBAO__NATS__NATS_JWT="dummy.jwt.token") + gateway = NatsGateway(settings, client_factory=lambda: fake) + + await gateway.connect() + + kwargs = fake.connect_kwargs + assert kwargs["user_jwt_cb"]() == b"dummy.jwt.token" + nonce = "nonce-123" + signature = base64.b64decode(kwargs["signature_cb"](nonce)) + assert keypair.verify(nonce.encode(), signature) is True + assert fake.jetstream_calls == 0 + + +@pytest.mark.parametrize( + "env", + [ + {"OPENBAO__NATS__NATS_SEED": "SUAxxx"}, + {"OPENBAO__NATS__NATS_JWT": "dummy.jwt.token"}, + ], +) +async def test_connect_with_only_one_credential_is_config_error( + monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient, env: dict[str, str] +): + settings = make_settings(monkeypatch, **env) + gateway = NatsGateway(settings, client_factory=lambda: fake) + + with pytest.raises(NatsConfigError, match="NATS_SEED и NATS_JWT"): + await gateway.connect() + assert fake.connect_kwargs == {} + + +async def test_connect_with_unreadable_seed_is_config_error(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): + settings = make_settings(monkeypatch, OPENBAO__NATS__NATS_SEED="не-seed", OPENBAO__NATS__NATS_JWT="dummy.jwt") + gateway = NatsGateway(settings, client_factory=lambda: fake) + + with pytest.raises(NatsConfigError, match="NATS_SEED"): + await gateway.connect() + + +async def test_nats_disabled_does_not_connect(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): + settings = make_settings(monkeypatch, NATS_ENABLED="false") + created: list[FakeNatsClient] = [] + + def factory() -> FakeNatsClient: + created.append(fake) + return fake + + gateway = NatsGateway(settings, client_factory=factory) + + assert await gateway.connect() is False + assert created == [] + assert gateway.is_connected is False + + +async def test_is_connected_reflects_client_state(gateway: NatsGateway, fake: FakeNatsClient): + assert gateway.is_connected is False # до connect клиента нет + await gateway.connect() + assert gateway.is_connected is True + fake.is_connected = False # nats-py переподключается + assert gateway.is_connected is False + + +async def test_reconnect_callbacks_log_and_keep_state(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.connect() + kwargs = fake.connect_kwargs + + fake.is_connected = False + await kwargs["disconnected_cb"]() + await kwargs["error_cb"](nats_errors.StaleConnectionError()) + assert gateway.is_connected is False + + fake.is_connected = True + await kwargs["reconnected_cb"]() + assert gateway.is_connected is True + await kwargs["closed_cb"]() + + +async def test_close_drains_connected_client(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.connect() + await gateway.close() + assert fake.drained is True + assert gateway.is_connected is False + + +async def test_close_without_connection_is_noop(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.close() + assert fake.drained is False + assert fake.closed is False + + +async def test_close_falls_back_to_close_when_drain_fails(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.connect() + + async def failing_drain() -> None: + raise nats_errors.ConnectionReconnectingError() + + fake.drain = failing_drain # type: ignore[method-assign] + await gateway.close() + assert fake.closed is True + + +# --- подписка --------------------------------------------------------------------------------------------------- + + +async def test_subscribe_requests_uses_image_subject_and_queue_group(gateway: NatsGateway, fake: FakeNatsClient): + received: list[FakeMsg] = [] + + async def on_request(msg: Any) -> None: + received.append(msg) + + await gateway.connect() + await gateway.subscribe_requests(on_request) + + assert REQUEST_QUEUE_GROUP == "scan-gateway" + [subscription] = fake.subscriptions + assert subscription["subject"] == IMAGE_REQUEST_SUBJECT == "recognition.image.request" + assert subscription["queue"] == "scan-gateway" + assert fake.flush_calls == 1 # подписка подтверждена сервером до возврата + + msg = FakeMsg(b'{"job_id": "x"}', {NATS_MSG_ID_HEADER: "x:1"}) + await subscription["cb"](msg) + assert received == [msg] # обработчик получает исходное сообщение nats-py (data, headers) + + +async def test_subscribe_requests_handler_exception_is_logged_not_raised(gateway: NatsGateway, fake: FakeNatsClient): + async def broken(msg: Any) -> None: + raise RuntimeError("сбой обработчика") + + await gateway.connect() + await gateway.subscribe_requests(broken) + await fake.subscriptions[0]["cb"](FakeMsg()) # не бросает: подписка живёт + + +async def test_subscribe_requests_requires_connection(gateway: NatsGateway): + async def on_request(msg: Any) -> None: + return None + + with pytest.raises(NatsNotConnectedError): + await gateway.subscribe_requests(on_request) + + +# --- публикация ------------------------------------------------------------------------------------------------- + + +async def test_publish_sets_msg_id_header_and_flushes(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.connect() + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") + + assert fake.published == [(RESULT_SUBJECT, b"payload", {"Nats-Msg-Id": "result:job:1"})] + assert fake.flush_calls == 1 + + +async def test_publish_retries_transient_errors_until_success( + gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float] +): + await gateway.connect() + fake.publish_errors = [nats_errors.ConnectionReconnectingError(), nats_errors.FlushTimeoutError()] + + await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1") + + assert len(fake.published) == 3 # первая попытка + два повтора из PUBLISH_RETRIES=3 + assert all(headers == {"Nats-Msg-Id": "ack:job:1"} for _, _, headers in fake.published) + assert sleeps == [0, 0] # пауза перед каждым повтором + + +async def test_publish_succeeds_on_last_retry(gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float]): + await gateway.connect() + fake.publish_errors = [nats_errors.ConnectionClosedError()] * 3 + + await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1") + + assert len(fake.published) == 4 # 1 + 3 повтора: последний повтор успешен + assert sleeps == [0, 0, 0] + + +@pytest.mark.parametrize( + "error", + [ + nats_errors.ConnectionClosedError(), + nats_errors.ConnectionReconnectingError(), + nats_errors.OutboundBufferLimitError(), + nats_errors.StaleConnectionError(), + nats_errors.TimeoutError(), + TimeoutError(), + ConnectionResetError(), + ], +) +async def test_publish_raises_publish_failed_after_initial_attempt_and_all_retries( + gateway: NatsGateway, fake: FakeNatsClient, error: BaseException +): + await gateway.connect() + fake.publish_errors = [error] * 10 + + with pytest.raises(PublishFailed) as exc_info: + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") + + assert len(fake.published) == 4 # PUBLISH_RETRIES=3: первая попытка + 3 повтора + assert exc_info.value.attempts == 4 + assert exc_info.value.msg_id == "result:job:1" + assert exc_info.value.__cause__ is error + + +async def test_publish_retries_parameter_overrides_settings(gateway: NatsGateway, fake: FakeNatsClient): + await gateway.connect() + fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 + + with pytest.raises(PublishFailed): + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=5) + assert len(fake.published) == 6 + + +async def test_publish_zero_retries_makes_exactly_one_attempt( + gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float] +): + await gateway.connect() + fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 + + with pytest.raises(PublishFailed) as exc_info: + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=0) + assert len(fake.published) == 1 + assert exc_info.value.attempts == 1 + assert sleeps == [] + + +async def test_publish_uses_publish_retries_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): + settings = make_settings(monkeypatch, PUBLISH_RETRIES="2") + gateway = NatsGateway(settings, client_factory=lambda: fake, retry_delay=0) + await gateway.connect() + fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 + + with pytest.raises(PublishFailed): + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") + assert len(fake.published) == 3 # PUBLISH_RETRIES=2: 1 + 2 повтора + + +@pytest.mark.parametrize("error", [nats_errors.MaxPayloadError(), nats_errors.BadSubjectError(), ValueError("x")]) +async def test_publish_does_not_retry_non_transient_errors( + gateway: NatsGateway, fake: FakeNatsClient, error: BaseException +): + await gateway.connect() + fake.publish_errors = [error] * 10 + + with pytest.raises(PublishFailed) as exc_info: + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") + assert len(fake.published) == 1 + assert exc_info.value.attempts == 1 + assert exc_info.value.__cause__ is error + + +async def test_publish_without_connection_fails_without_attempts(gateway: NatsGateway, fake: FakeNatsClient): + with pytest.raises(PublishFailed) as exc_info: + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") + assert exc_info.value.attempts == 0 + assert fake.published == [] + + +async def test_publish_rejects_negative_retries(gateway: NatsGateway): + await gateway.connect() + with pytest.raises(ValueError, match="retries"): + await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=-1) + + +# --- публикация конверта ---------------------------------------------------------------------------------------- + +SERVICE = ServiceInfo(name="scan-gateway", version="1.0.0") + + +async def test_publish_result_ack_uses_ack_msg_id(gateway: NatsGateway, fake: FakeNatsClient): + job_id, file_id = uuid.uuid4(), uuid.uuid4() + message = build_ack_message(job_id, file_id, SERVICE) + await gateway.connect() + + await gateway.publish_result(message, attempt=2) + + [(subject, payload, headers)] = fake.published + assert subject == RESULT_SUBJECT + assert headers == {"Nats-Msg-Id": f"ack:{job_id}:2"} + assert json.loads(payload) == message.to_wire() + + +async def test_publish_result_final_uses_result_msg_id_and_utf8(gateway: NatsGateway, fake: FakeNatsClient): + job_id, file_id = uuid.uuid4(), uuid.uuid4() + message = build_error_message(job_id, file_id, SERVICE, ErrorCode.TIMEOUT, "ИС не ответила") + await gateway.connect() + + await gateway.publish_result(message, attempt=1) + + [(_, payload, headers)] = fake.published + assert headers == {"Nats-Msg-Id": f"result:{job_id}:1"} + assert "ИС не ответила".encode() in payload # UTF-8 без \u-экранирования + assert json.loads(payload) == message.to_wire() diff --git a/api/tests/unit/test_normalizer.py b/api/tests/unit/test_normalizer.py new file mode 100644 index 0000000..7a751b6 --- /dev/null +++ b/api/tests/unit/test_normalizer.py @@ -0,0 +1,390 @@ +"""Нормализатор ответа ИС Сканирование (spec image-recognition-gateway «Результат по контракту», D-5, R-12, R-25). + +Вход `NullNormalizer` — склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}` (D-4). +Успех файла — `error.code == ""` (реальный успешный ответ несёт `error = {"code": "", "message": ""}`, а не `null`); +ошибочный файл — непустой `error.code`, `parse_error` или корень ответа не объект. Сводка: все файлы ошибочны — код +первого по имени; есть успешный — `NOT_IMPLEMENTED`; ошибочные файлы — строки `warnings`. +Реальные ответы `../fixtures/ocr/*.json` — маркер `fixtures`. +""" + +import copy +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import merge_answers +from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome, Normalizer, NullNormalizer + +API_DIR = Path(__file__).resolve().parents[2] +# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как у test_emulator.py +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +JOB = "3f1c2b7a-0000-4000-8000-000000000001" +REAL_MESSAGE = "Не удалось определить тип документа." +SUCCESS_ERROR = {"code": "", "message": ""} + + +def _real(name: str) -> dict[str, Any]: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + loaded: dict[str, Any] = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8")) + return loaded + + +def _real_set(*names: str) -> dict[str, Any]: + """Склейка реальных ответов под именами каталога ответа: `aep_N.json` — `.json`, `aep_N_.json` — + `_.json`.""" + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + files = [] + for name in names: + suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2] + target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json" + files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes())) + return dict(merge_answers(JOB, files)) + + +def _file(name: str, response: object = None, parse_error: str | None = None) -> dict[str, Any]: + return {"name": name, "response": response, "parse_error": parse_error} + + +def _merged(*files: dict[str, Any]) -> dict[str, Any]: + return {"job_id": JOB, "files": list(files)} + + +def _one(response: object) -> dict[str, Any]: + return _merged(_file(f"{JOB}.json", response)) + + +def _success(**extra: Any) -> dict[str, Any]: + return {"file_uid": JOB, "error": dict(SUCCESS_ERROR), "data_ocr": {"table_structure": []}, **extra} + + +def _failure(code: object, message: object = "сообщение ИС") -> dict[str, Any]: + return {"file_uid": JOB, "error": {"code": code, "message": message}, "data_ocr": {"table_structure": []}} + + +def _assert_error(outcome: NormalizeOutcome, code: ErrorCode, message: str | None = None) -> None: + assert outcome.status is ResultStatus.ERROR + assert outcome.error is not None + assert outcome.error.code is code + if message is not None: + assert outcome.error.message == message + assert outcome.documents == [] + assert outcome.metrics is None + + +def _assert_not_implemented(outcome: NormalizeOutcome, warnings: list[str]) -> None: + _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED, NOT_IMPLEMENTED_MESSAGE) + assert outcome.warnings == warnings + + +@pytest.mark.fixtures +class TestRealFixtures: + """Регрессия: реальные успешные ответы (`error = {"code": "", "message": ""}`) — не ошибка.""" + + @pytest.mark.parametrize("name", ["aep_6.json", "aep_3.json"]) + def test_real_success_is_not_an_error(self, name: str) -> None: + assert _real(name)["error"] == SUCCESS_ERROR + _assert_not_implemented(NullNormalizer().normalize(_real_set(name)), []) + + def test_aep_3_with_ofr_gives_not_implemented_and_one_warning(self) -> None: + outcome = NullNormalizer().normalize(_real_set("aep_3.json", "aep_3_ofr.json")) + _assert_not_implemented(outcome, [f"{JOB}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]) + + @pytest.mark.parametrize("name", ["aep_3_ofr.json", "aep_5.json", "aep_8_form4.json"]) + def test_real_error_alone_is_invalid_document(self, name: str) -> None: + merged = _real_set(name) + [entry] = merged["files"] + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, REAL_MESSAGE) + assert outcome.warnings == [f"{entry['name']}: INVALID_DOCUMENT: {REAL_MESSAGE}"] + + +class TestFileSuccess: + """Успех файла — `error.code == ""`; отсутствующий/`null` `error` и `error` без кода ошибкой не считаются.""" + + def test_success_shape_of_real_response(self) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one(_success())), []) + + @pytest.mark.parametrize( + "error", + [None, {"code": None, "message": "m"}, {"message": "Нет кода"}, {}, "Что-то сломалось", ["a"], 42, 0, ""], + ids=["null", "code_null", "no_code", "empty_object", "string", "list", "int", "zero", "empty_string"], + ) + def test_no_error_code_is_success(self, error: object) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one(_success(error=error))), []) + + def test_error_key_absent(self) -> None: + response = _success() + del response["error"] + _assert_not_implemented(NullNormalizer().normalize(_one(response)), []) + + def test_minimal_object(self) -> None: + _assert_not_implemented(NullNormalizer().normalize(_one({})), []) + + +class TestFileError: + def test_known_code_and_message_verbatim(self) -> None: + outcome = NullNormalizer().normalize(_one(_failure("FILE_READ_ERROR", "Файл повреждён: стр. 3 «пусто»"))) + _assert_error(outcome, ErrorCode.FILE_READ_ERROR, "Файл повреждён: стр. 3 «пусто»") + assert outcome.warnings == [f"{JOB}.json: FILE_READ_ERROR: Файл повреждён: стр. 3 «пусто»"] + + @pytest.mark.parametrize("code", [c.value for c in ErrorCode]) + def test_every_contract_code_passes(self, code: str) -> None: + _assert_error(NullNormalizer().normalize(_one(_failure(code))), ErrorCode(code), "сообщение ИС") + + def test_unknown_code_is_unknown_error_keeping_original(self) -> None: + outcome = NullNormalizer().normalize(_one(_failure("SOME_NEW_CODE", "Превышена квота"))) + _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) + assert outcome.error is not None + assert "SOME_NEW_CODE" in outcome.error.message + assert "Превышена квота" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: SOME_NEW_CODE: Превышена квота"] + + def test_lowercase_code_is_unknown(self) -> None: + outcome = NullNormalizer().normalize(_one(_failure("invalid_document", "m"))) + _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) + assert outcome.error is not None + assert "invalid_document" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: invalid_document: m"] + + @pytest.mark.parametrize("code", [5, ["INVALID_DOCUMENT"], {"a": 1}, True]) + def test_code_not_a_string_is_unknown_error(self, code: object) -> None: + outcome = NullNormalizer().normalize(_one(_failure(code, "m-text"))) + _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) + assert outcome.error is not None + assert repr(code) in outcome.error.message + assert "m-text" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: {code!r}: m-text"] + + @pytest.mark.parametrize("message", [None, ""]) + def test_missing_or_empty_message_gets_default(self, message: object) -> None: + response = _failure("INVALID_DOCUMENT", message) + outcome = NullNormalizer().normalize(_one(response)) + _assert_error(outcome, ErrorCode.INVALID_DOCUMENT) + assert outcome.error is not None + assert "INVALID_DOCUMENT" in outcome.error.message + assert outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: {outcome.error.message}"] + + def test_message_absent_gets_default(self) -> None: + outcome = NullNormalizer().normalize(_one({"error": {"code": "UNREADABLE"}})) + _assert_error(outcome, ErrorCode.UNREADABLE) + assert outcome.error is not None + assert outcome.error.message + + def test_message_not_a_string_is_not_lost(self) -> None: + outcome = NullNormalizer().normalize(_one(_failure("UNREADABLE", {"detail": "x"}))) + _assert_error(outcome, ErrorCode.UNREADABLE) + assert outcome.error is not None + assert "{'detail': 'x'}" in outcome.error.message + + def test_unknown_code_and_no_message(self) -> None: + outcome = NullNormalizer().normalize(_one({"error": {"code": "WEIRD"}})) + _assert_error(outcome, ErrorCode.UNKNOWN_ERROR) + assert outcome.error is not None + assert "WEIRD" in outcome.error.message + + def test_error_wins_over_data(self) -> None: + response = _success(error={"code": "RECOGNITION_FAILED", "message": "сбой"}) + _assert_error(NullNormalizer().normalize(_one(response)), ErrorCode.RECOGNITION_FAILED, "сбой") + + def test_parse_error_is_invalid_format(self) -> None: + """Сценарий «Все файлы не JSON».""" + merged = _merged(_file(f"{JOB}.json", None, "JSONDecodeError: Expecting value: line 1 column 1 (char 0)")) + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_FORMAT) + assert outcome.error is not None + assert "Expecting value" in outcome.error.message + [warning] = outcome.warnings + assert warning.startswith(f"{JOB}.json: INVALID_FORMAT: ") + assert "JSONDecodeError: Expecting value: line 1 column 1 (char 0)" in warning + + @pytest.mark.parametrize("response", [[], [{"error": None}], "строка", "", None, 0, 42, 1.5, True]) + def test_root_not_object_is_invalid_format(self, response: object) -> None: + """Сценарий «Корень ответа не объект».""" + outcome = NullNormalizer().normalize(_one(response)) + _assert_error(outcome, ErrorCode.INVALID_FORMAT, NOT_OBJECT_MESSAGE) + assert outcome.warnings == [f"{JOB}.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"] + + +class TestSummary: + def test_all_files_erroneous_takes_first_by_name(self) -> None: + """Сценарий «Все документы ошибочны»: первый — INVALID_DOCUMENT, второй — не JSON.""" + merged = _merged( + _file(f"{JOB}.json", _failure("INVALID_DOCUMENT", "нет типа")), + _file(f"{JOB}_ofr.json", None, "JSONDecodeError: Expecting value"), + ) + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.INVALID_DOCUMENT, "нет типа") + assert len(outcome.warnings) == 2 + assert outcome.warnings[0] == f"{JOB}.json: INVALID_DOCUMENT: нет типа" + assert outcome.warnings[1].startswith(f"{JOB}_ofr.json: INVALID_FORMAT: ") + + def test_first_erroneous_parse_error_gives_invalid_format(self) -> None: + merged = _merged( + _file(f"{JOB}.json", None, "JSONDecodeError: x"), + _file(f"{JOB}_ofr.json", _failure("INVALID_DOCUMENT")), + ) + _assert_error(NullNormalizer().normalize(merged), ErrorCode.INVALID_FORMAT) + + def test_one_success_gives_not_implemented_with_warnings_for_the_rest(self) -> None: + """Сценарий «Ошибка одного из документов».""" + merged = _merged( + _file(f"{JOB}.json", _success()), + _file(f"{JOB}_ofr.json", _failure("INVALID_DOCUMENT", "нет типа")), + _file(f"{JOB}_x.json", [1]), + ) + _assert_not_implemented( + NullNormalizer().normalize(merged), + [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет типа", f"{JOB}_x.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}"], + ) + + def test_success_not_first_still_not_implemented(self) -> None: + merged = _merged(_file("a.json", _failure("UNREADABLE", "u")), _file("b.json", _success())) + _assert_not_implemented(NullNormalizer().normalize(merged), ["a.json: UNREADABLE: u"]) + + def test_several_successes_no_warnings(self) -> None: + merged = _merged(_file("a.json", _success()), _file("b.json", _success(error=None))) + _assert_not_implemented(NullNormalizer().normalize(merged), []) + + def test_input_is_not_mutated(self) -> None: + merged = _merged(_file("a.json", _failure("WEIRD", "m")), _file("b.json", None, "e")) + before = copy.deepcopy(merged) + NullNormalizer().normalize(merged) + assert merged == before + + +class TestMergedStructure: + @pytest.mark.parametrize("raw", [[], "строка", None, 0, True]) + def test_not_an_object_is_invalid_format(self, raw: object) -> None: + _assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT) + + @pytest.mark.parametrize( + "raw", [{}, {"job_id": JOB}, {"job_id": JOB, "files": None}, {"job_id": JOB, "files": {"a.json": {}}}] + ) + def test_files_not_a_list_is_invalid_format(self, raw: object) -> None: + _assert_error(NullNormalizer().normalize(raw), ErrorCode.INVALID_FORMAT) + + def test_no_files_is_invalid_format(self) -> None: + _assert_error(NullNormalizer().normalize(_merged()), ErrorCode.INVALID_FORMAT) + + @pytest.mark.parametrize("entry", [None, "a.json", [], {"response": _success()}]) + def test_malformed_entry_is_erroneous_file(self, entry: object) -> None: + merged = {"job_id": JOB, "files": [entry, _file("b.json", _success())]} + outcome = NullNormalizer().normalize(merged) + _assert_error(outcome, ErrorCode.NOT_IMPLEMENTED) + [warning] = outcome.warnings + assert warning.startswith("files[0]: INVALID_FORMAT: ") + + def test_real_merge_output_is_accepted(self) -> None: + merged = merge_answers(JOB, [AnswerFile(f"{JOB}.json", json.dumps(_success()).encode())]) + _assert_not_implemented(NullNormalizer().normalize(merged), []) + + +class TestNormalizeOutcome: + def test_error_outcome(self) -> None: + outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m")) + assert outcome.status is ResultStatus.ERROR + assert outcome.documents == [] + assert outcome.metrics is None + assert outcome.warnings == [] + + def test_warnings_are_kept(self) -> None: + outcome = NormalizeOutcome( + status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m"), warnings=["a: B: c"] + ) + assert outcome.warnings == ["a: B: c"] + + def test_error_requires_error(self) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome(status="error") + + @pytest.mark.parametrize("status", ["done", "needs_check"]) + def test_success_forbids_error(self, status: str) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome( + status=status, + documents=[_statement()], + error=MessageError(code=ErrorCode.UNREADABLE, message="m"), + ) + + @pytest.mark.parametrize("status", ["done", "needs_check"]) + def test_success_requires_documents(self, status: str) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome(status=status) + + @pytest.mark.parametrize("status", ["done", "needs_check"]) + def test_success_outcome(self, status: str) -> None: + metrics = Metrics(pages=2, rows=10) + outcome = NormalizeOutcome(status=status, documents=[_statement()], metrics=metrics) + assert outcome.status is ResultStatus(status) + assert outcome.error is None + assert outcome.metrics == metrics + assert len(outcome.documents) == 1 + + def test_error_forbids_documents(self) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome( + status="error", + documents=[_statement()], + error=MessageError(code=ErrorCode.UNREADABLE, message="m"), + ) + + def test_error_may_carry_metrics(self) -> None: + outcome = NormalizeOutcome( + status="error", + error=MessageError(code=ErrorCode.UNREADABLE, message="m"), + metrics=Metrics(pages=1), + ) + assert outcome.metrics == Metrics(pages=1) + + @pytest.mark.parametrize("status", ["processing", "ok", "", None]) + def test_status_restricted(self, status: object) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome(status=status, error=MessageError(code=ErrorCode.UNREADABLE, message="m")) + + def test_frozen(self) -> None: + outcome = NormalizeOutcome(status="error", error=MessageError(code=ErrorCode.UNREADABLE, message="m")) + with pytest.raises(ValidationError): + outcome.status = ResultStatus.DONE + + def test_unknown_field_forbidden(self) -> None: + with pytest.raises(ValidationError): + NormalizeOutcome( + status="error", + error=MessageError(code=ErrorCode.UNREADABLE, message="m"), + extra_field=1, + ) + + +class TestProtocol: + def test_null_normalizer_is_normalizer(self) -> None: + assert isinstance(NullNormalizer(), Normalizer) + + def test_object_without_normalize_is_not(self) -> None: + assert not isinstance(object(), Normalizer) + + def test_custom_implementation_is_accepted(self) -> None: + class Custom: + def normalize(self, raw: object) -> NormalizeOutcome: + return NormalizeOutcome(status="done", documents=[_statement()]) + + normalizer: Normalizer = Custom() + assert normalizer.normalize({}).status is ResultStatus.DONE + + +def _statement() -> StatementDocument: + return StatementDocument.model_validate( + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": {}, + "columns": [], + "lines": [], + } + ) diff --git a/api/tests/unit/test_smb_hotfolder.py b/api/tests/unit/test_smb_hotfolder.py new file mode 100644 index 0000000..2ed90d1 --- /dev/null +++ b/api/tests/unit/test_smb_hotfolder.py @@ -0,0 +1,573 @@ +"""Горячая папка на SMB-шаре (spec image-recognition-gateway, D-1, R-5, slice-0-skeleton). + +Модуль `smbclient` подменён фейком: UNC-пути `\\\\host\\share\\...` отображаются на `tmp_path`, ошибки — настоящие +`SMBOSError` (как у smbprotocol), все вызовы записываются. Тесты проверяют сами вызовы и UNC-пути. +""" + +import os +import uuid +from pathlib import Path +from types import SimpleNamespace +from typing import IO, Any + +import pytest +from smbprotocol.exceptions import NtStatus, SMBException, SMBOSError + +from src.gateway import smb +from src.gateway.hotfolder import AnswerFile, HotFolder, inbox_name +from src.gateway.smb import SmbHotFolder + +HOST = "smb.example.local" +ROOT = "\\\\smb.example.local\\share\\scan" +INPUT = ROOT + "\\Input" +OUTPUT = ROOT + "\\Output" +TMP = ROOT + "\\Tmp" + + +class FakeClock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + +class FakeSmbClient: + """Подмена модуля `smbclient` (только используемые функции) поверх локального каталога.""" + + def __init__(self, base: Path) -> None: + self.base = base + self.calls: list[tuple[str, tuple[str, ...], dict[str, Any]]] = [] + self.fail: dict[str, BaseException] = {} # имя функции -> исключение, которое она бросит + self.path = SimpleNamespace(isdir=self._isdir) # подмодуль smbclient.path + + def _isdir(self, path: str, **kwargs: Any) -> bool: + self._record("isdir", (path,), kwargs) + return self._local(path).is_dir() + + def _record(self, func: str, args: tuple[str, ...], kwargs: dict[str, Any]) -> None: + self.calls.append((func, args, kwargs)) + if func in self.fail: + raise self.fail[func] + + def _local(self, unc: str) -> Path: + assert unc.startswith("\\\\"), f"не UNC-путь: {unc!r}" + assert "/" not in unc, f"прямой слеш в UNC-пути: {unc!r}" + return self.base.joinpath(*[part for part in unc.split("\\") if part]) + + def open_file(self, path: str, mode: str = "r", **kwargs: Any) -> IO[Any]: + self._record("open_file", (path, mode), kwargs) + local = self._local(path) + if not local.parent.is_dir(): + raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path) + if "r" in mode and not local.is_file(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) + return open(local, mode) # noqa: SIM115 — возвращаем файловый объект, как smbclient.open_file + + def mkdir(self, path: str, **kwargs: Any) -> None: + self._record("mkdir", (path,), kwargs) + local = self._local(path) + if local.exists(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_COLLISION, path) + if not local.parent.is_dir(): + raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path) + local.mkdir() + + def replace(self, src: str, dst: str, **kwargs: Any) -> None: + self._record("replace", (src, dst), kwargs) + local_src = self._local(src) + if not local_src.exists(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, src) + os.replace(local_src, self._local(dst)) + + def remove(self, path: str, **kwargs: Any) -> None: + self._record("remove", (path,), kwargs) + local = self._local(path) + if not local.is_file(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) + local.unlink() + + def scandir(self, path: str, **kwargs: Any) -> list[SimpleNamespace]: + """Как `smbclient.scandir`: элементы с `name`, `is_file()` и `smb_info.end_of_file` (размер из листинга).""" + self._record("scandir", (path,), kwargs) + local = self._local(path) + if not local.parent.is_dir(): + raise SMBOSError(NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, path) + if not local.exists(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) + if not local.is_dir(): + raise SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, path) + return [ + SimpleNamespace( + name=p.name, + is_file=p.is_file, + smb_info=SimpleNamespace(end_of_file=p.stat().st_size if p.is_file() else 0), + ) + for p in local.iterdir() + ] + + def listdir(self, path: str, **kwargs: Any) -> list[str]: + self._record("listdir", (path,), kwargs) + local = self._local(path) + if not local.exists(): + raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) + if not local.is_dir(): + raise SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, path) + return [p.name for p in local.iterdir()] + + def funcs(self) -> list[str]: + return [func for func, _, _ in self.calls] + + +@pytest.fixture +def fake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FakeSmbClient: + fake = FakeSmbClient(tmp_path / "smb") + monkeypatch.setattr(smb, "smbclient", fake) + return fake + + +@pytest.fixture +def share_root(fake: FakeSmbClient) -> Path: + """Существующий корень горячей папки `\\\\smb.example.local\\share\\scan`.""" + root = fake.base / HOST / "share" / "scan" + root.mkdir(parents=True) + return root + + +def _job_id() -> str: + return str(uuid.uuid4()) + + +def _hotfolder(**kwargs: Any) -> SmbHotFolder: + kwargs.setdefault("settle_seconds", 0) + return SmbHotFolder(HOST, "share/scan", **kwargs) + + +def _write_answer(share_root: Path, job_id: str, data: bytes, name: str | None = None) -> Path: + folder = share_root / "Output" / job_id + folder.mkdir(parents=True, exist_ok=True) + path = folder / (name or f"{job_id}.json") + path.write_bytes(data) + return path + + +class TestPaths: + def test_default_layout(self) -> None: + hf = _hotfolder() + job_id = _job_id() + assert hf.root == ROOT + assert hf.input_dir == INPUT + assert hf.output_dir == OUTPUT + assert hf.tmp_dir == TMP + assert hf.answer_dir(job_id) == f"{OUTPUT}\\{job_id}" + assert hf.answer_path(job_id) == f"{OUTPUT}\\{job_id}\\{job_id}.json" + + @pytest.mark.parametrize("path", ["share/scan", "share\\scan", "/share/scan/", "\\share\\scan\\"]) + def test_relative_share_path_is_joined_with_host(self, path: str) -> None: + assert SmbHotFolder(HOST, path).root == ROOT + + @pytest.mark.parametrize("path", ["\\\\srv\\share\\scan", "//srv/share/scan", "\\\\srv\\share\\scan\\"]) + def test_full_unc_path_is_used_as_is(self, path: str) -> None: + assert SmbHotFolder(HOST, path).root == "\\\\srv\\share\\scan" + + def test_share_root_only(self) -> None: + assert SmbHotFolder(HOST, "share").root == "\\\\smb.example.local\\share" + + def test_custom_dir_names(self) -> None: + hf = SmbHotFolder(HOST, "share/scan", input_dir="Входящие", output_dir="Исходящие", tmp_dir="Служебный") + assert hf.input_dir == ROOT + "\\Входящие" + assert hf.output_dir == ROOT + "\\Исходящие" + assert hf.tmp_dir == ROOT + "\\Служебный" + assert hf.answer_path("abc") == ROOT + "\\Исходящие\\abc\\abc.json" + + @pytest.mark.parametrize(("host", "path"), [(HOST, ""), (HOST, "/"), ("", "share/scan"), ("", "\\\\srv")]) + def test_rejects_incomplete_location(self, host: str, path: str) -> None: + with pytest.raises(ValueError, match="SMB"): + SmbHotFolder(host, path) + + @pytest.mark.parametrize("job_id", ["", ".", "..", "../x", "a/b", "a\\b", "a:b", "a\x00b"]) + def test_answer_path_rejects_unsafe_job_id(self, job_id: str) -> None: + with pytest.raises(ValueError, match="job_id"): + _hotfolder().answer_path(job_id) + with pytest.raises(ValueError, match="job_id"): + _hotfolder().answer_dir(job_id) + + def test_negative_settle_is_rejected(self) -> None: + with pytest.raises(ValueError, match="settle"): + _hotfolder(settle_seconds=-1) + + def test_constructor_does_not_touch_share(self, fake: FakeSmbClient) -> None: + _hotfolder(login="user", password="secret") + assert fake.calls == [] + + def test_implements_protocol(self, fake: FakeSmbClient, share_root: Path) -> None: + hf: HotFolder = _hotfolder() + assert hf.healthy() is True + + +class TestPut: + def test_writes_part_in_tmp_then_replaces_into_input(self, fake: FakeSmbClient, share_root: Path) -> None: + name = inbox_name(_job_id(), "aep_5.PDF") + data = bytes(range(256)) * 10 + _hotfolder().put(name, data) + part = f"{TMP}\\.{name}.part" + assert fake.calls == [ + ("mkdir", (TMP,), {}), + ("mkdir", (INPUT,), {}), + ("open_file", (part, "wb"), {}), + ("replace", (part, f"{INPUT}\\{name}"), {}), + ] + assert (share_root / "Input" / name).read_bytes() == data + assert [p.name for p in (share_root / "Input").iterdir()] == [name] + assert list((share_root / "Tmp").iterdir()) == [] + + def test_input_has_no_part_during_write(self, fake: FakeSmbClient, share_root: Path) -> None: + """OQ-79: пока пишется временный файл, во входящем каталоге его нет — он в `Tmp`.""" + name = "a.pdf" + seen: dict[str, list[str]] = {} + real_replace = fake.replace + + def spy(src: str, dst: str, **kwargs: Any) -> None: + seen["input"] = sorted(p.name for p in (share_root / "Input").iterdir()) + seen["tmp"] = sorted(p.name for p in (share_root / "Tmp").iterdir()) + real_replace(src, dst, **kwargs) + + fake.replace = spy # type: ignore[method-assign] + _hotfolder().put(name, b"data") + assert seen == {"input": [], "tmp": [".a.pdf.part"]} + assert [p.name for p in (share_root / "Input").iterdir()] == [name] + + def test_existing_tmp_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Tmp").mkdir() + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + + def test_tmp_mkdir_denied_but_tmp_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Tmp").mkdir() + (share_root / "Input").mkdir() + fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + assert fake.calls[:4] == [ + ("mkdir", (TMP,), {}), + ("isdir", (TMP,), {}), + ("mkdir", (INPUT,), {}), + ("isdir", (INPUT,), {}), + ] + + def test_tmp_mkdir_denied_and_no_tmp_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Input").mkdir() + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + fake.fail["mkdir"] = denied + with pytest.raises(SMBOSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert excinfo.value is denied + assert fake.funcs() == ["mkdir", "isdir"] + + def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Input").mkdir() + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + + def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None: + with pytest.raises(OSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert isinstance(excinfo.value, SMBOSError) + assert fake.funcs() == ["mkdir"] + assert not fake.base.exists() + + def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None: + """Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно.""" + (share_root / "Tmp").mkdir() + (share_root / "Input").mkdir() + fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + _hotfolder().put("a.pdf", b"1") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"1" + assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir", "open_file", "replace"] + assert fake.calls[3] == ("isdir", (INPUT,), {}) + + def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: + (share_root / "Tmp").mkdir() + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["mkdir"] = denied + with pytest.raises(SMBOSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert excinfo.value is denied + assert fake.funcs() == ["mkdir", "isdir", "mkdir", "isdir"] + + def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None: + denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + fake.fail["mkdir"] = denied + fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, TMP) + with pytest.raises(SMBOSError) as excinfo: + _hotfolder().put("a.pdf", b"1") + assert excinfo.value is denied + assert fake.funcs() == ["mkdir", "isdir"] + + def test_overwrites_existing_file(self, fake: FakeSmbClient, share_root: Path) -> None: + hf = _hotfolder() + hf.put("a.pdf", b"old") + hf.put("a.pdf", b"new") + assert (share_root / "Input" / "a.pdf").read_bytes() == b"new" + assert [p.name for p in (share_root / "Input").iterdir()] == ["a.pdf"] + + def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None: + _hotfolder().put("empty.pdf", b"") + assert (share_root / "Input" / "empty.pdf").read_bytes() == b"" + + def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None: + name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf") + _hotfolder().put(name, b"x") + assert (share_root / "Input" / name).read_bytes() == b"x" + + @pytest.mark.parametrize( + "name", + [ + "", + "../escape.pdf", + "sub/a.pdf", + "sub\\a.pdf", + "/abs.pdf", + ".hidden.pdf", + ".a.pdf.part", + "..", + ".", + "a\x00b", + "a:b.pdf", + "ab.pdf", + 'a"b.pdf', + "a|b.pdf", + "a?b.pdf", + "a*b.pdf", + "a\x01b.pdf", + "a\nb.pdf", + ], + ) + def test_rejects_invalid_names_before_any_call(self, fake: FakeSmbClient, share_root: Path, name: str) -> None: + with pytest.raises(ValueError, match="имя файла"): + _hotfolder().put(name, b"x") + assert fake.calls == [] + + def test_cleans_part_file_on_replace_failure(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + with pytest.raises(OSError): + _hotfolder().put("a.pdf", b"data") + part = f"{TMP}\\.a.pdf.part" + assert fake.calls[-1] == ("remove", (part,), {}) + assert list((share_root / "Input").iterdir()) == [] + assert list((share_root / "Tmp").iterdir()) == [] + + def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None: + fake.fail["replace"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT) + fake.fail["remove"] = SMBException("соединение потеряно") + with pytest.raises(SMBOSError): + _hotfolder().put("a.pdf", b"data") + assert fake.funcs() == ["mkdir", "mkdir", "open_file", "replace", "remove"] + + def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None: + _hotfolder(login="user", password="secret").put("a.pdf", b"1") + assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 4 + + +class TestPoll: + def test_none_until_answer_dir_has_json(self, fake: FakeSmbClient, share_root: Path) -> None: + hf = _hotfolder() + job_id = _job_id() + assert hf.poll(job_id) is None # нет Output: STATUS_OBJECT_PATH_NOT_FOUND + (share_root / "Output").mkdir() + assert hf.poll(job_id) is None # нет Output/: STATUS_OBJECT_NAME_NOT_FOUND + (share_root / "Output" / job_id).mkdir() + assert hf.poll(job_id) is None # каталог пуст + assert fake.funcs() == ["scandir", "scandir", "scandir"] + + def test_lists_answer_dir_and_reads_every_json(self, fake: FakeSmbClient, share_root: Path) -> None: + hf = _hotfolder() + job_id = _job_id() + _write_answer(share_root, job_id, b'{"n": 2}', f"{job_id}_ofr.json") + _write_answer(share_root, job_id, b'{"n": 1}') + _write_answer(share_root, job_id, b"text", "readme.txt") + (share_root / "Output" / job_id / "nested.json").mkdir() + assert hf.poll(job_id) == [ + AnswerFile(f"{job_id}.json", b'{"n": 1}'), + AnswerFile(f"{job_id}_ofr.json", b'{"n": 2}'), + ] + folder = f"{OUTPUT}\\{job_id}" + assert fake.calls == [ + ("scandir", (folder,), {}), + ("open_file", (f"{folder}\\{job_id}.json", "rb"), {}), + ("open_file", (f"{folder}\\{job_id}_ofr.json", "rb"), {}), + ("scandir", (folder,), {}), # T2.1: повторный листинг — набор не изменился за время чтения + ] + + def test_one_json_after_settle(self, fake: FakeSmbClient, share_root: Path) -> None: + clock = FakeClock() + hf = _hotfolder(settle_seconds=2, clock=clock) + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 1.9 + assert hf.poll(job_id) is None + clock.now += 0.1 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + # до готовности файлы не читаются; после чтения — повторный листинг (T2.1) + assert fake.funcs() == ["scandir", "scandir", "scandir", "open_file", "scandir"] + + def test_growing_file_is_not_ready_until_stable(self, fake: FakeSmbClient, share_root: Path) -> None: + clock = FakeClock() + hf = _hotfolder(settle_seconds=2, clock=clock) + job_id = _job_id() + path = _write_answer(share_root, job_id, b'{"a": ') + assert hf.poll(job_id) is None + clock.now += 1.5 + path.write_bytes(b'{"a": 1}') + assert hf.poll(job_id) is None + clock.now += 1.5 + assert hf.poll(job_id) is None + clock.now += 0.5 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b'{"a": 1}')] + + def test_reads_only_own_answer(self, fake: FakeSmbClient, share_root: Path) -> None: + job_id, other = _job_id(), _job_id() + _write_answer(share_root, other, b"{}") + hf = _hotfolder() + assert hf.poll(job_id) is None + assert hf.poll(other) == [AnswerFile(f"{other}.json", b"{}")] + + def test_missing_root_is_none(self, fake: FakeSmbClient) -> None: + assert _hotfolder().poll(_job_id()) is None + + def test_plain_file_not_found_is_none(self, fake: FakeSmbClient) -> None: + fake.fail["scandir"] = FileNotFoundError("нет каталога") + assert _hotfolder().poll(_job_id()) is None + + def test_file_vanished_before_read_is_none(self, fake: FakeSmbClient, share_root: Path) -> None: + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, OUTPUT) + assert _hotfolder().poll(job_id) is None + + def test_file_changed_between_listing_and_read_is_none(self, fake: FakeSmbClient, share_root: Path) -> None: + job_id = _job_id() + path = _write_answer(share_root, job_id, b"{}") + real_scandir = fake.scandir + + def scandir_then_grow(folder: str, **kwargs: Any) -> list[SimpleNamespace]: + entries = real_scandir(folder, **kwargs) + path.write_bytes(b'{"more": 1}') + return entries + + fake.scandir = scandir_then_grow # type: ignore[method-assign] + assert _hotfolder().poll(job_id) is None + + @pytest.mark.parametrize("status", [NtStatus.STATUS_SHARING_VIOLATION, NtStatus.STATUS_DELETE_PENDING]) + def test_answer_being_written_or_deleted_is_none( + self, fake: FakeSmbClient, share_root: Path, status: NtStatus + ) -> None: + """ИС ещё держит файл (монопольное открытие не проходит) — ответа пока нет, опросим позже.""" + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + fake.fail["open_file"] = SMBOSError(status, OUTPUT) + assert _hotfolder().poll(job_id) is None + + def test_answer_dir_is_a_file_is_none(self, fake: FakeSmbClient, share_root: Path) -> None: + """T2.2: на месте `Output/` — файл (ENOTDIR из листинга) — ответа нет, как у `LocalHotFolder`.""" + job_id = _job_id() + (share_root / "Output").mkdir() + (share_root / "Output" / job_id).write_bytes(b"{}") + assert _hotfolder().poll(job_id) is None + + def test_new_file_appeared_while_reading_is_none_and_set_resettles( + self, fake: FakeSmbClient, share_root: Path + ) -> None: + """T2.1: после чтения каталог листается заново; набор изменился — `None`, набор настаивается заново.""" + clock = FakeClock() + hf = _hotfolder(settle_seconds=2, clock=clock) + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + assert hf.poll(job_id) is None + clock.now += 2 + real_open = fake.open_file + + def open_then_add(path: str, mode: str = "r", **kwargs: Any) -> IO[Any]: + fd = real_open(path, mode, **kwargs) + _write_answer(share_root, job_id, b"[]", f"{job_id}_ofr.json") + return fd + + fake.open_file = open_then_add # type: ignore[method-assign] + assert hf.poll(job_id) is None + fake.open_file = real_open # type: ignore[method-assign] + assert hf.poll(job_id) is None # состояние сброшено: новый набор настаивается с нуля + clock.now += 2 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}"), AnswerFile(f"{job_id}_ofr.json", b"[]")] + + @pytest.mark.parametrize( + "error", + [ + SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, OUTPUT), + SMBOSError(NtStatus.STATUS_SHARING_VIOLATION, OUTPUT), + SMBOSError(NtStatus.STATUS_NOT_A_DIRECTORY, OUTPUT), + ], + ids=["ENOENT", "SHARING_VIOLATION", "ENOTDIR"], + ) + def test_listing_error_resets_settle_state(self, fake: FakeSmbClient, share_root: Path, error: SMBOSError) -> None: + """T2.4: «нет ответа»/«ещё занят» во время настаивания сбрасывает накопленное состояние settle.""" + clock = FakeClock() + hf = _hotfolder(settle_seconds=2, clock=clock) + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + assert hf.poll(job_id) is None + assert hf.pending_jobs() == 1 + fake.fail["scandir"] = error + clock.now += 2 + assert hf.poll(job_id) is None + assert hf.pending_jobs() == 0 + del fake.fail["scandir"] + assert hf.poll(job_id) is None # набор настаивается заново + clock.now += 2 + assert hf.poll(job_id) == [AnswerFile(f"{job_id}.json", b"{}")] + + @pytest.mark.parametrize("func", ["scandir", "open_file"]) + def test_other_errors_propagate(self, fake: FakeSmbClient, share_root: Path, func: str) -> None: + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + fake.fail[func] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, OUTPUT) + with pytest.raises(SMBOSError): + _hotfolder().poll(job_id) + + def test_invalid_job_id_rejected_before_any_call(self, fake: FakeSmbClient) -> None: + with pytest.raises(ValueError, match="job_id"): + _hotfolder().poll("../x") + assert fake.calls == [] + + def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None: + job_id = _job_id() + _write_answer(share_root, job_id, b"{}") + _hotfolder(login="user", password="secret").poll(job_id) + assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 3 + + +class TestHealthy: + def test_true_for_existing_root(self, fake: FakeSmbClient, share_root: Path) -> None: + assert _hotfolder().healthy() is True + assert fake.calls == [("listdir", (ROOT,), {})] + + def test_false_for_missing_root(self, fake: FakeSmbClient) -> None: + assert _hotfolder().healthy() is False + + def test_false_for_file_root(self, fake: FakeSmbClient) -> None: + file_root = fake.base / HOST / "share" / "scan" + file_root.parent.mkdir(parents=True) + file_root.write_bytes(b"x") + assert _hotfolder().healthy() is False + + @pytest.mark.parametrize( + "error", [SMBException("сервер недоступен"), ValueError("плохой путь"), ConnectionError("сброс"), OSError()] + ) + def test_never_raises(self, fake: FakeSmbClient, share_root: Path, error: Exception) -> None: + fake.fail["listdir"] = error + assert _hotfolder().healthy() is False + + def test_passes_credentials(self, fake: FakeSmbClient, share_root: Path) -> None: + _hotfolder(login="user", password="secret").healthy() + assert fake.calls == [("listdir", (ROOT,), {"username": "user", "password": "secret"})] diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py new file mode 100644 index 0000000..69be5e8 --- /dev/null +++ b/api/tests/unit/test_worker.py @@ -0,0 +1,883 @@ +"""Воркер шлюза (`src.core.worker`, задача 5.3): сценарии spec image-recognition-gateway, D-2, D-3, D-7, R-14..R-20. + +Обвязка (фейки S3/NATS, шпион горячей папки, эмулятор ИС, журнал событий) — `tests/worker_helpers.py`, фикстура +`make_env` — `tests/unit/conftest.py`. Параллелизм и остановка при очереди на слот — `test_worker_concurrency.py`. +""" + +import asyncio +import json +import shutil +import uuid +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker + +from src.contract import ( + ErrorCode, + MessageError, + Metrics, + ResultStatus, + StatementDocument, +) +from src.core.clients import JSON_CONTENT_TYPE +from src.core.worker import MAX_ERROR_MESSAGE_CHARS, MAX_WARNING_CHARS, MAX_WARNINGS +from src.gateway.hotfolder import AnswerFile +from src.gateway.merge import dump_merged, merge_answers +from src.normalizer import NOT_IMPLEMENTED_MESSAGE, NOT_OBJECT_MESSAGE, NormalizeOutcome +from tests.worker_helpers import ( + API_DIR, + BUCKET, + CONTRACTS_DIR, + PDF_BYTES, + SUCCESS_ANSWER, + Env, + EnvFactory, + emulator, + eventually, + merged_raw, +) + +# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в тестах эмулятора +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +RESULT_VALIDATOR = Draft202012Validator( + json.loads((CONTRACTS_DIR / "recognition-result.schema.json").read_text(encoding="utf-8")), + format_checker=FormatChecker(), +) +STATEMENT_EXAMPLE = json.loads((CONTRACTS_DIR / "examples" / "result-statement-minimal.json").read_text("utf-8")) +REAL_MESSAGE = "Не удалось определить тип документа." +INVALID_DOCUMENT_ANSWER = { + "file_uid": "x", + "error": {"code": "INVALID_DOCUMENT", "message": REAL_MESSAGE}, + "data_ocr": {"table_structure": []}, +} + + +def _json(obj: object) -> bytes: + return json.dumps(obj, ensure_ascii=False).encode("utf-8") + + +def _raw(env: Env, job_id: str) -> Any: + """Склеенный сырой ответ из S3: всегда валидный JSON с типом application/json.""" + key = (BUCKET, f"raw/{job_id}.json") + assert env.storage.content_types[key] == JSON_CONTENT_TYPE + return json.loads(env.storage.objects[key]) + + +def _real_fixture(name: str) -> bytes: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + return (REAL_FIXTURES / name).read_bytes() + + +# Логи воркера перехватываются (`log_lines` из conftest): вывод pytest чистый +pytestmark = pytest.mark.usefixtures("log_lines") + + +class RaisingNormalizer: + def normalize(self, raw: object) -> NormalizeOutcome: + raise RuntimeError("нормализатор сломан") + + +class StubNormalizer: + """Возвращает заданный исход (done / needs_check / error) — в Срезе 0 успешный путь достижим только так.""" + + def __init__(self, outcome: NormalizeOutcome) -> None: + self.outcome = outcome + self.calls: list[object] = [] + + def normalize(self, raw: object) -> NormalizeOutcome: + self.calls.append(raw) + return self.outcome + + +def assert_error(body: dict[str, Any], code: ErrorCode, *, raw: bool) -> None: + assert body["status"] == "error" + assert body["error"]["code"] == code.value + assert body["error"]["message"] + if raw: + assert body["raw_s3_key"] == f"raw/{body['job_id']}.json" + else: + assert "raw_s3_key" not in body + assert "result_s3" not in body + + +# --- подтверждение запроса до обработки --------------------------------------------------------------------------- +async def test_ack_is_published_before_s3_read_and_before_file_appears(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + async with emulator(env): + await env.run(request) + job_id = request["job_id"] + name = f"{job_id}_ok_doc.pdf" + kinds = [event for event in env.events if event[1] in (f"ack:{job_id}:1", request["s3"]["key"], name)] + assert kinds == [("publish", f"ack:{job_id}:1"), ("s3_get", request["s3"]["key"]), ("put", name)] + + +async def test_file_in_inbox_is_byte_for_byte_s3_object(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(original_name="aep_5.pdf") + await env.deliver(request) + path = env.local.input_dir / f"{request['job_id']}_aep_5.pdf" + await eventually(path.exists) + assert path.read_bytes() == PDF_BYTES + await env.worker.stop() + + +async def test_repeated_request_gets_second_ack_and_one_file(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(original_name="aep_5.pdf") + await env.deliver(request) + job_id = request["job_id"] + await eventually(lambda: len(env.hotfolder.puts) == 1) + await env.deliver({**request, "attempt": 2}) # ядро не дождалось ack и повторило запрос + await eventually(lambda: len(env.acks(job_id)) == 2) + await asyncio.sleep(0.05) + assert env.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:2"] + assert env.inbox() == [f"{job_id}_aep_5.pdf"] + assert env.hotfolder.puts == [f"{job_id}_aep_5.pdf"] + assert len(env.storage.get_calls) == 1 + + +async def test_repeated_request_after_result_gets_ack_without_reprocessing(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + async with emulator(env): + await env.run(request) + await env.deliver(request) + await asyncio.wait_for(env.worker.wait_idle(), 5) + job_id = request["job_id"] + assert env.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1"] + assert len(env.results(job_id)) == 1 + assert env.hotfolder.puts == [f"{job_id}_ok_doc.pdf"] + + +async def test_done_cache_is_bounded_lru(make_env: EnvFactory) -> None: + env = await make_env(DONE_CACHE_SIZE=2) + requests = [env.request() for _ in range(3)] + async with emulator(env): + for request in requests: + await env.run(request) + # первый job_id вытеснен из LRU размера 2 — его повтор обрабатывается заново (в проде — 1000 заданий назад) + await env.deliver(requests[0]) + await asyncio.wait_for(env.worker.wait_idle(), 5) + # третий ещё в кэше — повтор только с ack + await env.deliver(requests[2]) + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert len(env.results(requests[0]["job_id"])) == 2 + assert len(env.results(requests[2]["job_id"])) == 1 + assert len(env.acks(requests[2]["job_id"])) == 2 + + +async def test_on_request_returns_without_waiting_for_publication(make_env: EnvFactory) -> None: + env = await make_env() + env.client.block = asyncio.Event() + request = env.request() + await asyncio.wait_for(env.deliver(request), 0.2) # публикация ack висит, а колбэк уже вернулся (T11.4) + assert env.client.published == [] + env.client.block.set() + async with emulator(env): + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert len(env.results(request["job_id"])) == 1 + + +# --- результат по контракту --------------------------------------------------------------------------------------- +async def test_success_answer_gives_not_implemented_with_raw_key(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(attempt=3) + async with emulator(env): + body = await env.run(request) + job_id = request["job_id"] + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE + assert body["file_id"] == request["file_id"] + assert [mid for mid, _ in env.published()] == [f"ack:{job_id}:3", f"result:{job_id}:3"] + assert "metrics" not in body # ошибочных файлов нет — warnings опущено + answer = json.loads(env.local.answer_path(job_id).read_bytes()) + assert answer["error"] == {"code": "", "message": ""} # успех в форме реального ответа ИС + assert _raw(env, job_id) == merged_raw(job_id, (f"{job_id}.json", answer, None)) + # сырой ответ сохранён раньше публикации результата + assert env.events.index(("s3_put", f"raw/{job_id}.json")) < env.events.index(("publish", f"result:{job_id}:3")) + assert env.inbox() == [f"{job_id}_ok_doc.pdf.done"] + + +@pytest.mark.fixtures +async def test_real_fixture_error_is_passed_through(make_env: EnvFactory, tmp_path: Path) -> None: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + real = tmp_path / "real-fixtures" + real.mkdir() + for source in REAL_FIXTURES.glob("*.json"): + shutil.copy(source, real / source.name) + expected = json.loads((real / "aep_5.json").read_text(encoding="utf-8"))["error"] + env = await make_env() + request = env.request(original_name="aep_5.PDF") + async with emulator(env, real): + body = await env.run(request) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + assert body["error"]["message"] == expected["message"] + job_id = request["job_id"] + assert body["metrics"] == {"warnings": [f"{job_id}.json: INVALID_DOCUMENT: {expected['message']}"]} + assert _raw(env, job_id)["files"][0]["response"]["error"] == expected + + +async def test_unknown_name_gets_emulator_invalid_document(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(original_name="unknown.pdf") + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + assert "unknown.pdf" in body["error"]["message"] + + +@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) +async def test_successful_outcome_writes_result_file(make_env: EnvFactory, status: ResultStatus) -> None: + document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) + normalizer = StubNormalizer(NormalizeOutcome(status=status, documents=[document])) + env = await make_env(normalizer=normalizer) + request = env.request() + async with emulator(env): + body = await env.run(request) + job_id, file_id = request["job_id"], request["file_id"] + key = f"results/{file_id}/{job_id}/result.json" + assert body["status"] == status.value + assert body["result_s3"] == {"bucket": BUCKET, "key": key} + assert body["result_schema_version"] == "1.0" + assert body["raw_s3_key"] == f"raw/{job_id}.json" + assert "error" not in body + result = json.loads(env.storage.objects[(BUCKET, key)].decode("utf-8")) + RESULT_VALIDATOR.validate(result) + assert result["status"] == status.value + assert result["job_id"] == job_id + assert result["file_id"] == file_id + assert result["service"]["name"] == "scan-gateway" + assert result["raw_s3_key"] == f"raw/{job_id}.json" + assert result["documents"] == STATEMENT_EXAMPLE["documents"] + assert env.storage.content_types[(BUCKET, key)] == JSON_CONTENT_TYPE + # нормализатор получает склеенный ответ — тот же объект, что записан в raw/ + assert normalizer.calls == [merged_raw(job_id, (f"{job_id}.json", {**SUCCESS_ANSWER, "file_uid": job_id}, None))] + assert normalizer.calls == [_raw(env, job_id)] + assert env.events.index(("s3_put", key)) < env.events.index(("publish", f"result:{job_id}:1")) + + +async def test_result_file_write_failure_is_unknown_error_with_raw_key(make_env: EnvFactory) -> None: + document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) + env = await make_env(normalizer=StubNormalizer(NormalizeOutcome(status=ResultStatus.DONE, documents=[document]))) + env.storage.fail_put_prefixes = ("results/",) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) + assert "результат" in body["error"]["message"] + + +# --- сырой ответ и нормализатор ----------------------------------------------------------------------------------- +async def test_normalizer_exception_is_unknown_error_with_raw_saved(make_env: EnvFactory) -> None: + env = await make_env(normalizer=RaisingNormalizer()) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) + assert "нормализатор" in body["error"]["message"] + assert (BUCKET, f"raw/{request['job_id']}.json") in env.storage.objects + + +async def test_raw_write_failure_is_unknown_error_without_raw_key(make_env: EnvFactory) -> None: + normalizer = StubNormalizer( + NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="UNREADABLE", message="x")) + ) + env = await make_env(normalizer=normalizer) + env.storage.fail_put_prefixes = ("raw/",) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=False) + assert "сырой ответ" in body["error"]["message"] + assert normalizer.calls == [] # без сохранённого сырого ответа нормализатор не вызывается (D-4) + + +async def test_answer_not_json_is_parse_error_and_invalid_format(make_env: EnvFactory) -> None: + """Сценарий «Все файлы не JSON»: файл — `parse_error` в склейке, задание заканчивается `INVALID_FORMAT`.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + await env.deliver(request) + await eventually(lambda: len(env.hotfolder.puts) == 1) + env.answer(job_id, b"\xff\xfenot json") + await asyncio.wait_for(env.worker.wait_idle(), 5) + [(_, body)] = env.results(job_id) + assert_error(body, ErrorCode.INVALID_FORMAT, raw=True) + [entry] = _raw(env, job_id)["files"] + assert entry["name"] == f"{job_id}.json" + assert entry["response"] is None + assert entry["parse_error"] + [warning] = body["metrics"]["warnings"] + assert warning.startswith(f"{job_id}.json: INVALID_FORMAT: ") + assert entry["parse_error"] in warning + + +async def test_invalid_json_after_settle_is_not_reread(make_env: EnvFactory) -> None: + """R-24: набор уже настоялся — недописанный JSON не перечитывается, а становится `parse_error` файла.""" + env = await make_env() + full = _json({**SUCCESS_ANSWER, "file_uid": "x"}) + env.hotfolder.poll_script = [None, [AnswerFile("x.json", full[:10])], [AnswerFile("x.json", full)]] + body = await env.run(env.request()) + assert_error(body, ErrorCode.INVALID_FORMAT, raw=True) + assert env.hotfolder.polls == 2 + [entry] = _raw(env, body["job_id"])["files"] + assert entry["response"] is None + assert entry["parse_error"] + + +async def test_merged_raw_contains_every_file_by_name(make_env: EnvFactory) -> None: + """Сценарий «Склеенный сырой ответ»: оба файла в `raw/`, по возрастанию имени, нормализатор получает склейку.""" + normalizer = StubNormalizer( + NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="NOT_IMPLEMENTED", message="x")) + ) + env = await make_env(normalizer=normalizer) + request = env.request() + job_id = request["job_id"] + first = {**SUCCESS_ANSWER, "file_uid": job_id} + second = {**INVALID_DOCUMENT_ANSWER, "file_uid": job_id} + env.hotfolder.poll_script = [ + [AnswerFile(f"{job_id}.json", _json(first)), AnswerFile(f"{job_id}_ofr.json", _json(second))] + ] + await env.run(request) + expected = merged_raw(job_id, (f"{job_id}.json", first, None), (f"{job_id}_ofr.json", second, None)) + assert _raw(env, job_id) == expected + assert normalizer.calls == [expected] + assert env.events.index(("s3_put", f"raw/{job_id}.json")) < env.events.index(("publish", f"result:{job_id}:1")) + + +async def test_one_erroneous_file_does_not_fail_the_job(make_env: EnvFactory) -> None: + """Сценарий «Ошибка одного из документов»: `NOT_IMPLEMENTED`, ровно одна строка warnings про `_ofr`.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [ + AnswerFile(f"{job_id}.json", _json(SUCCESS_ANSWER)), + AnswerFile(f"{job_id}_ofr.json", _json(INVALID_DOCUMENT_ANSWER)), + ] + ] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE + assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + + +async def test_parse_error_of_one_file_goes_to_warnings(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [ + AnswerFile(f"{job_id}.json", _json(SUCCESS_ANSWER)), + AnswerFile(f"{job_id}_bad.json", b"{not json"), + AnswerFile(f"{job_id}_list.json", b"[1, 2]"), + ] + ] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + bad, listed = body["metrics"]["warnings"] + assert bad.startswith(f"{job_id}_bad.json: INVALID_FORMAT: ") + assert listed == f"{job_id}_list.json: INVALID_FORMAT: {NOT_OBJECT_MESSAGE}" + files = _raw(env, job_id)["files"] + assert [f["name"] for f in files] == [f"{job_id}.json", f"{job_id}_bad.json", f"{job_id}_list.json"] + assert files[1]["response"] is None and files[1]["parse_error"] + assert files[2] == {"name": f"{job_id}_list.json", "response": [1, 2], "parse_error": None} + + +async def test_all_files_erroneous_take_code_of_first(make_env: EnvFactory) -> None: + """Сценарий «Все документы ошибочны»: код первого по имени, две строки warnings, не-JSON — INVALID_FORMAT.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [AnswerFile(f"{job_id}.json", _json(INVALID_DOCUMENT_ANSWER)), AnswerFile(f"{job_id}_ofr.json", b"garbage")] + ] + body = await env.run(request) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + assert body["error"]["message"] == REAL_MESSAGE + first, second = body["metrics"]["warnings"] + assert first == f"{job_id}.json: INVALID_DOCUMENT: {REAL_MESSAGE}" + assert second.startswith(f"{job_id}_ofr.json: INVALID_FORMAT: ") + + +async def test_unknown_code_is_unknown_error_with_original_in_warnings(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + job_id = request["job_id"] + answer = {"file_uid": job_id, "error": {"code": "SOME_NEW_CODE", "message": "новое"}, "data_ocr": {}} + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", _json(answer))]] + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) + assert "SOME_NEW_CODE" in body["error"]["message"] + assert body["metrics"] == {"warnings": [f"{job_id}.json: SOME_NEW_CODE: новое"]} + + +async def _unserialisable_depth() -> bytes: + """JSON вложенности, который разбирается, но после обёртки склейки не сериализуется (`RecursionError`). + + Глубина подбирается в потоке тем же способом, каким воркер вызывает склейку и сериализацию (`to_thread`): окно + ~2 уровня зависит от глубины стека потока и версии интерпретатора. + """ + for depth in range(900, 1000): + data = ("[" * depth + "]" * depth).encode() + merged = await asyncio.to_thread(merge_answers, "probe", [AnswerFile("p.json", data)]) + if merged["files"][0]["parse_error"] is not None: + continue + try: + await asyncio.to_thread(dump_merged, merged) + except RecursionError: + return data + raise AssertionError("не найдена глубина: разбирается, но не сериализуется") + + +async def test_unserialisable_answer_is_unknown_error_without_raw_and_worker_survives(make_env: EnvFactory) -> None: + """M-2 (T3.1): `RecursionError` при сериализации склейки — как сбой записи сырого ответа (`UNKNOWN_ERROR`, без + `raw_s3_key`), а не непредвиденная ошибка; воркер продолжает работать.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", await _unserialisable_depth())]] + body = await env.run(request) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=False) + assert "сырой ответ ИС не сохранён" in body["error"]["message"] + assert (BUCKET, f"raw/{job_id}.json") not in env.storage.objects + async with emulator(env): # воркер жив: следующее задание обрабатывается штатно + assert_error(await env.run(env.request()), ErrorCode.NOT_IMPLEMENTED, raw=True) + + +@pytest.mark.fixtures +async def test_real_aep_6_success_is_not_implemented_without_warnings(make_env: EnvFactory) -> None: + """Регрессия: реальный успешный ответ `aep_6.json` (`error = {"code": "", "message": ""}`) — не ошибка ИС.""" + data = _real_fixture("aep_6.json") + env = await make_env() + request = env.request(original_name="aep_6.pdf") + job_id = request["job_id"] + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", data)]] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["error"]["message"] == NOT_IMPLEMENTED_MESSAGE + assert "metrics" not in body + assert _raw(env, job_id) == merged_raw(job_id, (f"{job_id}.json", json.loads(data), None)) + + +@pytest.mark.fixtures +async def test_real_aep_3_with_ofr_is_not_implemented_with_one_warning(make_env: EnvFactory) -> None: + main, ofr = _real_fixture("aep_3.json"), _real_fixture("aep_3_ofr.json") + env = await make_env() + request = env.request(original_name="aep_3.pdf") + job_id = request["job_id"] + env.hotfolder.poll_script = [[AnswerFile(f"{job_id}.json", main), AnswerFile(f"{job_id}_ofr.json", ofr)]] + body = await env.run(request) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert body["metrics"] == {"warnings": [f"{job_id}_ofr.json: INVALID_DOCUMENT: {REAL_MESSAGE}"]} + assert _raw(env, job_id) == merged_raw( + job_id, (f"{job_id}.json", json.loads(main), None), (f"{job_id}_ofr.json", json.loads(ofr), None) + ) + + +@pytest.mark.parametrize("status", [ResultStatus.DONE, ResultStatus.NEEDS_CHECK]) +async def test_outcome_warnings_join_metrics_in_envelope_and_result(make_env: EnvFactory, status: ResultStatus) -> None: + document = StatementDocument.model_validate(STATEMENT_EXAMPLE["documents"][0]) + outcome = NormalizeOutcome( + status=status, documents=[document], metrics=Metrics(pages=2, warnings=["свои"]), warnings=["a.json: X: y"] + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert body["metrics"] == {"pages": 2, "warnings": ["свои", "a.json: X: y"]} + result = json.loads(env.storage.objects[(BUCKET, f"results/{request['file_id']}/{request['job_id']}/result.json")]) + RESULT_VALIDATOR.validate(result) + assert result["metrics"] == body["metrics"] + + +async def test_error_outcome_without_warnings_keeps_its_metrics(make_env: EnvFactory) -> None: + outcome = NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code="UNREADABLE", message="x"), metrics=Metrics(pages=1) + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + async with emulator(env): + body = await env.run(env.request()) + assert body["metrics"] == {"pages": 1} + + +async def test_huge_warnings_are_bounded(make_env: EnvFactory) -> None: + """R-30: число строк warnings и длина каждой ограничены — конверт остаётся меньше лимита NATS.""" + lines = [f"f{i}.json: INVALID_DOCUMENT: " + "я" * 50_000 for i in range(MAX_WARNINGS * 3)] + outcome = NormalizeOutcome( + status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message="m"), warnings=lines + ) + env = await make_env(normalizer=StubNormalizer(outcome)) + async with emulator(env): + body = await env.run(env.request()) + warnings = body["metrics"]["warnings"] + assert len(warnings) == MAX_WARNINGS + assert all(len(line) <= MAX_WARNING_CHARS for line in warnings) + assert warnings[0].startswith("f0.json: INVALID_DOCUMENT: я") + assert warnings[-2].startswith(f"f{MAX_WARNINGS - 2}.json") + assert str(MAX_WARNINGS * 3 - (MAX_WARNINGS - 1)) in warnings[-1] + assert f"raw/{body['job_id']}.json" in warnings[-1] + assert all(len(payload) < 900_000 for _, payload, _ in env.client.published) + + +async def test_answer_ready_only_after_settle(make_env: EnvFactory) -> None: + """Сценарий «Ответ из одного файла» с настоящим settle: ответ принимается после стабилизации набора.""" + env = await make_env(SCAN_SETTLE_SECONDS=0.05) + async with emulator(env): + body = await env.run(env.request()) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert env.hotfolder.polls >= 2 # первый опрос, увидевший набор, его ещё не отдаёт + + +async def test_set_not_settled_before_deadline_is_timeout(make_env: EnvFactory) -> None: + """Сценарий «Набор не успел настояться до дедлайна»: `*.json` есть, но не настоялся — `TIMEOUT`, без raw.""" + env = await make_env(SCAN_SETTLE_SECONDS=30, SCAN_TIMEOUT_SECONDS=0.2) + request = env.request() + async with emulator(env): + body = await env.run(request) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert env.local.answer_path(request["job_id"]).exists() + assert (BUCKET, f"raw/{request['job_id']}.json") not in env.storage.objects + + +# --- таймаут ------------------------------------------------------------------------------------------------------ +async def test_timeout_by_scan_timeout_keeps_file_and_never_resends(make_env: EnvFactory) -> None: + env = await make_env(SCAN_TIMEOUT_SECONDS=0.2) + request = env.request(original_name="aep_5.pdf", deadline_s=60) + started = asyncio.get_running_loop().time() + body = await env.run(request) + elapsed = asyncio.get_running_loop().time() - started + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert 0.15 < elapsed < 2 + assert env.inbox() == [f"{request['job_id']}_aep_5.pdf"] # входящий файл остаётся + assert env.hotfolder.puts == [f"{request['job_id']}_aep_5.pdf"] # повторной отправки нет + assert env.hotfolder.polls >= 5 + + +async def test_timeout_by_reply_deadline(make_env: EnvFactory) -> None: + env = await make_env(SCAN_TIMEOUT_SECONDS=300) + request = env.request(deadline_s=0.2) + started = asyncio.get_running_loop().time() + body = await env.run(request) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert asyncio.get_running_loop().time() - started < 2 + assert len(env.hotfolder.puts) == 1 + + +async def test_deadline_passed_before_sending_gives_timeout_without_file(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(deadline_s=-1) + body = await env.run(request) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert env.hotfolder.puts == [] + assert env.storage.get_calls == [] + assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"] + + +async def test_deadline_uses_injected_clock(make_env: EnvFactory) -> None: + """Дедлайн считается по инжектируемым часам (R-20): часы «убежали» на час — таймаут на первом же опросе.""" + shift = {"value": timedelta(0)} + + def clock() -> datetime: + return datetime.now(UTC) + shift["value"] + + env = await make_env(clock=clock, SCAN_TIMEOUT_SECONDS=60) + request = env.request(deadline_s=120) + await env.deliver(request) + await eventually(lambda: len(env.hotfolder.puts) == 1) + shift["value"] = timedelta(hours=1) + await asyncio.wait_for(env.worker.wait_idle(), 2) + [(_, body)] = env.results(request["job_id"]) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + + +# --- ошибки транспорта -------------------------------------------------------------------------------------------- +async def test_missing_hotfolder_root_is_recognition_failed(make_env: EnvFactory) -> None: + env = await make_env(root_exists=False) + request = env.request() + body = await env.run(request) + assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False) + assert "горячая папка недоступна" in body["error"]["message"] + assert not env.root.exists() # корень не создаётся (R-5) + assert env.hotfolder.healthy() is False + + +async def test_unusable_original_name_is_recognition_failed(make_env: EnvFactory) -> None: + env = await make_env() + body = await env.run(env.request(original_name="..")) + assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False) + assert "недопустимое имя файла" in body["error"]["message"] + assert env.hotfolder.puts == [] + + +async def test_transient_poll_errors_do_not_fail_the_job(make_env: EnvFactory) -> None: + env = await make_env() + env.hotfolder.poll_errors = [OSError(5, "STATUS_SHARING_VIOLATION"), ConnectionResetError("smb")] + async with emulator(env): + body = await env.run(env.request()) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + + +async def test_poll_failing_until_deadline_is_recognition_failed(make_env: EnvFactory) -> None: + env = await make_env(SCAN_TIMEOUT_SECONDS=0.1) + env.hotfolder.poll_always = OSError(13, "STATUS_ACCESS_DENIED") + body = await env.run(env.request()) + assert_error(body, ErrorCode.RECOGNITION_FAILED, raw=False) + assert "горячая папка недоступна" in body["error"]["message"] + + +async def test_poll_failing_sometimes_then_silence_is_timeout(make_env: EnvFactory) -> None: + env = await make_env(SCAN_TIMEOUT_SECONDS=0.1) + env.hotfolder.poll_errors = [OSError(13, "STATUS_ACCESS_DENIED")] + body = await env.run(env.request()) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + + +async def test_poll_error_alternating_with_empty_polls_is_timeout_with_error_description( + make_env: EnvFactory, +) -> None: + """R-39 (design #80): при settle > 0 устойчивая ошибка чтения чередуется с пустыми опросами (готовность сбрасывает + состояние), `failures != polls` — код остаётся `TIMEOUT`, но текст несёт описание последней ошибки опроса.""" + env = await make_env(SCAN_TIMEOUT_SECONDS=0.2) + env.hotfolder.poll_script = [None, PermissionError(13, "STATUS_ACCESS_DENIED"), *([None] * 2)] * 50 + body = await env.run(env.request()) + assert_error(body, ErrorCode.TIMEOUT, raw=False) + assert "ИС Сканирование не ответила" in body["error"]["message"] + assert "STATUS_ACCESS_DENIED" in body["error"]["message"] + + +async def test_missing_s3_object_is_file_read_error(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(data=None) + body = await env.run(request) + assert_error(body, ErrorCode.FILE_READ_ERROR, raw=False) + assert request["s3"]["key"] in body["error"]["message"] + assert env.hotfolder.puts == [] + assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"] + + +async def test_foreign_bucket_is_file_read_error_without_reading(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request(bucket="other-bucket") + body = await env.run(request) + assert_error(body, ErrorCode.FILE_READ_ERROR, raw=False) + assert "other-bucket" in body["error"]["message"] + assert env.storage.get_calls == [] + assert env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"] + + +async def test_exception_outside_steps_is_unknown_error(make_env: EnvFactory) -> None: + class BadOutcomeNormalizer: + def normalize(self, raw: object) -> NormalizeOutcome: + return "не исход" # type: ignore[return-value] + + env = await make_env(normalizer=BadOutcomeNormalizer()) + async with emulator(env): + body = await env.run(env.request()) + assert_error(body, ErrorCode.UNKNOWN_ERROR, raw=True) + + +# --- SCHEMA_MISMATCH без ack (R-16) ------------------------------------------------------------------------------- +def _mutations() -> list[tuple[str, Callable[[dict[str, Any]], dict[str, Any]]]]: + return [ + ("major_2", lambda r: {**r, "schema_version": "2.0"}), + ("pipeline_tabular", lambda r: {**r, "pipeline": "tabular", "file_type": "XLSX"}), + ("no_s3", lambda r: {k: v for k, v in r.items() if k != "s3"}), + ("bad_file_type", lambda r: {**r, "file_type": "DOCX"}), + ("naive_deadline", lambda r: {**r, "reply_deadline": "2026-10-03T12:45:00"}), + ("attempt_zero", lambda r: {**r, "attempt": 0}), + ("empty_name", lambda r: {**r, "original_name": ""}), + ] + + +@pytest.mark.parametrize(("case", "mutate"), _mutations(), ids=[case for case, _ in _mutations()]) +async def test_invalid_request_is_schema_mismatch_without_ack( + make_env: EnvFactory, case: str, mutate: Callable[[dict[str, Any]], dict[str, Any]] +) -> None: + env = await make_env() + request = env.request() + body = mutate(request) + await env.deliver(body) + await asyncio.wait_for(env.worker.wait_idle(), 5) + job_id = request["job_id"] + published = env.published() + assert env.acks(job_id) == [] + attempt = body["attempt"] if body["attempt"] >= 1 else 1 + assert [mid for mid, _ in published] == [f"result:{job_id}:{attempt}"] + assert_error(published[0][1], ErrorCode.SCHEMA_MISMATCH, raw=False) + assert published[0][1]["file_id"] == request["file_id"] + assert env.storage.get_calls == [] + assert env.hotfolder.puts == [] + + +@pytest.mark.parametrize( + "data", + [ + b"not json at all", + b"\xff\xfe\x00broken utf-8", + b"[1, 2, 3]", + b'"string"', + b"[" * 100_000, # глубокая вложенность: RecursionError парсера не роняет воркер + json.dumps({"schema_version": "1.0", "job_id": "not-a-uuid", "file_id": str(uuid.uuid4())}).encode(), + json.dumps({"schema_version": "1.0", "job_id": str(uuid.uuid4())}).encode(), + json.dumps({"job_id": 5, "file_id": None}).encode(), + ], + ids=["text", "bad_utf8", "array", "string", "deep", "bad_job_id", "no_file_id", "wrong_types"], +) +async def test_unparsable_request_without_ids_is_dropped( + make_env: EnvFactory, data: bytes, log_lines: list[str] +) -> None: + env = await make_env() + await env.deliver(data) + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert env.client.published == [] + assert env.hotfolder.puts == [] + assert any("SCHEMA_MISMATCH" in line for line in log_lines) + + +@pytest.mark.parametrize(("attempt", "expected"), [(3, 3), (True, 1), ("2", 1), (-1, 1), (2**40, 1)]) +async def test_schema_mismatch_attempt_from_body(make_env: EnvFactory, attempt: Any, expected: int) -> None: + env = await make_env() + request = env.request() + await env.deliver({**request, "schema_version": "2.0", "attempt": attempt}) + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert [mid for mid, _ in env.published()] == [f"result:{request['job_id']}:{expected}"] + + +async def test_schema_mismatch_does_not_register_job_id(make_env: EnvFactory) -> None: + """Исправленный запрос с тем же job_id после SCHEMA_MISMATCH обрабатывается (это не повтор).""" + env = await make_env() + request = env.request() + await env.deliver({**request, "schema_version": "2.0"}) + async with emulator(env): + await env.deliver(request) + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert len(env.hotfolder.puts) == 1 + + +async def test_schema_mismatch_for_active_or_done_job_id_is_not_published( + make_env: EnvFactory, log_lines: list[str] +) -> None: + """T12.1: битый запрос с job_id задания в работе или уже обработанного не публикует `result::`. + + Иначе ядро (дедуп по `result_msg_id`) отбросило бы настоящий итог с тем же Nats-Msg-Id. UUID в теле может прийти + в верхнем регистре — ключ дедупликации нормализуется. + """ + env = await make_env() + request = env.request() + job_id = request["job_id"] + broken = {**request, "schema_version": "2.0"} + await env.deliver(request) + await eventually(lambda: len(env.hotfolder.puts) == 1) # задание ждёт ответа ИС + await env.deliver(broken) + await env.deliver({**broken, "job_id": job_id.upper()}) + async with emulator(env): + await asyncio.wait_for(env.worker.wait_idle(), 5) + await env.deliver(broken) # задание уже обработано + await env.deliver({**broken, "job_id": job_id.upper()}) + await asyncio.wait_for(env.worker.wait_idle(), 5) + published = env.published() + assert [mid for mid, _ in published] == [f"ack:{job_id}:1", f"result:{job_id}:1"] + assert_error(published[1][1], ErrorCode.NOT_IMPLEMENTED, raw=True) + assert sum("SCHEMA_MISMATCH для задания в работе/обработанного" in line for line in log_lines) == 4 + + +# --- публикация, размер и кодировка ------------------------------------------------------------------------------- +async def test_publish_failure_is_logged_and_worker_keeps_working(make_env: EnvFactory, log_lines: list[str]) -> None: + env = await make_env() + env.client.fail = True + first = env.request() + async with emulator(env): + await env.deliver(first) + await asyncio.wait_for(env.worker.wait_idle(), 5) + assert env.client.published == [] + assert any(line.startswith("ERROR|") and first["job_id"] in line for line in log_lines) + env.client.fail = False + second = env.request() + body = await env.run(second) + assert_error(body, ErrorCode.NOT_IMPLEMENTED, raw=True) + assert len(env.hotfolder.puts) == 2 + + +async def test_huge_error_message_is_truncated(make_env: EnvFactory) -> None: + huge = "я" * 2_000_000 + normalizer = StubNormalizer( + NormalizeOutcome(status=ResultStatus.ERROR, error=MessageError(code="INVALID_DOCUMENT", message=huge)) + ) + env = await make_env(normalizer=normalizer) + async with emulator(env): + body = await env.run(env.request()) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + message = body["error"]["message"] + assert len(message) == MAX_ERROR_MESSAGE_CHARS + assert message.endswith("…") + assert all(len(payload) < 900_000 for _, payload, _ in env.client.published) + + +async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + job_id = request["job_id"] + await env.deliver(request) + await eventually(lambda: len(env.hotfolder.puts) == 1) + env.answer(job_id, b'{"file_uid": "x", "error": {"code": "INVALID_DOCUMENT", "message": "bad \\ud800 tail"}}') + await asyncio.wait_for(env.worker.wait_idle(), 5) + [(_, body)] = env.results(job_id) + assert_error(body, ErrorCode.INVALID_DOCUMENT, raw=True) + assert body["error"]["message"].startswith("bad ") + assert body["error"]["message"].endswith(" tail") + [warning] = body["metrics"]["warnings"] + assert warning.startswith(f"{job_id}.json: INVALID_DOCUMENT: bad ") + assert warning.endswith(" tail") + # сырой ответ записан валидным JSON, суррогат сохранён экранированным + assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail" + + +async def test_lone_surrogate_in_original_name_is_schema_mismatch(make_env: EnvFactory) -> None: + """Одиночный суррогат в `original_name` схема запроса не пропускает (pydantic `string_unicode`): SCHEMA_MISMATCH + без ack и без файла в горячей папке; сам конверт публикуется.""" + env = await make_env() + request = env.request(original_name="скан\udc80.pdf") + data = json.dumps(request, ensure_ascii=True).encode("ascii") # в JSON суррогат приходит экранированным + await env.deliver(data) + await asyncio.wait_for(env.worker.wait_idle(), 5) + job_id = request["job_id"] + [(_, body)] = env.results(job_id) + assert_error(body, ErrorCode.SCHEMA_MISMATCH, raw=False) + assert env.acks(job_id) == [] + assert env.hotfolder.puts == [] + + +# --- остановка и прямой вызов handle (параллелизм — test_worker_concurrency.py) --------------------------------------- +async def test_stop_cancels_jobs_and_rejects_new_requests(make_env: EnvFactory) -> None: + env = await make_env() + request = env.request() + await env.deliver(request) + await eventually(lambda: len(env.hotfolder.puts) == 1) + await asyncio.wait_for(env.worker.stop(), 1) + assert env.worker.active_jobs == 0 + assert env.results(request["job_id"]) == [] + await env.deliver(env.request()) + await asyncio.wait_for(env.worker.wait_idle(), 1) + assert len(env.client.published) == 1 # только ack первого запроса + await env.worker.stop() # повторная остановка безопасна + + +async def test_service_info_and_handle_returns_published_message(make_env: EnvFactory) -> None: + from src.contract import RecognitionRequest # noqa: PLC0415 + + env = await make_env() + request = RecognitionRequest.model_validate(env.request(data=None)) + message = await env.worker.handle(request) + assert message.service.name == "scan-gateway" + assert message.service.version == env.settings.VERSION + assert message.error is not None and message.error.code is ErrorCode.FILE_READ_ERROR + [(_, body)] = env.results(str(request.job_id)) + assert body == message.to_wire() diff --git a/api/tests/unit/test_worker_concurrency.py b/api/tests/unit/test_worker_concurrency.py new file mode 100644 index 0000000..58fe82d --- /dev/null +++ b/api/tests/unit/test_worker_concurrency.py @@ -0,0 +1,238 @@ +"""Параллельная обработка ограничена (spec image-recognition-gateway, задача 5.4; D-3, `SCAN_MAX_INFLIGHT`). + +Сценарий spec «Пять заданий подряд»: пять запросов при `SCAN_MAX_INFLIGHT = 4` → пять ack'ов сразу, четыре файла во +входящем каталоге, пятый — только после завершения одного из четырёх. Плюс строгая очерёдность при одном слоте и +остановка воркера, когда задания ждут слот (T12.9). + +Детерминизм без «поспать и посмотреть»: пауза между опросами горячей папки подменена (`SteppedSleep`) — задание, +записавшее файл и опросившее исходящий каталог, «паркуется» и опрашивает снова только по шагу теста. Ожидания — +по событиям (`asyncio.Condition`) с явным тайм-аутом. Ответ ИС кладёт сам тест (`env.answer`) и ровно для того +задания, слот которого освобождается. Чтение S3 у фейка не уступает цикл событий, поэтому задание, прошедшее мимо +семафора, читает S3 в том же проходе цикла, что и его ack, — счётчик чтений S3 различает «ждёт слот» и «слот не нужен» +без гонок. +""" + +import asyncio +import json +from collections import Counter +from typing import Any + +import pytest + +from tests.worker_helpers import SUCCESS_ANSWER, Env, EnvFactory + +# Логи воркера перехватываются (`log_lines` из conftest): вывод pytest чистый +pytestmark = pytest.mark.usefixtures("log_lines") + +WAIT_TIMEOUT = 3.0 + + +class SteppedSleep: + """Подмена `sleep` воркера: пауза между опросами длится, пока тест не сделает шаг (`step`) или не откроет её. + + Поколение растёт с каждым шагом: `wait_parked(n)` ждёт n заданий, припаркованных уже после последнего шага, — + так ожидание не срабатывает на заданиях, которые только что отпущены, но ещё не успели проснуться. + """ + + def __init__(self) -> None: + self._generation = 0 + self._parked: Counter[int] = Counter() + self._gate = asyncio.Event() + self._opened = False + self._changed = asyncio.Condition() + + @property + def parked(self) -> int: + """Сколько заданий припарковано после последнего шага.""" + return self._parked[self._generation] + + async def __call__(self, seconds: float) -> None: + if self._opened: + await asyncio.sleep(0) + return + generation, gate = self._generation, self._gate + async with self._changed: + self._parked[generation] += 1 + self._changed.notify_all() + try: + await gate.wait() + finally: + self._parked[generation] -= 1 + + def step(self) -> None: + """Отпустить всех припаркованных: каждое задание опросит исходящий каталог ещё раз.""" + self._gate.set() + self._gate = asyncio.Event() + self._generation += 1 + + def open(self) -> None: + """Снять паузы совсем: дальше опросы идут без ожидания.""" + self._opened = True + self._gate.set() + + async def wait_parked(self, count: int) -> None: + async with self._changed: + await asyncio.wait_for(self._changed.wait_for(lambda: self.parked >= count), WAIT_TIMEOUT) + + +def job_of(name: str) -> str: + """`job_id` из имени файла входящего каталога `_`.""" + return name.split("_", 1)[0] + + +def answer(env: Env, job_id: str) -> None: + """Ответ ИС для задания (синтетический успех R-10 → `NOT_IMPLEMENTED`).""" + env.answer(job_id, json.dumps({**SUCCESS_ANSWER, "file_uid": job_id}).encode()) + + +def index_of(env: Env, event: tuple[str, str]) -> int: + assert env.events.count(event) == 1, (event, env.events) + return env.events.index(event) + + +def source_key(request: dict[str, Any]) -> str: + return str(request["s3"]["key"]) + + +async def finish_all(env: Env, poll_sleep: SteppedSleep, jobs: list[str]) -> None: + """Ответить на все задания, снять паузы, дождаться конца; у каждого задания — ровно один ack и один итог.""" + for job in jobs: + if not env.local.answer_path(job).exists(): + answer(env, job) + poll_sleep.open() + await asyncio.wait_for(env.worker.wait_idle(), WAIT_TIMEOUT) + for job in jobs: + assert env.acks(job) == [f"ack:{job}:1"] + [(msg_id, body)] = env.results(job) + assert msg_id == f"result:{job}:1" + assert body["status"] == "error" + assert body["error"]["code"] == "NOT_IMPLEMENTED" + assert env.worker.active_jobs == 0 + + +# --- spec «Пять заданий подряд» ----------------------------------------------------------------------------------- +async def test_five_jobs_with_four_slots(make_env: EnvFactory) -> None: + poll_sleep = SteppedSleep() + env = await make_env(SCAN_MAX_INFLIGHT=4, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep) + requests = [env.request() for _ in range(5)] + jobs = [request["job_id"] for request in requests] + for request in requests: + await env.deliver(request) + + # Четыре слота заняты: каждое из четырёх заданий записало файл и ждёт ответа ИС + await poll_sleep.wait_parked(4) + # пять ack'ов уже опубликованы — ни один слот ещё не освободился, итогов нет + assert [env.acks(job) for job in jobs] == [[f"ack:{job}:1"] for job in jobs] + assert all(env.results(job) == [] for job in jobs) + # во входящем каталоге ровно четыре файла, пятое задание даже не читало S3 + assert len(env.storage.get_calls) == 4 + assert len(env.hotfolder.puts) == 4 + assert env.inbox() == sorted(env.hotfolder.puts) + busy = [job_of(name) for name in env.hotfolder.puts] + [waiting] = [job for job in jobs if job not in busy] + assert poll_sleep.parked == 4 + assert env.worker.active_jobs == 5 + + # Ещё один круг опроса занятых слотов: пятое задание по-прежнему ждёт + poll_sleep.step() + await poll_sleep.wait_parked(4) + assert len(env.hotfolder.puts) == 4 + assert len(env.storage.get_calls) == 4 + assert waiting not in {job_of(name) for name in env.inbox()} + + # Ответ ИС для одного задания: его слот освобождается, и только тогда появляется пятый файл + freed = busy[0] + answer(env, freed) + poll_sleep.step() + await poll_sleep.wait_parked(4) # три прежних задания + пятое, записавшее файл + assert [job_of(name) for name in env.hotfolder.puts] == [*busy, waiting] + assert len(env.inbox()) == 5 + assert all(env.results(job) == [] for job in jobs if job != freed) + freed_result = index_of(env, ("publish", f"result:{freed}:1")) + waiting_request = requests[jobs.index(waiting)] + assert max(index_of(env, ("publish", f"ack:{job}:1")) for job in jobs) < freed_result + assert freed_result < index_of(env, ("s3_get", source_key(waiting_request))) + assert freed_result < index_of(env, ("put", f"{waiting}_ok_doc.pdf")) + + # Все пять заданий заканчиваются конвертами + await finish_all(env, poll_sleep, jobs) + assert len(env.inbox()) == 5 + + +async def test_single_slot_serialises_jobs(make_env: EnvFactory) -> None: + poll_sleep = SteppedSleep() + env = await make_env(SCAN_MAX_INFLIGHT=1, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep) + requests = [env.request() for _ in range(3)] + jobs = [request["job_id"] for request in requests] + for request in requests: + await env.deliver(request) + + for done in range(3): + await poll_sleep.wait_parked(1) + # в работе ровно одно задание; ack'и всех трёх — сразу + assert len(env.hotfolder.puts) == done + 1 + assert len(env.storage.get_calls) == done + 1 + assert len(env.inbox()) == done + 1 + assert all(env.acks(job) == [f"ack:{job}:1"] for job in jobs) + current = job_of(env.hotfolder.puts[-1]) + assert env.results(current) == [] + answer(env, current) + poll_sleep.step() + await asyncio.wait_for(env.worker.wait_idle(), WAIT_TIMEOUT) + + # Строгая очерёдность в журнале: следующий файл записан только после итога предыдущего задания + order = [job_of(name) for name in env.hotfolder.puts] + assert sorted(order) == sorted(jobs) + files_and_results = [ + (kind, ref) for kind, ref in env.events if kind == "put" or (kind == "publish" and ref.startswith("result:")) + ] + expected: list[tuple[str, str]] = [] + for job in order: + expected += [("put", f"{job}_ok_doc.pdf"), ("publish", f"result:{job}:1")] + assert files_and_results == expected + await finish_all(env, poll_sleep, jobs) + + +# --- остановка при очереди на слот (T12.9) ------------------------------------------------------------------------ +async def test_stop_while_jobs_wait_for_slot(make_env: EnvFactory, log_lines: list[str]) -> None: + poll_sleep = SteppedSleep() + env = await make_env(SCAN_MAX_INFLIGHT=2, SCAN_TIMEOUT_SECONDS=60, sleep=poll_sleep) + tasks_before = asyncio.all_tasks() + requests = [env.request() for _ in range(5)] + for request in requests: + await env.deliver(request) + await poll_sleep.wait_parked(2) + assert env.worker.active_jobs == 5 + assert len(env.hotfolder.puts) == 2 # три задания ждут слот + assert all(env.acks(request["job_id"]) == [f"ack:{request['job_id']}:1"] for request in requests) + job_tasks = asyncio.all_tasks() - tasks_before + assert len(job_tasks) == 5 + + # без исключения; `asyncio.timeout`, а не `wait_for`: отдельная задача-обёртка дала бы отменённым заданиям + # лишние проходы цикла, и проверка «stop() дождался заданий» стала бы слепой + async with asyncio.timeout(WAIT_TIMEOUT): + await env.worker.stop() + + # Все задачи заданий завершены отменой, новых задач не осталось, итоговых конвертов нет + assert all(task.cancelled() for task in job_tasks) + assert asyncio.all_tasks() - tasks_before == set() + assert env.worker.active_jobs == 0 + assert all(env.results(request["job_id"]) == [] for request in requests) + assert not [line for line in log_lines if line.startswith(("ERROR|", "CRITICAL|"))] + # После остановки файлы во входящий каталог больше не пишутся и S3 не читается, даже если цикл событий крутится + for _ in range(20): + await asyncio.sleep(0) + assert len(env.hotfolder.puts) == 2 + assert len(env.inbox()) == 2 + assert len(env.storage.get_calls) == 2 + assert poll_sleep.parked == 0 + + # Слоты не утекли: после повторного запуска оба слота свободны + env.worker.start() + again = [env.request() for _ in range(3)] + for request in again: + await env.deliver(request) + await poll_sleep.wait_parked(2) + assert len(env.hotfolder.puts) == 4 + assert len(env.storage.get_calls) == 4 + await finish_all(env, poll_sleep, [request["job_id"] for request in again]) diff --git a/api/tests/worker_helpers.py b/api/tests/worker_helpers.py new file mode 100644 index 0000000..790f4bf --- /dev/null +++ b/api/tests/worker_helpers.py @@ -0,0 +1,308 @@ +"""Общая обвязка unit-тестов воркера шлюза (`test_worker.py`, `test_worker_concurrency.py`); фикстура `make_env` — +в `tests/unit/conftest.py`. + +Без Docker и сети: S3 — словарь в памяти (ошибки — классы `src.core.clients`), NATS — настоящий `NatsGateway` поверх +фейкового клиента nats-py (тело, `Nats-Msg-Id`, повторы и `PublishFailed` — как в проде), горячая папка — настоящий +`LocalHotFolder` на `tmp_path` под шпионом, ИС Сканирование — `process_once` эмулятора (задержка 0) в фоновой задаче. +Общий журнал событий (`events`) упорядочивает публикации, чтения S3 и запись во входящий каталог. +Каждый опубликованный конверт проверяется JSON Schema `recognition-result-message.schema.json`. +""" + +import asyncio +import json +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker +from nats import errors as nats_errors + +from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM, S3ObjectNotFoundError, S3WriteError +from src.core.config import Settings +from src.core.nats import NatsGateway +from src.core.worker import Worker +from src.gateway.hotfolder import AnswerFile, LocalHotFolder +from src.scan_emulator import process_once + +API_DIR = Path(__file__).resolve().parents[1] +CONTRACTS_DIR = API_DIR / "contracts" +BUCKET = "arm-ka" +PDF_BYTES = b"%PDF-1.4\n% scan-gateway test\n\x00\x01\x02binary" + +MESSAGE_VALIDATOR = Draft202012Validator( + json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")), + format_checker=FormatChecker(), +) + +# Успешный ответ ИС в форме реальных `fixtures/ocr/aep_3.json`, `aep_6.json` (OQ-77): успех — `error.code == ""`, +# `error` — объект `{"code": "", "message": ""}`, а не `null`; `table_structure` — список таблиц с `label` и `rows` +SUCCESS_ANSWER: dict[str, Any] = { + "file_uid": "ok_doc", + "error": {"code": "", "message": ""}, + "data_ocr": { + "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0}, + "organization": {"form_name": "ООО Ромашка", "confidence": 97.5}, + "inn": {"form_name": "7700000000", "confidence": 99.0}, + "units": {"form_name": "тыс. руб.", "confidence": 95.0}, + "date": {"form_name": "30.09.2025", "confidence": 96.0}, + "table_structure": [ + { + "label": {"form_name": "Бухгалтерский баланс", "confidence": 99.0}, + "rows": [ + { + "row_index": 0, + "cells": [ + {"column_index": 0, "value": "БАЛАНС", "confidence": 97.0}, + {"column_index": 1, "value": "1600", "confidence": 99.0}, + {"column_index": 2, "value": "5678", "confidence": 96.0}, + ], + } + ], + } + ], + }, +} + + +def merged_raw(job_id: str, *files: tuple[str, object, str | None]) -> dict[str, Any]: + """Ожидаемый склеенный ответ `raw/{job_id}.json` (D-4): `(имя, response, parse_error)` по возрастанию имени.""" + return { + "job_id": job_id, + "files": [{"name": name, "response": response, "parse_error": error} for name, response, error in files], + } + + +# --- фейки -------------------------------------------------------------------------------------------------------- +class FakeStorage: + """S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`.""" + + def __init__(self, events: list[tuple[str, str]], bucket: str = BUCKET) -> None: + self.events = events + self.bucket = bucket + self.objects: dict[tuple[str, str], bytes] = {} + self.content_types: dict[tuple[str, str], str] = {} + self.get_calls: list[tuple[str, str]] = [] + self.fail_put_prefixes: tuple[str, ...] = () + + async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes: + bucket_name = bucket or self.bucket + self.get_calls.append((bucket_name, key)) + self.events.append(("s3_get", key)) + try: + return self.objects[(bucket_name, key)] + except KeyError: + raise S3ObjectNotFoundError( + f"S3: объект {bucket_name}/{key} не прочитан (NoSuchKey)", bucket=bucket_name, key=key + ) from None + + async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None: + if self.fail_put_prefixes and key.startswith(self.fail_put_prefixes): + raise S3WriteError( + f"S3: объект {self.bucket}/{key} не записан (ConnectionError)", bucket=self.bucket, key=key + ) + self.objects[(self.bucket, key)] = data + self.content_types[(self.bucket, key)] = content_type + self.events.append(("s3_put", key)) + + async def put_json(self, key: str, obj: Any) -> None: + await self.put_bytes(key, json.dumps(obj, ensure_ascii=False).encode("utf-8"), JSON_CONTENT_TYPE) + + +class FakeNatsClient: + """Подмена `nats.aio.client.Client` для настоящего `NatsGateway`: записывает публикации в общий журнал.""" + + def __init__(self, events: list[tuple[str, str]]) -> None: + self.events = events + self.is_connected = False + self.is_closed = False + self.published: list[tuple[str, bytes, dict[str, str]]] = [] + self.fail = False + self.block: asyncio.Event | None = None + + async def connect(self, *args: Any, **kwargs: Any) -> None: + self.is_connected = True + + async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: + if self.block is not None: + await self.block.wait() + if self.fail: + raise nats_errors.ConnectionClosedError + assert headers is not None + self.published.append((subject, payload, headers)) + self.events.append(("publish", headers[NATS_MSG_ID_HEADER])) + + async def flush(self, timeout: float = 10) -> None: + return None + + async def drain(self) -> None: + self.is_connected = False + self.is_closed = True + + async def close(self) -> None: + self.is_connected = False + self.is_closed = True + + +class SpyHotFolder: + """`LocalHotFolder` под шпионом: журнал записей во входящий каталог, сценарии сбоев `poll`.""" + + def __init__(self, inner: LocalHotFolder, events: list[tuple[str, str]]) -> None: + self.inner = inner + self.events = events + self.puts: list[str] = [] + self.polls = 0 + self.poll_errors: list[BaseException] = [] + self.poll_always: BaseException | None = None + self.poll_script: list[list[AnswerFile] | BaseException | None] = [] + + def put(self, name: str, data: bytes) -> None: + self.inner.put(name, data) + self.puts.append(name) + self.events.append(("put", name)) + + def poll(self, job_id: str) -> list[AnswerFile] | None: + self.polls += 1 + if self.poll_always is not None: + raise self.poll_always + if self.poll_errors: + raise self.poll_errors.pop(0) + if self.poll_script: + step = self.poll_script.pop(0) + if isinstance(step, BaseException): + raise step + return step + return self.inner.poll(job_id) + + def healthy(self) -> bool: + return self.inner.healthy() + + +# --- окружение теста ---------------------------------------------------------------------------------------------- +@dataclass +class Env: + settings: Settings + root: Path + fixtures: Path + events: list[tuple[str, str]] + storage: FakeStorage + client: FakeNatsClient + gateway: NatsGateway + hotfolder: SpyHotFolder + worker: Worker + local: LocalHotFolder = field(init=False) + + def __post_init__(self) -> None: + self.local = self.hotfolder.inner + + # запрос ------------------------------------------------------------------------------------------------------- + def request( # noqa: PLR0913 - параметры запроса для сценариев + self, + *, + original_name: str = "ok_doc.pdf", + deadline_s: float = 60, + attempt: int = 1, + bucket: str = BUCKET, + data: bytes | None = PDF_BYTES, + job_id: str | None = None, + ) -> dict[str, Any]: + job = job_id or str(uuid.uuid4()) + file_id = str(uuid.uuid4()) + key = f"uploads/{file_id}/{job}/source.bin" + if data is not None: + self.storage.objects[(bucket, key)] = data + deadline = datetime.now(UTC) + timedelta(seconds=deadline_s) + return { + "schema_version": "1.0", + "job_id": job, + "file_id": file_id, + "pipeline": "image", + "file_type": "PDF", + "original_name": original_name, + "s3": {"bucket": bucket, "key": key}, + "reply_deadline": deadline.isoformat().replace("+00:00", "Z"), + "attempt": attempt, + } + + async def deliver(self, body: dict[str, Any] | bytes) -> None: + data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8") + await self.worker.on_request(FakeMsg(data)) + + async def run(self, body: dict[str, Any], timeout: float = 5) -> dict[str, Any]: + """Доставить запрос, дождаться окончания всех заданий, вернуть единственный итоговый конверт задания.""" + await self.deliver(body) + await asyncio.wait_for(self.worker.wait_idle(), timeout) + results = self.results(body["job_id"]) + assert len(results) == 1, results + return results[0][1] + + # публикации --------------------------------------------------------------------------------------------------- + def published(self) -> list[tuple[str, dict[str, Any]]]: + """(Nats-Msg-Id, тело) всех публикаций; каждое тело проверено схемой конверта.""" + out = [] + for subject, payload, headers in self.client.published: + assert subject == RESULT_SUBJECT + body = json.loads(payload.decode("utf-8")) + MESSAGE_VALIDATOR.validate(body) + assert body["service"]["name"] == "scan-gateway" + assert body["schema_version"] == "1.0" + out.append((headers[NATS_MSG_ID_HEADER], body)) + return out + + def acks(self, job_id: str) -> list[str]: + return [mid for mid, body in self.published() if body["job_id"] == job_id and body["status"] == "processing"] + + def results(self, job_id: str) -> list[tuple[str, dict[str, Any]]]: + return [ + (mid, body) for mid, body in self.published() if body["job_id"] == job_id and body["status"] != "processing" + ] + + def inbox(self) -> list[str]: + if not self.local.input_dir.is_dir(): + return [] + return sorted(p.name for p in self.local.input_dir.iterdir() if not p.name.startswith(".")) + + def answer(self, job_id: str, content: bytes, name: str | None = None) -> None: + path = self.local.answer_dir(job_id) / (name or f"{job_id}.json") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +class FakeMsg: + def __init__(self, data: bytes) -> None: + self.data = data + self.subject = "recognition.image.request" + self.headers: dict[str, str] | None = None + + +EnvFactory = Callable[..., Awaitable[Env]] + + +@asynccontextmanager +async def emulator(env: Env, fixtures: Path | None = None) -> AsyncIterator[None]: + """ИС Сканирование: `process_once` (задержка 0) каждые 5 мс, пока открыт контекст.""" + + async def loop() -> None: + while True: + process_once(env.root, fixtures or env.fixtures, 0) + await asyncio.sleep(0.005) + + task = asyncio.create_task(loop()) + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +async def eventually(predicate: Callable[[], bool], timeout: float = 3.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while not predicate(): + if asyncio.get_running_loop().time() > deadline: + raise AssertionError("условие не выполнилось вовремя") + await asyncio.sleep(0.005) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md new file mode 100644 index 0000000..c8f0ff0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -0,0 +1,84 @@ +# Срез 0 — скелет scan-gateway — план исполнения (Срез 0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/scan-gateway`). **Issue:** #1. +**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`). + +## Global Constraints + +- Сервис без БД и фронта (DECISION-4), **SMB остаётся**. Раскладка шаблона, команды из `api/`. +- Платформа: Python 3.11, NATS **без JetStream** (queue group `scan-gateway`, ack `processing` до чтения S3, LRU `job_id`, публикация с повторами), один бакет S3, Nexus недоступен, CI нет (`make check` / `make check-full`). +- Горячие папки: протокол `HotFolder`, режимы `local` / `smb`, каталоги `SCAN_INPUT_DIR`/`SCAN_OUTPUT_DIR` (по умолчанию `Input`/`Output`), файл `_`, ответ `//.json`; таймаут `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS=300)`, опрос 5 с, `SCAN_MAX_INFLIGHT=4`; повторной отправки в ИС нет (OQ-15). +- Сырой ответ → S3 `raw/{job_id}.json` **до** нормализации, `raw_s3_key` в конверте; в БД не регистрируется. `NullNormalizer`: ошибка ИС → её код, успех → `NOT_IMPLEMENTED`. +- Эмулятор: `python -m src.scan_emulator`, фикстуры `../fixtures/ocr/*.json`, подстановка `file_uid`, неизвестное имя → `INVALID_DOCUMENT`, `.done` после обработки. +- Контракт: копии схем из `../docs/contracts/` байт в байт; `service.name = "scan-gateway"`; `Nats-Msg-Id` по правилам контракта. +- Линтеры: ruff (120, E,F,I,UP,B,PL) + mypy; pytest-asyncio auto; маркеры `integration`/`fixtures`. +- Язык комментариев/коммитов — русский, идентификаторы — английские; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. + +## Задачи + +### Task 1: [1.1] Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/ + +- [x] Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; убрать `Dockerfile` из `.gitignore`; проверка: `git status` — только удаления и `.gitignore` + +### Task 2: [1.2] `src/app.py` + +- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200 + +### Task 3: [1.3] `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск + +- [x] `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск против compose и эмулятора), `CLAUDE.md` — актуализировать «Соглашения шаблона» по факту; проверка: упомянутые файлы существуют + +### Task 4: [2.1] `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt + +- [x] `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11, без `raisa_*`), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `emulator`; проверка: `make venv && make lint` зелёные + +### Task 5: [2.2] `Dockerfile` (`python + +- [x] `Dockerfile` (`python:3.11-slim`; `CMD python run_server.py`, эмулятор — через `command` в compose), `.env.example`, `.env.test`; проверка: `docker build` успешен без Nexus + +### Task 6: [3.1] `api/contracts/` — копии схем и `examples/` из `../docs/contracts` + +- [x] `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (конверт со статусами `processing | done | needs_check | error`); `tests/unit/test_contract.py` — идентичность (skip без docs), примеры, round-trip; проверка: тесты зелёные + +### Task 7: [4.1] `src/gateway/hotfolder.py` + +- [x] `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `_`, poll возвращает `None` до появления ответа и байты после + +### Task 8: [4.2] `src/gateway/smb.py` + +- [x] `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется + +### Task 9: [4.3] `src/scan_emulator/__init__.py` + `__main__.py` + +- [x] `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`; `tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями `../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный + +### Task 10: [5.1] `src/normalizer/__init__.py` + +- [x] `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`, синтетическом ответе с `error`; проверка: зелёный + +### Task 11: [5.2] `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` + +- [x] `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys, автопереподключение, `subscribe_requests(queue="scan-gateway", cb)`, `publish` с `Nats-Msg-Id` и повторами); проверка: unit с фейками, интеграционный тест подключения к compose-NATS без JetStream + +### Task 12: [5.3] `src/core/worker.py` + +- [x] `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с укороченными таймаутами, RECOGNITION_FAILED при отсутствии каталога, SCHEMA_MISMATCH без ack, FILE_READ_ERROR) + +### Task 13: [5.4] Тест параллелизма + +- [x] Тест параллелизма: пять заданий при `SCAN_MAX_INFLIGHT = 4` — пять ack сразу, четыре файла во входящем каталоге, пятый после освобождения слота (фейковые S3/NATS, `LocalHotFolder`); проверка: зелёный + +### Task 14: [6.1] `tests/integration/test_roundtrip.py` (маркер `integration`) + +- [x] `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный + +### Task 15: [6.2] `make check-full` + +- [x] `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` + +### Task 16: [6.3] Прогон DoD + +- [x] Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md`; `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR + diff --git a/docs/superpowers/plans/2026-10-04-multi-file-response.md b/docs/superpowers/plans/2026-10-04-multi-file-response.md new file mode 100644 index 0000000..83d0927 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-multi-file-response.md @@ -0,0 +1,56 @@ +# scan-gateway — доработка по реальным ответам ИС Сканирование (Срез 0, правки в PR #3) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/scan-gateway`), поверх уже выполненного плана `2026-10-03-slice-0-skeleton.md`. PR #3 открыт, правки идут в ту же ветку. +**Spec:** `openspec/changes/slice-0-skeleton/` (specs `image-recognition-gateway`, `scan-emulator`, `design.md`). Спека — единственный источник истины; задача 1 приводит её в соответствие с решениями человека 04.10.2026, остальные задачи реализуют обновлённую спеку. + +## Решения человека (04.10.2026) — основание правок + +1. **OQ-78.** ИС Сканирование кладёт **все распознанные документы одновременно** в каталог `//` — по одному json на документ. Шлюз читает **все** json каталога и склеивает в один ответ (как в старом коде `reference-old-arm/api/src/core/PDFWorker/PDFTempPoller.py`, но с понятной структурой). +2. **OQ-79.** Временный файл атомарной записи уходит из `Input` в служебный каталог (`SCAN_TMP_DIR`, по умолчанию `Tmp`, рядом с `Input`/`Output`, на той же шаре/ФС, чтобы `rename` оставался атомарным). +3. **OQ-77 / OQ-76.** Образцы реальных ответов добавлены в `../fixtures/ocr`: `aep_3.pdf` + `aep_3.json` (успех: таблицы «Бухгалтерский баланс» 42 строки и «Отчет о финансовых результатах» 18 строк) + `aep_3_ofr.json` (второй ответ ИС для того же файла: `INVALID_DOCUMENT`), `aep_6.pdf` + `aep_6.json` (успех: баланс 122 строки с шапкой дат + ОФР 49 строк); `aep_5.json`, `aep_8_form4.json` — ответы-ошибки. + +## Факты о формате реального ответа (проверены по `../fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json`) + +- Корень: `{"file_uid": str, "error": {"code": str, "message": str}, "data_ocr": {...}}`. **`data_ocr` — объект, не список** (`label`, `organization`, `inn`, `units`, `date` — каждый `{"form_name": str, "confidence": number 0..100}`; `table_structure` — **список таблиц** `{"label": {"form_name": "Бухгалтерский баланс" | "Отчет о финансовых результатах", "confidence"}, "rows": [{"row_index": int, "cells": [{"column_index": int, "value": str, "confidence": number}]}]}`). +- **Успех = `error.code == ""`** (а `error` не `null`!): `{"code": "", "message": ""}`. Ошибка: `code` непустой (`INVALID_DOCUMENT`, …), `data_ocr` с пустыми полями и `table_structure: []`. Проверка «`error` не null» для реального ответа **неверна**. +- В одном json может быть несколько таблиц разных форм (в `aep_3.json` и `aep_6.json` — баланс и ОФР), у каждой свой `label`; тип документа задаёт `label`. Таблицы не интерпретируются в Срезе 0 (Срез 3). +- `confidence` — числа 0–100 (OQ-13 закрыт для scan-gateway); поля организации/ИНН/даты — шумный OCR-текст в `form_name` (в `aep_6.json` `date.form_name` — мусор, даты периодов — в строке-шапке таблицы `row_index 0`). Разбор — Срез 3, здесь не интерпретируется. + +## Global Constraints + +- Сервис без БД и фронта (DECISION-4). Раскладка шаблона, команды из `api/`. Python 3.11 (`uv venv --python 3.11`), NATS без JetStream, один бакет S3. CI нет — `make check` / `make check-full`. +- Контракт v1.0 в `../docs/contracts/` **не менять** (файлы схем/примеров не трогать; `raw_s3_key` — по-прежнему одна строка `raw/{job_id}.json`). Нормализация в `statementDocument` — Срез 3, не делать: `NullNormalizer` остаётся заглушкой. +- Горячие папки: протокол `HotFolder` (расширить), режимы `local` / `smb`; каталоги `SCAN_INPUT_DIR` (`Input`), `SCAN_OUTPUT_DIR` (`Output`), `SCAN_TMP_DIR` (`Tmp`, новый). Файл во входящем каталоге — `_`. +- Сырой ответ — в S3 `raw/{job_id}.json` **до** нормализации; в БД не регистрируется. Новая структура склеенного ответа — `{"job_id": str, "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": str | null}, ...]}`, файлы по возрастанию имени; в `files` — все `*.json` каталога ответа, другие файлы игнорируются. +- Готовность: каталог ответа считается готовым, когда в нём есть хотя бы один `*.json` и набор `*.json` с их размерами не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2) — защита от недописанного набора; таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. +- Результат (Срез 0, `NullNormalizer`, вход — склеенный ответ): файл с `error.code ≠ ""` или `parse_error ≠ null` — «ошибочный». **Все** файлы ошибочные → `status = error`, `error.code` — код первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`), `error.message` — его сообщение, `metrics.warnings` — по строке на каждый ошибочный файл (`<имя>: <код>: <сообщение>`). Есть хотя бы один успешный файл → `status = error`, `error.code = NOT_IMPLEMENTED` (нормализация — Срез 3), ошибочные файлы — в `metrics.warnings` так же. Успех никогда не принимается за ошибку по `error != null`. +- Эмулятор: по входящему `_.` кладёт в `Output//` **все** фикстуры `.json` и `_*.json` из `SCAN_FIXTURES_DIR` (не префикс `*`: `aep_3` не должен забирать `aep_30*`), имена в каталоге ответа — `.json` для `.json` и `_.json` для `_.json`, подстановка `file_uid = `; пары нет — один ответ `INVALID_DOCUMENT` как раньше; запись набора — атомарно целиком (сначала во временный каталог эмулятора, затем rename каталога) либо с паузой между файлами — на усмотрение, но поведение должно быть покрыто тестом «готовность по settle». +- Линтеры: ruff (120, E,F,I,UP,B,PL) + mypy; pytest-asyncio auto; маркеры `integration`/`fixtures`. Язык комментариев/коммитов — русский, идентификаторы — английские; коммит заканчивается строкой `Co-Authored-By: Claude Sonnet 5.5 `. + +## Задачи + +### Task 1: [R1] Обновить change под решения человека + +- [x] В `openspec/changes/slice-0-skeleton/`: обновить `proposal.md` (разделы «Горячие папки», «Воркер», «Нормализатор», «Эмулятор»), `design.md` (D-1 горячие папки, D-3/D-4 сырой ответ и склейка, D-5 нормализатор, D-6 эмулятор; новые решения — продолжением нумерации таблицы «Decisions made without discussion», сейчас последняя строка №53), `specs/image-recognition-gateway/spec.md` (требования «Передача файла…», «Сырой ответ…», «Результат по контракту» — склейка всех `*.json` каталога, готовность по settle, `SCAN_TMP_DIR`, per-file ошибки в `metrics.warnings`, успех = `error.code == ""`) и `specs/scan-emulator/spec.md` (набор файлов на задание); отметить в `open-questions-proposal.md` пункты про OQ-77/78/79/76 как решённые человеком 04.10.2026 (с фактами выше); `tasks.md` — добавить группу «7. Доработка по реальным ответам ИС» с задачами 7.0–7.5 (7.0 = Task 1 этого плана, 7.1–7.5 = Task 2–6... по числу задач плана). Проверка: `openspec validate slice-0-skeleton --strict` — valid; в spec нет упоминаний «читает ровно `.json`» и `.part` в `Input`. + +### Task 2: [R2] Горячие папки: чтение всех файлов, готовность, служебный каталог + +- [x] `src/gateway/` и настройки: `SCAN_TMP_DIR` (`Tmp`), `SCAN_SETTLE_SECONDS` (2); протокол `HotFolder.poll(job_id)` возвращает `None` (ответа нет/ещё не готов) либо список `AnswerFile(name: str, data: bytes)` в порядке возрастания имени — только `*.json` каталога `//`, с проверкой готовности по settle (набор имён и размеров не менялся `SCAN_SETTLE_SECONDS`); `LocalHotFolder` и `SmbHotFolder` — временный файл записи (`.part`) создаётся в `//` (каталог создаётся, если нет; на той же ФС/шаре) и переименовывается в `Input/` (атомарно); `healthy()` без изменений. Тесты: `tmp_path` — во время записи в `Input` нет `.part`, после put там только итоговый файл; poll: каталога нет → `None`; один json → после settle список из одного; два json → оба, по имени; файл дописывается (размер растёт) → `None` до стабилизации; посторонний не-json файл игнорируется; `SmbHotFolder` — с подменённым `smbclient`. Проверка: `make check` зелёный. + +### Task 3: [R3] Склейка ответа, сырой файл, нормализатор, воркер + +- [x] `src/gateway/` (или `src/normalizer/`): функция склейки `merge_answers(job_id, files) -> MergedResponse` по структуре из Global Constraints (разбор каждого файла `json.loads`, невалидный json → `parse_error`); воркер: опрос `poll` → `raw/{job_id}.json` в S3 = склеенный ответ (до нормализации; `raw_s3_key` в конверте) → `Normalizer.normalize(merged)`; `NullNormalizer` по правилам Global Constraints (успех = `error.code == ""`; **регрессионный тест на реальных `aep_6.json` и `aep_3.json`, которые не должны трактоваться как ошибка**, на `aep_3_ofr.json` и `aep_5.json` — как ошибка `INVALID_DOCUMENT`; `aep_3.json`+`aep_3_ofr.json` вместе → `NOT_IMPLEMENTED` с одной строкой warnings про `aep_3_ofr.json`; все файлы ошибочные → код первого); `metrics.warnings` в конверте результата. Тесты: unit на `merge_answers` (порядок, невалидный json), нормализатор на реальных фикстурах (маркер `fixtures`), воркер с фейками (склеенный raw в S3 содержит все файлы; ошибка одного файла не роняет задание; `parse_error` → warnings). Проверка: `make check` зелёный. + +### Task 4: [R4] Эмулятор: набор файлов на задание + +- [x] `src/scan_emulator/`: по входящему `_.` собрать из `SCAN_FIXTURES_DIR` все `.json` и `_*.json` (точное правило из Global Constraints), записать в `Output//` под именами `.json` / `_.json` с `file_uid = `; пары нет — прежний `INVALID_DOCUMENT`; `aep_3` не забирает `aep_30*`. Тесты (маркер `fixtures`, `tmp_path`): `aep_3.pdf` → два файла (`.json` успех, `_ofr.json` ошибка), `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `aep_8_form4.json` как раньше (правило `_*.json` покрывает `aep_8_form4`), неизвестное имя → `INVALID_DOCUMENT`; сквозной тест «put → эмулятор → poll (с settle) → merge» на `aep_3`. Проверка: `make check` зелёный. + +### Task 5: [R5] Интеграционные тесты и документация + +- [x] Интеграционный тест (`tests/integration`, маркер `integration`): реальные NATS и MinIO, эмулятор и шлюз в процессе теста: `../fixtures/ocr/aep_3.pdf` в `uploads/…` → ack `processing`, затем конверт `error NOT_IMPLEMENTED` с `metrics.warnings` про `aep_3_ofr.json`, `raw/{job_id}.json` в MinIO содержит два файла; `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings; `aep_5.PDF` → `INVALID_DOCUMENT`. Актуализировать `README.md` и `CLAUDE.md` (раскладка каталога ответа, `SCAN_TMP_DIR`, `SCAN_SETTLE_SECONDS`, склеенный raw, успех = `error.code == ""`), `.env.example`/`.env.test`. Проверка: `make check-full` (с поднятым compose-проектом `arm-ka`: `make up`) — exit 0; вывод в отчёт. + +### Task 6: [R6] Проверка в реальном compose + +- [x] Без правок `../docs`: `docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build` нужных сервисов (`scan-gateway`, `scan-emulator` + зависимости; `--wait` по именам сервисов, не голый), затем сценарии через реальный compose: положить `aep_3.pdf`, `aep_6.pdf`, `aep_5.PDF` из `../fixtures/ocr` в MinIO и вставить задания способом из `../docs/dev/README.md` («Проверка окружения»); зафиксировать выводы (в т.ч. содержимое `raw/.json`) в отчёт. В конце окружение остановить (`down -v` — заменено R-27, design #77: `rm -sfv` собственных сервисов). Если `../docs/dev/docker-compose.yml` требует правки (например, переменных `SCAN_TMP_DIR`/`SCAN_SETTLE_SECONDS`) — не править, а записать точный фрагмент в `open-questions-proposal.md`. diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 9478e39..521b85c 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -4,7 +4,7 @@ См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`a9159aa`). Протокол ИС Сканирование (FNR02, `reference-old-arm/api/src/core/PDFWorker`): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется -в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr[]`), таймаут +в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr` — объект, OQ-77), по одному json на распознанный документ, таймаут 5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном процессе с ядром — это и есть то, от чего уходит DECISION-4. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет. @@ -18,11 +18,17 @@ unit-тестах без Docker; таймаут и ошибки ИС всегд ## Decisions ### D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы -`HotFolder.put(name, data)`, `poll(job_id) -> bytes | None`, `healthy() -> bool`. `LocalHotFolder(root)` работает с +`HotFolder.put(name, data)`, `poll(job_id) -> list[AnswerFile] | None` (`AnswerFile(name, data)`), `healthy() -> bool`. +`poll` возвращает `None`, пока ответ не готов, и все `*.json` каталога `//` по возрастанию имени, когда +готов (OQ-78: ИС кладёт все распознанные документы задания одновременно, по одному json на документ; другие файлы +каталога игнорируются). Готовность — по стабилизации: есть хотя бы один `*.json`, а набор имён и размеров `*.json` не +менялся `SCAN_SETTLE_SECONDS` (2) — защита от недописанного набора. `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`. +(как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Временный +файл атомарной записи (`.part`) лежит не в `Input`, а в служебном каталоге `SCAN_TMP_DIR` (`Tmp`) рядом с `Input`/`Output` +на той же шаре/ФС, чтобы `rename` в `Input` оставался атомарным, а ИС без маски не забрала недописанный файл (OQ-79); +каталог создаётся при первой записи. Режим — `SCAN_HOTFOLDER_MODE = local | smb`. ### D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении @@ -34,30 +40,51 @@ NATS без JetStream: сообщение доставляется один ра ### 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` заданий. +(5) до `deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS)`; ответ принимается, когда `poll` вернул +готовый набор файлов (settle, D-1). При таймауте (в т.ч. если набор так и не стабилизировался) — результат `TIMEOUT`, +файл во входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — +задача эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий. -### D-4. Сырой ответ — в S3 `raw/{job_id}.json`, в конверте `raw_s3_key` -По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется -**до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3. +### D-4. Сырой ответ — склейка всех файлов, в S3 `raw/{job_id}.json`, в конверте `raw_s3_key` +По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов (контракт v1.0 +не меняется: `raw_s3_key` — по-прежнему одна строка `raw/{job_id}.json`). Объект — **склеенный** ответ ИС: +`{"job_id": "", "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}`, +`files` — все `*.json` каталога ответа по возрастанию имени. Каждый файл разбирается `json.loads` независимо: не JSON → +`response = null`, `parse_error` заполнен (задание не падает и файл не перечитывается — к моменту склейки набор уже +стабилен). Сохраняется **до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в 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 подменяет реализацию +### D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает `NOT_IMPLEMENTED` +`Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics, warnings)` (`raw` — склеенный ответ, +разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> `INVALID_FORMAT`). +`NullNormalizer` относит файл к «ошибочным», если `parse_error` не `null` либо `response.error.code` — непустая строка. Кроме того, `error.code` не строка и не `null`/`""` (число, список, bool) — тоже ошибка файла, код `UNKNOWN_ERROR` (R-32, design #71); +**успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому +проверка «`error` не `null`» неверна; отсутствующий или `null` `error` ошибкой не считается). Если ошибочны **все** +файлы — `status = error`, `error.code` — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем +контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении; +корень ответа файла не объект JSON — ошибка этого файла `INVALID_FORMAT`), +`error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`, +где `<код>` — исходный код ИС как получен (для `parse_error` и корня ответа, не являющегося объектом, — `INVALID_FORMAT`); +если ошибочных файлов нет, поле `warnings` опускается. `NormalizeOutcome.warnings: list[str]` (по умолчанию пуст, +R-25) — внутреннее поле: воркер дописывает его в конец `metrics.warnings` конверта и файла результата (R-30, R-33). +Если есть хотя бы один успешный файл — `status = error`, `error.code = NOT_IMPLEMENTED` с сообщением «нормализация ответа +ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в `metrics.warnings` так же. Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется. ### D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint -`python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `_.` → поиск -`SCAN_FIXTURES_DIR/*.json` (точное имя, затем префикс) → через `SCAN_EMULATOR_DELAY` запись `Output//.json` -(с подстановкой `file_uid = `); нет пары → ответ Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", -"message": "..."}, "data_ocr": []}`. Обработанные входящие файлы переименовываются в `_..done`, чтобы -не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его. +`python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `_.` → набор фикстур из +`SCAN_FIXTURES_DIR`: все `.json` и `_*.json` (не префикс `*`: `aep_3` не должен забирать `aep_30*`) → +через `SCAN_EMULATOR_DELAY` запись набора в `Output//` под именами `.json` для `.json` и +`_.json` для `_.json`, с подстановкой `file_uid = `. Набор появляется в `Output//` +атомарно целиком: пишется во временный каталог эмулятора, затем каталог переименовывается. Нет пары → один ответ +Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": {...}}`, где `data_ocr` — +объект с пустыми полями, как у реальных ответов-ошибок `aep_5.json` и `aep_8_form4.json` (`label`, `organization`, `inn`, +`units`, `date` — `{"form_name": "", "confidence": 0}`, `table_structure: []`; R-28). Обработанные входящие файлы переименовываются в +`_..done`, чтобы не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его. ### D-7. Ошибки транспорта — результат, не молчание Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку -упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее → +упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); файл ответа не JSON → `parse_error` файла (D-4), а +если ошибочны все файлы — `INVALID_FORMAT` (D-5); прочее → `UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error, ядро закроет по `reply_deadline`. @@ -66,11 +93,16 @@ NATS без JetStream: сообщение доставляется один ра queue group восстанавливается клиентом; `/healthcheck` отдаёт `nats` и `hotfolder`. ### D-9. Тесты -Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end -«put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды +Unit: `LocalHotFolder` на `tmp_path` (в `Input` нет `.part`; `poll` — нет каталога, один файл, два файла, дописываемый +файл, посторонний файл); склейка и `NullNormalizer` — на реальных `aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, +`aep_6.json`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end «put → эмулятор → poll +(с settle) → склейка» без 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. +`integration`): настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (R-7; контейнера +`scan-emulator` в compose пока нет) — `request-image.json` для `aep_3.pdf` → ack, затем `NOT_IMPLEMENTED` с +`metrics.warnings` про `_ofr.json` и `raw/{job_id}.json` с двумя файлами; `aep_6.pdf` → `NOT_IMPLEMENTED` без +warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/` в MinIO. Синтетический успешный ответ (R-10) не +нужен: реальные успешные образцы `aep_3.json`, `aep_6.json` добавлены человеком 04.10.2026 (OQ-76). ### D-10. Dev без Nexus, Python 3.11 `Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*`. Конфигураций CI нет: @@ -84,8 +116,11 @@ D-7 и таймаут (с укороченными настройками). `Smb - [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре. - [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди; ядро переводит в `timeout`, replay — Срез 3+. -- [Эмулятор знает только две пары фикстур] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые - пары добавляются в `fixtures/ocr` без изменения кода. +- [Эмулятор знает только пары из `fixtures/ocr`] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые + пары и новые файлы набора (`_*.json`) добавляются в `fixtures/ocr` без изменения кода. +- [ИС пишет набор с паузой больше `SCAN_SETTLE_SECONDS` между файлами] → шлюз склеит неполный набор, недостающие + документы потеряны (повторов нет, OQ-15) → значение настраивается; проверить на стенде в Срезе 3. +- [`Tmp` и `Input` на разных ФС/шарах] → `rename` перестаёт быть атомарным → `SCAN_TMP_DIR` обязан лежать на той же шаре. ## Migration Plan @@ -93,8 +128,9 @@ D-7 и таймаут (с укороченными настройками). `Smb ## Open Questions -Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего каталогов и путь SMB -на стенде ИС Сканирование — нужны к Срезу 3. +Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего/служебного каталогов, +путь SMB и подходящее `SCAN_SETTLE_SECONDS` на стенде ИС Сканирование — нужны к Срезу 3. OQ-76/77/78/79 решены человеком +04.10.2026 (см. `open-questions-proposal.md`). ## Decisions made without discussion @@ -104,8 +140,79 @@ D-7 и таймаут (с укороченными настройками). `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` | нет | +| 5 | 2026-10-03 | D-5: `NullNormalizer` пробрасывает ошибки ИС, успех → `NOT_IMPLEMENTED` **[частично заменено №57–58]** | честный статус; коды Content AI совпадают с контрактом | нет | +| 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки **[частично заменено №61–62]** | общие соглашения горячих папок; `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` как есть, имя `_` собирает вызывающий (`inbox_name`); запись атомарна (`.part` + rename), `Input/` создаётся, корень — нет; `healthy()` = корень существует; эмулятор игнорирует dot-файлы и `.done` **[частично заменено №59: `.part` — в `SCAN_TMP_DIR`]** | неоднозначность плана («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`; читается один `.json` (старый код читал все файлы `Output//`) **[заменено №54–55 и №59]** | спека; реальная ИС может забирать файлы без маски — риск вынесен в `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` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`), поэтому путь «успех -> `NOT_IMPLEMENTED`» проверяется на синтетическом успешном ответе (копия `aep_5.json` с `error = null` и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с `ok_doc.json`); реальные фикстуры используются для сценария «ошибка ИС проброшена» **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `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` **[запись ответа — частично заменено №62: набор целиком через временный каталог]** | контейнеру эмулятора не нужны переменные 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` = отсутствует (текст по умолчанию) **[частично заменено №57, №60]** | 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` **[частично заменено №55–56: файл ответа не JSON даёт `parse_error` в склейке, `INVALID_FORMAT` — только если ошибочны все файлы]** | 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) | нет | +| 46 | 2026-10-04 | Задача 5.3 (подробно — 15 решений в отчёте исполнителя): задание, получившее слот после `reply_deadline`, отвечает `TIMEOUT` без записи файла и чтения S3 (ack уже опубликован); ответ ИС, не являющийся JSON, перечитывается через `SCAN_POLL_SECONDS` и принимается, когда стал JSON или перестал меняться (на дедлайне — последний прочитанный; затем `INVALID_FORMAT`); дедупликация в двух структурах (`_active` не вытесняется, `_done` — LRU `DONE_CACHE_SIZE`); `SCHEMA_MISMATCH` не регистрирует `job_id`; `error.message` ≤ 4000 символов, суррогаты заменяются; `raw/` пишется с `application/json` (не-JSON — `application/octet-stream`); нормализатор вызывается через `to_thread`; `stop()` не публикует конверты отменённых заданий (D-2); сбой SMB-сессии виден через `healthy()` (listdir с теми же учётными данными), без флага; проба `hotfolder`: таймаут 2 с, кэш 5 с, одна проба за раз; NATS подключается в фоновой задаче lifespan, ошибка `connect`/`subscribe` -> `nats = down` до перезапуска; порядок остановки: отмена подключения -> `worker.stop()` -> drain NATS -> S3 -> SMB; `/healthcheck` всегда 200 **[в части перечитывания не-JSON ответа заменено №56]** | экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации | поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший `poll` по SMB (до 60 с) не прерывается дедлайном (T12.2) | +| 47 | 2026-10-04 | Задача 5.4: общая обвязка тестов воркера — `api/tests/worker_helpers.py` и `api/tests/unit/conftest.py`; ответ ИС для освобождения одного слота пишет тест (не `process_once`); отсутствие утечки слотов проверяется поведением (повторный `start()`); отмена воркера во время `to_thread(hotfolder.put)` не прерывает запись в потоке (файл уже в ИС, конверта нет, D-2) | эмулятор ответил бы на все четыре файла сразу; образец arm-core | возможна повторная отправка в ИС после рестарта (OQ-15) | +| 48 | 2026-10-04 | Задача 6.1: интеграционный тест поднимает настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ `ok_doc.json` строится по форме §11 `05-domain-rules.md`; сценарии 1–2 пропускаются без `../fixtures`, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче `nats-connect`; `ResourceWarning` aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) **[заменено D-9: реальные успешные образцы aep_3/aep_6, OQ-76]** | контейнера `scan-emulator` ещё нет (R-7); fixtures не содержат успешных ответов (R-10) | интеграционный тест может забрать чужие запросы из общей queue group `scan-gateway` на общем NATS (T14.1) | +| 49 | 2026-10-04 | R-21: финальная волна исправлений = ровно список финального ревью (C-1, I-1, T12.1, T12.4, T7.3+T7.5, T2.2, неточности документов, строки в предложении для docs); I-2 (compose-фрагмент не применён) и I-3 (replay с `attempt+1` для `job_id` из LRU) оставлены человеку | второй волны нет; I-2 и I-3 меняют спеку/другой репозиторий | остальные находки — follow-up в `open-questions-proposal.md` | +| 50 | 2026-10-04 | C-1: пакет `src` не создаёт приложение при импорте (`api/src/__init__.py` без импортов; `run_server.py` импортирует `src.app`); эмулятор стартует без `OPENBAO__*` и завершается кодом 2 при отсутствии корня горячей папки | R-11: контейнеру эмулятора не нужны настройки шлюза; обнаружено финальным ревью, тесты работали с полным `.env.test` | любой код, импортировавший `src:app`, должен перейти на `src.app:app` | +| 51 | 2026-10-04 | I-1: `make up` после `up -d --wait postgres nats minio` выполняет `run --rm minio-init` (идемпотентно) | на свежем томе бакет `arm-ka` иначе не создаётся и `make check-full` падает на `NoSuchBucket` | `tabular-parser` — тот же пробел (чужой репозиторий, в предложении) | +| 52 | 2026-10-04 | T12.1: `SCHEMA_MISMATCH` не публикуется для `job_id` (нормализованного через `uuid.UUID`), который в работе или в LRU | тот же `Nats-Msg-Id`, что у настоящего результата, привёл бы к потере настоящего результата при дедупликации ядром | повторный битый запрос того же задания остаётся без ответа (лог warning) | +| 53 | 2026-10-04 | T7.3: `inbox_name` укорачивает основу имени так, чтобы `._<имя>.part` ≤ 240 байт UTF-8 (по code points, расширение ≤ 16 символов сохраняется, `surrogatepass`); `put` подавляет `OSError` при удалении `.part` | ENAMETOOLONG на ext4/NTFS давал ложный `RECOGNITION_FAILED` на допустимое имя; имя для ИС значения не имеет | эмулятор не найдёт фикстуру по усечённому имени (> ~200 байт — нереалистично) | +| 54 | 2026-10-04 | Готовность набора ответов: `poll` отдаёт список, когда есть хотя бы один `*.json` и набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (2); таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, ненастоявшийся набор на дедлайне — `TIMEOUT` | OQ-78 (решение человека: читать все json, готовность — по стабилизации) не задаёт момента готовности; ИС кладёт файлы без общего маркера завершения | ИС пишет набор с паузой > 2 с — часть документов потеряна, повторов нет (OQ-15); значение настраивается | +| 55 | 2026-10-04 | Склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}`, файлы по возрастанию имени, в `files` — все `*.json` каталога (другие игнорируются); `raw/{job_id}.json` = склеенный ответ; `raw_s3_key` остаётся одной строкой, контракт v1.0 не меняется | OQ-78 требует «склеить в один ответ»; структура — наша; сохранение имён файлов нужно для разбора инцидентов и `metrics.warnings` | Срез 3 нормализатор читает склеенную структуру, а не одиночный ответ ИС — единственный вход, переделка локальна | +| 56 | 2026-10-04 | R-24 (design #56): файл, не разобранный `json.loads` после настаивания набора, не перечитывается и не роняет задание: `response = null`, `parse_error` заполнен; при ошибочности всех файлов — `INVALID_FORMAT` | набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle | недописанный, но стабильный 2 с файл будет принят как `parse_error`, а не дождётся конца записи | +| 57 | 2026-10-04 | Успех файла — `error.code == ""`; ошибочный — непустой `error.code` либо `parse_error`; отсутствующий/`null` `error` ошибкой не считается; не-объект на корне склейки → `INVALID_FORMAT`; код вне перечня контракта → `UNKNOWN_ERROR` с исходным кодом в сообщении (R-12 сохранён) | образцы `aep_3.json`, `aep_6.json` (OQ-77): у успеха `error = {"code": "", "message": ""}`; проверка `error != null` принимала успех за ошибку | ИС, возвращающая `error` строкой/другого типа, будет трактоваться по правилу «нет `error.code` — успех» | +| 58 | 2026-10-04 | Сводка по файлам в `NullNormalizer`: все файлы ошибочны → `status = error`, `error.code`/`message` первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`); есть хотя бы один успешный → `error.code = NOT_IMPLEMENTED`; каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings` в обоих случаях | OQ-78: ошибка одного документа не отменяет остальные; `NOT_IMPLEMENTED` — единственный честный статус, пока нормализации нет (Срез 3); `metrics.warnings` уже есть в контракте (`array`), расширять контракт не нужно | потребитель, ждущий `error.code` ошибочного файла при частичном успехе, увидит `NOT_IMPLEMENTED` — до Среза 3 различия не нужны | +| 59 | 2026-10-04 | `SCAN_TMP_DIR` (`Tmp`) — служебный каталог на корне горячей папки; `.part` создаётся в `<корень>/Tmp/`, каталог создаётся при записи, `rename` в `Input/`; обе реализации (`LocalHotFolder`, `SmbHotFolder`) | OQ-79 (решение человека: временный файл — вне `Input`); корень остаётся единственной шарой, чтобы `rename` был атомарным | `Tmp` на другой ФС/шаре — `rename` не атомарен; ИС с маской, захватывающей `Tmp`, — неизвестно, проверить на стенде | +| 60 | 2026-10-04 | `Normalizer.normalize(raw)` получает склеенный ответ (D-4), а `json.loads` каждого файла выполняет функция склейки `merge_answers` (на стороне воркера); воркер больше не отображает «не JSON» в `INVALID_FORMAT` сам — это делает нормализатор по `parse_error` | R-12 писал вход как «ответ ИС» (один файл); с несколькими файлами решение «ошибка или нет» принимается по набору | нет | +| 61 | 2026-10-04 | Правило выбора фикстур эмулятора: `.json` и `_*.json` (не префикс `*`); имена в каталоге ответа — `.json` и `_.json`; `aep_8.pdf` → `aep_8_form4.json` покрывается `_*` | `*` забрал бы `aep_30*` при запросе `aep_3`; набор из нескольких файлов нужен для `aep_3.json` + `aep_3_ofr.json` | фикстура с именем вида `-x.json` не будет найдена | +| 62 | 2026-10-04 | R-26 (design #62): набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output//` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает | +| 63 | 2026-10-04 | R-22 (design #63): задача 7.1 переходно адаптирует вызов `poll` в воркере и фейки в тестах к `list[AnswerFile]`; вся склейка и нормализация — задача 7.2 | `poll` меняет тип результата, воркер не должен ломаться между задачами | небольшой переходный код, удаляемый в 7.2 | +| 64 | 2026-10-04 | R-23 (design #64): существующие тесты воркера, таймаута и параллелизма ставят `SCAN_SETTLE_SECONDS=0`; settle тестируется отдельно с внедряемыми часами | не удлинять тесты на 2 с каждый и не делать их зависимыми от реального времени | если 0 не отключает проверку стабилизации полностью, тесты таймаута флапают | +| 65 | 2026-10-04 | R-25 (design #65): `NormalizeOutcome` получает необязательное поле `warnings`, которое воркер кладёт в `metrics.warnings` конверта; поле опускается, когда ошибочных файлов нет | `metrics.warnings` в контракте есть (`array`), а нормализатор — единственное место, знающее об ошибках по файлам | внутренний тип, контракт v1.0 не меняется; Срез 3 может заменить | +| 66 | 2026-10-04 | R-28 (design #66): ответ эмулятора «нет пары» — `error.code = INVALID_DOCUMENT` и `data_ocr` — **объект** с пустыми полями (`label`/`organization`/`inn`/`units`/`date` — `{form_name: "", confidence: 0}`, `table_structure: []`) как у реальных ответов-ошибок `aep_5.json`, `aep_8_form4.json`, а не `[]` | OQ-77: `data_ocr` — объект; эмулятор должен воспроизводить реальную форму | нет | +| 67 | 2026-10-04 | R-29 (design #67): готовность набора по settle отсчитывается от первого опроса, при котором наблюдается окончательный набор (не по mtime); сценарий «Ответ из одного файла»: набор передаётся не позднее `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после этого опроса | часы SMB-сервера и шлюза могут расходиться, mtime ненадёжен; внедряемые монотонные часы делают поведение тестируемым; прежняя формулировка «не позднее `SCAN_POLL_SECONDS`» недостижима без mtime (Concern Task 2) | ответ приходит до settle + poll позже стабилизации набора (боевые 2 + 5 с) | +| 68 | 2026-10-04 | Решения исполнителя Task 2 (7.1): состояние settle ограничено 1024 заданиями с вытеснением самых давних и сбрасывается при выдаче набора или пустом каталоге, `forget` в протокол `HotFolder` не входит; `*.json` сравнивается без учёта регистра; dot-файлы с расширением `.json` входят в набор; settle — от первого наблюдения в этом процессе; файл изменился между листингом и чтением (размер) или исчез → `None` и набор настаивается заново; в `put` `Tmp` создаётся раньше `Input`; сигнатуры: `tmp_dir` — четвёртый позиционный, `settle_seconds`/`clock` — только именованные, публичные `answer_dir`, `settle_seconds`, `pending_jobs`, `answer_path` сохранён | ИС на Windows/SMB (маски регистронезависимы); память не растёт от заданий, ушедших по таймауту; не трогать протокол и фейки ради редкого случая; защита от гонки «листинг → чтение» | вытесненное активное задание настаивается заново (ответ позже на settle); служебный `X.JSON` или `.tmp.json` ИС попадёт в набор | +| 69 | 2026-10-04 | R-30 (design #69): `metrics.warnings` конверта ограничено: не больше 100 строк (`MAX_WARNINGS`) по 1000 символов (`MAX_WARNING_CHARS`, `clean_text`: суррогаты заменяются, длинная строка обрезается с «…»); при избытке первые 99 строк + сводная «ещё предупреждений: N; все ошибки файлов — в raw/{job_id}.json» | конверт NATS ≤ 1 МБ при любом числе файлов и длине сообщений ИС (как T10.1 для `error.message`); полные данные остаются в сыром ответе | потребитель не увидит в конверте ошибки файлов сверх 99-го и хвост длинного сообщения — только в `raw/` | +| 70 | 2026-10-04 | R-31 (design #70): склейка — `src/gateway/merge.py` (`merge_answers`, `MergedResponse`/`MergedFile` — `TypedDict`, т. е. обычный dict); тот же объект пишется в `raw/{job_id}.json` (`dump_merged`: UTF-8 с читаемой кириллицей, при одиночном суррогате — ASCII-экранирование, всегда `application/json`) и передаётся нормализатору; `parse_error` — `<тип исключения>: <текст>` ≤ 300 символов; `merge_answers` сам сортирует файлы по имени | склейка — транспортная часть (вход — файлы горячей папки), не зависит от реализации нормализатора, которую подменит Срез 3; нормализатор видит ровно то, что сохранено в `raw/` | Срез 3 может захотеть типизированную модель вместо dict — правка локальна | +| 71 | 2026-10-04 | R-32 (design #71): крайние случаи `NullNormalizer`: `error.code` не строка и не `null`/`""` (число, список, `true`, `0`) — ошибка `UNKNOWN_ERROR`, в warnings — `repr` кода; пустое/отсутствующее сообщение ошибки ИС — «ИС Сканирование вернула ошибку <код> без сообщения» (и в `error.message`, и в warnings); `parse_error` — сообщение «ответ ИС не разбирается как JSON: »; вход не dict или `files` не список — `INVALID_FORMAT` «склеенный ответ ИС не соответствует структуре…»; `files` пуст — `INVALID_FORMAT` «в ответе ИС нет ни одного файла»; элемент `files` не объект или без непустого `name` — ошибочный файл `files[]` с `INVALID_FORMAT`; «первый по имени» — первый в порядке `files` (склейка уже отсортировала) | спека задаёт только «непустой `error.code`»; нормализатор не бросает ни на каком входе; Срез 3 заменит реализацию | ИС, кодирующая успех как `0`/`false`, будет понята как ошибка `UNKNOWN_ERROR` (реальные образцы — строка `""`) | +| 72 | 2026-10-04 | R-33 (design #72): `warnings` исхода дописываются в конец `metrics.warnings` исхода; итоговые `metrics` одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает `metrics` | одно место правды для предупреждений; файл результата и конверт не расходятся | нет | +| 73 | 2026-10-04 | T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → `None` (набор настаивается заново); T2.2: `SmbHotFolder` — ENOTDIR из листинга `Output/` → `None`; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR | файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local | один лишний листинг на каждый выданный набор | +| 74 | 2026-10-04 | R-34 (design #74): интеграционный `tests/integration/test_roundtrip.py` минимально переведён на формат склейки `raw/` и успех `error = {"code": "", "message": ""}` (без новых сценариев — они в 7.4) | иначе он проверял бы старый формат `raw/`; полноценная переработка — задача 7.4 | не прогнан: в поднятом compose нет бакета `arm-ka` (`NoSuchBucket`), проверка — 7.4 после `make up` | +| 75 | 2026-10-04 | R-35 (design #75): решения исполнителя Task 4 (7.3): эмулятор читает `SCAN_TMP_DIR` (по умолчанию `Tmp`) через `EmulatorSettings.tmp_dir`, как шлюз, не завися от настроек шлюза; набор собирается в `//..answer` и целиком переименовывается в `Output//`; остаток прерванной сборки удаляется, ответ с тем же uuid, уже лежащий в `Output`, заменяется (rmtree + rename); повреждённая фикстура набора даёт ответ-ошибку `INVALID_DOCUMENT` на своём месте, остальные файлы набора остаются; фикстуры с одним `suffix` (`a.JSON`/`a.json`) — берётся первая по имени; `aep_8.pdf` теперь даёт `_form4.json`, а не `.json` (прежнее правило `*`) | spec scan-emulator: набор появляется атомарно, rename каталога возможен только в пределах одной ФС, служебная папка корня уже есть у шлюза (`Tmp`) | повторная публикация uuid на миг убирает каталог; потребители, ждавшие `aep_8` как `.json`, должны брать набор целиком | +| 76 | 2026-10-04 | R-36 (design #76): решения исполнителя Task 5 (7.4): интеграционный `test_roundtrip.py` с `SCAN_SETTLE_SECONDS=0.2` и `SCAN_POLL_SECONDS=0.1` (`.env.test` держит settle = 0 для остальных тестов); эмулятору теста отдаются копии только `aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json` и синтетический `ok_doc.json` (не весь `fixtures/ocr`); сценарий `aep_5` берёт реальное имя `aep_5.PDF` (расширение в верхнем регистре) и проверяет, что единственный ошибочный файл попадает и в итоговую ошибку, и в `metrics.warnings` (D-5); проверка `raw/` общая для всех сценариев: имена файлов склейки = все `*.json` каталога ответа по возрастанию, тела совпадают с файлами; в сценарии `unknown` устаревшее ожидание `data_ocr = []` заменено на объект с `table_structure: []` (R-28) — расхождение обнаружено первым прогоном (в Task 4 тест не запускался, №74); T3.3: D-5 уточнён — не строковый, не `null`/`""` `error.code` (число, список, `true`) — тоже ошибка файла с `UNKNOWN_ERROR` (R-32); T4.1: сквозной тест `TestAnswerSet` на реальных фикстурах помечен `fixtures` | тест должен проверять то, что есть в коде и спеке, а не прежнюю форму ответа; settle 0.2 с не удлиняет тест заметно; полная копия `fixtures/ocr` подтянула бы лишние наборы | тесты `fixtures` пропускаются без репозитория `../fixtures`; интеграционные тесты делят NATS и queue group `scan-gateway` с другими экземплярами шлюза | +| 77 | 2026-10-04 | R-27 (design #77): уборка после сквозной проверки Task 6 (7.5) — `docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka --profile services rm -sfv` только своих сервисов (`scan-gateway`, `scan-emulator`, `arm-core`, `arm-core-migrate`, одноразовый `minio-init`) и удаление тома `arm-ka_hotfolder` после проверки, что его не использует ни один контейнер, вместо `down -v` из плана; `postgres`/`nats`/`minio` и их тома не тронуты | проект compose `arm-ka` общий для всех worktree: `down -v` удалил бы и тома `postgres-data`/`minio-data` с данными другой работы | человек хотел полный сброс — одна команда `docker compose --profile services down -v` из `docs/dev` | +| 78 | 2026-10-04 | R-37 (design #78): состав и сборка сервисов в Task 6 (7.5): `up -d --build arm-core scan-gateway scan-emulator`, затем `up -d --wait` по тем же именам; `tabular-parser` не поднимался (конвейер `image` его не использует); контекст сборки `scan-gateway` в compose — `../../scan-gateway` от `docs/dev` worktree, т. е. сам worktree ветки (`.wt/slice-0/scan-gateway`), поэтому отдельный `make docker-build` не нужен; `arm-core:dev` пересобран из worktree `arm-core` (кэш, ID образа не изменился) | проверяется шлюз; лишний сервис только добавил бы шум на шине; правка compose не требовалась | если `tabular-parser` нужен для проверки чего-то ещё — отдельный прогон | +| 79 | 2026-10-04 | R-38 (design #79): после Task 6 тестовые строки БД оставлены (`legal_entity` ИНН `7700000001`, четыре `s3_object`/`stored_file`/`recognition_job` — id в отчёте Task 6), а объекты `uploads/…` и `raw/…` в MinIO удалены (`mc rm` поимённо); строки `s3_object` поэтому указывают на несуществующие ключи | по указанию контроллера (dispatch Task 6): строки БД — оставить, объекты MinIO — удалить; бакет до прогона был пуст | чтение этих `s3_object` даст `NoSuchKey`; убрать — SQL в порядке FK (подставить id из отчёта Task 6: `` — `015ea48c-d91f-4d90-a7d8-0d559a8739a3`, `1bc7743c-d902-40c5-9009-09bda2de5f72`, `0ea16933-4cc1-4b50-bda9-a97089a918c5`, `9942727c-7f7d-43e8-a82d-925e0140c2e7`; `` — `92b342b2-2d65-489e-b252-e26569780373`, `072e922f-b550-461c-9bfc-7cbb1961a46b`, `61a53aaf-af6c-4b30-8651-97c03bd0ba8e`, `e2d4f412-ecd7-4fd3-b2e3-91693432a186`; `` — `d569c917-c764-4591-be16-8058a688abf7`, `2ef2d832-d4f1-4819-9467-36bd24bf4c93`, `cd61b5f0-9582-4da5-bc55-77e505a5b32e`, `c81c8d72-9c6f-48f8-bbbb-3c6ce7a43e4f`): `begin; delete from dkb.recognition_job where id in (, …); delete from dkb.stored_file where id in (, …); delete from dkb.s3_object where id in (, …); delete from dkb.legal_entity where inn = '7700000001'; commit;` (порядок: `recognition_job.file_id → stored_file`, `stored_file.s3_object_id → s3_object`, `stored_file.legal_entity_id → legal_entity`; `stored_file.current_recognition_job_id` у этих строк пуст, `document`/`osv_entry` не создавались) либо полный сброс | +| 80 | 2026-10-04 | R-39 (design #80): M-1 финального ревью — при `SCAN_SETTLE_SECONDS > 0` устойчивая ошибка чтения файла ответа (`PermissionError` локально, `STATUS_ACCESS_DENIED` на SMB) чередуется с пустыми опросами (готовность сбрасывает состояние), поэтому `failures != polls` и задание заканчивается `TIMEOUT`, а не `RECOGNITION_FAILED`; код итога остаётся `TIMEOUT` (спека и контракт не меняются), а при `failures > 0` текст сообщения дополняется числом ошибок опроса и описанием последней (`_describe(last_error)`) | не менять код итога и контракт ради диагностики; конверт при проблеме с чтением горячей папки не должен вести к ИС | код итога не `RECOGNITION_FAILED`: диагностика только в тексте сообщения; ядро, фильтрующее по коду, не отличит этот случай от молчания ИС | diff --git a/openspec/changes/slice-0-skeleton/open-questions-proposal.md b/openspec/changes/slice-0-skeleton/open-questions-proposal.md new file mode 100644 index 0000000..7916c30 --- /dev/null +++ b/openspec/changes/slice-0-skeleton/open-questions-proposal.md @@ -0,0 +1,178 @@ +# Предложения по итогам Среза 0 scan-gateway: compose, контракт, открытые вопросы + +Передача для владельца `docs` и человека. Файлы в `../docs` этим change'ом **не менялись**; всё ниже — предложения. +Источник: прогон DoD (задача 6.3), `progress.md` контроллера, ревью задач 1-15. Маркеры (`T12.5`, `O-1`, `R-13`) — из ledger. + +## Фрагмент compose для scan-emulator и scan-gateway + +Вставить в `../docs/dev/docker-compose.yml` в раздел `services:` (после `minio-init`) и добавить в раздел `volumes:` том +`hotfolder:`. Профиль `services`: `docker compose --profile services up -d --wait scan-gateway scan-emulator` (в +`up -d --wait` указывать сервисы явно, как для инфраструктуры). Образ собирается из корня репозитория `scan-gateway` +(`Dockerfile`), эмулятор использует тот же образ (команда `python -m src.scan_emulator`). Эмулятору переменные +MinIO/NATS не нужны (R-11). Контекст `../../scan-gateway` верен и для workspace, и для worktree `.wt/<срез>/docs/dev`. + +```yaml + # Эмулятор ИС Сканирование: тот же образ, что у шлюза; общий том hotfolder с шлюзом. Переменные MinIO/NATS не нужны. + scan-emulator: + profiles: ["services"] + build: + context: ../../scan-gateway + image: scan-gateway:dev + command: ["python", "-m", "src.scan_emulator"] + restart: unless-stopped + environment: + SCAN_HOTFOLDER_PATH: /data/hotfolder + SCAN_FIXTURES_DIR: /fixtures/ocr + SCAN_EMULATOR_DELAY: "2" + volumes: + - hotfolder:/data/hotfolder + - ../../fixtures/ocr:/fixtures/ocr:ro + + scan-gateway: + profiles: ["services"] + build: + context: ../../scan-gateway + image: scan-gateway:dev + restart: unless-stopped + depends_on: + nats: + condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: + OPENBAO__MINIO__MINIO_URL: minio:9000 + OPENBAO__MINIO__MINIO_ADMIN_LOGIN: ${MINIO_ROOT_USER:-minioadmin} + OPENBAO__MINIO__MINIO_ADMIN_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + OPENBAO__MINIO__MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka} + OPENBAO__MINIO__MINIO_SECURE: "false" + OPENBAO__NATS__NATS_URL: nats://nats:4222 + NATS_ENABLED: "true" + SCAN_HOTFOLDER_MODE: local + SCAN_HOTFOLDER_PATH: /data/hotfolder + volumes: + - hotfolder:/data/hotfolder + ports: + - "${BIND_ADDR:-127.0.0.1}:${SCAN_GATEWAY_PORT:-8002}:8000" + healthcheck: + # curl в python:3.11-slim нет; /healthcheck всегда отвечает 200, поэтому читаем поля nats и hotfolder + test: + - CMD + - python + - -c + - "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' and d['hotfolder']=='ok' else 1)" + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s +``` + +И в конец файла: + +```yaml +volumes: + postgres-data: + minio-data: + hotfolder: +``` + +Проверено: YAML разбирается (`yaml.safe_load`); `docker compose -f <копия> --project-directory docs/dev --profile services +config -q` на временной копии файла с этими сервисами — код 0 (реальный проект `arm-ka` не затрагивался). Образ +`scan-gateway:dev` запущен отдельным контейнером в сети `arm-ka_default` с теми же переменными окружения: +`/healthcheck` -> `nats: ok`, `hotfolder: ok`, команда healthcheck из фрагмента возвращает 0 (контейнер удалён). + +Что искать в логах: у эмулятора при старте — `Эмулятор ИС Сканирование: корень /data/hotfolder, входящий каталог +/data/hotfolder/Input, исходящий каталог /data/hotfolder/Output, задержка 2.0 с, опрос 1.0 с`, затем +`Фикстуры: /fixtures/ocr, фикстур: 2` (при `0`/«не найден» эмулятор отвечает `INVALID_DOCUMENT` на всё). У шлюза — +`Подписка NATS активна. subject=recognition.image.request, queue=scan-gateway` и `Воркер scan-gateway запущен`. +Имена переменных сверены с `api/src/core/config.py` и `api/src/scan_emulator/__init__.py`. + +**Фрагмент рабочий только после исправления C-1** (ветка `feat/1-slice-0-skeleton`, коммит `ad3fb34`): раньше +`python -m src.scan_emulator` импортировал пакет `src`, а тот создавал приложение шлюза и требовал `OPENBAO__*`, поэтому +контейнер эмулятора без переменных MinIO/NATS падал с `ValidationError`. Теперь импорт пакета `src` настроек шлюза не +требует; проверено на пересобранном образе: `docker run --rm -e SCAN_HOTFOLDER_PATH=/nonexistent scan-gateway:dev +python -m src.scan_emulator` завершается кодом 2 с сообщением `Эмулятор не запущен: корень горячей папки /nonexistent не +существует или не каталог` (не `ValidationError`); шлюз из того же образа стартует, `/healthcheck` отвечает 200. + +Проверить после применения фрагмента: + +- `docker compose --profile services up -d --wait scan-gateway scan-emulator` возвращает код 0: `scan-gateway` + зависит от `minio-init` (`service_completed_successfully`), а в списке `--wait` одноразового `minio-init` нет (по + `../docs/dev/README.md` голый `docker compose up -d --wait` возвращает 1 из-за завершившегося `minio-init`); +- в логе эмулятора при старте — строка о каталогах и строка о числе фикстур (формат `run()` в + `api/src/scan_emulator/__init__.py`: `Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, + задержка {} с, опрос {} с` и `Фикстуры: {}, фикстур: {}`), то есть `Эмулятор ИС Сканирование: корень /data/hotfolder, + входящий каталог /data/hotfolder/Input, исходящий каталог /data/hotfolder/Output, задержка 2.0 с, опрос 1.0 с` и + `Фикстуры: /fixtures/ocr, фикстур: 2`; +- `scan-gateway` — `healthy`, `/healthcheck` на `127.0.0.1:8002` даёт `nats: ok`, `hotfolder: ok`. + +Примечания: (а) порт хоста 8002 — по spec docs, у сервиса порт по умолчанию 8000 (`SERVER__PORT`), поэтому маппинг +`8002:8000`; (б) `/healthcheck` всегда отвечает 200 (пункт 9 таблицы), поэтому healthcheck читает поля `nats`/`hotfolder`; +(в) контейнеры работают от root, права на том `hotfolder` не настраиваются; (г) `build` задан у обоих сервисов с одним +`image:` — compose соберёт образ один раз за счёт кэша. + +## Предложения по контракту, OQ и документам + +| # | Что | Откуда | Предложение | +|---|---|---|---| +| 1 | `PUBLISH_RETRIES` = число повторов после первой попытки (1+3 = 4 попытки) | R-13 (design #37), T11.1; `api/src/core/config.py` | Согласовать с tabular-parser (там может быть «3 попытки всего»); зафиксировать в `03-architecture.md` одной фразой | +| 2 | `03-architecture.md` («Схема `schema_version`»): «неизвестный MAJOR -> `SCHEMA_MISMATCH` и ack»; спека change'а и воркер — без ack | R-16 (design #41), T12.13 | Привести документ в соответствие (ack не публикуется, конверт `error` — если из тела извлекаются `job_id` и `file_id`) либо пересмотреть правило и воркер: ack на отвергнутый запрос защищает ядро от зацикливания повторов доставки, цена — лишнее сообщение и расхождение с R-16 | +| 3 | **Решено человеком 04.10.2026 (OQ-76, частично):** исходно — реальные фикстуры `fixtures/ocr/aep_5.json`, `aep_8_form4.json` были ответами-ошибками ИС (`error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`). Добавлены реальные успешные образцы: `aep_3.pdf` + `aep_3.json` (успех: «Бухгалтерский баланс» 42 строки + «Отчет о финансовых результатах» 18 строк) + `aep_3_ofr.json` (второй ответ ИС по тому же файлу, `INVALID_DOCUMENT`), `aep_6.pdf` + `aep_6.json` (успех: баланс 122 строки с шапкой дат + ОФР 49 строк); `aep_5.json`, `aep_8_form4.json` — ответы-ошибки | O-1, R-10 (design #32); OQ-76 | Транспорт Среза 0 проверяется на реальных образцах (склейка, `NOT_IMPLEMENTED`, `metrics.warnings`), синтетический успешный ответ не нужен; разбор успешных ответов — Срез 3 (нормализатор, OQ-13/14) | +| 4 | **Решено человеком 04.10.2026 (OQ-77):** исходно — §11 `05-domain-rules.md` описывал `data_ocr[]` (список), а в фикстурах `data_ocr` — объект. Факт по `aep_3.json`, `aep_6.json`: `data_ocr` — **объект**, один на json-файл (`label`, `organization`, `inn`, `units`, `date` — `{form_name, confidence}` с `confidence` 0–100; `table_structure` — **список таблиц** `{label, rows[]}`; в одном json может быть несколько таблиц разных форм (в `aep_3.json` и `aep_6.json` — баланс и ОФР), у каждой свой `label`; тип документа задаёт `label`; таблицы не интерпретируются в Срезе 0 (Срез 3)); успех — `error = {"code": "", "message": ""}` (не `null`), ошибка — непустой `code` и пустой `table_structure`. §11 исправлен в `docs` | O-1, T3.2; OQ-77 | В Срезе 0 успех файла определяется как `error.code == ""` (design #57); разбор таблиц — Срез 3 | +| 5 | **Решено человеком 04.10.2026 (OQ-78):** исходно шлюз ожидал единственный файл `//.json`, старый код читал все файлы каталога `Output//`. Факт: ИС кладёт **все распознанные документы одновременно** в каталог ``, по одному json на документ (`aep_3.json` + `aep_3_ofr.json`); шлюз читает **все** `*.json` каталога и склеивает в один ответ; готовность — по стабилизации набора (`SCAN_SETTLE_SECONDS`, 2 с); ошибка одного документа не отменяет остальные (`metrics.warnings`) | T8.5; OQ-78 | Реализуется доработкой PR #3 (tasks 7.x, design #54–#58); подходящее значение settle и поведение ИС на реальной шаре — проверить на стенде в Срезе 3 | +| 6 | **Решено человеком 04.10.2026 (OQ-79):** исходно временный файл `.<имя>.part` лежал в `Input` (R-5), и ИС без маски могла забрать недописанный файл. Решение: временный файл уходит в служебный каталог `SCAN_TMP_DIR` (по умолчанию `Tmp`) рядом с `Input`/`Output`, на той же шаре/ФС, затем `rename` в `Input` (атомарно) | R-5, T7/T8; OQ-79 | Реализуется доработкой PR #3 (tasks 7.x, design #59); `Tmp` должен лежать на той же шаре, что `Input`; поведение ИС на реальной шаре — проверить в Срезе 3 | +| 7 | Сценарий «NATS перезапущен -> подписка восстановлена за <= 20 с» не автоматизирован: общий NATS нельзя перезапускать в проверках | T11.7 | Ручная проверка на отдельном `nats-server` (поднять, подключить шлюз, убить, поднять снова) либо отдельный compose-профиль для тестов | +| 8 | Штатная остановка не публикует конверты для принятых (ack) заданий; D-2 допускает потерю при падении, при rolling deploy задание теряется до `reply_deadline` | T12.5, D-2 | Публиковать `UNKNOWN_ERROR` «сервис остановлен» в `stop()` для всех принятых заданий; Срез 3+ | +| 9 | `/healthcheck` всегда 200, даже если NATS постоянно недоступен (`nats: down`) | T12.10 | Платформе (readiness/liveness) читать поле `nats`/`hotfolder`, а не код ответа; либо отдельные `/ready` и `/live` | +| 10 | `../docs/dev/README.md` устарел относительно шлюза: «у scan-gateway Makefile пока нет», `nats = off` (шлюз показывает `disabled`), порт 8002 против 8000, шлюз требует `SCAN_HOTFOLDER_*` и не использует `OPENBAO__DATABASE__*` | Task 3, O-a | Обновить раздел «Запуск сервисов из venv» и «Интеграционные тесты»; добавить профиль `services` (фрагмент выше); `make up`/`make check-full`/`make emulator` в `scan-gateway` есть | +| 11 | Интеграционные тесты шлюза и arm-core делят общий NATS: in-process шлюз подписывается на queue group `scan-gateway` на реальном subject и может забрать запросы тестов arm-core, идущих параллельно (и наоборот) | T14.1, T11.8 | Сделать queue group и/или subject-префикс настраиваемыми для тестов (например `NATS_TEST_PREFIX`), либо отдельный NATS на тесты; пока — не гонять `make test-integration` двух сервисов одновременно | +| 12 | Пример `contracts/examples/request-image.json` имеет `reply_deadline` в прошлом (`2026-10-03T12:45:00Z`) | Task 3, O-e | Для ручного прогона подставлять будущий срок (иначе сразу `TIMEOUT`); в README контракта отметить, либо сделать пример с датой далеко в будущем | +| 13 | `DEBUG` в настройках шлюза не используется (оставлен шаблоном; `raisa_*` не импортируются) | Task 3 | Принято; убрать вместе с остатками шаблона или оставить для единообразия с arm-core | +| 14 | `ResourceWarning` aiohttp при загрузке > 1 МиБ (miniopy-async) скрыт фильтром в одном модуле тестов; при ручном прогоне со скриптом тоже виден `Unclosed client session` | T14.5 | Если сырые ответы ИС будут > 1 МиБ — пересмотреть фильтр/закрывать сессию клиента явно | +| 15 | Зависший `poll`/`put` по SMB (до 60 с) не прерывается дедлайном; нет `connection_timeout` у `smbclient` | T12.2, T8.4, design #46 | Задать `connection_timeout` при регистрации сессии и ограничить вызовы `wait_for` остатком до дедлайна; проверить на стенде | +| 16 | Порт шлюза на хосте задаётся `${SCAN_GATEWAY_PORT:-8002}` (фрагмент выше), но в `../docs/dev/.env.example` и в таблице переменных `../docs/dev/README.md` его нет | T16.1 | Владельцу `docs`: добавить `SCAN_GATEWAY_PORT=8002` в `../docs/dev/.env.example`, строку в таблицу переменных `../docs/dev/README.md` (рядом с `MINIO_CONSOLE_PORT`) и 8002 — в список свободных портов хоста | +| 17 | Replay с `attempt + 1` для `job_id`, который есть в LRU процесса, получает только ack и не обрабатывается (R-17, spec «обработан этим процессом»); по `../docs/context/03-architecture.md` replay — «сброс в `queued` с `attempt + 1`». На единственном инстансе такое задание повисает до `reply_deadline` -> `timeout` | I-3, R-17 | При одобрении: LRU `_done` с ключом `(job_id, attempt)` — более высокий `attempt` готового задания обрабатывать заново; `_active` — по `job_id` (повтор во время обработки — только ack). Решает человек | +| 18 | `make up` в tabular-parser (`../tabular-parser/Makefile`, цель `up`) тоже не запускает `minio-init`: на свежем томе бакета нет | I-1 | Чужой репозиторий — только сообщить владельцу; в scan-gateway исправлено (`run --rm minio-init` после `up -d --wait`) | +| 19 | Расхождения и наблюдения контроллера при исполнении (`Observation` в `progress.md`) | `progress.md` | См. ниже | +| 20 | Сквозная проверка 7.5 (Task 6, 04.10.2026) на профиле `services`: `aep_3.pdf` → `error NOT_IMPLEMENTED` + `metrics.warnings` про `_ofr.json`, `aep_6.pdf` → `error NOT_IMPLEMENTED` без `metrics`, `aep_5.PDF` → `error INVALID_DOCUMENT` + одна строка `warnings`; **`../docs/dev/docker-compose.yml` функциональных правок не требует** (только устаревший комментарий, п. 11): умолчания `SCAN_TMP_DIR=Tmp` и `SCAN_SETTLE_SECONDS=2` подходят (`Tmp` создаётся шлюзом в томе `hotfolder`, после прогона пуст). Устарел раздел «Проверка окружения» `../docs/dev/README.md` (сценарий 2 и абзац «не покрывает») | Task 6, R-37 (design #78) | Владельцу `docs`, в `../docs/dev/README.md`: (1) строки 220–221 заменить на `# [2026-10-04 11:23:58 UTC] 600B STANDARD 0ea16933-4cc1-4b50-bda9-a97089a918c5.json` и `dmc cat "local/arm-ka/raw/$JOB.json" # {"job_id": "", "files": [{"name": ".json", "response": {"file_uid": "", "error": {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}, "data_ocr": {…}}, "parse_error": null}]}`; (2) в строке 226 лог эмулятора — `_aep_5.PDF -> /data/hotfolder/Output/` (каталог набора, не файл); (3) строки 227–229 («успешный путь image … не проходит — нужен успешный ответ ИС») заменить на: «Успешные ответы ИС есть (`aep_3.json` + `aep_3_ofr.json`, `aep_6.json`); шлюз склеивает все `*.json` каталога ответа и отвечает `NOT_IMPLEMENTED` (нормализация — Срез 3), ошибочные файлы набора — в `metrics.warnings`»; (4) строку 230 заменить на «`processing` виден ~10 с: задержка эмулятора 2 с, затем до `SCAN_POLL_SECONDS + SCAN_SETTLE_SECONDS` (5 + 2 с) после первого опроса, увидевшего набор»; (5) в строке 255 «у ошибок `scan-gateway` `recognition_job.metrics` пустой» заменить на «у ошибок `scan-gateway` в `recognition_job.metrics` только `warnings` — по строке `<имя файла>: <код>: <сообщение>` на ошибочный файл ответа ИС, если такие есть»; (6) после сценария 2 добавить сценарий «2а. `image` → успешный ответ ИС → `NOT_IMPLEMENTED`»: `new_job image PDF ocr/aep_3.pdf && poll 60` → `# 11:22:07 queued` / `# 11:22:08 processing` / `# 11:22:18 error NOT_IMPLEMENTED`; `dpsql -c "select status, error_code, metrics from dkb.recognition_job where id = '$JOB'"` → `# error | NOT_IMPLEMENTED | {"warnings": ["_ofr.json: INVALID_DOCUMENT: Не удалось определить тип документа."]}`; `new_job image PDF ocr/aep_6.pdf && poll 60` → `error NOT_IMPLEMENTED`, `metrics` пусто; (7) в той же строке 255 «полная очистка — `docker compose --profile services down -v`» дополнить оговоркой, что это удаляет и общие тома `postgres-data`/`minio-data` всех worktree (R-27, design #77); (8) строки 185–187 (абзац «Рецепт прогнан дословно при уже существовавшей в БД строке `legal_entity` … отдельно не проверялась.») заменить на «Рецепт прогнан дословно на чистой БД (вставка `legal_entity` без конфликта по `inn`) и повторно при уже существующей строке (`on conflict (inn) do nothing`); оба пути проверены.» (или удалить абзац: ветка вставки на чистой БД проверена в Task 6); (9) строки 224–226: «Код `INVALID_DOCUMENT` здесь — **настоящий ответ ИС из фикстуры**: обе пары в `fixtures/ocr` (`aep_5.json`, `aep_8_form4.json`) — ответы-ошибки ИС Сканирование («Не удалось определить тип документа.», пустой `table_structure`). Эмулятор нашёл пару (в логе `scan-emulator`: `_aep_5.PDF -> …/Output//.json`; при отсутствии пары сообщение было бы «…: нет эталонного ответа в фикстурах»).» заменить на «Код `INVALID_DOCUMENT` здесь — **настоящий ответ ИС из фикстуры**: `aep_5.json` и `aep_8_form4.json` — ответы-ошибки ИС Сканирование («Не удалось определить тип документа.», пустой `table_structure`); у `aep_3` и `aep_6` есть успешные ответы (сценарий 2а). Эмулятор нашёл набор ответов (в логе `scan-emulator`: `_aep_5.PDF -> /data/hotfolder/Output/`; при отсутствии набора сообщение было бы «…: нет эталонного ответа в фикстурах»).» (строка 227 «Поэтому успешный путь `image` … не проходит — нужен успешный ответ ИС в `fixtures/ocr`.» заменяется по (3)); (10) строки 252–253: «успешные результаты (`done`/`needs_check`, регистрация файла `results/…` в `s3_object`) — заглушка парсера и фикстуры ИС дают только ошибки;» заменить на «успешные результаты (`done`/`needs_check`, регистрация файла `results/…` в `s3_object`) — заглушка парсера и нормализатор шлюза Среза 0 (`NOT_IMPLEMENTED`) дают только ошибки, хотя фикстуры ИС (`aep_3`, `aep_6`) успешные ответы содержат;»; (11) `../docs/dev/docker-compose.yml:184`: «# Фикстуры ответов ИС — ../../fixtures/ocr (только чтение): пара <имя>.json для входного файла UUID_<имя>.ext.» заменить на «# Фикстуры ответов ИС — ../../fixtures/ocr (только чтение): набор <имя>.json + <имя>_*.json для входного файла UUID_<имя>.ext.» (только комментарий, поведение compose не меняется) | + +Строки `Observation` из `progress.md` (только пересказ, статусы — в ledger): + +- **O-1** — **[закрыто 04.10.2026: образцы `aep_3.json`, `aep_6.json` (OQ-76), пункты 3 и 4 таблицы]**. Исходно: обе + реальные фикстуры (`aep_5.json`, `aep_8_form4.json`) сами являлись ответами-ошибками ИС (`INVALID_DOCUMENT`, `data_ocr` + — объект, не список), поэтому путь `NOT_IMPLEMENTED` проверялся синтетическим ответом (R-10, design №32, заменено D-9); + теперь он проверяется на реальных успешных ответах. +- **O-2** — исполнитель T11 вопреки инструкции поставил трейлер харнесса; история локальных коммитов переписана + (`filter-branch`), в remote ничего не уходило. Процессное, к docs не относится. +- **O-a..O-e** (по итогам Task 3) — `docs/dev/README.md` устарел (пункт 10); порт 8002 против 8000 (пункт 10 и + примечание к compose); `design.md` D-9 описывал compose-интеграцию эмулятора — исправлено до R-7 (`e228635`); + `check-full` без docker-сборки — закрыто задачей 15; `DEBUG` не используется (пункт 13); `reply_deadline` в примере + в прошлом (пункт 12). + +Дополнительно по итогам прогона DoD (реальные процессы шлюза и эмулятора, SIGTERM): + +- шлюз при SIGTERM корректно завершает lifespan (`Приложение закрывается`, `Соединение с NATS закрыто`), но код выхода + процесса — 143 (uvicorn повторно поднимает полученный сигнал); для оркестраторов это штатно, скрипты, ожидающие 0, + должны учитывать; +- кэш `hotfolder` в `/healthcheck` — 5 с: после возврата каталога поле остаётся `down` до 5 с. + +## Что осталось человеку + +- [ ] Просмотреть и влить PR ветки `feat/1-slice-0-skeleton` (merge — только по явной команде); вывод `make check-full` + — в описании PR (`.superpowers/sdd/2026-10-03-slice-0-skeleton/make-check-full.log`, локальный). +- [ ] После merge: `openspec archive slice-0-skeleton`, коммит и push архива в main (чеклист закрытия). +- [ ] Перенести compose-фрагмент выше в `../docs/dev/docker-compose.yml` (и обновить `README.md`, пункт 10) — + отдельным PR репозитория `docs`. +- [ ] Перенести правки раздела «Проверка окружения» `../docs/dev/README.md` по итогам сквозной проверки 7.5 (пункт 20) — + тем же PR репозитория `docs`; compose правки не требует. +- [ ] Согласовать с владельцем tabular-parser семантику `PUBLISH_RETRIES` (пункт 1) и решить судьбу правила про + `SCHEMA_MISMATCH` + ack (пункт 2). +- [x] Пункты 3-6 (OQ-76/77/78/79: образцы успешных ответов, формат `data_ocr`, состав файлов ответа, временный файл) + решены человеком 04.10.2026 и внесены в `docs/context/07-open-questions.md`; пункты 7, 8, 15 — в follow-up issue + (`Follow-ups from #1`). +- [ ] Не архивировать change `slice-0-skeleton`, пока compose-фрагмент не применён в `docs` и не проверена команда + `docker compose --profile services up -d --wait scan-gateway scan-emulator` (I-2). +- [ ] Решить I-3 (пункт 17: replay с `attempt + 1` для задания из LRU процесса). +- [ ] Определить приоритет follow-up по T12.5 (пункт 8): при каждом деплое задания в работе превращаются в + окончательный `timeout`. diff --git a/openspec/changes/slice-0-skeleton/proposal.md b/openspec/changes/slice-0-skeleton/proposal.md index 68b9e36..286df55 100644 --- a/openspec/changes/slice-0-skeleton/proposal.md +++ b/openspec/changes/slice-0-skeleton/proposal.md @@ -21,25 +21,38 @@ `Dockerfile` (`python:3.11-slim`; строка убрана из `.gitignore`), `Makefile` с общими целями + `venv`, `emulator`, `.env.example`, `.env.test`; `Settings` читает `ENV_FILE`; `SERVICE_NAME = "scan-gateway"`. - **Контракт**: вендоренные схемы `api/contracts/` + pydantic-модели `src/contract/`; тест идентичности с `../docs/contracts`. -- **Горячие папки** `src/gateway/`: протокол `HotFolder` (`put(name, data)`, `poll(uuid) -> bytes | None`) с двумя - реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона; прод). Имена подкаталогов - настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`); файл кладётся как `_`, - ответ ожидается в `//.json`. +- **Горячие папки** `src/gateway/`: протокол `HotFolder` (`put(name, data)`, `poll(job_id) -> list[AnswerFile] | None`, + `healthy()`) с двумя реализациями — `LocalHotFolder` (каталог; dev и тесты) и `SmbHotFolder` (`smbclient` шаблона; + прод). Имена подкаталогов настраиваются (`SCAN_INPUT_DIR = "Input"`, `SCAN_OUTPUT_DIR = "Output"`, `SCAN_TMP_DIR = + "Tmp"`); файл кладётся как `_`; временный файл атомарной записи (`.part`) лежит в служебном + каталоге `<корень>//` рядом с `Input`/`Output` (на той же шаре/ФС) и переименовывается в `Input`, так что + в `Input` недописанных файлов не бывает (OQ-79). ИС кладёт **все** распознанные документы задания одновременно в + `//` — по одному `*.json` на документ (OQ-78); `poll` возвращает все `*.json` каталога по возрастанию + имени, когда набор готов: есть хотя бы один `*.json`, а набор имён и размеров не менялся `SCAN_SETTLE_SECONDS` (2). - **Воркер** `src/core/worker.py` (NATS без JetStream): подписка на `recognition.image.request` в queue group `scan-gateway`; по получении — сразу ack `processing` в `recognition.result`; до `SCAN_MAX_INFLIGHT` (4) заданий параллельно: S3 `uploads/…` → горячая папка → опрос каждые `SCAN_POLL_SECONDS` (5) до - `min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → сырой ответ в S3 `raw/{job_id}.json` → нормализатор → + `min(reply_deadline, now + SCAN_TIMEOUT_SECONDS=300)` → склейка всех `*.json` ответа в один сырой ответ + `{"job_id", "files": [{"name", "response", "parse_error"}]}` в S3 `raw/{job_id}.json` → нормализатор → `results/{file_id}/{job_id}/result.json` → конверт результата (`raw_s3_key` заполнен). Таймаут → `error.code = TIMEOUT`. Process-local множество `job_id` в работе: повторный запрос получает ack и не создаёт второй файл в горячей папке. -- **Нормализатор как интерфейс** `src/normalizer/`: протокол `Normalizer(raw_json) -> documents | error`; в Срезе 0 — - `NullNormalizer`: ошибки Content AI (`error.code` из ответа: `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`, - `RECOGNITION_FAILED`, `UNKNOWN_ERROR`) пробрасываются как есть; успешный ответ → `NOT_IMPLEMENTED` (Срез 3 реализует §11). +- **Нормализатор как интерфейс** `src/normalizer/`: протокол `Normalizer(merged) -> documents | error`, вход — склеенный + ответ; в Срезе 0 — `NullNormalizer`. Успех файла ИС — `error.code == ""` (поле `error` не `null`: у реального успешного + ответа `error = {"code": "", "message": ""}`); ошибка — непустой `error.code` (`FILE_READ_ERROR`, `INVALID_FORMAT`, + `INVALID_DOCUMENT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`) либо `parse_error` (файл не разобран как JSON). Если ошибочны + **все** файлы — результат `error` с кодом первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`), + каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings`. Есть хотя бы один успешный файл → + `NOT_IMPLEMENTED` (Срез 3 реализует §11), ошибочные файлы — в `metrics.warnings` так же. - **Эмулятор ИС Сканирование** `src/scan_emulator/` (`python -m src.scan_emulator`): следит за `Input/`, через - `SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output//.json` содержимое `SCAN_FIXTURES_DIR/<имя без uuid и расширения>*.json` - (`aep_5.pdf` → `aep_5.json`, `aep_8.pdf` → `aep_8_form4.json`), для неизвестных имён — ответ Content AI с - `error.code = INVALID_DOCUMENT`. Запускается тем же образом в compose (`scan-emulator`). + `SCAN_EMULATOR_DELAY` (2 с) кладёт в `Output//` **набор** ответов: все `SCAN_FIXTURES_DIR/.json` и + `_*.json` (`` — имя без uuid и расширения; не префикс `*`) под именами `.json` и + `_.json`, с `file_uid = ` (`aep_3.pdf` → `.json` + `_ofr.json`, `aep_6.pdf` → один + файл, `aep_8.pdf` → `_form4.json`); набор появляется в `Output//` атомарно целиком (временный каталог + + переименование каталога). Для неизвестных имён — один ответ Content AI с `error.code = INVALID_DOCUMENT`. Запускается + тем же образом в compose (`scan-emulator`). - **Ошибки транспорта** → результат: запрос не по схеме → `SCHEMA_MISMATCH`; S3 не читается → `FILE_READ_ERROR`; - горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON → `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`. + горячая папка недоступна → `RECOGNITION_FAILED`; ответ ИС не JSON (после готовности набора) → `parse_error` файла, + при ошибочности всех файлов — `INVALID_FORMAT`; исключение → `UNKNOWN_ERROR`. - **Healthcheck** дополняется полями `nats` и `hotfolder` (`ok`/`down`). - **Проверки качества** вместо CI: `make check` и `make check-full` по `../docs/dev/checks.md`. diff --git a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md index c4d9401..fc85cd9 100644 --- a/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/image-recognition-gateway/spec.md @@ -33,52 +33,110 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO ### Requirement: Передача файла в ИС Сканирование через горячие папки После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как -`_`; затем SHALL опрашивать исходящий каталог `//.json` каждые -`SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. +`_`; временный файл записи SHALL лежать в служебном каталоге `<корень>//` +(по умолчанию `Tmp`, на той же шаре/ФС, что `Input`; создаётся при записи) и переименовываться в `Input` атомарно, так +что во входящем каталоге MUST NOT появляться недописанные и временные файлы. Затем сервис SHALL опрашивать исходящий +каталог `//` каждые `SCAN_POLL_SECONDS` до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`. +ИС кладёт в этот каталог все распознанные документы задания — по одному `*.json` на документ. Ответ SHALL считаться +готовым, когда в каталоге есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` +(по умолчанию 2); готовый набор SHALL включать **все** `*.json` каталога по возрастанию имени, остальные файлы +игнорируются. #### Scenario: Файл положен во входящий каталог - **WHEN** получен запрос для `aep_5.pdf` с `job_id = 3f1c…` -- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3 +- **THEN** во входящем каталоге появляется `3f1c…_aep_5.pdf`, байт в байт равный объекту S3; во время записи во входящем каталоге нет временных файлов, а `Tmp/` существует -#### Scenario: Ответ получен -- **WHEN** в `Output/3f1c…/3f1c….json` появляется ответ ИС до истечения таймаута -- **THEN** опрос прекращается не позднее чем через `SCAN_POLL_SECONDS`, ответ передаётся дальше по конвейеру +#### Scenario: Ответ из одного файла +- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру + +#### Scenario: Ответ из нескольких файлов +- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS` +- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется + +#### Scenario: Набор ещё пишется +- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад +- **THEN** ответ не считается готовым, опрос продолжается до дедлайна ### Requirement: Сырой ответ сохраняется в S3 до нормализации -Полученный ответ ИС SHALL быть записан как есть в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; -ключ SHALL передаваться в конверте результата полем `raw_s3_key`; в БД объект не регистрируется (ядро его не создаёт). +Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "", "files": [{"name": "<имя файла>", +"response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}` (файлы по возрастанию имени) и записаны +в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; файл, не разобранный как JSON, SHALL получить +`response = null` и заполненный `parse_error` и MUST NOT прерывать задание. Ключ SHALL передаваться в конверте результата +полем `raw_s3_key` (одна строка `raw/{job_id}.json`); в БД объект не регистрируется (ядро его не создаёт). #### Scenario: Нормализатор упал - **WHEN** ответ ИС получен, а нормализатор бросил исключение - **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR` +#### Scenario: Склеенный сырой ответ +- **WHEN** ИС положила в `Output//` файлы `.json` и `_ofr.json` +- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`.json`, затем `_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null` + +#### Scenario: Файл не JSON +- **WHEN** один из файлов ответа не разбирается как JSON +- **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает + ### Requirement: Результат по контракту Сервис SHALL публиковать в `recognition.result` конверт по `recognition-result-message.schema.json` с `service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл результата в `results/{file_id}/{job_id}/result.json` и `result_s3`; при неудаче публикации SHALL повторить её до трёх раз. -В Срезе 0 нормализатор SHALL возвращать: при `error ≠ null` в ответе ИС — `status = error` с кодом из ответа; иначе — -`error.code = NOT_IMPLEMENTED`. +В Срезе 0 нормализатор получает склеенный ответ и SHALL считать файл успешным, если `error.code == ""` (поле `error` +реального успешного ответа — `{"code": "", "message": ""}`, а не `null`; отсутствующий или `null` `error` ошибкой не +считается), и ошибочным, если `error.code` непуст, `parse_error` не `null` либо корень ответа файла не является объектом +JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться проверкой «`error` не `null`». Если ошибочны +**все** файлы — `status = error`, `error.code` и `error.message` берутся у первого ошибочного файла по имени (для +`parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не входит в перечень кодов контракта, +`error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. Если есть хотя бы один успешный +файл — `status = error`, `error.code = NOT_IMPLEMENTED`. В обоих случаях `metrics.warnings` SHALL содержать по одной +строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как получен (для +`parse_error` и не-объекта — `INVALID_FORMAT`); если ошибочных файлов нет, поле `warnings` SHALL быть опущено. #### Scenario: Успешный ответ ИС в Срезе 0 -- **WHEN** ответ ИС содержит `data_ocr` без `error` -- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.json` +- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`) +- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `raw_s3_key = raw/.json`, поле `metrics.warnings` отсутствует #### Scenario: Ошибка ИС -- **WHEN** ответ ИС содержит `error.code = INVALID_DOCUMENT` -- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен +- **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` +- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `.json: INVALID_DOCUMENT: <сообщение>` + +#### Scenario: Ошибка одного из документов +- **WHEN** ответ состоит из `.json` (успех) и `_ofr.json` (`INVALID_DOCUMENT`) — как у фикстур `aep_3.json` и `aep_3_ofr.json` +- **THEN** конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `metrics.warnings` содержит ровно одну строку `_ofr.json: INVALID_DOCUMENT: <сообщение>` + +#### Scenario: Все документы ошибочны +- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) +- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT` + +#### Scenario: Все файлы не JSON +- **WHEN** единственный файл ответа не разбирается как JSON +- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит одну строку `.json: INVALID_FORMAT: <сообщение разбора>` + +#### Scenario: Корень ответа не объект +- **WHEN** файл ответа — корректный JSON, но корень — массив +- **THEN** этот файл ошибочен с кодом `INVALID_FORMAT`; остальные файлы оцениваются по общим правилам + +#### Scenario: Код ошибки ИС вне перечня контракта +- **WHEN** единственный файл ответа содержит `error.code = SOME_NEW_CODE` +- **THEN** конверт `status = error`, `error.code = UNKNOWN_ERROR`, `error.message` содержит `SOME_NEW_CODE`, строка в `metrics.warnings` — `.json: SOME_NEW_CODE: <сообщение>` ### Requirement: Таймаут ожидания ответа -Если ответ не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; +Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`; повторная отправка файла в ИС MUST NOT выполняться. #### Scenario: ИС молчит - **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется - **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге +#### Scenario: Набор не успел настояться до дедлайна +- **WHEN** в `Output//` есть `*.json`, но набор имён или размеров менялся позже, чем `SCAN_SETTLE_SECONDS` до дедлайна +- **THEN** ответ не считается готовым, опубликован конверт `TIMEOUT`, недостающие файлы не ожидаются + ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не -удалась → `RECOGNITION_FAILED`; ответ ИС не разбирается как JSON → `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. +удалась → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error` этого файла, а если ошибочны все +файлы — `INVALID_FORMAT`; иное исключение → `UNKNOWN_ERROR`. Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться. #### Scenario: Горячая папка недоступна diff --git a/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md b/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md index 0c61776..3201c7e 100644 --- a/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/scan-emulator/spec.md @@ -10,32 +10,49 @@ ### Requirement: Эмулятор отвечает по протоколу горячих папок Процесс `python -m src.scan_emulator` SHALL опрашивать входящий каталог каждую секунду; для нового файла -`_.` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать `//.json`; -после обработки входящий файл SHALL быть переименован в `_..done`, чтобы не обрабатываться повторно. +`_.` через `SCAN_EMULATOR_DELAY` секунд (по умолчанию 2) SHALL создать каталог `//` с +набором ответов (по одному файлу на документ); каталог SHALL появиться в `` атомарно целиком: набор записывается +во временный каталог эмулятора и переименовывается. После обработки входящий файл SHALL быть переименован в `_..done`, чтобы не обрабатываться повторно. #### Scenario: Один файл - **WHEN** во входящем каталоге появляется `3f1c…_aep_5.pdf` - **THEN** через ≤ 5 с существует `Output/3f1c…/3f1c….json`, а входящий файл переименован в `…pdf.done` -### Requirement: Ответ берётся из фикстур по имени -Эмулятор SHALL искать в `SCAN_FIXTURES_DIR` файл `.json`, затем `*.json` (первый по алфавиту), где `` — -имя входящего файла без префикса `_` и расширения; содержимое SHALL быть записано в ответ с `file_uid = `. +#### Scenario: Набор появляется целиком +- **WHEN** эмулятор записывает набор из нескольких файлов +- **THEN** каталог `Output/3f1c…/` либо не существует, либо уже содержит весь набор; частичного набора в `Output` нет + +### Requirement: Ответы берутся из фикстур по имени +Эмулятор SHALL собрать в `SCAN_FIXTURES_DIR` **все** файлы `.json` и `_*.json`, где `` — имя входящего +файла без префикса `_` и расширения (регистр расширения входящего файла не важен). Файл `.json` SHALL быть +записан как `.json`, файл `_.json` — как `_.json`; в каждом `file_uid = `. +Фикстуры вида `<другие символы>.json` без разделителя `_` (например, `aep_30.json` для `aep_3`) MUST NOT попадать в набор. #### Scenario: Точное имя - **WHEN** входящий файл — `…_aep_5.pdf`, в фикстурах есть `aep_5.json` -- **THEN** ответ равен `aep_5.json` с заменённым `file_uid` +- **THEN** в `Output//` один файл `.json`, равный `aep_5.json` с заменённым `file_uid` -#### Scenario: Префикс +#### Scenario: Суффикс - **WHEN** входящий файл — `…_aep_8.pdf`, в фикстурах есть `aep_8_form4.json` -- **THEN** ответ равен `aep_8_form4.json` с заменённым `file_uid` +- **THEN** в `Output//` файл `_form4.json`, равный `aep_8_form4.json` с заменённым `file_uid` + +#### Scenario: Несколько файлов на одно задание +- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть `aep_3.json` и `aep_3_ofr.json` +- **THEN** в `Output//` два файла: `.json` (из `aep_3.json`) и `_ofr.json` (из `aep_3_ofr.json`), в обоих `file_uid = ` + +#### Scenario: Чужой префикс +- **WHEN** входящий файл — `…_aep_3.pdf`, в фикстурах есть также `aep_30.json` +- **THEN** `aep_30.json` в набор не попадает ### Requirement: Неизвестный файл — ошибка в формате Content AI -Если пара не найдена, эмулятор SHALL записать ответ `{"file_uid": "", "error": {"code": "INVALID_DOCUMENT", -"message": "<имя>: нет эталонного ответа в фикстурах"}, "data_ocr": []}`. +Если ни одного файла набора не найдено, эмулятор SHALL записать один ответ `.json` в формате реальных ответов-ошибок +ИС (`aep_5.json`, `aep_8_form4.json`): `file_uid = `, `error = {"code": "INVALID_DOCUMENT", "message": "<имя>: нет +эталонного ответа в фикстурах"}`, `data_ocr` — **объект** с пустыми полями (`label`, `organization`, `inn`, `units`, +`date` — `{"form_name": "", "confidence": 0}`) и `table_structure: []`. #### Scenario: Нет пары - **WHEN** входящий файл — `…_unknown.pdf` -- **THEN** ответ содержит `error.code = INVALID_DOCUMENT` и пустой `data_ocr`; `scan-gateway` публикует конверт с тем же кодом +- **THEN** ответ содержит `error.code = INVALID_DOCUMENT`, `data_ocr` — объект с пустыми полями и `table_structure: []`; `scan-gateway` публикует конверт с тем же кодом ### Requirement: Эмулятор запускается из того же образа В `docs/dev/docker-compose.yml` сервис `scan-emulator` SHALL использовать образ `scan-gateway` с командой diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 4f93a14..e64d60b 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -5,67 +5,106 @@ ## 1. Вырезать фронт и БД из шаблона (SMB остаётся) -- [ ] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, +- [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; убрать `Dockerfile` из `.gitignore`; проверка: `git status` — только удаления и `.gitignore` -- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию +- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, импорт `raisa_fastapi_protected_api`; оставить SMB-сессию (только при `SCAN_HOTFOLDER_MODE=smb`); `src/core/config.py`: убрать `DBSettings`; добавить `ENV_FILE`, `SERVICE_NAME="scan-gateway"`, `NATS_ENABLED=true`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`, `SCAN_HOTFOLDER_MODE="local"`, `SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR="Input"`, `SCAN_OUTPUT_DIR="Output"`, `SCAN_POLL_SECONDS=5`, `SCAN_TIMEOUT_SECONDS=300`, `SCAN_MAX_INFLIGHT=4`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: чистый venv (python3.11), `python run_server.py` с `.env.example`, `/healthcheck` 200 -- [ ] 1.3 `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск против compose и эмулятора), +- [x] 1.3 `README.md` — описание сервиса (назначение, протокол горячих папок, env, запуск против compose и эмулятора), `CLAUDE.md` — актуализировать «Соглашения шаблона» по факту; проверка: упомянутые файлы существуют ## 2. Инструменты -- [ ] 2.1 `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, +- [x] 2.1 `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11, без `raisa_*`), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `emulator`; проверка: `make venv && make lint` зелёные -- [ ] 2.2 `Dockerfile` (`python:3.11-slim`; `CMD python run_server.py`, эмулятор — через `command` в compose), +- [x] 2.2 `Dockerfile` (`python:3.11-slim`; `CMD python run_server.py`, эмулятор — через `command` в compose), `.env.example`, `.env.test`; проверка: `docker build` успешен без Nexus ## 3. Контракт -- [ ] 3.1 `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (конверт со +- [x] 3.1 `api/contracts/` — копии схем и `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (конверт со статусами `processing | done | needs_check | error`); `tests/unit/test_contract.py` — идентичность (skip без docs), примеры, round-trip; проверка: тесты зелёные ## 4. Горячие папки и эмулятор -- [ ] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` +- [x] 4.1 `src/gateway/hotfolder.py`: протокол `HotFolder`, `LocalHotFolder` (put/poll/healthy); `tests/unit/test_local_hotfolder.py` на `tmp_path`; проверка: put пишет `_`, poll возвращает `None` до появления ответа и байты после -- [ ] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с +- [x] 4.2 `src/gateway/smb.py`: `SmbHotFolder` поверх `smbclient` (UNC `\\host\path\Input`), те же методы; unit-тест с подменённым `smbclient` (вызовы `open_file`, `listdir`); проверка: тест зелёный, в режиме `local` модуль не импортируется -- [ ] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск +- [x] 4.3 `src/scan_emulator/__init__.py` + `__main__.py`: функция `process_once(root, fixtures, delay)` и цикл; поиск фикстуры по имени/префиксу, подстановка `file_uid`, ошибка `INVALID_DOCUMENT`, переименование в `.done`; `tests/unit/test_emulator.py` — сценарии spec «Точное имя», «Префикс», «Нет пары» на `tmp_path` с копиями `../fixtures/ocr/*.json` (маркер `fixtures`, skip без фикстур); проверка: зелёный ## 5. Воркер и нормализатор-заглушка -- [ ] 5.1 `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из +- [x] 5.1 `src/normalizer/__init__.py`: протокол `Normalizer`, `NormalizeOutcome`, `NullNormalizer` (ошибка ИС → код из ответа; успех → `NOT_IMPLEMENTED`; не JSON → `INVALID_FORMAT`); unit-тесты на `aep_5.json`, `aep_8_form4.json`, синтетическом ответе с `error`; проверка: зелёный -- [ ] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys, +- [x] 5.2 `src/core/clients.py` (`get_bytes`, `put_json`, `put_bytes`), `src/core/nats.py` (подключение с/без nkeys, автопереподключение, `subscribe_requests(queue="scan-gateway", cb)`, `publish` с `Nats-Msg-Id` и повторами); проверка: unit с фейками, интеграционный тест подключения к compose-NATS без JetStream -- [ ] 5.3 `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор +- [x] 5.3 `src/core/worker.py`: `on_request(msg)`: валидация → LRU `job_id` → ack `processing` → очередь; семафор `SCAN_MAX_INFLIGHT`; `handle(job)`: S3 get → `hotfolder.put` → опрос до дедлайна → `raw/{job_id}.json` → `Normalizer` → результат/конверт → publish; таблица ошибок D-7; `/healthcheck.nats`, `.hotfolder`; проверка: unit-тесты `handle` с фейками и `LocalHotFolder` + `process_once` эмулятора — сценарии spec (ack раньше файла, повторный запрос → один файл, успех → NOT_IMPLEMENTED с `raw_s3_key`, INVALID_DOCUMENT, TIMEOUT с укороченными таймаутами, RECOGNITION_FAILED при отсутствии каталога, SCHEMA_MISMATCH без ack, FILE_READ_ERROR) -- [ ] 5.4 Тест параллелизма: пять заданий при `SCAN_MAX_INFLIGHT = 4` — пять ack сразу, четыре файла во входящем каталоге, +- [x] 5.4 Тест параллелизма: пять заданий при `SCAN_MAX_INFLIGHT = 4` — пять ack сразу, четыре файла во входящем каталоге, пятый после освобождения слота (фейковые S3/NATS, `LocalHotFolder`); проверка: зелёный ## 6. Интеграция, проверки, завершение -- [ ] 6.1 `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в +- [x] 6.1 `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный -- [ ] 6.2 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs +- [x] 6.2 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` -- [ ] 6.3 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md`; `openspec validate +- [x] 6.3 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md`; `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR + +## 7. Доработка по реальным ответам ИС + +Основание: решения человека 04.10.2026 (OQ-76/77/78/79), план `docs/superpowers/plans/2026-10-04-multi-file-response.md`, +design №54–66. Контракт v1.0 не меняется; нормализация в `statementDocument` — Срез 3. + +Формулировки уже закрытых задач 4.3, 5.1, 5.3, 6.1 (поиск фикстуры по префиксу `*`, `aep_5.pdf` как единственный +сценарий, единственный файл `.json`, «не JSON → `INVALID_FORMAT`» без склейки) заменены задачами 7.x и +обновлёнными спеками; действует группа 7. + +- [x] 7.0 Привести change в соответствие с решениями человека: `proposal.md`, `design.md` (D-1, D-3–D-6, решения №54–62), + `specs/image-recognition-gateway/spec.md`, `specs/scan-emulator/spec.md`, `open-questions-proposal.md` + (OQ-76/77/78/79 решены), эта группа; проверка: `openspec validate slice-0-skeleton --strict` — valid; в спеках нет + «читает ровно `.json`», `.part` в `Input`, `data_ocr` как списка и успеха по `error != null` +- [x] 7.1 Горячие папки: `SCAN_TMP_DIR` (`Tmp`), `SCAN_SETTLE_SECONDS` (2); `HotFolder.poll(job_id)` возвращает `None` либо + список `AnswerFile(name, data)` — все `*.json` каталога ответа по имени после settle; `.part` создаётся в `<корень>/Tmp/` + (`LocalHotFolder`, `SmbHotFolder`), в `Input` — только итоговый файл; проверка: unit-тесты на `tmp_path` (нет `.part` в + `Input`; нет каталога → `None`; один json; два json по имени; дописываемый файл → `None` до стабилизации; посторонний файл + игнорируется; `SmbHotFolder` с подменённым `smbclient`), `make check` зелёный +- [x] 7.2 Склейка, сырой файл, нормализатор, воркер: `merge_answers(job_id, files)`; `raw/{job_id}.json` = склеенный ответ; + `NullNormalizer` по правилам D-5 (успех = `error.code == ""`, per-file ошибки в `metrics.warnings`, `NOT_IMPLEMENTED` + при хотя бы одном успешном файле); проверка: unit-тесты `merge_answers` (порядок, невалидный json → `parse_error`), + нормализатор на реальных фикстурах `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json` + (`INVALID_DOCUMENT`), `aep_3.json` + `aep_3_ofr.json` в склейке под именами `.json` / `_ofr.json` → + `NOT_IMPLEMENTED` с одной строкой warnings `_ofr.json: INVALID_DOCUMENT: …`, воркер с фейками; `make check` зелёный +- [x] 7.3 Эмулятор: набор файлов на задание — `.json` и `_*.json` в `Output//` как `.json` / + `_.json` с `file_uid = `, атомарная запись каталога; проверка: unit-тесты (маркер `fixtures`) — + `aep_3.pdf` → два файла, `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `_form4.json`, `aep_30*` не + попадает в набор `aep_3`, неизвестное имя → `INVALID_DOCUMENT`; сквозной «put → эмулятор → poll (с settle) → склейка» на + `aep_3`; `make check` зелёный +- [x] 7.4 Интеграционные тесты и документация: реальные NATS и MinIO — `aep_3.pdf` → `NOT_IMPLEMENTED` с warnings про + `_ofr.json` и двумя файлами в `raw/`, `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings, `aep_5.PDF` → `INVALID_DOCUMENT`; + `README.md`, `CLAUDE.md`, `.env.example`, `.env.test` актуализированы (`SCAN_TMP_DIR`, `SCAN_SETTLE_SECONDS`, склеенный raw); + проверка: `make check-full` (при поднятом `make up`) — exit 0, вывод в отчёт +- [x] 7.5 Проверка в реальном compose (без правок `../docs`): сервисы `scan-gateway` и `scan-emulator` собираются и стартуют, + сценарии `aep_3.pdf`, `aep_6.pdf`, `aep_5.PDF` проходят способом из `../docs/dev/README.md`, содержимое + `raw/.json` зафиксировано; окружение остановлено (`down -v`; по R-27, design #77 — `rm -sfv` своих сервисов и том + `hotfolder`, общие `postgres`/`nats`/`minio` не тронуты); необходимые правки compose (`SCAN_TMP_DIR`, + `SCAN_SETTLE_SECONDS`) — точным фрагментом в `open-questions-proposal.md`; проверка: отчёт со снимками выводов diff --git a/setup/.npmrc b/setup/.npmrc deleted file mode 100644 index fca0c11..0000000 --- a/setup/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -registry=http://nexus.dev.raisa.go.rshbank.ru/repository/npm/ -strict-ssl=false \ No newline at end of file diff --git a/setup/setup.sh b/setup/setup.sh index 01f21ac..4bc4335 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -10,14 +10,5 @@ pip install -r ./../api/requirements.txt echo "complete setup development api" -echo "setup web application" - -cp ./.npmrc ./../web/.npmrc -cd ./../web - -npm i - -echo "complete setup web application" echo "To start api, cd to api directory, and use venv, start run_server.py" -echo "To start api, cd to web directory, and use command npm run start!" echo "Happy Hacking! :)" \ No newline at end of file diff --git a/web/biome.json b/web/biome.json deleted file mode 100644 index fb06a60..0000000 --- a/web/biome.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": true, - "ignore": ["node_modules"] - }, - "formatter": { - "enabled": true, - "lineWidth": 140, - "formatWithErrors": true - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "ignore": ["*.json"], - "enabled": true, - "rules": { - "recommended": true, - "a11y": { - "useButtonType": "off", - "noSvgWithoutTitle": "off" - }, - "complexity": { - "noForEach": "off", - "noUselessTypeConstraint": "warn" - }, - "correctness": { - "noUnusedVariables": "error", - "noUnusedImports": "error", - "useExhaustiveDependencies": "info" - }, - "nursery": { - "noCommonJs": "error", - "noDuplicateElseIf": "error", - "noDuplicateProperties": "error", - "noNestedTernary": "error", - "useAtIndex": "error", - "useCollapsedIf": "error", - "useConsistentCurlyBraces": "error" - }, - "performance": { - "noDelete": "off", - "noAccumulatingSpread": "off" - }, - "style": { - "useDefaultSwitchClause": "error", - "useForOf": "error", - "useFragmentSyntax": "error", - "useConsistentArrayType": "error", - "useShorthandAssign": "error" - }, - "suspicious": { - "noConsole": "warn", - "noEmptyBlockStatements": "error", - "noEvolvingTypes": "error", - "noExplicitAny": "warn", - "noSkippedTests": "warn", - "useAwait": "error" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "jsxQuoteStyle": "single", - "bracketSameLine": true - }, - "linter": { - "enabled": true - } - }, - "json": { - "parser": { - "allowComments": true - } - }, - "css": { - "formatter": { - "enabled": true - }, - "parser": { - "cssModules": true - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index df8ff5d..0000000 --- a/web/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "frontend", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "rsbuild build", - "start": "rsbuild dev --open", - "preview": "rsbuild preview" - }, - "dependencies": { - "@ant-design/icons": "^6.0.0", - "@reduxjs/toolkit": "^2.8.2", - "antd": "^6.5.0", - "markdown-to-jsx": "^9.1.2", - "pdfjs-dist": "^5.7.284", - "raisa-fastapi-protected-web": "^1.0.0", - "raisa-ui": "^1.0.0", - "react": "^18.3.1", - "react-daisyui": "^5.0.5", - "react-dom": "^18.3.1", - "react-redux": "^9.2.0", - "react-router-dom": "^7.8.0" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@biomejs/cli-linux-x64-musl": "^1.9.4", - "@rsbuild/core": "1.6.7", - "@rsbuild/plugin-react": "^1.1.0", - "@types/react": "^18.3.1", - "@types/react-dom": "^18.3.1", - "daisyui": "^4.12.10", - "tailwindcss": "^3.4.13", - "typescript": "^5.7.3" - } -} diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs deleted file mode 100644 index e48f63b..0000000 --- a/web/postcss.config.cjs +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - } -} \ No newline at end of file diff --git a/web/public/.gitkeep b/web/public/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/public/index.html b/web/public/index.html deleted file mode 100644 index 75b67e5..0000000 --- a/web/public/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Пример React-приложения - - - -
- - \ No newline at end of file diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts deleted file mode 100644 index 08f6224..0000000 --- a/web/rsbuild.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { defineConfig, loadEnv } from '@rsbuild/core'; -import { pluginReact } from '@rsbuild/plugin-react'; - -const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']}); - -export default defineConfig({ - server: { - base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/' - }, - output: { - filename: { - js: '[name]_script.[contenthash:8].js', - css: '[name]_style.[contenthash:8].css', - }, - distPath: { - favicon: 'static/image', - }, - assetPrefix: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : './' - }, - html: { - template: './public/index.html', - favicon: './src/assets/favicon.ico', - }, - source: { - include: [{ not: /[\\/]core-js[\\/]/ }], - define: { - ...publicVars, - 'process.env': JSON.stringify(rawPublicVars), - } - }, - plugins: [pluginReact()], -}); diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index 9e13510..0000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import './App.css'; -import { Layout } from 'antd'; -import { Provider } from 'react-redux'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import { Config } from './conf/config'; -import { Main } from './layouts/Main'; -import { store } from './store/store'; -import { Sidebar } from './widgets/Sidebar/Sidebar'; - -// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами. -function App() { - return ( - - - - - - - } /> - - - - - - ); -} - -export default App; diff --git a/web/src/assets/favicon.ico b/web/src/assets/favicon.ico deleted file mode 100755 index 00067be..0000000 Binary files a/web/src/assets/favicon.ico and /dev/null differ diff --git a/web/src/components/.gitkeep b/web/src/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts deleted file mode 100644 index acd3f47..0000000 --- a/web/src/conf/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -// biome-ignore lint/suspicious/noExplicitAny: process.env подставляется rsbuild на этапе сборки -const environment = (process as any).env; - -export const Config = { - API_URL: (environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '') as string, - APP_ADDRESS: `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}` as string, -}; diff --git a/web/src/env.d.ts b/web/src/env.d.ts deleted file mode 100644 index b0ac762..0000000 --- a/web/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/src/index.css b/web/src/index.css deleted file mode 100644 index 9200763..0000000 --- a/web/src/index.css +++ /dev/null @@ -1,116 +0,0 @@ -/* :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} */ - -body { - font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif; - margin: 0px; -} diff --git a/web/src/index.tsx b/web/src/index.tsx deleted file mode 100644 index ae87a8b..0000000 --- a/web/src/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { createRoot } from 'react-dom/client'; -import './index.css'; -import { ConfigProvider } from 'antd'; -import App from './App'; - -const rootElement = document.getElementById('root'); -if (!rootElement) { - throw new Error('Элемент #root не найден'); -} - -createRoot(rootElement).render( - // - - - , - // , -); diff --git a/web/src/layouts/Main.tsx b/web/src/layouts/Main.tsx deleted file mode 100644 index 95571bb..0000000 --- a/web/src/layouts/Main.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md. -export function Main() { - return ( -
-

АРМ КА

-
- ); -} diff --git a/web/src/store/slices/.gitkeep b/web/src/store/slices/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/store/store.ts b/web/src/store/store.ts deleted file mode 100644 index e3364ed..0000000 --- a/web/src/store/store.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { useDispatch, useSelector } from 'react-redux'; - -// Каркас: слайсы добавляются срезами в ./slices. -export const store = configureStore({ - reducer: {}, -}); - -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; -export const useAppDispatch = useDispatch.withTypes(); -export const useAppSelector = useSelector.withTypes(); diff --git a/web/src/utils.ts b/web/src/utils.ts deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx deleted file mode 100644 index fd32ea5..0000000 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { HomeOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; -import { Divider, Layout, Tooltip } from 'antd'; -import { type ReactNode, memo, useState } from 'react'; -import { NavLink } from 'react-router-dom'; -import styles from './styles.module.css'; - -type NavItem = { - link: string; - label: string; - icon: ReactNode; -}; - -type Group = { - title: string; - key: string; - children: NavItem[]; -}; - -// Каркас: разделы меню — по карте экранов docs/context/10-user-journey.md, добавляются срезами. -const config: Group[] = [ - { - title: 'Главная', - key: 'home', - children: [{ link: '/', icon: , label: 'Главная' }], - }, -]; - -function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) { - return ( - - `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}> - {icon} - - - ); -} - -function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { - return ( - `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}> - {children} - - ); -} - -const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) { - return !isCollapsed ? ( -
-
{group.title}
- {group.children.map((i) => ( - - {i.icon} - {i.label} - - ))} - -
- ) : ( - <> - {group.children.map((i) => ( - - ))} - - ); -}); - -export function Sidebar() { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( - -
-
- -
- {config.map((g) => ( -
- ))} -
-
- ); -} diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css deleted file mode 100644 index 7f90c29..0000000 --- a/web/src/widgets/Sidebar/styles.module.css +++ /dev/null @@ -1,188 +0,0 @@ -.company_group_item { - border: 2px solid grey; - border-radius: 2px; - height: 50px; - width: 90%; - cursor: pointer; - background-color: lightgray; -} -.active_cg { - background-color: white; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.brand { - font-weight: 700; - white-space: nowrap; -} - -.collapsedList { - display: flex; - flex-direction: column; - gap: 8px; - align-items: center; - margin-top: 8px; -} - -.section { - margin-bottom: 0px; - padding: 12px; - border-radius: 10px; -} - -.sectionTitle { - font-size: 12px; - font-weight: 700; - color: #8c8c8c; - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 10px; -} - -.search { - margin-bottom: 10px; -} - -.subgroupToggle { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - border: 0; - background: transparent; - padding: 6px 10px; - margin-bottom: 6px; - cursor: pointer; - color: rgba(0, 0, 0, 0.88); - font-weight: 600; -} - -.subgroup { - /* padding-left: 8px; */ - display: flex; - flex-direction: column; - /* gap: 10px; */ - margin-bottom: 12px; -} - -.linkBase { - display: inline-flex; - gap: 10px; - padding: 8px 10px; - width: 100%; - border-radius: 8px; - color: rgba(55, 65, 81, 0.88); - text-decoration: none; - background: transparent; - transition: background 0.2s, color 0.2s; -} - -/* .linkActive:hover { - color: rgb(31, 114, 55); -} */ - -.linkBase:hover { - color: rgba(55, 65, 81, 0.88); - background: rgb(249, 250, 251); -} - -.linkBase.linkActive { - color: rgb(31, 114, 55); - background: #edf8ee; - font-weight: 600; -} - -.state { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 0; -} - -.emptyWrap { - padding-top: 8; -} - -.wrapper { - height: 100%; - display: flex; - flex-direction: column; - padding: 12px; - overflow: hidden; - transition: width 0.2s ease; -} - -.wrapperCollapsed { - padding: 12px 0; - align-items: center; -} - -.collapsedList { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - align-items: center; - margin-top: 8px; -} - -.iconBtn { - width: 40px; - height: 40px; - border: 0; - border-radius: 12px; - background: transparent; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: rgba(0, 0, 0, 0.75); - transition: background 0.2s, color 0.2s; - font-size: 22px; -} - -.iconBtnActive, -.iconBtnActive:hover { - background: rgba(46, 166, 75, 0.16); - color: rgb(31, 114, 55); -} - -.iconBtnNav:hover { - color: #101828; - background: #f2f4f7; -} - -.iconBtnAction { - background: linear-gradient(160deg, #2ea64b, #1f7237); - font-size: small; - box-shadow: 0 1px 3px #1f723740; - color: white; -} - -.iconBtnAction:hover { - background: linear-gradient(160deg, #268f40, #185c2b); -} - -/* .iconBtn. */ - -.collapseBtn { - width: 40px; - height: 40px; - border: 1px solid rgba(0, 0, 0, 0.25); - border-radius: 12px; - background: transparent; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: rgba(0, 0, 0, 0.75); - transition: background 0.2s, color 0.2s; - font-size: 12px; - padding: 0; -} diff --git a/web/tailwind.config.js b/web/tailwind.config.js deleted file mode 100644 index 9ef3fea..0000000 --- a/web/tailwind.config.js +++ /dev/null @@ -1,43 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/**/*.{html,js,jsx,ts,tsx}", - "node_modules/daisyui/dist/**/*.js", - "node_modules/react-daisyui/dist/**/*.js", - "node_modules/raisa-ui/dist/**/*.js" - ], - theme: { - extend: {}, - }, - plugins: [require('daisyui')], - daisyui: { - themes: [ - { - rshb: { - ...require('daisyui/src/theming/themes')['light'], - - "primary": "#171A1C", - "primary-content": "#fcfcfd", - "secondary": "#ECEDEF", - "secondary-content": "#687178", - "accent": "#8ecaff", - "accent-content": "#fcfcfd", - "neutral": "#ecedef", - "neutral-content": "#171A1C", - "base-100": "#FCFCFD", - "base-200": "#F6F7F8", - "base-300": "#ECEDEF", - "base-content": "#171A1C", - "info": "#0894FF", - "info-content": "#E3E5E8", - "success": "#39B33D", - "success-content": "#E3E5E8", - "warning": "#E6B200", - "warning-content": "#fcfcfd", - "error": "#FF371A", - "error-content": "#fcfcfd", - } - } - ] - } -} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 9b1f256..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "lib": ["DOM", "ES2020"], - "jsx": "react-jsx", - "target": "ES2020", - "noEmit": true, - "skipLibCheck": true, - "useDefineForClassFields": true, - - /* modules */ - "module": "ESNext", - "isolatedModules": true, - "resolveJsonModule": true, - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - - /* type checking */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true - }, - "include": ["src"] -}