From 716d80902d4b43f01f8f51807a6cf080eaf11b48 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 17:53:56 +0400 Subject: [PATCH 01/50] =?UTF-8?q?chore:=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?SDD=20=D0=B8=D0=B7=20tasks.md=20change'=D0=B0,=20=D1=81=D0=BB?= =?UTF-8?q?=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=B2=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/superpowers/plans/2026-10-03-slice-0-skeleton*.md сгенерирован 1:1 из openspec/changes/slice-0-skeleton/tasks.md (заголовки «Task N» для скриптов subagent-driven-development, Global Constraints из спеки и CLAUDE.md). .gitignore: .codegraph/, .superpowers/, .bounded, .DS_Store. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 4 +- .../plans/2026-10-03-slice-0-skeleton.md | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-10-03-slice-0-skeleton.md diff --git a/.gitignore b/.gitignore index c09eafc..34c68f6 100644 --- a/.gitignore +++ b/.gitignore @@ -327,4 +327,6 @@ docker-compose.yml .dockerignore .vscode my_project_env -.cursorignore \ No newline at end of file +.cursorignore.codegraph/ +.superpowers/ +.bounded 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..ce8d1a4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -0,0 +1,67 @@ +# Срез 0 — скелет tabular-parser — план исполнения (Срез 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/tabular-parser`). **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): видит только NATS и S3. Раскладка шаблона, команды из `api/`. +- Платформа: Python 3.11, NATS **без JetStream** (подписка queue group `tabular-parser`, ack `status = processing` до чтения S3, process-local LRU `job_id`, публикация с повторами), один бакет S3, Nexus недоступен, CI нет (`make check` / `make check-full`). +- Контракт: копии схем из `../docs/contracts/` в `api/contracts/` байт в байт; `service.name = "tabular-parser"`; результат `results/{file_id}/{job_id}/result.json`; `Nats-Msg-Id` по правилам контракта; заглушка парсера — `NOT_IMPLEMENTED`. +- Парсер — интерфейс без NATS/S3; никаких координат ячеек, букв колонок, имён листов в коде (Срез 2 — вне этого плана). +- Ошибки транспорта → конверт `status = error` (`SCHEMA_MISMATCH`, `FILE_READ_ERROR`, `UNKNOWN_ERROR`); процесс не падает. +- Линтеры: 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/`, `a + +- [ ] Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) + +### Task 2: [1.2] `src/app.py` + +- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 + +### Task 3: [1.3] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против c + +- [ ] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют + +### Task 4: [2.1] `api/pyproject.toml` (ruff + +- [ ] `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные + +### Task 5: [2.2] `Dockerfile` (`python + +- [ ] `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен + +### Task 6: [3.1] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts` + +- [ ] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (`RecognitionRequest`, `RecognitionResultMessage` со статусами `processing | done | needs_check | error`, `RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги как `Decimal` ↔ строка); `tests/unit/test_contract.py` — идентичность с docs (skip без docs), валидация примеров, round-trip модели → JSON → схема; проверка: тесты зелёные + +### Task 7: [4.1] `src/parser/__init__.py` + +- [ ] `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты + +### Task 8: [4.2] `src/core/clients.py` + +- [ ] `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком + +### Task 9: [4.3] `src/core/worker.py` + +- [ ] `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing` → очередь; рабочая корутина: S3 get → `Parser` → S3 put результата → конверт → publish; таблица ошибок D-5; `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`; `/healthcheck.nats`; проверка: unit-тесты с фейками — сценарии spec (ack раньше результата, повторный запрос, NOT_IMPLEMENTED, FILE_READ_ERROR, SCHEMA_MISMATCH без ack, подмена парсера → `done`, повтор публикации) + +### Task 10: [4.4] `tests/integration/test_roundtrip.py` (маркер `integration`) + +- [ ] `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать `request-tabular.json`, собрать сообщения `recognition.result`: первое — `processing`, второе — `error NOT_IMPLEMENTED` с правильными `Nats-Msg-Id`; повторная публикация запроса даёт только ещё один ack; проверка: зелёный против compose + +### Task 11: [5.1] `make check-full` + +- [ ] `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` + +### Task 12: [5.2] Прогон DoD + +- [ ] Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR + -- 2.47.2 From 723d4b5511af70ad06afb2ee3d36b14d5604394c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 17:54:50 +0400 Subject: [PATCH 02/50] =?UTF-8?q?chore:=20.gitignore=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20.cursorignore?= =?UTF-8?q?=20(=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B0=D1=8E=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 34c68f6..c2b4ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -327,6 +327,7 @@ docker-compose.yml .dockerignore .vscode my_project_env -.cursorignore.codegraph/ +.cursorignore +.codegraph/ .superpowers/ .bounded -- 2.47.2 From e991999dfbbccb6262636c8c52d74141b752f43d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:21:52 +0400 Subject: [PATCH 03/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1=D1=81=D1=83?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20R-1..R-9=20(pre-flight?= =?UTF-8?q?=20SDD)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 8e9ba1e..b372f6b 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -87,3 +87,12 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 5 | 2026-10-03 | D-5: транспортные ошибки превращаются в результат; публикация с 3 повторами | ядро не должно ждать таймаута, если сервис жив | нет | | 6 | 2026-10-03 | D-8: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега | | 7 | 2026-10-03 | Каталог словарей — `api/rules/` (относительно рабочего каталога `api/`) | все команды шаблона идут из `api/` | перенос каталога | +| 8 | 2026-10-04 | R-1: проверка старта в задаче 1.2 — через gitignored `api/.env`/окружение; `.env.example` и повторная проверка старта — в 2.2 | 1.2 идёт раньше 2.2 по плану | перенос проверки | +| 9 | 2026-10-04 | R-2: маркеры pytest и filterwarnings в `api/pytest.ini`; `pyproject.toml` — только ruff и mypy | `pytest.ini` приоритетнее `[tool.pytest]`; как в arm-core | правка конфига | +| 10 | 2026-10-04 | R-3: `.env.test` содержит dev-значения compose (localhost, minioadmin, arm-ka, пустые NATS seed/jwt) | как в arm-core; dev-пароли публичны в docs | нет | +| 11 | 2026-10-04 | R-4: шаблонный `core/nats_callback.py` удаляется в задаче 4.3 | заменён воркером; consumer `recognition.result` — зона ядра | восстановить из git | +| 12 | 2026-10-04 | R-5: критерий 1.1 «только удаления и `.gitignore`» читается как «удаления, `.gitignore`, `setup/setup.sh`» | npm-шаги setup.sh входят в текст задачи | нет | +| 13 | 2026-10-04 | R-6: из `requirements.txt` убираются и транзитивные сироты удалённых пакетов, если без них всё работает | сервис без БД/SMB не должен тянуть их | вернуть строку | +| 14 | 2026-10-04 | R-7: README (1.3) может описывать файлы из 2.1/2.2; окончательная сверка существования — в 5.2 и финальном ревью | порядок задач | правка README | +| 15 | 2026-10-04 | R-8: `make lint` = ruff check + ruff format --check + mypy src; остальные файлы шаблона форматируются в коммите 2.1 | как arm-core | отключить format-check | +| 16 | 2026-10-04 | R-9: трейлер коммитов `Co-Authored-By: Claude Fable 5.1 ` | указание вызывающего, Global Constraints плана | нет | -- 2.47.2 From b428f68e9e91b3aa95e7d16bc5c989bd37f45dd7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:22:43 +0400 Subject: [PATCH 04/50] =?UTF-8?q?chore:=20=D0=B2=D1=8B=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=D0=BD=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D0=B8=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=B8=D0=B7=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалены web/, setup/.npmrc, api/alembic/, api/alembic.ini, api/src/db/, api/src/models/, api/src/repositories/; из setup/setup.sh убраны npm-шаги, из .gitignore убрана строка Dockerfile. Задача 1.1 отмечена выполненной. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 1 - api/alembic.ini | 149 -------------- api/alembic/README | 1 - api/alembic/env.py | 102 ---------- api/alembic/script.py.mako | 28 --- api/alembic/versions/.gitkeep | 0 api/src/db/__init__.py | 1 - api/src/db/db.py | 16 -- api/src/models/__init__.py | 4 - api/src/models/base.py | 14 -- api/src/repositories/__init__.py | 0 .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- setup/.npmrc | 2 - setup/setup.sh | 12 +- web/biome.json | 91 --------- web/package.json | 36 ---- web/postcss.config.cjs | 5 - web/public/.gitkeep | 0 web/public/index.html | 13 -- web/rsbuild.config.ts | 32 --- web/src/App.css | 0 web/src/App.tsx | 28 --- web/src/assets/favicon.ico | Bin 40382 -> 0 bytes web/src/components/.gitkeep | 0 web/src/conf/config.ts | 7 - web/src/env.d.ts | 1 - web/src/index.css | 116 ----------- web/src/index.tsx | 41 ---- web/src/layouts/Main.tsx | 15 -- web/src/store/slices/.gitkeep | 0 web/src/store/store.ts | 12 -- web/src/utils.ts | 0 web/src/widgets/Sidebar/Sidebar.tsx | 90 --------- web/src/widgets/Sidebar/styles.module.css | 188 ------------------ web/tailwind.config.js | 43 ---- web/tsconfig.json | 23 --- 37 files changed, 3 insertions(+), 1072 deletions(-) delete mode 100644 api/alembic.ini delete mode 100644 api/alembic/README delete mode 100644 api/alembic/env.py delete mode 100644 api/alembic/script.py.mako delete mode 100644 api/alembic/versions/.gitkeep delete mode 100644 api/src/db/__init__.py delete mode 100644 api/src/db/db.py delete mode 100644 api/src/models/__init__.py delete mode 100644 api/src/models/base.py delete mode 100644 api/src/repositories/__init__.py delete mode 100644 setup/.npmrc delete mode 100644 web/biome.json delete mode 100644 web/package.json delete mode 100644 web/postcss.config.cjs delete mode 100644 web/public/.gitkeep delete mode 100644 web/public/index.html delete mode 100644 web/rsbuild.config.ts delete mode 100644 web/src/App.css delete mode 100644 web/src/App.tsx delete mode 100755 web/src/assets/favicon.ico delete mode 100644 web/src/components/.gitkeep delete mode 100644 web/src/conf/config.ts delete mode 100644 web/src/env.d.ts delete mode 100644 web/src/index.css delete mode 100644 web/src/index.tsx delete mode 100644 web/src/layouts/Main.tsx delete mode 100644 web/src/store/slices/.gitkeep delete mode 100644 web/src/store/store.ts delete mode 100644 web/src/utils.ts delete mode 100644 web/src/widgets/Sidebar/Sidebar.tsx delete mode 100644 web/src/widgets/Sidebar/styles.module.css delete mode 100644 web/tailwind.config.js delete mode 100644 web/tsconfig.json diff --git a/.gitignore b/.gitignore index c2b4ca0..8349ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -322,7 +322,6 @@ dist # Custom ignore .DS_Store -Dockerfile docker-compose.yml .dockerignore .vscode 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/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/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/repositories/__init__.py b/api/src/repositories/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index ce8d1a4..8cdbdca 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -19,7 +19,7 @@ ### Task 1: [1.1] Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `a -- [ ] Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) +- [x] Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) ### Task 2: [1.2] `src/app.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0c5527f..01f128d 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -5,7 +5,7 @@ ## 1. Вырезать фронт и БД из шаблона -- [ ] 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/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) - [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; 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..23923f9 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -9,15 +9,5 @@ source ./../api/.venv/bin/activate 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 +echo "Happy Hacking! :)" 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 00067be545e46b6f8f601649375d2248c13fe76a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40382 zcmeI2KWh|09EK-_+&4&Vq2On*%V)5)5G1W&V`Y(~vQDiK!9olF6+*x!0SkpdE*2`N z2%<)a#DrKF2#m8V;ojYOy~-m|CZ2x${gd_GuDH2&H7+b) zoQYJ$MtfsUraL#xNga`JQtQCA%ku~)aH8G?Cvc+i(9Yv0wIXl=Cv7&g3@30h?EaK> zR~;X40w-|7^RDXoCC?!Jnravyr)~HNeo`xdpPVw`{=*5Jz)9I>-~>+K1Wu~o`}sY- zez$`AlQIu(%~jRmC-@0|QsxIv-~>+Kgmu^8eOA_8Wgb{}=~Xk&^E+d$3Gc0VmRNVy z@c}1r@;@)(K7QaUZ~`ZA!h0*cx8m1R!`RR^)?KW-2Cf<-_a~kwIDr#5@oeBH8X0h+ zYTzfH8TC0cxLbuIDr$-27aQE0Vk>ke&U(IPv8VjJRA6lMh2Xy z8u*E420wulIPq-YCmI=WqH5qLo*DcEPT<6|fuCq(z=^7XpLk~Q6F7kr&jx;?kpU;F z27cn1!B5}>PCOgIqLBe7ss?`InZZxs1Wr5~_=!daoTwW3iDw2sffG3KY?S=u`F@P| z_eM*bhpN8MZ~p!oU;O$s@pN=@><1vVuRgqt>-{G)kv{IelaY}{0Td`H5M=A!mO{$* z(!pBO3pcbFdW}-rzias*+|ffiAKxBt*Rpn=@}2vi_VMlW9rt*$`EPd6S+DVrcS%Q- z#jiG|d>+ru9rE+J)z~3#Z#0Lj|E}lTb^m7f-Q#!M^IQ79vp&DGzP^k)A5%E~ALCnI AlK=n! 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"] -} -- 2.47.2 From 9cdc62b26d16ce7d35c6d881f7291073813ceabf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:25:29 +0400 Subject: [PATCH 05/50] =?UTF-8?q?chore:=20=D1=83=D0=BF=D1=80=D0=BE=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20app.py,=20config.py=20=D0=B8=20requirement?= =?UTF-8?q?s.txt=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны SPA, CORS, SMB, РАИСА, SQLAlchemy-обработчики и зависимости БД/SMB; настройки: ENV_FILE, SERVICE_NAME, NATS_ENABLED, WORKER_CONCURRENCY, PUBLISH_RETRIES, DONE_CACHE_SIZE, MINIO_SECURE. Co-Authored-By: Claude Fable 5.1 --- api/requirements.txt | 9 - api/src/app.py | 177 +++++------------- api/src/core/config.py | 52 +++-- api/src/core/exception_handlers.py | 15 -- .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 6 files changed, 86 insertions(+), 171 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index f26f6d9..baf800b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,13 +2,9 @@ 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 -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 +16,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 +23,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,14 +42,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 typing-inspection==0.4.2 diff --git a/api/src/app.py b/api/src/app.py index 845e7c0..d994ecd 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,21 +1,16 @@ -"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. +"""Точка входа FastAPI сервиса tabular-parser. -Что здесь есть: настройки, подключение 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). +Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые seed/jwt — без аутентификации), +middleware (gzip, proxy headers), healthcheck, обработчики исключений. Сервис без БД и фронта (DECISION-4). +Воркер обработки `recognition.request` и поле `nats` в healthcheck добавляются позже (src/core/nats.py). """ + import base64 -import os from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime import nkeys -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 @@ -26,120 +21,86 @@ from src.api.router import router from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers +from src.core.logger import setup_logger settings = get_settings() VERSION = settings.VERSION -UP_TIME = datetime.now(timezone.utc) +UP_TIME = datetime.now(UTC) + +setup_logger(level="DEBUG" if settings.DEBUG else "INFO") -# --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) +async def _connect_nats() -> NATS | None: + """Подключение к NATS (core, без JetStream); при ошибке — ``None``, приложение всё равно стартует.""" + nats_settings = settings.OPENBAO.NATS + options: dict = {"max_reconnect_attempts": 2} + if nats_settings.NATS_SEED and nats_settings.NATS_JWT: + kp = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8")) + def sig_cb(nonce: bytes | str) -> bytes: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(kp.sign(nonce)) -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: + options |= {"user_jwt_cb": lambda: nats_settings.NATS_JWT.encode("utf-8"), "signature_cb": sig_cb} + nc = NATS() try: - from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports] - AuthorizationMiddleware, - OpenEndpoint, - ProtectedOAuthSettings, - ProtectedRolesSettings, - ProtectedSettings, - SearchType, + await nc.connect(nats_settings.NATS_URL, **options) + 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), ) - - protected_middleware_available = True - except ImportError: - protected_middleware_available = False + return None + logger.info("Подключение к NATS произошло") + return nc -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") - +async def _connect_s3() -> S3Storage | None: + """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" + minio = settings.OPENBAO.MINIO s3_client = Minio( - settings.OPENBAO.MINIO.MINIO_URL, - access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, - secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, + minio.MINIO_URL, + access_key=minio.MINIO_ADMIN_LOGIN, + secret_key=minio.MINIO_ADMIN_PASSWORD, + secure=minio.MINIO_SECURE, 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) + bucket_exists = await s3_client.bucket_exists(minio.MINIO_BUCKET) 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) + await s3_client.close_session() + return None + logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + return S3Storage(client=s3_client, bucket_name=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), - ) - return - app.state.nats = nc - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.storage = await _connect_s3() + app.state.nats = await _connect_nats() if settings.NATS_ENABLED else None + if not settings.NATS_ENABLED: + logger.info("NATS выключен (NATS_ENABLED=false)") 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: + if app.state.nats: await app.state.nats.close() if app.state.storage: await app.state.storage.client.close_session() -app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) +app = FastAPI(title="tabular-parser", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) @app.get("/healthcheck", status_code=200) @@ -153,37 +114,3 @@ 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): - 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 - - -app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..9e4fd8c 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,50 +1,62 @@ +import os + +from pydantic import PositiveInt 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 (локальная разработка, тесты) + NATS_JWT: str = "" -class SMBSettings(BaseSettings): - SMB_HOST: str - SMB_PATH: str - SMB_LOGIN: str - SMB_PASSWORD: str class OpenBAOSettings(BaseSettings): - DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings - SMB: SMBSettings + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" PORT: int = 8000 + class Settings(BaseSettings): + """Настройки сервиса. + + Файл окружения выбирается переменной ``ENV_FILE`` (по умолчанию ``.env``; значение читается при импорте модуля, + относительный путь считается от текущего каталога — ``api/``). Тесты используют ``.env.test``. + """ + 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 = "tabular-parser" # `service.name` конвертов контракта и имя queue group NATS + NATS_ENABLED: bool = True # false — приложение не подключается к NATS (воркер не стартует) + WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно + PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации ack/результата в NATS + DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id + + model_config = SettingsConfigDict( + extra="ignore", + env_file=os.environ.get("ENV_FILE", ".env"), + env_nested_delimiter="__", + nested_model_default_partial_update=False, + ) - class Config: - extra = "ignore" - env_file = ".env" - env_nested_delimiter = "__" - nested_model_default_partial_update = False def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file + +settings = get_settings() 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/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 8cdbdca..8dfba80 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -23,7 +23,7 @@ ### Task 2: [1.2] `src/app.py` -- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 +- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 ### Task 3: [1.3] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против c diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 01f128d..b3ef57e 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -8,7 +8,7 @@ - [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) -- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; +- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; -- 2.47.2 From 1b87cd2fdff8f93ed0097034c90c6e1b2346b929 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:27:36 +0400 Subject: [PATCH 06/50] =?UTF-8?q?fix:=20=D0=B1=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D1=87=D0=BD=D0=BE=D0=B5=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20NATS=20=D0=B8=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D1=8B=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit max_reconnect_attempts=-1 (D-6), колбэки disconnected/reconnected/closed, options типизирован как dict[str, Any]. Co-Authored-By: Claude Fable 5.1 --- api/src/app.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/src/app.py b/api/src/app.py index d994ecd..9d0922e 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -8,6 +8,7 @@ middleware (gzip, proxy headers), healthcheck, обработчики исклю import base64 from contextlib import asynccontextmanager from datetime import UTC, datetime +from typing import Any import nkeys from fastapi import FastAPI @@ -34,7 +35,23 @@ setup_logger(level="DEBUG" if settings.DEBUG else "INFO") async def _connect_nats() -> NATS | None: """Подключение к NATS (core, без JetStream); при ошибке — ``None``, приложение всё равно стартует.""" nats_settings = settings.OPENBAO.NATS - options: dict = {"max_reconnect_attempts": 2} + + async def disconnected_cb() -> None: + logger.warning("NATS: соединение потеряно") + + async def reconnected_cb() -> None: + logger.info("NATS: соединение восстановлено") + + async def closed_cb() -> None: + logger.warning("NATS: соединение закрыто") + + # -1 — бесконечное автопереподключение (D-6): иначе клиент закрывается навсегда после короткого сбоя NATS + options: dict[str, Any] = { + "max_reconnect_attempts": -1, + "disconnected_cb": disconnected_cb, + "reconnected_cb": reconnected_cb, + "closed_cb": closed_cb, + } if nats_settings.NATS_SEED and nats_settings.NATS_JWT: kp = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8")) -- 2.47.2 From ffaa982fb1d4cfcb934829b4a67bef70cc9938d7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:30:00 +0400 Subject: [PATCH 07/50] =?UTF-8?q?docs:=20README=20=D1=81=20=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B0=20=D0=B8=20=D0=B0=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20CLAUDE.md=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 36 ++- README.md | 270 ++++++++---------- .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 4 files changed, 148 insertions(+), 162 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2b585c..ed6efb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,21 +20,29 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — -`../docs/context/03-architecture.md`, «Соглашения шаблона команды». Сейчас репозиторий — полная копия каркаса `arm-core`; -change `slice-0-skeleton` приводит его к сервису **без фронта и без БД**. Все команды — из `api/`. +`../docs/context/03-architecture.md`, «Соглашения шаблона команды». Репозиторий приведён к сервису **без фронта и без БД** +(change `slice-0-skeleton`, задачи 1.1–1.2 выполнены). Все команды — из `api/`. -**Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan, -`GET /healthcheck`, gzip/proxy middleware, обработчики ошибок), `core/config.py` (pydantic-settings, `OPENBAO__MINIO__*`, -`OPENBAO__NATS__*`, `DEBUG`), `core/clients.py` (S3), `core/nats_callback.py`, `core/logger.py`, `core/exception_handlers.py`, -`common/exceptions.py`, `pytest.ini`, `requirements*.txt`, `raisa_config.yml`, `setup/setup.sh` + `pip.conf` (без npm-шага). +**Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan: +подключение к MinIO и NATS, ошибка подключения логируется и не мешает старту; `GET /healthcheck`, gzip/proxy middleware), +`core/config.py` (pydantic-settings, `OPENBAO__MINIO__*` включая `MINIO_SECURE`, `OPENBAO__NATS__*`, `DEBUG`, `ENV_FILE`, +`NATS_ENABLED`, `WORKER_CONCURRENCY`, `PUBLISH_RETRIES`, `DONE_CACHE_SIZE`), `core/clients.py` (S3), `core/logger.py`, +`core/exception_handlers.py` (без SQLAlchemy), `common/exceptions.py`, `pytest.ini`, `requirements*.txt`, `raisa_config.yml`, +`setup/setup.sh` + `pip.conf` (без npm-шага). Шаблонный `core/nats_callback.py` пока на месте, удаляется задачей 4.3 — +его заменит воркер. **Вырезано (Срез 0):** `web/` целиком и `setup/.npmrc`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`), -CORS, регистрация SMB-сессии, импорт `raisa_fastapi_protected_api`; `db/`, `models/`, `repositories/`, `alembic/`, -`alembic.ini`; из настроек — `OPENBAO.DATABASE`, `OPENBAO.SMB`; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, -`smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. +CORS, регистрация SMB-сессии, импорт `raisa_fastapi_protected_api`, зависимость от SQLAlchemy в обработчиках ошибок; +`db/`, `models/`, `repositories/`, `alembic/`, `alembic.ini`; из настроек — `OPENBAO.DATABASE`, `OPENBAO.SMB`; из +`requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `greenlet`, `Mako`, +`raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. -**Добавлено:** `api/src/parser/` (ОСВ §1 и формы §12 — чистые функции над книгой Excel; Срез 2), `api/src/contract/` -(pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в `make check`), `api/rules/*.yaml` -(словари синонимов — в документах это `rules/*.yaml` относительно `api/`), `api/src/core/worker.py` (подписка queue group -`recognition.tabular.request` → S3 `uploads/` → парсер → S3 `results/` → `recognition.result`), `Dockerfile`, -`api/pyproject.toml` (ruff, mypy). Проверки: `pytest`, `ruff check . && ruff format --check .`, `mypy src`. +**Добавлено** (целевое состояние среза; каждый пункт появляется своей задачей `openspec/changes/slice-0-skeleton/tasks.md`, +до этого файла нет): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые +функции над книгой Excel, Срезы 2+), `api/src/contract/` (pydantic-модели `recognition-result` v1.0) и `api/contracts/` +(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/*.yaml` (словари +синонимов — в документах это `rules/*.yaml` относительно `api/`) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` +(подписка queue group `tabular-parser` на `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → +`recognition.result`) — 4.2–4.3, `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1, `Dockerfile`, `.env.example`, +`.env.test` — 2.2. Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), +`make check-full` перед PR. diff --git a/README.md b/README.md index 6b8cbb1..ff67937 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,141 @@ -[![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 -____ +# tabular-parser — разбор XLS/XLSX/XLSB для АРМ КА -| | Исполнитель | Заказчик | -|-----------|:----------------------------------------:|:----------------------------------------------:| -| Сотрудник | raykov-mse | raykov-mse | -| Отдел | aurora | aurora | -| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru | ------ -# Шаблон FastAPI +Stateless-сервис распознавания табличных файлов (ОСВ по счетам, ББ, ОФР из Excel) для автоматизированного рабочего +места кредитного аналитика ДКБ (АО «Россельхозбанк»). Сервис **не имеет доступа к БД и фронта** и видит только две +внешние системы: NATS (core, без JetStream) и S3 (MinIO, один бакет). Он получает задание, читает исходный файл, +возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается +`arm-core`. -## Описание +Обмен с `arm-core` идёт по контракту **v1.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в +`api/contracts/` (добавляется задачей 3.1 среза и проверяется на идентичность в `make check`). -Данное шаблон предоставляет проект, в котором приложение состоит из API и UI. При использовании данного типа проекта, Вы можете разрабатывать, как отдельные API сервисы, так и WEB приложения с интерфейсом. Для построения WEB интерфейса используется React. Далее описаны основные иснтрументы предустановленные данным шаблоном: +**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта), +а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с +`error.code = NOT_IMPLEMENTED`. Настоящий парсер (ОСВ — Срез 2, формы ББ/ОФР — позже) подключается через интерфейс +`Parser` без изменений воркера. -### Подготовка к разработке +Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних +репозиториях: -Для удобной настройки проекта под разработку. Перейдите в каталог setup и выполните слудеющие команды: +- [`../CLAUDE.md`](../CLAUDE.md) — инварианты и процесс работы всего workspace; +- [`../docs/`](../docs/) — контекст (`context/`), контракты (`contracts/`), окружение разработки (`dev/`); +- [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория. + +Репозитории должны лежать рядом: `tabular-parser/` и `docs/` — соседние каталоги (так же устроено зеркало `.wt/<срез>/`). +Пути `../docs/...` ниже и в `Makefile` рассчитаны на это. + +## Как обрабатывается запрос + +1. `arm-core` публикует запрос в NATS, subject `recognition.tabular.request` (схема `recognition-request`), исходный + файл лежит в S3 под ключом `uploads/{file_id}/{job_id}/<имя>`. +2. Сервис подписан на этот subject в queue group `tabular-parser` — несколько экземпляров делят нагрузку между собой. +3. Запрос валидируется по схеме. Если `job_id` ещё не обработан этим процессом (process-local LRU), сервис **до чтения + файла** публикует в `recognition.result` конверт `status = processing` (ack) с заголовком + `Nats-Msg-Id = ack::`. Повторный запрос с тем же `job_id` получает повторный ack и второй раз не + обрабатывается. +4. Файл читается из S3 (`s3.bucket` / `s3.key`) и передаётся парсеру («байты + тип файла → документы, метрики или ошибка»). +5. Результат (схема `recognition-result`) записывается в S3 под ключом `results/{file_id}/{job_id}/result.json`. +6. В `recognition.result` публикуется конверт результата (схема `recognition-result-message`; статусы + `processing | done | needs_check | error`) с заголовком `Nats-Msg-Id = result::`. Публикация + повторяется до `PUBLISH_RETRIES` раз. + +Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос — +`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение парсера — +`UNKNOWN_ERROR`, заглушка парсера в Срезе 0 — `NOT_IMPLEMENTED`. + +## Состав репозитория + +``` +api/ бэкенд (все команды разработки — отсюда или через make из корня) + run_server.py точка входа платформы (uvicorn; SERVER__HOST, SERVER__PORT) + src/app.py FastAPI: lifespan (подключение к MinIO и NATS), GET /healthcheck, middleware, обработчики ошибок + src/core/ config.py (настройки), clients.py (S3), logger.py, exception_handlers.py + src/parser/ интерфейс Parser и реализации (добавляется задачей 4.1) + src/contract/ pydantic-модели контракта v1.0 (добавляется задачей 3.1) + contracts/ копии JSON-схем контракта (добавляется задачей 3.1) + rules/ словари синонимов *.yaml для парсера (добавляется задачей 4.1) + tests/ pytest: unit и integration (маркер `integration`) +setup/ pip.conf и setup.sh (создание venv без uv) +raisa_config.yml конфигурация платформы РАИСА +Makefile команды разработки (добавляется задачей 2.1) +Dockerfile образ сервиса, python:3.11-slim (добавляется задачей 2.2) +openspec/ требования и дельта текущего среза (openspec/changes/slice-0-skeleton/) +``` + +## Предварительные требования + +- **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит + зависимости через `uv`; `uv` сам подберёт интерпретатор 3.11, системный `python3` другой версии не подойдёт. +- **Docker** и compose v2 — для NATS и MinIO (compose лежит в `../docs/dev/`; он же поднимает PostgreSQL для + `arm-core`, этому сервису БД не нужна). +- `make`. + +Пакеты `raisa_*` из Nexus банка вне банка недоступны: `make venv` пропускает строки `raisa_*` в `requirements.txt`. + +## Команды `make` + +Выполняются из корня репозитория; инструменты берутся из `api/.venv`. + +| Команда | Что делает | +|---|---| +| `make venv` | создаёт `api/.venv` (Python 3.11) и ставит `requirements.txt` и `requirements-dev.txt` через `uv` | +| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` | +| `make test` | `pytest -m "not integration"` — без Docker | +| `make test-integration` | `pytest -m integration` — против NATS и MinIO из compose | +| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) | +| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | +| `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | + +Цели `Makefile` появляются в задачах 2.1 (`venv`, `lint`, `test`, `test-integration`, `check`, `up`) и 5.1 (`check-full`). + +## Запуск локально против compose ```bash -cd setup -chmod +x setup.sh -./setup.sh +make venv +cp ../docs/dev/.env.example ../docs/dev/.env # один раз: учётные данные и порты compose (в git не попадает) +make up # NATS (4222), MinIO (9000, консоль 9001), бакет arm-ka +cp api/.env.example api/.env # один раз: настройки сервиса (в git не попадает) +cd api && .venv/bin/python run_server.py ``` - По итогу успешного выполнения Вы получите сообщение следующего + +Значения из `api/.env.example` уже совпадают с `../docs/dev/.env` (стандартные порты и учётные данные compose); править +`api/.env` нужно, только если вы меняли порты или пароли compose. Файл `api/.env.example` создаётся задачей 2.2. +Подробности про compose, смену портов и сброс — [`../docs/dev/README.md`](../docs/dev/README.md). + +Сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Проверка: ```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! :) +curl -s localhost:8000/healthcheck ``` -Далее просто запускайте проекты и приступайте к разработке +В ответе — `uptime`, `version` и (после добавления воркера, задача 4.3) `nats`: `ok` — подключён, `down` — включён +(`NATS_ENABLED=true`), но связи нет, `off` — выключен. -### Backend +Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS +восстанавливается автоматически (бесконечное автопереподключение). -Исходный код API находится в каталоге api. После клонирования репозитория требуется создать виртуальное окружение и установить зависимости, это достигается следующими командами: +## Переменные окружения -```bash -cd api -python -m venv ./.venv -cp ./../setup/pip.conf ./.venv/pip.conf -source ./.venv/bin/python -pip install -r requirements.txt -``` +Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание. +Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env` в каталоге `api/`, тесты используют `.env.test`). -Запуск приложения осуществляется через файл: run_server.py - -В качестве фреймворка ля api используется FastAPI. (Как понятно из названия шаблона) Подробнее ознакомится с возможностями Fast API вы можете в официальной документации: [Fast API документация](https://fastapi.tiangolo.com/) - -Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла requirements.txt - -Базовый шаблон предоставляет минимальный функционал по работе с данными. Далее описаны основные моменты влияющие на работу приложения. - -#### Основная структура проекта - -```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 +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO без схемы, `host:port` | +| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин S3 | +| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль S3 | +| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | бакет (`arm-ka`) | +| `OPENBAO__MINIO__MINIO_SECURE` | `true` | `true` — https (платформа), `false` — http (MinIO из compose) | +| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS, `nats://host:4222` | +| `OPENBAO__NATS__NATS_SEED` | пусто | nkeys seed; пусто вместе с JWT — подключение без аутентификации (dev) | +| `OPENBAO__NATS__NATS_JWT` | пусто | JWT пользователя NATS | +| `DEBUG` | `false` | уровень логов DEBUG | +| `ENV_FILE` | `.env` | путь к файлу окружения (от `api/`) | +| `NATS_ENABLED` | `true` | `false` — не подключаться к NATS, воркер не стартует | +| `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно | +| `PUBLISH_RETRIES` | `3` | попытки публикации ack и результата в NATS | +| `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация внутри процесса) | +| `SERVER__HOST` | `0.0.0.0` | адрес привязки HTTP-сервера | +| `SERVER__PORT` | `8000` | порт HTTP-сервера | +Имя сервиса в конвертах контракта (`service.name`) и имя queue group — `tabular-parser` (`SERVICE_NAME`, менять не нужно). diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 8dfba80..72d68ea 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -27,7 +27,7 @@ ### Task 3: [1.3] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против c -- [ ] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют +- [x] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют ### Task 4: [2.1] `api/pyproject.toml` (ruff diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index b3ef57e..5cda2a4 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -13,7 +13,7 @@ `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 -- [ ] 1.3 `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать +- [x] 1.3 `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют ## 2. Инструменты -- 2.47.2 From 85839a0fbfabd8e9ba59912d9f4e2ec42bfe62ec Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:30:19 +0400 Subject: [PATCH 08/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20R-10=20(=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20.env.example/.env.test)?= =?UTF-8?q?=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index b372f6b..da2fceb 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -96,3 +96,4 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 14 | 2026-10-04 | R-7: README (1.3) может описывать файлы из 2.1/2.2; окончательная сверка существования — в 5.2 и финальном ревью | порядок задач | правка README | | 15 | 2026-10-04 | R-8: `make lint` = ruff check + ruff format --check + mypy src; остальные файлы шаблона форматируются в коммите 2.1 | как arm-core | отключить format-check | | 16 | 2026-10-04 | R-9: трейлер коммитов `Co-Authored-By: Claude Fable 5.1 ` | указание вызывающего, Global Constraints плана | нет | +| 17 | 2026-10-04 | R-10: `.env.example` и `.env.test` лежат в `api/` (рядом с `pytest.ini`, как в arm-core) | команды идут из `api/`, `pytest.ini` задаёт `ENV_FILE=.env.test` относительно `api/` | правка двух строк README | -- 2.47.2 From a24fee09c60711cecef5ad4603e2595239f9711b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:32:49 +0400 Subject: [PATCH 09/50] =?UTF-8?q?style:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20ruff?= =?UTF-8?q?=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D1=88=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=D0=B8=20=D1=82=D0=B8=D0=BF=20=D1=83=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D1=8F=20=D0=B2=20InterceptHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нужно для зелёного make lint (R-8). Co-Authored-By: Claude Fable 5.1 --- api/run_server.py | 2 +- api/src/__init__.py | 6 +++--- api/src/common/exceptions.py | 3 ++- api/src/core/clients.py | 7 ++++--- api/src/core/logger.py | 18 +++++++++++++++--- api/src/core/nats_callback.py | 1 + 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/api/run_server.py b/api/run_server.py index f2449e1..37a9775 100644 --- a/api/run_server.py +++ b/api/run_server.py @@ -1,4 +1,5 @@ import uvicorn + from src import app, get_settings if __name__ == "__main__": @@ -9,4 +10,3 @@ if __name__ == "__main__": port=settings.server.PORT, lifespan="on", ) - diff --git a/api/src/__init__.py b/api/src/__init__.py index 61c3faa..97b1796 100644 --- a/api/src/__init__.py +++ b/api/src/__init__.py @@ -2,6 +2,6 @@ from .app import app from .core.config import get_settings __all__ = [ - 'app', - 'get_settings', -] \ No newline at end of file + "app", + "get_settings", +] 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/core/clients.py b/api/src/core/clients.py index 8f07d24..ceb87e5 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -1,6 +1,7 @@ """Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0 по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). """ + from io import BytesIO from miniopy_async import Minio @@ -10,11 +11,11 @@ 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)) + await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data)) except: raise @@ -23,5 +24,5 @@ class S3Storage: response = await self.client.get_object(self.bucket, obj_name) # file_bytes = await response.content.read() return response - except: + except: raise 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_callback.py b/api/src/core/nats_callback.py index 2f2e05b..e3420b5 100644 --- a/api/src/core/nats_callback.py +++ b/api/src/core/nats_callback.py @@ -4,6 +4,7 @@ маппинг 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 -- 2.47.2 From 8e156969136e8dd69eda52abefc509aeae2f2859 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:32:49 +0400 Subject: [PATCH 10/50] =?UTF-8?q?chore:=20pyproject.toml=20(ruff,=20mypy),?= =?UTF-8?q?=20requirements-dev.txt,=20Makefile,=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=20pytest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 2.1. make test допускает код 5 pytest (тестов пока нет). README: все цели Makefile создаются в 2.1 (T3.1). Co-Authored-By: Claude Fable 5.1 --- Makefile | 37 ++++++++++++ README.md | 2 +- api/pyproject.toml | 30 ++++++++++ api/pytest.ini | 3 + api/requirements-dev.txt | 60 ++++--------------- .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 7 files changed, 83 insertions(+), 53 deletions(-) create mode 100644 Makefile create mode 100644 api/pyproject.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1c2b2b --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# Команды разработки tabular-parser. Backend — api/.venv; запуск из корня репозитория. +VENV := api/.venv +BIN := $(CURDIR)/$(VENV)/bin +COMPOSE_FILE ?= ../docs/dev/docker-compose.yml + +.PHONY: venv lint test test-integration check docker-build check-full up + +# 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 + +# Код 5 («тесты не собраны») допускаем только до появления первых тестов; после — убрать обёртку +test: + cd api && $(BIN)/pytest -m "not integration"; rc=$$?; if [ $$rc -eq 5 ]; then echo "тестов пока нет (pytest exit 5) — допустимо"; exit 0; fi; exit $$rc + +test-integration: + cd api && $(BIN)/pytest -m integration + +check: lint test + +# Сборка образа из корня репозитория +docker-build: + docker build -t tabular-parser . + +# Полная проверка перед PR: быстрые проверки, затем NATS/MinIO из compose (make up), затем образ +check-full: check test-integration docker-build + +# Только инфраструктура, нужная сервису (без arm-core): NATS и MinIO; postgres — для общей среды compose +up: + docker compose -f $(COMPOSE_FILE) up -d --wait postgres nats minio diff --git a/README.md b/README.md index ff67937..38b931f 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ openspec/ требования и дельта текущего ср | `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | | `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | -Цели `Makefile` появляются в задачах 2.1 (`venv`, `lint`, `test`, `test-integration`, `check`, `up`) и 5.1 (`check-full`). +Все цели `Makefile` (включая `check-full`) создаются задачей 2.1; `docker build` в `check-full` заработает после задачи 2.2 (`Dockerfile`). ## Запуск локально против compose diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..048aae1 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,30 @@ +# Настройки инструментов качества (ruff, mypy). Зависимости — в requirements*.txt, pytest (маркеры, env) — в pytest.ini. + +[tool.ruff] +line-length = 120 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "PL"] + +[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 + +# Сторонние пакеты без типов (или недоступные вне банка) +[[tool.mypy.overrides]] +module = ["nkeys.*", "nats.*", "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..cf530e1 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -19,3 +19,6 @@ env = APP_ENV=test ENV_FILE=.env.test norecursedirs = .git __pycache__ .venv env build dist +markers = + integration: тесты, которым нужны NATS и MinIO (docker compose, make up); запускаются отдельно от юнит-тестов + fixtures: тесты на реальных данных из ../fixtures (внутренний доступ) diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index e4d6841..043c04c 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -1,50 +1,10 @@ -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 (не меняем). +# Нижние границы мажорных версий: воспроизводимо, но без конфликта с пинами платформы. +-r requirements.txt +ruff>=0.8 +mypy>=1.13 +pytest>=8.3 +pytest-asyncio>=0.24 +pytest-env>=1.1 +httpx>=0.27 +jsonschema>=4.23 diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 72d68ea..ee785d7 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -31,7 +31,7 @@ ### Task 4: [2.1] `api/pyproject.toml` (ruff -- [ ] `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные +- [x] `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные ### Task 5: [2.2] `Dockerfile` (`python diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 5cda2a4..1ec1b1a 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -18,7 +18,7 @@ ## 2. Инструменты -- [ ] 2.1 `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` +- [x] 2.1 `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные - [ ] 2.2 `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен -- 2.47.2 From ebf87f39dadfbb1fdc2b40536141648c4686b02e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:35:48 +0400 Subject: [PATCH 11/50] =?UTF-8?q?chore:=20Dockerfile=20(python:3.11-slim),?= =?UTF-8?q?=20api/.env.example=20=D0=B8=20api/.env.test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- Dockerfile | 19 ++++++++++ api/.env.example | 37 +++++++++++++++++++ api/.env.test | 26 +++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 api/.env.example create mode 100644 api/.env.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc4e97a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Образ для dev и `make check-full`. Контекст сборки — корень репозитория tabular-parser. +# Платформенные пакеты 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 + +CMD ["python", "run_server.py"] diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..ef59d5c --- /dev/null +++ b/api/.env.example @@ -0,0 +1,37 @@ +# Пример локального окружения. Скопируйте в api/.env: cp .env.example .env +# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (`make up`: NATS 4222, MinIO 9000). +# Сервис без БД: видит только NATS и S3. + +DEBUG=true + +# Dev-запуск слушает только loopback. По умолчанию приложения (без этих строк) — 0.0.0.0:8000, как в контейнере платформы. +SERVER__HOST=127.0.0.1 +SERVER__PORT=8000 + +# MinIO (адрес без схемы — так ждёт miniopy-async). Один бакет для входных файлов и результатов. +OPENBAO__MINIO__MINIO_URL=localhost:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka +# false — MinIO по http (docker compose); по умолчанию true (https, как на платформе) +OPENBAO__MINIO__MINIO_SECURE=false + +# NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys +OPENBAO__NATS__NATS_URL=nats://localhost:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +# Необязательные настройки (значения ниже — значения по умолчанию из src/core/config.py) +# Префикс путей приложения за прокси и версия в OpenAPI +ROOT_PATH=/ +VERSION=1.0.0 +# `service.name` конвертов контракта и имя queue group NATS; менять не нужно, пока контракт v1.x +SERVICE_NAME=tabular-parser +# false — не подключаться к NATS (воркер не стартует; /healthcheck отдаёт nats = down) +NATS_ENABLED=true +# Число заданий, обрабатываемых одновременно +WORKER_CONCURRENCY=1 +# Попытки публикации ack и результата в NATS +PUBLISH_RETRIES=3 +# Размер process-local LRU обработанных job_id (защита от дублей доставки) +DONE_CACHE_SIZE=1000 diff --git a/api/.env.test b/api/.env.test new file mode 100644 index 0000000..099d508 --- /dev/null +++ b/api/.env.test @@ -0,0 +1,26 @@ +# Окружение тестов (pytest.ini: ENV_FILE=.env.test). Только тестовые значения, секретов здесь нет. +# Порты и учётные данные — стандартные для docs/dev/docker-compose.yml (NATS 4222, MinIO 9000): +# после `make up` интеграционные тесты работают без правок. + +DEBUG=true + +# Как в .env.example (тесты сервер не запускают) +SERVER__HOST=127.0.0.1 +SERVER__PORT=8000 + +OPENBAO__MINIO__MINIO_URL=localhost:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka +# false — MinIO по http (docker compose); по умолчанию true (https, как на платформе) +OPENBAO__MINIO__MINIO_SECURE=false + +OPENBAO__NATS__NATS_URL=nats://localhost:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +# Unit-тесты поднимают приложение без NATS; интеграционные включают его сами (см. design D-6) +NATS_ENABLED=false +WORKER_CONCURRENCY=1 +PUBLISH_RETRIES=3 +DONE_CACHE_SIZE=1000 diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index ee785d7..ef0bfb1 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -35,7 +35,7 @@ ### Task 5: [2.2] `Dockerfile` (`python -- [ ] `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен +- [x] `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен ### Task 6: [3.1] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 1ec1b1a..0880897 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -21,7 +21,7 @@ - [x] 2.1 `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv` (python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные -- [ ] 2.2 `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен +- [x] 2.2 `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен ## 3. Контракт -- 2.47.2 From e676e75c38a53a6f8e5dae3620d6bc5a94428f38 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:36:16 +0400 Subject: [PATCH 12/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-11,=20R-12=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index da2fceb..c469bef 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -97,3 +97,5 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 15 | 2026-10-04 | R-8: `make lint` = ruff check + ruff format --check + mypy src; остальные файлы шаблона форматируются в коммите 2.1 | как arm-core | отключить format-check | | 16 | 2026-10-04 | R-9: трейлер коммитов `Co-Authored-By: Claude Fable 5.1 ` | указание вызывающего, Global Constraints плана | нет | | 17 | 2026-10-04 | R-10: `.env.example` и `.env.test` лежат в `api/` (рядом с `pytest.ini`, как в arm-core) | команды идут из `api/`, `pytest.ini` задаёт `ENV_FILE=.env.test` относительно `api/` | правка двух строк README | +| 18 | 2026-10-04 | R-11: `.dockerignore` не добавляется (он в `.gitignore` шаблона, как в arm-core) | на чистом checkout `api/.venv` и `api/.env` в образ не попадают | локально образ тяжелее; добавить `.dockerignore` одной строкой | +| 19 | 2026-10-04 | R-12: `api/.env.test` содержит `NATS_ENABLED=false`; интеграционные тесты включают NATS сами | D-6: unit-тесты поднимают приложение без NATS | правка фикстур | -- 2.47.2 From f8a584af15d6f3e94980c4c74de52621f59c8eaf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:38:17 +0400 Subject: [PATCH 13/50] =?UTF-8?q?chore:=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= =?UTF-8?q?=20=D1=81=D1=85=D0=B5=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B0=20v1.0=20=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B2=20api/contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скопировано из ../docs/contracts байт в байт (cp, diff -r чистый). Co-Authored-By: Claude Fable 5.1 --- api/contracts/examples/request-image.json | 14 ++ api/contracts/examples/request-tabular.json | 14 ++ api/contracts/examples/result-error.json | 12 ++ .../examples/result-message-done.json | 21 ++ .../examples/result-message-error.json | 11 + .../examples/result-message-processing.json | 7 + .../examples/result-osv-minimal.json | 79 +++++++ .../examples/result-statement-minimal.json | 50 +++++ api/contracts/recognition-request.schema.json | 28 +++ .../recognition-result-message.schema.json | 70 +++++++ api/contracts/recognition-result.schema.json | 197 ++++++++++++++++++ 11 files changed, 503 insertions(+) create mode 100644 api/contracts/examples/request-image.json create mode 100644 api/contracts/examples/request-tabular.json create mode 100644 api/contracts/examples/result-error.json create mode 100644 api/contracts/examples/result-message-done.json create mode 100644 api/contracts/examples/result-message-error.json create mode 100644 api/contracts/examples/result-message-processing.json create mode 100644 api/contracts/examples/result-osv-minimal.json create mode 100644 api/contracts/examples/result-statement-minimal.json create mode 100644 api/contracts/recognition-request.schema.json create mode 100644 api/contracts/recognition-result-message.schema.json create mode 100644 api/contracts/recognition-result.schema.json 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" } + } + } + } + } + } + } + } + } + } +} -- 2.47.2 From 5b913803b4a84d19a2edd6d902f045ad2d973e95 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:38:17 +0400 Subject: [PATCH 14/50] =?UTF-8?q?feat:=20pydantic-=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0=20v1.0=20(src/contract)=20=D0=B8=20unit-=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Запрос, конверт (processing|done|needs_check|error), файл результата, ОСВ и формы, деньги Decimal <-> строка, subject'ы и Nats-Msg-Id. Тесты: идентичность с docs (skip без docs, ARM_KA_DOCS), примеры, round-trip, согласованность моделей и схем. Co-Authored-By: Claude Fable 5.1 --- api/src/contract/__init__.py | 63 ++++ api/src/contract/common.py | 233 ++++++++++++ api/src/contract/message.py | 63 ++++ api/src/contract/request.py | 27 ++ api/src/contract/result.py | 189 ++++++++++ api/src/contract/subjects.py | 45 +++ api/tests/unit/test_contract.py | 651 ++++++++++++++++++++++++++++++++ 7 files changed, 1271 insertions(+) create mode 100644 api/src/contract/__init__.py create mode 100644 api/src/contract/common.py create mode 100644 api/src/contract/message.py create mode 100644 api/src/contract/request.py create mode 100644 api/src/contract/result.py create mode 100644 api/src/contract/subjects.py create mode 100644 api/tests/unit/test_contract.py diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py new file mode 100644 index 0000000..79922fd --- /dev/null +++ b/api/src/contract/__init__.py @@ -0,0 +1,63 @@ +"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). + +Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений. +""" + +from src.contract.common import ( + CONTRACT_VERSION, + ContractModel, + ErrorCode, + Metrics, + S3Ref, + ServiceInfo, + ServiceName, +) +from src.contract.message import MessageError, MessageStatus, RecognitionResultMessage +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", + "TABULAR_REQUEST_SUBJECT", + "ContractModel", + "ErrorCode", + "FileType", + "MessageError", + "MessageStatus", + "Metrics", + "OsvDocument", + "Pipeline", + "RecognitionRequest", + "RecognitionResult", + "RecognitionResultMessage", + "ResultStatus", + "S3Ref", + "ServiceInfo", + "ServiceName", + "StatementDocument", + "ack_msg_id", + "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..d0e27a8 --- /dev/null +++ b/api/src/contract/common.py @@ -0,0 +1,233 @@ +"""Общие основы моделей контракта 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" + +# Шаблоны версий — буквально из схем +SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR +RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version + +T = TypeVar("T") + + +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..828b109 --- /dev/null +++ b/api/src/contract/message.py @@ -0,0 +1,63 @@ +"""Конверт сообщения на `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` схема не запрещает). +""" + +from enum import StrEnum +from typing import Annotated, Self + +from pydantic import Field, model_validator + +from src.contract.common import ( + ContractModel, + ContractUUID, + 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 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/tests/unit/test_contract.py b/api/tests/unit/test_contract.py new file mode 100644 index 0000000..16ae4be --- /dev/null +++ b/api/tests/unit/test_contract.py @@ -0,0 +1,651 @@ +"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-contract, design D-7). + +Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и +JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. +""" + +import copy +import json +import os +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, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ContractModel, + MessageStatus, + RecognitionRequest, + RecognitionResult, + RecognitionResultMessage, + ack_msg_id, + 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`: каталог docs из `ARM_KA_DOCS`, иначе соседний `docs` рядом с репозиторием `tabular-parser`.""" + docs = os.environ.get("ARM_KA_DOCS") + docs_dir = Path(docs) if docs else API_DIR.parent.parent / "docs" + return docs_dir / "contracts" + + +def test_vendored_contracts_are_identical_to_docs() -> None: + docs_dir = _docs_contracts_dir() + if not docs_dir.is_dir(): + pytest.skip( + "docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_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) -- 2.47.2 From 6a8f0d119a1e6934c825da22cea1e2aba523a479 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:38:17 +0400 Subject: [PATCH 15/50] =?UTF-8?q?chore:=20make=20test=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=91=D1=80=D1=82=D0=BA=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B4=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC=205=20pytest;=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20Task=206=20/=203.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- Makefile | 3 +-- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index b1c2b2b..a3a946f 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,8 @@ lint: cd api && $(BIN)/ruff format --check . cd api && $(BIN)/mypy src -# Код 5 («тесты не собраны») допускаем только до появления первых тестов; после — убрать обёртку test: - cd api && $(BIN)/pytest -m "not integration"; rc=$$?; if [ $$rc -eq 5 ]; then echo "тестов пока нет (pytest exit 5) — допустимо"; exit 0; fi; exit $$rc + cd api && $(BIN)/pytest -m "not integration" test-integration: cd api && $(BIN)/pytest -m integration diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index ef0bfb1..1f20205 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -39,7 +39,7 @@ ### Task 6: [3.1] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts` -- [ ] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (`RecognitionRequest`, `RecognitionResultMessage` со статусами `processing | done | needs_check | error`, `RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги как `Decimal` ↔ строка); `tests/unit/test_contract.py` — идентичность с docs (skip без docs), валидация примеров, round-trip модели → JSON → схема; проверка: тесты зелёные +- [x] `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (`RecognitionRequest`, `RecognitionResultMessage` со статусами `processing | done | needs_check | error`, `RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги как `Decimal` ↔ строка); `tests/unit/test_contract.py` — идентичность с docs (skip без docs), валидация примеров, round-trip модели → JSON → схема; проверка: тесты зелёные ### Task 7: [4.1] `src/parser/__init__.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0880897..892335c 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -25,7 +25,7 @@ ## 3. Контракт -- [ ] 3.1 `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели +- [x] 3.1 `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели (`RecognitionRequest`, `RecognitionResultMessage` со статусами `processing | done | needs_check | error`, `RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги как `Decimal` ↔ строка); `tests/unit/test_contract.py` — идентичность с docs (skip без docs), валидация примеров, round-trip модели → JSON → схема; проверка: тесты зелёные -- 2.47.2 From b7af0d1824c686fae0c5630abc9abeebed3d5c04 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:41:02 +0400 Subject: [PATCH 16/50] =?UTF-8?q?docs:=20README=20=D0=BA=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B0=20api/rules=20(=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=20=D1=81=D0=B8=D0=BD=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=D0=B0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/rules/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 api/rules/README.md diff --git a/api/rules/README.md b/api/rules/README.md new file mode 100644 index 0000000..e58ac19 --- /dev/null +++ b/api/rules/README.md @@ -0,0 +1,11 @@ +# rules/ — словари синонимов парсера + +Каталог для словарей `rules/*.yaml`, по которым `tabular-parser` ищет структуру файла по содержимому: заголовки +колонок, группировки, коды строк, названия форм. Словари — **данные, а не код**: новый синоним добавляется строкой в +YAML без правки парсера. Парсер не привязан к координатам (буквы колонок, номера строк, имена листов). + +В Срезе 0 каталог пуст: парсер — заглушка (`NullParser`, код `NOT_IMPLEMENTED`), словарей нет. Первые словари появятся в +Срезе 2 вместе с реализацией разбора ОСВ / ББ / ОФР (см. `docs/context/05-domain-rules.md` §1.0, §12 в репозитории +`docs`). + +Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. -- 2.47.2 From 84b540421576d8a63a27351c88157e19a85b5b9c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:41:02 +0400 Subject: [PATCH 17/50] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=84=D0=B5=D0=B9=D1=81=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20(Parser,=20ParseOutcome),=20NullParser=20=D1=81=20NOT?= =?UTF-8?q?=5FIMPLEMENTED,=20get=5Fparser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заглушка Среза 0 не читает входные байты; отметки Task 7 / 4.1. Co-Authored-By: Claude Fable 5.1 --- api/src/parser/__init__.py | 56 +++++++++++++++++++ api/tests/unit/test_null_parser.py | 49 ++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 api/src/parser/__init__.py create mode 100644 api/tests/unit/test_null_parser.py diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py new file mode 100644 index 0000000..6722288 --- /dev/null +++ b/api/src/parser/__init__.py @@ -0,0 +1,56 @@ +"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка». + +Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат. +Срез 0 — только заглушка `NullParser`; реализация Среза 2 подменяет фабрику `get_parser`, воркер не меняется. + +`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его +через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий. +""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument + +__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"] + +NOT_IMPLEMENTED_MESSAGE = "разбор XLS/XLSX/XLSB реализуется в Срезе 2" + + +@dataclass(frozen=True, slots=True) +class ParseOutcome: + """Итог разбора. Правила — как у файла результата контракта: `done | needs_check` требуют документов, + `error` — `error {code, message}`. + """ + + status: ResultStatus + documents: tuple[OsvDocument | StatementDocument, ...] = () + metrics: Metrics | None = None + error: MessageError | None = None + + def __post_init__(self) -> None: + if self.status is ResultStatus.ERROR: + if self.error is None: + raise ValueError("при status = error обязательно поле error") + elif not self.documents: + raise ValueError(f"при status = {self.status.value} список documents не может быть пустым") + + +@runtime_checkable +class Parser(Protocol): + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: ... + + +class NullParser: + """Заглушка Среза 0: всегда `error NOT_IMPLEMENTED`; входные байты не читает.""" + + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + return ParseOutcome( + status=ResultStatus.ERROR, + error=MessageError(code=ErrorCode.NOT_IMPLEMENTED, message=NOT_IMPLEMENTED_MESSAGE), + ) + + +def get_parser() -> Parser: + """Фабрика парсера для воркера. Срез 2 заменяет возвращаемую реализацию.""" + return NullParser() diff --git a/api/tests/unit/test_null_parser.py b/api/tests/unit/test_null_parser.py new file mode 100644 index 0000000..22aec10 --- /dev/null +++ b/api/tests/unit/test_null_parser.py @@ -0,0 +1,49 @@ +"""Парсер-заглушка Среза 0 (design D-3): `NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.""" + +from typing import Any, get_args + +import pytest + +from src.contract import ErrorCode, FileType, MessageError, ResultStatus +from src.parser import NullParser, ParseOutcome, Parser, get_parser + + +class UntouchableBytes(bytes): + """Байты, любое обращение к содержимому которых — ошибка теста.""" + + def _fail(self, *_args: Any, **_kwargs: Any) -> Any: + raise AssertionError("NullParser прикоснулся к входным байтам") + + __iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment] + + +@pytest.mark.parametrize("file_type", get_args(FileType)) +def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None: + data = UntouchableBytes(b"content") + + outcome = NullParser().parse(data, file_type) + + assert outcome.status is ResultStatus.ERROR + assert outcome.error == MessageError( + code=ErrorCode.NOT_IMPLEMENTED, + message="разбор XLS/XLSX/XLSB реализуется в Срезе 2", + ) + assert outcome.documents == () + assert outcome.metrics is None + + +def test_get_parser_returns_null_parser() -> None: + parser = get_parser() + + assert isinstance(parser, NullParser) + assert isinstance(parser, Parser) + + +def test_outcome_error_requires_error_payload() -> None: + with pytest.raises(ValueError, match="error"): + ParseOutcome(status=ResultStatus.ERROR) + + +def test_outcome_success_requires_documents() -> None: + with pytest.raises(ValueError, match="documents"): + ParseOutcome(status=ResultStatus.DONE) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 1f20205..d92efee 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -43,7 +43,7 @@ ### Task 7: [4.1] `src/parser/__init__.py` -- [ ] `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты +- [x] `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты ### Task 8: [4.2] `src/core/clients.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 892335c..10d821a 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -32,7 +32,7 @@ ## 4. Воркер и парсер-заглушка -- [ ] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; +- [x] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты - [ ] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, -- 2.47.2 From 5d5b1b7f3b2f7ed8750154f66b3b9aafd1cfef1f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:43:07 +0400 Subject: [PATCH 18/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-13,=20R-14=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index c469bef..d30ae7e 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -99,3 +99,5 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 17 | 2026-10-04 | R-10: `.env.example` и `.env.test` лежат в `api/` (рядом с `pytest.ini`, как в arm-core) | команды идут из `api/`, `pytest.ini` задаёт `ENV_FILE=.env.test` относительно `api/` | правка двух строк README | | 18 | 2026-10-04 | R-11: `.dockerignore` не добавляется (он в `.gitignore` шаблона, как в arm-core) | на чистом checkout `api/.venv` и `api/.env` в образ не попадают | локально образ тяжелее; добавить `.dockerignore` одной строкой | | 19 | 2026-10-04 | R-12: `api/.env.test` содержит `NATS_ENABLED=false`; интеграционные тесты включают NATS сами | D-6: unit-тесты поднимают приложение без NATS | правка фикстур | +| 20 | 2026-10-04 | R-13: `Parser.parse` синхронный, воркер вызывает через `asyncio.to_thread` | разбор Excel CPU-bound; без await внутри | смена сигнатуры протокола и одного вызова в воркере | +| 21 | 2026-10-04 | R-14: подключение NATS из `app.py` переезжает в `core/nats.py` (+ `retry_on_failed_connect=True`); шаблонные `put_object/get_object` в `S3Storage` заменяются типизированными `get_bytes/put_json` | нет дублирования; старт при недоступном NATS не должен оставлять воркер навсегда выключенным | восстановить из git | -- 2.47.2 From 4c32cd36ac1c95e045c790afc39e333b1dfdcc01 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:45:25 +0400 Subject: [PATCH 19/50] =?UTF-8?q?feat:=20S3Storage.get=5Fbytes/put=5Fjson?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20?= =?UTF-8?q?Storage=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20put=5Fobject/get?= =?UTF-8?q?=5Fobject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/core/clients.py | 46 +++++++++----- api/tests/unit/test_clients.py | 108 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 api/tests/unit/test_clients.py diff --git a/api/src/core/clients.py b/api/src/core/clients.py index ceb87e5..80692b6 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -1,28 +1,44 @@ -"""Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0 -по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). +"""Клиент S3 (MinIO) сервиса: чтение исходника и запись файла результата (design D-4). + +`Storage` — протокол, от которого зависит воркер (design D-7): в unit-тестах его подменяет фейк, в приложении — +`S3Storage` поверх `miniopy_async.Minio`. Бакет передаётся в каждый вызов: исходник читается из `request.s3.bucket`, +результат пишется в тот же бакет. """ +import json from io import BytesIO +from typing import Any, Protocol, runtime_checkable from miniopy_async import Minio +JSON_CONTENT_TYPE = "application/json" + + +@runtime_checkable +class Storage(Protocol): + """Хранилище объектов, нужное воркеру: байты исходника и JSON результата.""" + + async def get_bytes(self, bucket: str, key: str) -> bytes: ... + + async def put_json(self, bucket: str, key: str, obj: Any) -> None: ... + class S3Storage: - def __init__(self, client: Minio, bucket_name: str): + """`Storage` поверх MinIO. `bucket` — бакет из настроек (проверяется при старте), методы принимают бакет явно.""" + + def __init__(self, client: Minio, bucket_name: str) -> None: self.client = client self.bucket = bucket_name - async def put_object(self, obj_name, file_data): + async def get_bytes(self, bucket: str, key: str) -> bytes: + """Содержимое объекта целиком; ошибки MinIO/сети пробрасываются (воркер превращает их в `FILE_READ_ERROR`).""" + response = await self.client.get_object(bucket, key) try: - stream = BytesIO(file_data) - await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data)) - except: - raise + return await response.read() + finally: + response.release() - async def get_object(self, obj_name): - try: - response = await self.client.get_object(self.bucket, obj_name) - # file_bytes = await response.content.read() - return response - except: - raise + async def put_json(self, bucket: str, key: str, obj: Any) -> None: + """Записывает JSON-сериализуемый `obj` как UTF-8 (`application/json`, кириллица без экранирования).""" + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + await self.client.put_object(bucket, key, BytesIO(body), length=len(body), content_type=JSON_CONTENT_TYPE) diff --git a/api/tests/unit/test_clients.py b/api/tests/unit/test_clients.py new file mode 100644 index 0000000..2facb20 --- /dev/null +++ b/api/tests/unit/test_clients.py @@ -0,0 +1,108 @@ +"""Unit-тесты `S3Storage` (design D-4): чтение исходника байтами и запись JSON результата — с фейковым MinIO.""" + +import json +from typing import Any + +import pytest + +from src.core.clients import S3Storage, Storage + + +class _FakeResponse: + """Двойник `aiohttp.ClientResponse`: тело и признак освобождения соединения.""" + + def __init__(self, body: bytes) -> None: + self.body = body + self.released = False + + async def read(self) -> bytes: + return self.body + + def release(self) -> None: + self.released = True + + +class _FakeMinio: + """Двойник `miniopy_async.Minio`: объекты в словаре `(bucket, key) -> bytes`.""" + + def __init__(self) -> None: + self.objects: dict[tuple[str, str], bytes] = {} + self.put_calls: list[dict[str, Any]] = [] + self.responses: list[_FakeResponse] = [] + + async def get_object(self, bucket_name: str, object_name: str) -> _FakeResponse: + try: + body = self.objects[(bucket_name, object_name)] + except KeyError: + raise FileNotFoundError(f"{bucket_name}/{object_name}") from None + response = _FakeResponse(body) + 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() + assert len(body) == length + self.objects[(bucket_name, object_name)] = body + self.put_calls.append( + {"bucket": bucket_name, "key": object_name, "length": length, "content_type": content_type} + ) + + +def _storage(client: _FakeMinio) -> S3Storage: + return S3Storage(client=client, bucket_name="arm-ka") # type: ignore[arg-type] + + +async def test_get_bytes_reads_object_and_releases_response(): + client = _FakeMinio() + client.objects[("arm-ka", "uploads/f/osv.xlsx")] = b"\x50\x4b\x03\x04 xlsx" + + data = await _storage(client).get_bytes("arm-ka", "uploads/f/osv.xlsx") + + assert data == b"\x50\x4b\x03\x04 xlsx" + assert client.responses[0].released + + +async def test_get_bytes_uses_bucket_argument(): + client = _FakeMinio() + client.objects[("other-bucket", "k")] = b"x" + + assert await _storage(client).get_bytes("other-bucket", "k") == b"x" + + +async def test_get_bytes_missing_object_raises(): + with pytest.raises(FileNotFoundError): + await _storage(_FakeMinio()).get_bytes("arm-ka", "uploads/нет.xlsx") + + +async def test_put_json_writes_utf8_json_with_content_type(): + client = _FakeMinio() + obj = {"job_id": "j-1", "сообщение": "разбор", "amount": "1.10"} + + await _storage(client).put_json("arm-ka", "results/f/j-1/result.json", obj) + + body = client.objects[("arm-ka", "results/f/j-1/result.json")] + assert json.loads(body.decode("utf-8")) == obj + assert "разбор".encode() in body # кириллица без \u-экранирования + assert client.put_calls == [ + { + "bucket": "arm-ka", + "key": "results/f/j-1/result.json", + "length": len(body), + "content_type": "application/json", + } + ] + + +async def test_put_json_then_get_bytes_round_trip(): + storage = _storage(_FakeMinio()) + + await storage.put_json("arm-ka", "results/x.json", {"a": [1, 2]}) + + assert json.loads(await storage.get_bytes("arm-ka", "results/x.json")) == {"a": [1, 2]} + + +def test_s3_storage_satisfies_storage_protocol(): + storage: Storage = _storage(_FakeMinio()) + assert isinstance(storage, Storage) -- 2.47.2 From ff0890558073c542a23600e5f5d8c1de9f5c56bb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:47:17 +0400 Subject: [PATCH 20/50] =?UTF-8?q?feat:=20=D1=81=D0=BB=D0=BE=D0=B9=20NATS?= =?UTF-8?q?=20src/core/nats.py=20=E2=80=94=20=D1=84=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20(nkeys/=D0=B1=D0=B5=D0=B7),=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D0=B2=20queue=20g?= =?UTF-8?q?roup,=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/core/nats.py | 335 ++++++++++++++++++++++++++++++++++ api/tests/unit/test_nats.py | 349 ++++++++++++++++++++++++++++++++++++ 2 files changed, 684 insertions(+) create mode 100644 api/src/core/nats.py create mode 100644 api/tests/unit/test_nats.py diff --git a/api/src/core/nats.py b/api/src/core/nats.py new file mode 100644 index 0000000..7692a67 --- /dev/null +++ b/api/src/core/nats.py @@ -0,0 +1,335 @@ +"""Соединение сервиса с NATS: core NATS без JetStream (design D-2, D-5, D-6; spec «Соединение с NATS по правилам +шаблона, без JetStream», «Подтверждение запроса до обработки», «Результат по контракту»). Сторона сервиса +распознавания; образец — `arm-core/api/src/core/nats.py` (сторона ядра). + +- Аутентификация как в шаблоне: заданы `NATS_SEED` и `NATS_JWT` — nkeys/JWT; оба пусты — без учётных данных; задан + только один — `NatsConfigError` (подключение не выполняется, ошибка в логе). +- Первое подключение выполняет фоновый supervisor (`start()`): старт приложения не ждёт NATS. nats-py 2.14 с + `max_reconnect_attempts=-1` сам повторяет и первое подключение (параметра `retry_on_failed_connect` в 2.14 нет: + `connect` просто не возвращается, пока сервер недоступен), поэтому ждать его в lifespan нельзя. Если `connect` всё же + завершился исключением, supervisor повторяет попытку с паузой 1 → 2 → 4 → … ≤ 30 с. После подключения один раз + вызывается `on_connected(conn)` (подписки воркера); подписки после разрыва nats-py восстанавливает сам. +- `subscribe_requests(cb)` — подписка на `recognition.tabular.request` в queue group `tabular-parser`: из нескольких + инстанций сообщение получает одна. Исключения обработчика логируются, подписка живёт. +- `publish(subject, payload, msg_id)` — заголовок `Nats-Msg-Id` и `flush` (подтверждение сервером); до + `publish_retries` попыток с паузой `publish_retry_pause` между ними (D-5: 3 попытки, 2 с), затем `NatsPublishError` — + воркер логирует ошибку, задание дойдёт до `timeout` в ядре. +- Ошибки подключения логируются без шума: первая ошибка серии — warning, дальше не чаще раза в минуту. +""" + +import asyncio +import base64 +import time +from collections.abc import Awaitable, Callable +from contextlib import suppress +from typing import Any, Protocol, runtime_checkable + +import nkeys +from loguru import logger +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract.subjects import NATS_MSG_ID_HEADER, TABULAR_REQUEST_SUBJECT +from src.core.config import NATSSettings + +REQUEST_QUEUE_GROUP = "tabular-parser" +CLIENT_NAME = "tabular-parser" +MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконечно +RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py +CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения +FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG) +PUBLISH_RETRIES = 3 # попыток публикации (settings.PUBLISH_RETRIES) +PUBLISH_RETRY_PAUSE = 2.0 # секунд между попытками публикации (D-5) +BACKOFF_INITIAL = 1.0 +BACKOFF_MAX = 30.0 +ERROR_LOG_INTERVAL = 60.0 # не чаще раза в минуту повторяем warning об ошибках одной серии + +MessageHandler = Callable[[Msg], Awaitable[None]] +SleepFn = Callable[[float], Awaitable[None]] + + +class NatsError(Exception): + """Базовая ошибка слоя NATS сервиса.""" + + +class NatsConfigError(NatsError, RuntimeError): + """Ошибка конфигурации учётных данных NATS: повтор подключения бесполезен.""" + + +class NatsNotConnectedError(NatsError): + """Операция требует установленного соединения с NATS.""" + + +class NatsPublishError(NatsError): + """Публикация не подтверждена за все попытки.""" + + +@runtime_checkable +class Publisher(Protocol): + """Публикация в NATS, нужная воркеру (design D-7): исключение `NatsPublishError` — все попытки исчерпаны.""" + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: ... + + +def build_nats_auth(settings: NATSSettings) -> dict[str, Any]: + """Параметры аутентификации для `Client.connect`. + + Пустые `NATS_SEED` и `NATS_JWT` — без учётных данных (docker compose, тесты): `{}`. Заданы оба — колбэки + nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`. + """ + seed = settings.NATS_SEED + jwt = settings.NATS_JWT + 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: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(keypair.sign(nonce)) + + return {"user_jwt_cb": user_jwt_cb, "signature_cb": signature_cb} + + +class NatsConnection: + """Соединение сервиса с NATS: фоновое подключение, подписка на запросы, публикация с повторами. + + Реализует `Publisher`. `client_factory` и `sleep` подменяются в тестах; `sleep` используется и для пауз + переподключения supervisor'а, и для пауз между попытками публикации. + """ + + def __init__( # noqa: PLR0913 - все параметры, кроме settings, именованные: подмены для тестов и тайминги + self, + settings: NATSSettings, + *, + on_connected: Callable[["NatsConnection"], Awaitable[None]] | None = None, + publish_retries: int = PUBLISH_RETRIES, + publish_retry_pause: float = PUBLISH_RETRY_PAUSE, + client_factory: Callable[[], Any] = NATS, + sleep: SleepFn = asyncio.sleep, + backoff_initial: float = BACKOFF_INITIAL, + backoff_max: float = BACKOFF_MAX, + flush_timeout: int = FLUSH_TIMEOUT, + ) -> None: + if publish_retries < 1: + raise ValueError("publish_retries должен быть ≥ 1") + self._settings = settings + self._on_connected = on_connected + self._publish_retries = publish_retries + self._publish_retry_pause = publish_retry_pause + self._client_factory = client_factory + self._sleep = sleep + self._backoff_initial = backoff_initial + self._backoff_max = backoff_max + self._flush_timeout = flush_timeout + self._client: Any = None + self._supervisor: asyncio.Task[None] | None = None + self._ready = asyncio.Event() + self._stopping = False + self._error_streak = 0 + self._last_error_log = 0.0 + + # --- состояние ------------------------------------------------------------------------------ + @property + def is_connected(self) -> bool: + """Фактическое состояние соединения (False до первого подключения, во время переподключения и после stop).""" + return self._client is not None and bool(self._client.is_connected) + + async def wait_ready(self, timeout: float) -> bool: + """Ждёт подключения и выполнения `on_connected`; `False`, если не дождались за `timeout` секунд.""" + try: + await asyncio.wait_for(self._ready.wait(), timeout) + except TimeoutError: + return False + return True + + # --- жизненный цикл ------------------------------------------------------------------------- + async def start(self) -> None: + """Запускает фоновый supervisor подключения; NATS не ждёт.""" + if self._supervisor is None: + self._supervisor = asyncio.create_task(self._supervise(), name="nats-supervisor") + + async def stop(self) -> None: + """Останавливает supervisor и закрывает соединение; исключения наружу не выходят.""" + self._stopping = True + if self._supervisor is not None: + self._supervisor.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._supervisor + self._supervisor = None + client = self._client + if client is None or client.is_closed: + return + try: + if client.is_connected: + await client.drain() # дообработать полученное + else: + await client.close() + except Exception as exc: + logger.warning( + "Ошибка при закрытии соединения NATS. error_category=nats_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + with suppress(Exception): + await client.close() + + async def _connect(self) -> None: + """Одна попытка подключения новым клиентом (nats-py с `max_reconnect_attempts=-1` повторяет её сам).""" + auth = build_nats_auth(self._settings) + client = self._client_factory() + self._client = client + try: + await client.connect( + self._settings.NATS_URL, + name=CLIENT_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 + + async def _supervise(self) -> None: + delay = self._backoff_initial + while True: + try: + await self._connect() + break + except NatsConfigError as exc: + logger.error( + "Конфигурация NATS некорректна, подключение не выполняется. error_category=config_error, " + "error={error}", + error=str(exc), + ) + return + except Exception as exc: + self._report_error(exc) + await self._sleep(delay) + delay = min(delay * 2, self._backoff_max) + self._reset_error_streak() + logger.info("Подключение к NATS установлено. url={url}", url=self._settings.NATS_URL) + + delay = self._backoff_initial + while self._on_connected is not None: + try: + await self._on_connected(self) + break + except Exception as exc: + logger.error( + "Не удалось выполнить подписки NATS. error_category=nats_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await self._sleep(delay) + delay = min(delay * 2, self._backoff_max) + self._ready.set() + + # --- подписка и публикация ------------------------------------------------------------------ + async def subscribe_requests( + self, cb: MessageHandler, *, queue: str = REQUEST_QUEUE_GROUP, subject: str = TABULAR_REQUEST_SUBJECT + ) -> Any: + """Подписка на запросы (`recognition.tabular.request`) в queue group; исключения `cb` логируются.""" + if not self.is_connected: + raise NatsNotConnectedError(f"Нет соединения с NATS: подписка на {subject} невозможна") + + async def safe_cb(msg: Msg) -> None: + try: + await cb(msg) + except Exception as exc: + logger.exception( + "Ошибка обработки сообщения NATS. error_category=handler_error, subject={subject}, " + "error_type={error_type}, error={error}", + subject=msg.subject, + error_type=type(exc).__name__, + error=repr(exc), + ) + + subscription = await self._client.subscribe(subject, queue=queue, cb=safe_cb) + logger.info("Подписка NATS активна. subject={subject}, queue={queue}", subject=subject, queue=queue) + return subscription + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + """Публикует с `Nats-Msg-Id: `; до `publish_retries` попыток с паузой, затем `NatsPublishError`.""" + last_error: str = "" + for attempt in range(1, self._publish_retries + 1): + if attempt > 1: + await self._sleep(self._publish_retry_pause) + try: + await self._publish_once(subject, payload, msg_id) + return + except NatsPublishError as exc: + last_error = str(exc) + logger.warning( + "Публикация в NATS не удалась. error_category=nats_error, subject={subject}, msg_id={msg_id}, " + "attempt={attempt}, attempts={attempts}, error={error}", + subject=subject, + msg_id=msg_id, + attempt=attempt, + attempts=self._publish_retries, + error=last_error, + ) + raise NatsPublishError( + f"Публикация {msg_id} в {subject} не удалась за {self._publish_retries} попыток: {last_error}" + ) + + async def _publish_once(self, subject: str, payload: bytes, msg_id: str) -> None: + if not self.is_connected: + raise NatsPublishError("Нет соединения с NATS") + try: + await self._client.publish(subject, payload, headers={NATS_MSG_ID_HEADER: msg_id}) + await self._client.flush(timeout=self._flush_timeout) + except Exception as exc: + raise NatsPublishError(f"публикация не подтверждена ({type(exc).__name__})") from exc + + # --- колбэки nats-py и логирование без шума ------------------------------------------------- + def _report_error(self, exc: BaseException) -> None: + now = time.monotonic() + self._error_streak += 1 + if self._error_streak == 1 or now - self._last_error_log >= ERROR_LOG_INTERVAL: + logger.warning( + "Нет связи с NATS, повторяем подключение. error_category=nats_error, url={url}, " + "error_type={error_type}, error={error}, attempts={attempts}", + url=self._settings.NATS_URL, + error_type=type(exc).__name__, + error=repr(exc), + attempts=self._error_streak, + ) + self._last_error_log = now + + def _reset_error_streak(self) -> None: + self._error_streak = 0 + self._last_error_log = 0.0 + + async def _on_error(self, exc: Exception) -> None: + self._report_error(exc) + + async def _on_disconnected(self) -> None: + if not self._stopping: + logger.warning("Соединение с NATS потеряно, nats-py переподключается") + + async def _on_reconnected(self) -> None: + self._reset_error_streak() + logger.info("Соединение с NATS восстановлено") + + async def _on_closed(self) -> None: + logger.info("Соединение с NATS закрыто") diff --git a/api/tests/unit/test_nats.py b/api/tests/unit/test_nats.py new file mode 100644 index 0000000..d064662 --- /dev/null +++ b/api/tests/unit/test_nats.py @@ -0,0 +1,349 @@ +"""Unit-тесты `src/core/nats.py` без сервера NATS: аутентификация, фоновое подключение, подписка, публикация. + +Клиент nats-py подменяется фабрикой (`client_factory`), паузы — инжектируемым `sleep` (design D-5, D-6; spec +«Соединение с NATS по правилам шаблона, без JetStream», «Результат по контракту» — повтор публикации). +""" + +import asyncio +import base64 +import os +from collections.abc import Iterator +from types import SimpleNamespace +from typing import Any + +import nkeys +import pytest +from loguru import logger + +from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT +from src.core.config import NATSSettings +from src.core.nats import ( + REQUEST_QUEUE_GROUP, + NatsConfigError, + NatsConnection, + NatsNotConnectedError, + NatsPublishError, + Publisher, + build_nats_auth, +) + +JWT = "header.payload.signature" + + +def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: + return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt) + + +def _valid_seed() -> str: + return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8") + + +@pytest.fixture +def log_records() -> Iterator[list[Any]]: + """Записи loguru, выпущенные за время теста.""" + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + yield records + logger.remove(handler_id) + + +# --- build_nats_auth ---------------------------------------------------------------------------- +def test_build_nats_auth_empty_credentials_gives_no_kwargs(): + assert build_nats_auth(_nats_settings()) == {} + + +@pytest.mark.parametrize(("seed", "jwt"), [("SU-only-seed", ""), ("", "only-jwt")]) +def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str): + with pytest.raises(NatsConfigError, match="NATS_SEED"): + build_nats_auth(_nats_settings(seed, jwt)) + + +def test_build_nats_auth_invalid_seed_is_config_error(): + with pytest.raises(NatsConfigError, match="NATS_SEED"): + build_nats_auth(_nats_settings("not-a-seed", JWT)) + + +def test_build_nats_auth_valid_credentials_give_callbacks(): + seed = _valid_seed() + auth = build_nats_auth(_nats_settings(seed, JWT)) + + assert set(auth) == {"user_jwt_cb", "signature_cb"} + assert auth["user_jwt_cb"]() == JWT.encode() + signature = auth["signature_cb"]("nonce") + # Подпись проверяется публичным ключом той же пары + keypair = nkeys.from_seed(seed.encode()) + assert keypair.verify(b"nonce", base64.b64decode(signature)) + + +# --- поддельный клиент nats-py ------------------------------------------------------------------ +class _FakeClient: + """Минимальный двойник `nats.aio.client.Client`.""" + + def __init__(self, world: "_FakeWorld") -> None: + self.world = world + self.is_connected = False + self.is_closed = False + self.connect_options: dict[str, Any] = {} + self.subscriptions: list[dict[str, Any]] = [] + self.published: list[dict[str, Any]] = [] + self.drained = False + + async def connect(self, servers: str, **options: Any) -> None: + self.world.connect_calls += 1 + self.connect_options = {"servers": servers, **options} + if self.world.hang_connect: + await asyncio.Event().wait() # NATS недоступен: nats-py 2.14 повторяет подключение бесконечно + if self.world.connect_calls <= self.world.fail_times: + raise OSError("Connection refused") + self.is_connected = True + + async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> SimpleNamespace: + self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb}) + return SimpleNamespace(subject=subject, queue=queue) + + async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: + self.published.append({"subject": subject, "payload": payload, "headers": headers}) + + async def flush(self, timeout: int = 10) -> None: + if self.world.flush_failures > 0: + self.world.flush_failures -= 1 + raise TimeoutError("flush timeout") + + async def drain(self) -> None: + self.drained = True + await self.close() + + async def close(self) -> None: + self.is_connected = False + self.is_closed = True + + +class _FakeWorld: + """Фабрика клиентов: первые `fail_times` подключений падают; `flush_failures` первых flush — таймаут.""" + + def __init__(self, fail_times: int = 0, flush_failures: int = 0, hang_connect: bool = False) -> None: + self.fail_times = fail_times + self.flush_failures = flush_failures + self.hang_connect = hang_connect + self.connect_calls = 0 + self.clients: list[_FakeClient] = [] + + def __call__(self) -> _FakeClient: + client = _FakeClient(self) + self.clients.append(client) + return client + + +class _RecordingSleep: + """Подмена `asyncio.sleep`: запоминает паузы и не ждёт.""" + + def __init__(self) -> None: + self.delays: list[float] = [] + + async def __call__(self, delay: float) -> None: + self.delays.append(delay) + await asyncio.sleep(0) + + +async def _noop_handler(msg: Any) -> None: + return None + + +async def _connected(world: _FakeWorld, sleep: _RecordingSleep, **kwargs: Any) -> NatsConnection: + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=sleep, **kwargs) + await conn.start() + assert await conn.wait_ready(timeout=2) + return conn + + +# --- подключение ------------------------------------------------------------------------------ +async def test_connect_without_credentials_infinite_reconnect_and_callbacks(): + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + + assert conn.is_connected + options = world.clients[-1].connect_options + assert options["servers"] == "nats://localhost:4222" + assert options["max_reconnect_attempts"] == -1 + assert options["name"] == "tabular-parser" + for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"): + assert callable(options[callback]) + assert "user_jwt_cb" not in options + assert "signature_cb" not in options + await conn.stop() + + +async def test_connect_with_credentials_passes_nkeys_callbacks(): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(_valid_seed(), JWT), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + assert await conn.wait_ready(timeout=2) + + options = world.clients[-1].connect_options + assert options["user_jwt_cb"]() == JWT.encode() + assert callable(options["signature_cb"]) + await conn.stop() + + +async def test_supervisor_retries_with_backoff_then_connects_and_runs_on_connected_once(log_records: list[Any]): + world = _FakeWorld(fail_times=7) + sleep = _RecordingSleep() + called: list[NatsConnection] = [] + + async def on_connected(conn: NatsConnection) -> None: + called.append(conn) + await conn.subscribe_requests(_noop_handler) + + conn = NatsConnection(_nats_settings(), on_connected=on_connected, client_factory=world, sleep=sleep) + assert not conn.is_connected + await conn.start() + assert await conn.wait_ready(timeout=2) + + assert world.connect_calls == 8 # noqa: PLR2004 + assert sleep.delays == [1, 2, 4, 8, 16, 30, 30] + assert called == [conn] + # Первая ошибка серии — warning с причиной, остальные в пределах минуты подавлены + warnings = [r for r in log_records if r["level"].name == "WARNING"] + assert len(warnings) == 1 + assert "error_category=nats_error" in warnings[0]["message"] + await conn.stop() + + +async def test_start_does_not_wait_for_unavailable_nats_and_stop_is_clean(): + """Регрессия O-3: NATS недоступен при старте — start() возвращается сразу, stop() отменяет подключение.""" + world = _FakeWorld(hang_connect=True) + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + + await asyncio.wait_for(conn.start(), timeout=0.5) + await asyncio.sleep(0) + assert not conn.is_connected + assert not await conn.wait_ready(timeout=0.05) + + await asyncio.wait_for(conn.stop(), timeout=1) + assert world.clients[-1].is_closed + + +async def test_partial_credentials_stop_supervisor_with_config_error(log_records: list[Any]): + world = _FakeWorld() + conn = NatsConnection(_nats_settings(seed="", jwt=JWT), client_factory=world, sleep=_RecordingSleep()) + + await conn.start() + assert not await conn.wait_ready(timeout=0.1) + + assert world.connect_calls == 0 + errors = [r for r in log_records if r["level"].name == "ERROR"] + assert any("error_category=config_error" in r["message"] for r in errors) + await conn.stop() + + +async def test_stop_drains_connected_client(): + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + + await conn.stop() + + assert world.clients[-1].drained + assert not conn.is_connected + + +# --- подписка ----------------------------------------------------------------------------------- +async def test_subscribe_requests_uses_tabular_subject_and_queue_group(): + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + + await conn.subscribe_requests(_noop_handler) + + assert REQUEST_QUEUE_GROUP == "tabular-parser" + subscription = world.clients[-1].subscriptions[0] + assert (subscription["subject"], subscription["queue"]) == (TABULAR_REQUEST_SUBJECT, "tabular-parser") + await conn.stop() + + +async def test_subscribe_requests_handler_errors_are_logged_not_raised(log_records: list[Any]): + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + received: list[bytes] = [] + + async def handler(msg: Any) -> None: + if msg.data == b"boom": + raise ValueError("сломанное сообщение") + received.append(msg.data) + + await conn.subscribe_requests(handler) + safe_cb = world.clients[-1].subscriptions[0]["cb"] + await safe_cb(SimpleNamespace(subject=TABULAR_REQUEST_SUBJECT, data=b"boom")) + await safe_cb(SimpleNamespace(subject=TABULAR_REQUEST_SUBJECT, data=b"ok")) + + assert received == [b"ok"] + assert any("error_category=handler_error" in r["message"] for r in log_records) + await conn.stop() + + +async def test_subscribe_requests_without_connection_raises(): + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld(), sleep=_RecordingSleep()) + + with pytest.raises(NatsNotConnectedError): + await conn.subscribe_requests(_noop_handler) + + +# --- публикация --------------------------------------------------------------------------------- +async def test_publish_adds_nats_msg_id_header_and_flushes(): + world = _FakeWorld() + sleep = _RecordingSleep() + conn = await _connected(world, sleep) + + await conn.publish(RESULT_SUBJECT, b'{"status": "processing"}', "ack:j-1:1") + + assert world.clients[-1].published == [ + { + "subject": RESULT_SUBJECT, + "payload": b'{"status": "processing"}', + "headers": {NATS_MSG_ID_HEADER: "ack:j-1:1"}, + } + ] + assert sleep.delays == [] + await conn.stop() + + +async def test_publish_retries_with_pause_then_succeeds_with_same_msg_id(): + world = _FakeWorld(flush_failures=1) + sleep = _RecordingSleep() + conn = await _connected(world, sleep) + + await conn.publish(RESULT_SUBJECT, b"{}", "result:j-1:1") + + published = world.clients[-1].published + assert len(published) == 2 # noqa: PLR2004 - первая попытка не подтверждена flush, вторая успешна + assert {p["headers"][NATS_MSG_ID_HEADER] for p in published} == {"result:j-1:1"} + assert sleep.delays == [2.0] + await conn.stop() + + +async def test_publish_retries_exhausted_raises_publish_error(log_records: list[Any]): + world = _FakeWorld(flush_failures=10) + sleep = _RecordingSleep() + conn = await _connected(world, sleep, publish_retries=3, publish_retry_pause=0.5) + + with pytest.raises(NatsPublishError, match="result:j-1:1"): + await conn.publish(RESULT_SUBJECT, b"{}", "result:j-1:1") + + assert len(world.clients[-1].published) == 3 # noqa: PLR2004 + assert sleep.delays == [0.5, 0.5] # пауза только между попытками + warnings = [r for r in log_records if r["level"].name == "WARNING" and "result:j-1:1" in r["message"]] + assert len(warnings) == 3 # noqa: PLR2004 + await conn.stop() + + +async def test_publish_without_connection_retries_then_raises(): + sleep = _RecordingSleep() + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld(), sleep=sleep, publish_retries=2) + + with pytest.raises(NatsPublishError, match="Нет соединения"): + await conn.publish(RESULT_SUBJECT, b"{}", "ack:j-1:1") + + assert sleep.delays == [2.0] + + +async def test_nats_connection_satisfies_publisher_protocol(): + publisher: Publisher = NatsConnection(_nats_settings(), client_factory=_FakeWorld()) + assert isinstance(publisher, Publisher) -- 2.47.2 From b545143f9e4cb77add70fc95f78bc5d33ea807ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:47:47 +0400 Subject: [PATCH 21/50] =?UTF-8?q?refactor:=20lifespan=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BA?= =?UTF-8?q?=20NATS=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20core/nats.py=20(?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D0=BE,=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=82=20=D0=BD=D0=B5=20=D0=B6=D0=B4=D1=91=D1=82=20NATS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/app.py | 65 ++++++++++---------------------------------------- 1 file changed, 12 insertions(+), 53 deletions(-) diff --git a/api/src/app.py b/api/src/app.py index 9d0922e..b3e2592 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,20 +1,17 @@ """Точка входа FastAPI сервиса tabular-parser. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые seed/jwt — без аутентификации), -middleware (gzip, proxy headers), healthcheck, обработчики исключений. Сервис без БД и фронта (DECISION-4). -Воркер обработки `recognition.request` и поле `nats` в healthcheck добавляются позже (src/core/nats.py). +Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: nkeys JWT, пустые seed/jwt — без +аутентификации, фоновое подключение — старт не ждёт NATS), middleware (gzip, proxy headers), healthcheck, обработчики +исключений. Сервис без БД и фронта (DECISION-4). Воркер обработки `recognition.tabular.request` и поле `nats` +в healthcheck добавляются в задаче 4.3. """ -import base64 from contextlib import asynccontextmanager from datetime import UTC, datetime -from typing import Any -import nkeys from fastapi import FastAPI 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 @@ -23,6 +20,7 @@ from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.logger import setup_logger +from src.core.nats import NatsConnection settings = get_settings() @@ -32,49 +30,6 @@ UP_TIME = datetime.now(UTC) setup_logger(level="DEBUG" if settings.DEBUG else "INFO") -async def _connect_nats() -> NATS | None: - """Подключение к NATS (core, без JetStream); при ошибке — ``None``, приложение всё равно стартует.""" - nats_settings = settings.OPENBAO.NATS - - async def disconnected_cb() -> None: - logger.warning("NATS: соединение потеряно") - - async def reconnected_cb() -> None: - logger.info("NATS: соединение восстановлено") - - async def closed_cb() -> None: - logger.warning("NATS: соединение закрыто") - - # -1 — бесконечное автопереподключение (D-6): иначе клиент закрывается навсегда после короткого сбоя NATS - options: dict[str, Any] = { - "max_reconnect_attempts": -1, - "disconnected_cb": disconnected_cb, - "reconnected_cb": reconnected_cb, - "closed_cb": closed_cb, - } - if nats_settings.NATS_SEED and nats_settings.NATS_JWT: - kp = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8")) - - def sig_cb(nonce: bytes | str) -> bytes: - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(kp.sign(nonce)) - - options |= {"user_jwt_cb": lambda: nats_settings.NATS_JWT.encode("utf-8"), "signature_cb": sig_cb} - nc = NATS() - try: - await nc.connect(nats_settings.NATS_URL, **options) - 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), - ) - return None - logger.info("Подключение к NATS произошло") - return nc - - async def _connect_s3() -> S3Storage | None: """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" minio = settings.OPENBAO.MINIO @@ -102,8 +57,12 @@ async def _connect_s3() -> S3Storage | None: @asynccontextmanager async def lifespan(app: FastAPI): app.state.storage = await _connect_s3() - app.state.nats = await _connect_nats() if settings.NATS_ENABLED else None - if not settings.NATS_ENABLED: + app.state.nats = None + if settings.NATS_ENABLED: + # start() не ждёт NATS: при недоступном сервере подключение продолжается в фоне (design D-6) + app.state.nats = NatsConnection(settings.OPENBAO.NATS, publish_retries=settings.PUBLISH_RETRIES) + await app.state.nats.start() + else: logger.info("NATS выключен (NATS_ENABLED=false)") try: @@ -112,7 +71,7 @@ async def lifespan(app: FastAPI): finally: logger.info("Приложение закрывается") if app.state.nats: - await app.state.nats.close() + await app.state.nats.stop() if app.state.storage: await app.state.storage.client.close_session() -- 2.47.2 From 6525939888bfce4f315ddda1ea222635d7f27417 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:48:26 +0400 Subject: [PATCH 22/50] =?UTF-8?q?test:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20NATS=20(=D0=B7=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BE=D0=BA=20Nats-Msg-Id,=20queue=20group)?= =?UTF-8?q?=20=D0=B8=20S3Storage=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20?= =?UTF-8?q?compose;=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20Task=20?= =?UTF-8?q?8=20/=204.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../integration/test_nats_integration.py | 149 ++++++++++++++++++ api/tests/integration/test_s3_integration.py | 39 +++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 api/tests/integration/test_nats_integration.py create mode 100644 api/tests/integration/test_s3_integration.py diff --git a/api/tests/integration/test_nats_integration.py b/api/tests/integration/test_nats_integration.py new file mode 100644 index 0000000..2c7e0be --- /dev/null +++ b/api/tests/integration/test_nats_integration.py @@ -0,0 +1,149 @@ +"""Интеграционные тесты `src/core/nats.py` против compose-NATS без JetStream (`.env.test`: nats://localhost:4222; +spec «Соединение с NATS по правилам шаблона, без JetStream»; design D-6). + +`.env.test` содержит `NATS_ENABLED=false` (для приложения) — тесты строят соединение сами. Subject'ы уникальны на +тест (`test.tabular.`): общие `recognition.*` не трогаем, на шине может работать другой сервис. +""" + +import asyncio +import time +import uuid +from collections import Counter +from typing import Any + +import pytest +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract.subjects import NATS_MSG_ID_HEADER +from src.core.config import NATSSettings, get_settings +from src.core.nats import REQUEST_QUEUE_GROUP, NatsConnection, NatsPublishError + +pytestmark = pytest.mark.integration + +READY_TIMEOUT = 5.0 +START_MAX_SECONDS = 0.5 # start() не ждёт подключения +STOP_MAX_SECONDS = 2.0 + + +def _nats_settings() -> NATSSettings: + return get_settings().OPENBAO.NATS + + +def _unique_subject() -> str: + return f"test.tabular.{uuid.uuid4().hex}" + + +async def _wait_until(predicate: Any, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + while not predicate(): + if time.monotonic() > deadline: + raise AssertionError("условие не выполнено за отведённое время") + await asyncio.sleep(0.02) + + +async def _start(conn: NatsConnection) -> NatsConnection: + await conn.start() + if not await conn.wait_ready(timeout=READY_TIMEOUT): + await conn.stop() + pytest.fail(f"NATS недоступен ({_nats_settings().NATS_URL}, .env.test; make up)", pytrace=False) + return conn + + +async def _plain_client() -> NATS: + nc = NATS() + await nc.connect(_nats_settings().NATS_URL, connect_timeout=2, max_reconnect_attempts=1) + return nc + + +async def test_subscribe_requests_receives_message_with_msg_id_header(): + """Запрос ядра (сторонний клиент с `Nats-Msg-Id`) доходит до подписки сервиса в queue group с заголовком.""" + subject = _unique_subject() + received: list[Msg] = [] + + async def handler(msg: Msg) -> None: + received.append(msg) + + async def on_connected(conn: NatsConnection) -> None: + await conn.subscribe_requests(handler, subject=subject) + + conn = await _start(NatsConnection(_nats_settings(), on_connected=on_connected)) + core = await _plain_client() + try: + assert conn.is_connected + await core.publish(subject, b'{"job_id": "j-1"}', headers={NATS_MSG_ID_HEADER: "j-1:1"}) + await core.flush() + await _wait_until(lambda: len(received) == 1) + finally: + await core.close() + await conn.stop() + + msg = received[0] + assert msg.subject == subject + assert msg.data == b'{"job_id": "j-1"}' + assert msg.headers == {NATS_MSG_ID_HEADER: "j-1:1"} + + +async def test_publish_delivers_payload_with_msg_id_header(): + """Ack/результат сервиса доходит до подписчика (как ядро) с заголовком `Nats-Msg-Id`.""" + subject = _unique_subject() + core = await _plain_client() + subscription = await core.subscribe(subject) + conn = await _start(NatsConnection(_nats_settings())) + try: + await conn.publish(subject, b'{"status": "processing"}', "ack:j-1:1") + msg = await subscription.next_msg(timeout=5) + finally: + await conn.stop() + await core.close() + + assert msg.data == b'{"status": "processing"}' + assert msg.headers == {NATS_MSG_ID_HEADER: "ack:j-1:1"} + + +async def test_queue_group_delivers_each_request_to_exactly_one_instance(): + subject = _unique_subject() + received: dict[str, list[str]] = {"a": [], "b": []} + + def make_on_connected(name: str) -> Any: + async def handler(msg: Msg) -> None: + assert msg.headers is not None + received[name].append(msg.headers[NATS_MSG_ID_HEADER]) + + async def on_connected(conn: NatsConnection) -> None: + await conn.subscribe_requests(handler, subject=subject) + + return on_connected + + instance_a = await _start(NatsConnection(_nats_settings(), on_connected=make_on_connected("a"))) + instance_b = await _start(NatsConnection(_nats_settings(), on_connected=make_on_connected("b"))) + total = 20 + try: + for i in range(total): + await instance_a.publish(subject, b"{}", f"job-{i}:1") + await _wait_until(lambda: len(received["a"]) + len(received["b"]) >= total) + await asyncio.sleep(0.2) # дубль, если бы он был, успел бы прийти + finally: + await instance_a.stop() + await instance_b.stop() + + assert REQUEST_QUEUE_GROUP == "tabular-parser" + all_ids = received["a"] + received["b"] + assert Counter(all_ids) == Counter(f"job-{i}:1" for i in range(total)) + + +async def test_start_against_unreachable_server_does_not_block_and_stops_cleanly(): + """O-3: NATS недоступен при старте — start() не ждёт, публикация после повторов падает, stop() быстрый.""" + conn = NatsConnection(NATSSettings(NATS_URL="nats://127.0.0.1:54999"), publish_retries=2, publish_retry_pause=0.05) + + started = time.monotonic() + await conn.start() + assert time.monotonic() - started < START_MAX_SECONDS + await asyncio.sleep(0.3) + assert not conn.is_connected + with pytest.raises(NatsPublishError): + await conn.publish("test.tabular.unreachable", b"{}", "m:1") + + started = time.monotonic() + await conn.stop() + assert time.monotonic() - started < STOP_MAX_SECONDS diff --git a/api/tests/integration/test_s3_integration.py b/api/tests/integration/test_s3_integration.py new file mode 100644 index 0000000..80e29a1 --- /dev/null +++ b/api/tests/integration/test_s3_integration.py @@ -0,0 +1,39 @@ +"""Интеграционный тест `S3Storage` против compose-MinIO (`.env.test`: localhost:9000, бакет `arm-ka`; design D-4). + +Ключ уникален на прогон (`tests//x.json`) и удаляется после теста. +""" + +import json +import uuid + +import pytest +from miniopy_async import Minio + +from src.core.clients import S3Storage +from src.core.config import get_settings + +pytestmark = pytest.mark.integration + + +async def test_put_json_then_get_bytes_round_trip_in_minio(): + minio = get_settings().OPENBAO.MINIO + client = Minio( + minio.MINIO_URL, + access_key=minio.MINIO_ADMIN_LOGIN, + secret_key=minio.MINIO_ADMIN_PASSWORD, + secure=minio.MINIO_SECURE, + ) + storage = S3Storage(client=client, bucket_name=minio.MINIO_BUCKET) + key = f"tests/{uuid.uuid4()}/x.json" + obj = {"job_id": "j-1", "сообщение": "разбор XLSX", "amount": "1234.50"} + try: + await storage.put_json(minio.MINIO_BUCKET, key, obj) + + data = await storage.get_bytes(minio.MINIO_BUCKET, key) + stat = await client.stat_object(minio.MINIO_BUCKET, key) + finally: + await client.remove_object(minio.MINIO_BUCKET, key) + await client.close_session() + + assert json.loads(data.decode("utf-8")) == obj + assert stat.content_type == "application/json" diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index d92efee..f158944 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -47,7 +47,7 @@ ### Task 8: [4.2] `src/core/clients.py` -- [ ] `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком +- [x] `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком ### Task 9: [4.3] `src/core/worker.py` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 10d821a..47cd053 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -34,7 +34,7 @@ - [x] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты -- [ ] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: +- [x] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком -- 2.47.2 From a8dc5cc314689d078be34d0edb68058ab589d930 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:53:18 +0400 Subject: [PATCH 23/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-15,=20R-16=20=D0=B2=20design.md,=20=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20R-14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index d30ae7e..2f1bad3 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -100,4 +100,6 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 18 | 2026-10-04 | R-11: `.dockerignore` не добавляется (он в `.gitignore` шаблона, как в arm-core) | на чистом checkout `api/.venv` и `api/.env` в образ не попадают | локально образ тяжелее; добавить `.dockerignore` одной строкой | | 19 | 2026-10-04 | R-12: `api/.env.test` содержит `NATS_ENABLED=false`; интеграционные тесты включают NATS сами | D-6: unit-тесты поднимают приложение без NATS | правка фикстур | | 20 | 2026-10-04 | R-13: `Parser.parse` синхронный, воркер вызывает через `asyncio.to_thread` | разбор Excel CPU-bound; без await внутри | смена сигнатуры протокола и одного вызова в воркере | -| 21 | 2026-10-04 | R-14: подключение NATS из `app.py` переезжает в `core/nats.py` (+ `retry_on_failed_connect=True`); шаблонные `put_object/get_object` в `S3Storage` заменяются типизированными `get_bytes/put_json` | нет дублирования; старт при недоступном NATS не должен оставлять воркер навсегда выключенным | восстановить из git | +| 21 | 2026-10-04 | R-14: подключение NATS из `app.py` переезжает в `core/nats.py` (фоновое первое подключение, supervisor; параметра `retry_on_failed_connect` в nats-py 2.14 нет, а `connect()` при `max_reconnect_attempts=-1` не возвращается, пока NATS недоступен — старый lifespan блокировал старт, O-3); шаблонные `put_object/get_object` в `S3Storage` заменяются типизированными `get_bytes/put_json` | нет дублирования; старт при недоступном NATS не должен оставлять воркер навсегда выключенным | восстановить из git | +| 22 | 2026-10-04 | R-15: фоновый supervisor подключения к NATS в `core/nats.py` (по образцу arm-core) вместо `retry_on_failed_connect`; половинные учётные данные (только seed или только jwt) — `NatsConfigError` | проверено по исходникам nats-py 2.14; повтор подключения с неполными данными бесполезен | замена на штатный параметр nats-py при его появлении | +| 23 | 2026-10-04 | R-16: `PUBLISH_RETRIES=3` — это три повтора после первой попытки (до 4 попыток всего, пауза 2 с между ними); публикация при отсутствии соединения падает сразу и учитывается как неудачная попытка | дословное чтение spec «повторить её до трёх раз» и D-5; окно ≈ 6 с короче типичного рестарта NATS — результат при рестарте теряется, ядро закроет задание по `timeout` (в рамках D-5) | смена одной константы/цикла | -- 2.47.2 From 6c8d7b6e9b23769c31a74b0417d34e20926dbf16 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:57:23 +0400 Subject: [PATCH 24/50] =?UTF-8?q?fix:=20NATS=20=E2=80=94=20PUBLISH=5FRETRI?= =?UTF-8?q?ES=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20(R-16),=20flush=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8,=20=D1=81=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=B1=D0=BE=D0=B5=20?= =?UTF-8?q?on=5Fconnected,=20=D0=BF=D1=80=D0=BE=D0=B1=D1=80=D0=BE=D1=81=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=B2=20stop(),=20error?= =?UTF-8?q?-=D0=BB=D0=BE=D0=B3=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Находки ревью Task 8: T8.2–T8.6. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- api/.env.example | 2 +- api/src/core/config.py | 2 +- api/src/core/nats.py | 66 +++++++++++++++------- api/tests/unit/test_nats.py | 108 +++++++++++++++++++++++++++++++++--- 5 files changed, 150 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 38b931f..793221d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Stateless-сервис распознавания табличных файло 5. Результат (схема `recognition-result`) записывается в S3 под ключом `results/{file_id}/{job_id}/result.json`. 6. В `recognition.result` публикуется конверт результата (схема `recognition-result-message`; статусы `processing | done | needs_check | error`) с заголовком `Nats-Msg-Id = result::`. Публикация - повторяется до `PUBLISH_RETRIES` раз. + при неудаче повторяется до `PUBLISH_RETRIES` раз (после первой попытки, с паузой 2 с). Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос — `SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение парсера — @@ -133,7 +133,7 @@ curl -s localhost:8000/healthcheck | `ENV_FILE` | `.env` | путь к файлу окружения (от `api/`) | | `NATS_ENABLED` | `true` | `false` — не подключаться к NATS, воркер не стартует | | `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно | -| `PUBLISH_RETRIES` | `3` | попытки публикации ack и результата в NATS | +| `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с) | | `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация внутри процесса) | | `SERVER__HOST` | `0.0.0.0` | адрес привязки HTTP-сервера | | `SERVER__PORT` | `8000` | порт HTTP-сервера | diff --git a/api/.env.example b/api/.env.example index ef59d5c..17adc3a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -31,7 +31,7 @@ SERVICE_NAME=tabular-parser NATS_ENABLED=true # Число заданий, обрабатываемых одновременно WORKER_CONCURRENCY=1 -# Попытки публикации ack и результата в NATS +# Повторы публикации ack и результата в NATS после первой попытки (3 — всего до 4 попыток, пауза 2 с) PUBLISH_RETRIES=3 # Размер process-local LRU обработанных job_id (защита от дублей доставки) DONE_CACHE_SIZE=1000 diff --git a/api/src/core/config.py b/api/src/core/config.py index 9e4fd8c..191c48e 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -44,7 +44,7 @@ class Settings(BaseSettings): SERVICE_NAME: str = "tabular-parser" # `service.name` конвертов контракта и имя queue group NATS NATS_ENABLED: bool = True # false — приложение не подключается к NATS (воркер не стартует) WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно - PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации ack/результата в NATS + PUBLISH_RETRIES: PositiveInt = 3 # повторы публикации ack/результата в NATS после первой попытки (до 4 попыток) DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id model_config = SettingsConfigDict( diff --git a/api/src/core/nats.py b/api/src/core/nats.py index 7692a67..b2c1f6a 100644 --- a/api/src/core/nats.py +++ b/api/src/core/nats.py @@ -8,12 +8,15 @@ `max_reconnect_attempts=-1` сам повторяет и первое подключение (параметра `retry_on_failed_connect` в 2.14 нет: `connect` просто не возвращается, пока сервер недоступен), поэтому ждать его в lifespan нельзя. Если `connect` всё же завершился исключением, supervisor повторяет попытку с паузой 1 → 2 → 4 → … ≤ 30 с. После подключения один раз - вызывается `on_connected(conn)` (подписки воркера); подписки после разрыва nats-py восстанавливает сам. + вызывается `on_connected(conn)` (подписки воркера); если он упал, созданные в этой попытке подписки снимаются и + вызов повторяется с той же паузой. Подписки после разрыва nats-py восстанавливает сам. - `subscribe_requests(cb)` — подписка на `recognition.tabular.request` в queue group `tabular-parser`: из нескольких - инстанций сообщение получает одна. Исключения обработчика логируются, подписка живёт. -- `publish(subject, payload, msg_id)` — заголовок `Nats-Msg-Id` и `flush` (подтверждение сервером); до - `publish_retries` попыток с паузой `publish_retry_pause` между ними (D-5: 3 попытки, 2 с), затем `NatsPublishError` — - воркер логирует ошибку, задание дойдёт до `timeout` в ядре. + инстанций сообщение получает одна. После `subscribe` — `flush`: подписка считается активной, когда сервер принял SUB + (ошибки прав проявляются сразу). Исключения обработчика логируются, подписка живёт. +- `publish(subject, payload, msg_id)` — заголовок `Nats-Msg-Id` и `flush` (подтверждение сервером); первая попытка и + до `publish_retries` повторов с тем же `msg_id` и паузой `publish_retry_pause` между попытками (D-5, R-16: 3 повтора, + 2 с — всего до 4 попыток), затем `NatsPublishError` — воркер логирует ошибку, задание дойдёт до `timeout` в ядре. + Нет соединения — попытка неудачна сразу (без ожидания), пауза и повтор как обычно. - Ошибки подключения логируются без шума: первая ошибка серии — warning, дальше не чаще раза в минуту. """ @@ -38,7 +41,7 @@ MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконеч RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG) -PUBLISH_RETRIES = 3 # попыток публикации (settings.PUBLISH_RETRIES) +PUBLISH_RETRIES = 3 # повторов публикации после первой попытки (settings.PUBLISH_RETRIES, R-16) PUBLISH_RETRY_PAUSE = 2.0 # секунд между попытками публикации (D-5) BACKOFF_INITIAL = 1.0 BACKOFF_MAX = 30.0 @@ -61,7 +64,7 @@ class NatsNotConnectedError(NatsError): class NatsPublishError(NatsError): - """Публикация не подтверждена за все попытки.""" + """Публикация не подтверждена ни первой попыткой, ни повторами.""" @runtime_checkable @@ -121,8 +124,8 @@ class NatsConnection: backoff_max: float = BACKOFF_MAX, flush_timeout: int = FLUSH_TIMEOUT, ) -> None: - if publish_retries < 1: - raise ValueError("publish_retries должен быть ≥ 1") + if publish_retries < 0: + raise ValueError("publish_retries должен быть ≥ 0") self._settings = settings self._on_connected = on_connected self._publish_retries = publish_retries @@ -138,6 +141,7 @@ class NatsConnection: self._stopping = False self._error_streak = 0 self._last_error_log = 0.0 + self._subscriptions: list[Any] = [] # подписки subscribe_requests (снимаются при сбое on_connected) # --- состояние ------------------------------------------------------------------------------ @property @@ -162,10 +166,18 @@ class NatsConnection: async def stop(self) -> None: """Останавливает supervisor и закрывает соединение; исключения наружу не выходят.""" self._stopping = True - if self._supervisor is not None: - self._supervisor.cancel() - with suppress(asyncio.CancelledError, Exception): - await self._supervisor + supervisor = self._supervisor + if supervisor is not None: + supervisor.cancel() + try: + await supervisor + except asyncio.CancelledError: + # Подавляем только отмену самого supervisor'а; отмену вызывающей задачи пробрасываем + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise + except Exception: + logger.exception("Supervisor NATS завершился с ошибкой. error_category=nats_error") self._supervisor = None client = self._client if client is None or client.is_closed: @@ -230,6 +242,7 @@ class NatsConnection: delay = self._backoff_initial while self._on_connected is not None: + created_before = len(self._subscriptions) try: await self._on_connected(self) break @@ -240,10 +253,18 @@ class NatsConnection: error_type=type(exc).__name__, error=repr(exc), ) + await self._drop_subscriptions(created_before) await self._sleep(delay) delay = min(delay * 2, self._backoff_max) self._ready.set() + async def _drop_subscriptions(self, keep: int) -> None: + """Снимает подписки сверх первых `keep` (неудачная попытка `on_connected`): повтор не дублирует их.""" + stale, self._subscriptions = self._subscriptions[keep:], self._subscriptions[:keep] + for subscription in stale: + with suppress(Exception): + await subscription.unsubscribe() + # --- подписка и публикация ------------------------------------------------------------------ async def subscribe_requests( self, cb: MessageHandler, *, queue: str = REQUEST_QUEUE_GROUP, subject: str = TABULAR_REQUEST_SUBJECT @@ -265,13 +286,17 @@ class NatsConnection: ) subscription = await self._client.subscribe(subject, queue=queue, cb=safe_cb) + self._subscriptions.append(subscription) + # Подписка активна, когда сервер обработал SUB: ошибки прав/соединения проявляются здесь, а не позже + await self._client.flush(timeout=self._flush_timeout) logger.info("Подписка NATS активна. subject={subject}, queue={queue}", subject=subject, queue=queue) return subscription async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: - """Публикует с `Nats-Msg-Id: `; до `publish_retries` попыток с паузой, затем `NatsPublishError`.""" + """`Nats-Msg-Id: `; попытка + до `publish_retries` повторов с паузой, затем `NatsPublishError`.""" + attempts = self._publish_retries + 1 last_error: str = "" - for attempt in range(1, self._publish_retries + 1): + for attempt in range(1, attempts + 1): if attempt > 1: await self._sleep(self._publish_retry_pause) try: @@ -285,12 +310,10 @@ class NatsConnection: subject=subject, msg_id=msg_id, attempt=attempt, - attempts=self._publish_retries, + attempts=attempts, error=last_error, ) - raise NatsPublishError( - f"Публикация {msg_id} в {subject} не удалась за {self._publish_retries} попыток: {last_error}" - ) + raise NatsPublishError(f"Публикация {msg_id} в {subject} не удалась за {attempts} попыток: {last_error}") async def _publish_once(self, subject: str, payload: bytes, msg_id: str) -> None: if not self.is_connected: @@ -332,4 +355,7 @@ class NatsConnection: logger.info("Соединение с NATS восстановлено") async def _on_closed(self) -> None: - logger.info("Соединение с NATS закрыто") + if self._stopping: + logger.info("Соединение с NATS закрыто") + else: + logger.error("Соединение с NATS закрыто без остановки сервиса. error_category=nats_error") diff --git a/api/tests/unit/test_nats.py b/api/tests/unit/test_nats.py index d064662..42bf391 100644 --- a/api/tests/unit/test_nats.py +++ b/api/tests/unit/test_nats.py @@ -76,6 +76,19 @@ def test_build_nats_auth_valid_credentials_give_callbacks(): # --- поддельный клиент nats-py ------------------------------------------------------------------ +class _FakeSubscription: + def __init__(self, client: "_FakeClient", subject: str, queue: str, cb: Any) -> None: + self.client = client + self.subject = subject + self.queue = queue + self.cb = cb + self.active = True + + async def unsubscribe(self) -> None: + self.active = False + self.client.calls.append("unsubscribe") + + class _FakeClient: """Минимальный двойник `nats.aio.client.Client`.""" @@ -87,6 +100,7 @@ class _FakeClient: self.subscriptions: list[dict[str, Any]] = [] self.published: list[dict[str, Any]] = [] self.drained = False + self.calls: list[str] = [] # порядок subscribe/flush/unsubscribe async def connect(self, servers: str, **options: Any) -> None: self.world.connect_calls += 1 @@ -97,14 +111,17 @@ class _FakeClient: raise OSError("Connection refused") self.is_connected = True - async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> SimpleNamespace: - self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb}) - return SimpleNamespace(subject=subject, queue=queue) + async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> _FakeSubscription: + self.calls.append("subscribe") + subscription = _FakeSubscription(self, subject, queue, cb) + self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb, "sub": subscription}) + return subscription async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: self.published.append({"subject": subject, "payload": payload, "headers": headers}) async def flush(self, timeout: int = 10) -> None: + self.calls.append("flush") if self.world.flush_failures > 0: self.world.flush_failures -= 1 raise TimeoutError("flush timeout") @@ -114,6 +131,8 @@ class _FakeClient: await self.close() async def close(self) -> None: + if self.world.hang_close: + await asyncio.Event().wait() # медленная очистка соединения self.is_connected = False self.is_closed = True @@ -121,10 +140,13 @@ class _FakeClient: class _FakeWorld: """Фабрика клиентов: первые `fail_times` подключений падают; `flush_failures` первых flush — таймаут.""" - def __init__(self, fail_times: int = 0, flush_failures: int = 0, hang_connect: bool = False) -> None: + def __init__( + self, fail_times: int = 0, flush_failures: int = 0, hang_connect: bool = False, hang_close: bool = False + ) -> None: self.fail_times = fail_times self.flush_failures = flush_failures self.hang_connect = hang_connect + self.hang_close = hang_close self.connect_calls = 0 self.clients: list[_FakeClient] = [] @@ -246,6 +268,37 @@ async def test_stop_drains_connected_client(): assert not conn.is_connected +async def test_stop_propagates_cancellation_of_its_caller(): + """T8.5: отмена задачи, вызвавшей stop(), не проглатывается (подавляется только отмена самого supervisor'а).""" + world = _FakeWorld(hang_connect=True, hang_close=True) + conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep()) + await conn.start() + await asyncio.sleep(0) + + stop_task = asyncio.create_task(conn.stop()) + for _ in range(5): + await asyncio.sleep(0) # supervisor отменён и висит в медленном close() + assert not stop_task.done() + stop_task.cancel() + + # asyncio.wait, а не await: без исправления stop() проглатывает отмену и виснет в close() + await asyncio.wait({stop_task}, timeout=1) + assert stop_task.cancelled() + + +async def test_on_closed_logs_error_when_not_stopping_and_info_on_stop(log_records: list[Any]): + """T8.6: самопроизвольное закрытие клиента — error; закрытие в stop() — info.""" + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + + await conn._on_closed() + await conn.stop() + await conn._on_closed() + + closed = [r for r in log_records if "Соединение с NATS закрыто" in r["message"]] + assert [r["level"].name for r in closed] == ["ERROR", "INFO"] + + # --- подписка ----------------------------------------------------------------------------------- async def test_subscribe_requests_uses_tabular_subject_and_queue_group(): world = _FakeWorld() @@ -259,6 +312,42 @@ async def test_subscribe_requests_uses_tabular_subject_and_queue_group(): await conn.stop() +async def test_subscribe_requests_flushes_after_subscribe(): + """T8.3: подписка считается активной, когда сервер обработал SUB (flush после subscribe).""" + world = _FakeWorld() + conn = await _connected(world, _RecordingSleep()) + + await conn.subscribe_requests(_noop_handler) + + assert world.clients[-1].calls == ["subscribe", "flush"] + await conn.stop() + + +async def test_on_connected_failure_is_retried_without_duplicate_subscriptions(log_records: list[Any]): + """T8.4: on_connected упал после подписки — подписка снимается, повтор с паузой, в итоге одна активная подписка.""" + world = _FakeWorld() + sleep = _RecordingSleep() + calls = 0 + + async def on_connected(conn: NatsConnection) -> None: + nonlocal calls + calls += 1 + await conn.subscribe_requests(_noop_handler) + if calls == 1: + raise RuntimeError("сбой после подписки") + + conn = NatsConnection(_nats_settings(), on_connected=on_connected, client_factory=world, sleep=sleep) + await conn.start() + assert await conn.wait_ready(timeout=2) + + assert calls == 2 # noqa: PLR2004 + assert sleep.delays == [1] + subscriptions = [s["sub"] for s in world.clients[-1].subscriptions] + assert [s.active for s in subscriptions] == [False, True] + assert any("Не удалось выполнить подписки NATS" in r["message"] for r in log_records) + await conn.stop() + + async def test_subscribe_requests_handler_errors_are_logged_not_raised(log_records: list[Any]): world = _FakeWorld() conn = await _connected(world, _RecordingSleep()) @@ -327,10 +416,13 @@ async def test_publish_retries_exhausted_raises_publish_error(log_records: list[ with pytest.raises(NatsPublishError, match="result:j-1:1"): await conn.publish(RESULT_SUBJECT, b"{}", "result:j-1:1") - assert len(world.clients[-1].published) == 3 # noqa: PLR2004 - assert sleep.delays == [0.5, 0.5] # пауза только между попытками + # PUBLISH_RETRIES=3 — три повтора после первой попытки (R-16): 4 попытки, 3 паузы, один и тот же Nats-Msg-Id + published = world.clients[-1].published + assert len(published) == 4 # noqa: PLR2004 + assert {p["headers"][NATS_MSG_ID_HEADER] for p in published} == {"result:j-1:1"} + assert sleep.delays == [0.5, 0.5, 0.5] # пауза только между попытками warnings = [r for r in log_records if r["level"].name == "WARNING" and "result:j-1:1" in r["message"]] - assert len(warnings) == 3 # noqa: PLR2004 + assert len(warnings) == 4 # noqa: PLR2004 await conn.stop() @@ -341,7 +433,7 @@ async def test_publish_without_connection_retries_then_raises(): with pytest.raises(NatsPublishError, match="Нет соединения"): await conn.publish(RESULT_SUBJECT, b"{}", "ack:j-1:1") - assert sleep.delays == [2.0] + assert sleep.delays == [2.0, 2.0] # 1 попытка + 2 повтора async def test_nats_connection_satisfies_publisher_protocol(): -- 2.47.2 From ff3ec5684a181cc6b77f94b412d68ece2a60f5e7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:59:24 +0400 Subject: [PATCH 25/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20R-17=20(=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20nats=3Doff=20=D0=B2=20healthcheck)=20=D0=B2=20de?= =?UTF-8?q?sign.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 2f1bad3..d6d0abd 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -103,3 +103,4 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 21 | 2026-10-04 | R-14: подключение NATS из `app.py` переезжает в `core/nats.py` (фоновое первое подключение, supervisor; параметра `retry_on_failed_connect` в nats-py 2.14 нет, а `connect()` при `max_reconnect_attempts=-1` не возвращается, пока NATS недоступен — старый lifespan блокировал старт, O-3); шаблонные `put_object/get_object` в `S3Storage` заменяются типизированными `get_bytes/put_json` | нет дублирования; старт при недоступном NATS не должен оставлять воркер навсегда выключенным | восстановить из git | | 22 | 2026-10-04 | R-15: фоновый supervisor подключения к NATS в `core/nats.py` (по образцу arm-core) вместо `retry_on_failed_connect`; половинные учётные данные (только seed или только jwt) — `NatsConfigError` | проверено по исходникам nats-py 2.14; повтор подключения с неполными данными бесполезен | замена на штатный параметр nats-py при его появлении | | 23 | 2026-10-04 | R-16: `PUBLISH_RETRIES=3` — это три повтора после первой попытки (до 4 попыток всего, пауза 2 с между ними); публикация при отсутствии соединения падает сразу и учитывается как неудачная попытка | дословное чтение spec «повторить её до трёх раз» и D-5; окно ≈ 6 с короче типичного рестарта NATS — результат при рестарте теряется, ядро закроет задание по `timeout` (в рамках D-5) | смена одной константы/цикла | +| 24 | 2026-10-04 | R-17: `/healthcheck.nats` принимает значения `ok | down | off` (`off` — при `NATS_ENABLED=false`, как в arm-core); spec/README/`.env.example` приводятся к этому | спека определяет только `ok | down`, но выключенный по флагу NATS не является отказом | убрать `off`, отдавать `down` | -- 2.47.2 From e1231dcdb4be164f55c2671b04b8253dd9b937bc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:05:47 +0400 Subject: [PATCH 26/50] =?UTF-8?q?feat:=20=D0=B2=D0=BE=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20src/core/worker.py=20=E2=80=94=20ack?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20S3,=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20job=5Fid,=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=20D-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on_request: валидация (pydantic + MAJOR = 1) → повторный job_id получает только ack → ack processing → очередь; рабочие корутины: S3 get → Parser через asyncio.to_thread → файл результата → конверт с metrics.duration_ms → публикация с Nats-Msg-Id result::. SCHEMA_MISMATCH без ack, FILE_READ_ERROR, UNKNOWN_ERROR, NatsPublishError — error-лог. Docstring протокола Parser: ожидаемые сбои — исход error (T7.1). Co-Authored-By: Claude Fable 5.1 --- api/src/core/worker.py | 445 ++++++++++++++++++++ api/src/parser/__init__.py | 6 + api/tests/unit/test_worker.py | 741 ++++++++++++++++++++++++++++++++++ 3 files changed, 1192 insertions(+) create mode 100644 api/src/core/worker.py create mode 100644 api/tests/unit/test_worker.py diff --git a/api/src/core/worker.py b/api/src/core/worker.py new file mode 100644 index 0000000..4eaed96 --- /dev/null +++ b/api/src/core/worker.py @@ -0,0 +1,445 @@ +"""Воркер распознавания: запрос `recognition.tabular.request` → ack → S3 → парсер → S3 → `recognition.result`. + +Design D-2, D-4, D-5, D-7; spec tabular-recognition-service «Подтверждение запроса до обработки», «Результат по +контракту», «Ошибки транспорта превращаются в результат», «Парсер — заменяемый интерфейс». + +- `on_request(msg)` — обработчик подписки NATS (queue group `tabular-parser`): разбор тела и валидация по контракту + (pydantic `RecognitionRequest`, MAJOR `schema_version` = 1). Невалидный запрос — конверт `error SCHEMA_MISMATCH` + без ack (если `job_id`, `file_id` и `attempt` читаемы; иначе собрать валидный конверт нельзя — error-лог и сброс). + Валидный: `job_id` уже в работе или обработан этим процессом — только повторный ack (`ack::` этого + запроса), второй обработки нет; иначе — `job_id` в множество «в работе», ack `processing` **до** чтения S3, задание + во внутреннюю `asyncio.Queue`. +- Рабочие корутины (`concurrency`): S3 get → `Parser.parse` через `asyncio.to_thread` (R-13) → для `done | needs_check` + файл результата `results/{file_id}/{job_id}/result.json` → конверт `done | needs_check | error` с + `metrics.duration_ms` (всегда, ≥ 0) → публикация с `Nats-Msg-Id = result::`. +- Таблица ошибок D-5: S3 get упал → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR` (тип и текст исключения + одной строкой, без трассировки); запись файла результата упала → `UNKNOWN_ERROR`; публикация не удалась после + повторов (`NatsPublishError`) → error-лог, задание дальше не идёт (ядро закроет его по `reply_deadline`). + Никакое исключение не роняет процесс и рабочие корутины. +- Дедупликация: `job_id` в работе (очередь + обрабатываемые) — множество; завершённые (любой итог, в том числе ошибка и + неудачная публикация результата) — LRU размера `done_cache_size`. +- Остановка: новые запросы не принимаются (без ack — ядро переотправит другой инстанции), очередь и текущие задания + дорабатываются не дольше `timeout`, остальное отменяется с warning-логом. +""" + +import asyncio +import json +import re +import time +from collections import OrderedDict +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +from loguru import logger +from pydantic import ValidationError + +from src.contract import ( + CONTRACT_VERSION, + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ErrorCode, + MessageError, + MessageStatus, + Metrics, + RecognitionRequest, + RecognitionResult, + RecognitionResultMessage, + ResultStatus, + S3Ref, + ServiceInfo, + ack_msg_id, + result_msg_id, +) +from src.core.clients import Storage +from src.core.nats import NatsPublishError, Publisher +from src.parser import ParseOutcome, Parser + +SUPPORTED_MAJOR = "1" +STOP_TIMEOUT = 10.0 # секунд на доработку очереди при остановке (lifespan) +ERROR_MESSAGE_LIMIT = 1000 # символов в `error.message` конверта + +_VERSION_RE = re.compile(r"(\d+)\.\d+", re.ASCII) +_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) + +Clock = Callable[[], float] + + +class InboundMessage(Protocol): + """Входящее сообщение NATS (подмножество `nats.aio.msg.Msg`, нужное воркеру).""" + + @property + def data(self) -> bytes: ... + + @property + def headers(self) -> Mapping[str, str] | None: ... + + +def result_key(file_id: object, job_id: object) -> str: + """Ключ файла результата в бакете запроса (design D-4).""" + return f"results/{file_id}/{job_id}/result.json" + + +class InvalidRequestError(Exception): + """Запрос не прошёл контракт: `SCHEMA_MISMATCH`.""" + + +@dataclass(frozen=True, slots=True) +class _Job: + request: RecognitionRequest + + +def _one_line(text: str) -> str: + """Сообщение ошибки для конверта: одна строка, без трассировки, ограниченной длины.""" + flat = " ".join(text.split()) + return flat if len(flat) <= ERROR_MESSAGE_LIMIT else flat[: ERROR_MESSAGE_LIMIT - 1] + "…" + + +def _describe(exc: BaseException) -> str: + return _one_line(f"{type(exc).__name__}: {exc}") + + +def _validation_summary(exc: ValidationError) -> str: + parts = [] + for error in exc.errors(include_url=False, include_input=False): + location = ".".join(str(item) for item in error["loc"]) or "<тело>" + parts.append(f"{location}: {error['msg']}") + return "; ".join(parts) + + +class RecognitionWorker: + """Обработчик запросов распознавания; зависимости — протоколы (`Storage`, `Publisher`, `Parser`, design D-7).""" + + def __init__( # noqa: PLR0913 - все параметры, кроме зависимостей, именованные + self, + *, + storage: Storage, + publisher: Publisher, + parser: Parser, + service_name: str, + service_version: str, + concurrency: int = 1, + done_cache_size: int = 1000, + clock: Clock = time.monotonic, + ) -> None: + if concurrency < 1 or done_cache_size < 1: + raise ValueError("concurrency и done_cache_size должны быть ≥ 1") + self._storage = storage + self._publisher = publisher + self._parser = parser + self._service = ServiceInfo.model_validate({"name": service_name, "version": service_version}) + self._concurrency = concurrency + self._done_cache_size = done_cache_size + self._clock = clock + self._queue: asyncio.Queue[_Job] = asyncio.Queue() + self._in_progress: set[str] = set() # job_id в очереди или в обработке + self._done: OrderedDict[str, None] = OrderedDict() # LRU завершённых job_id + self._tasks: list[asyncio.Task[None]] = [] + self._accepting = True + + # --- жизненный цикл --------------------------------------------------------------------------------------------- + def start(self) -> None: + """Запускает рабочие корутины (`concurrency` штук).""" + if self._tasks: + return + self._accepting = True + self._tasks = [ + asyncio.create_task(self._run(), name=f"recognition-worker-{index}") for index in range(self._concurrency) + ] + logger.info("Воркер распознавания запущен. concurrency={concurrency}", concurrency=self._concurrency) + + async def join(self) -> None: + """Ждёт, пока очередь опустеет и все взятые задания завершатся.""" + await self._queue.join() + + async def stop(self, timeout: float = STOP_TIMEOUT) -> None: + """Перестаёт принимать запросы, дорабатывает очередь не дольше `timeout` секунд, затем отменяет остальное.""" + self._accepting = False + if not self._tasks: + return + try: + await asyncio.wait_for(self._queue.join(), timeout) + except TimeoutError: + logger.warning( + "Остановка воркера: задания не завершились за отведённое время и отменены (ack опубликован, ядро " + "закроет их по reply_deadline). error_category=shutdown, in_flight={in_flight}, pending={pending}", + in_flight=len(self._in_progress) - self._queue.qsize(), + pending=self._queue.qsize(), + ) + tasks, self._tasks = self._tasks, [] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + logger.info("Воркер распознавания остановлен") + + # --- приём запроса ---------------------------------------------------------------------------------------------- + async def on_request(self, msg: InboundMessage) -> None: + """Обработчик подписки на `recognition.tabular.request`; исключения наружу не выходят.""" + if not self._accepting: + logger.warning("Воркер останавливается: запрос не принят (без ack, ядро переотправит)") + return + started = self._clock() + try: + request = self._parse_request(msg.data) + except InvalidRequestError as exc: + await self._reject(msg, str(exc), started) + return + + job_id = str(request.job_id) + duplicate = job_id in self._in_progress or job_id in self._done + if duplicate: + if job_id in self._done: + self._done.move_to_end(job_id) + logger.info( + "Повторный запрос: только ack, без повторной обработки. job_id={job_id}, attempt={attempt}", + job_id=job_id, + attempt=request.attempt, + ) + else: + self._in_progress.add(job_id) + await self._publish_ack(request) + if not duplicate: + self._queue.put_nowait(_Job(request)) + + def _parse_request(self, data: bytes) -> RecognitionRequest: + try: + raw = json.loads(data) + except (ValueError, UnicodeDecodeError) as exc: + raise InvalidRequestError(f"тело запроса — не JSON ({type(exc).__name__})") from None + if isinstance(raw, dict): + version = raw.get("schema_version") + match = _VERSION_RE.fullmatch(version) if isinstance(version, str) else None + if match is not None and match.group(1) != SUPPORTED_MAJOR: + raise InvalidRequestError( + f"неподдерживаемая версия запроса schema_version={version!r}: сервис поддерживает " + f"{SUPPORTED_MAJOR}.x" + ) + try: + return RecognitionRequest.model_validate(raw) + except ValidationError as exc: + raise InvalidRequestError( + f"запрос не соответствует recognition-request.schema.json: {_validation_summary(exc)}" + ) from None + + async def _reject(self, msg: InboundMessage, reason: str, started: float) -> None: + """`SCHEMA_MISMATCH` без ack. Без читаемых `job_id`/`file_id`/`attempt` конверт не собрать — лог и сброс.""" + raw = self._raw_dict(msg.data) + job_id = self._readable_uuid(raw, "job_id") + file_id = self._readable_uuid(raw, "file_id") + attempt = self._readable_attempt(raw, msg.headers, job_id) if job_id is not None else None + if job_id is None or file_id is None or attempt is None: + logger.error( + "Запрос отброшен: не проходит контракт, а job_id/file_id/attempt не читаемы — результат не " + "публикуется. error_category=schema_mismatch, reason={reason}, size={size}", + reason=reason, + size=len(msg.data), + ) + return + logger.error( + "Запрос не проходит контракт: публикуется SCHEMA_MISMATCH без ack. error_category=schema_mismatch, " + "job_id={job_id}, reason={reason}", + job_id=job_id, + reason=reason, + ) + envelope = self._envelope( + job_id=job_id, + file_id=file_id, + status=MessageStatus.ERROR, + error=MessageError(code=ErrorCode.SCHEMA_MISMATCH, message=_one_line(reason)), + metrics=self._metrics(None, started), + ) + await self._publish(envelope, result_msg_id(job_id, attempt), job_id) + + @staticmethod + def _raw_dict(data: bytes) -> dict[str, Any]: + try: + raw = json.loads(data) + except (ValueError, UnicodeDecodeError): + return {} + return raw if isinstance(raw, dict) else {} + + @staticmethod + def _readable_uuid(raw: Mapping[str, Any], name: str) -> str | None: + value = raw.get(name) + return value if isinstance(value, str) and _UUID_RE.fullmatch(value) else None + + @staticmethod + def _readable_attempt(raw: Mapping[str, Any], headers: Mapping[str, str] | None, job_id: str) -> int | None: + """`attempt` из тела (целое ≥ 1), иначе из заголовка запроса `Nats-Msg-Id = :`.""" + attempt = raw.get("attempt") + if isinstance(attempt, int) and not isinstance(attempt, bool) and attempt >= 1: + return attempt + header = (headers or {}).get(NATS_MSG_ID_HEADER, "") + prefix, _, tail = header.rpartition(":") + if prefix.lower() == job_id.lower() and tail.isdigit() and int(tail) >= 1: + return int(tail) + return None + + async def _publish_ack(self, request: RecognitionRequest) -> None: + envelope = self._envelope(job_id=request.job_id, file_id=request.file_id, status=MessageStatus.PROCESSING) + await self._publish(envelope, ack_msg_id(request.job_id, request.attempt), str(request.job_id)) + + # --- обработка задания ------------------------------------------------------------------------------------------ + async def _run(self) -> None: + while True: + job = await self._queue.get() + job_id = str(job.request.job_id) + try: + await self._process(job.request) + except Exception: + # Страховка: _process сам превращает ошибки в конверты; сюда попадает только непредвиденное + logger.exception("Сбой обработки задания. error_category=worker_error, job_id={job_id}", job_id=job_id) + finally: + self._finish(job_id) + self._queue.task_done() + + def _finish(self, job_id: str) -> None: + self._in_progress.discard(job_id) + self._done[job_id] = None + self._done.move_to_end(job_id) + while len(self._done) > self._done_cache_size: + self._done.popitem(last=False) + + async def _process(self, request: RecognitionRequest) -> None: + started = self._clock() + job_id = str(request.job_id) + envelope = await self._build_result(request, started) + logger.info( + "Задание обработано. job_id={job_id}, status={status}, error_code={error_code}", + job_id=job_id, + status=envelope.status.value, + error_code=envelope.error.code.value if envelope.error else None, + ) + await self._publish(envelope, result_msg_id(request.job_id, request.attempt), job_id) + + async def _build_result(self, request: RecognitionRequest, started: float) -> RecognitionResultMessage: + """Конвейер S3 → парсер → S3; любой сбой — конверт `error` по таблице D-5.""" + try: + data = await self._storage.get_bytes(request.s3.bucket, request.s3.key) + except Exception as exc: + logger.warning( + "Не удалось прочитать исходный файл. error_category=s3_error, job_id={job_id}, key={key}, " + "error={error}", + job_id=request.job_id, + key=request.s3.key, + error=_describe(exc), + ) + message = f"не удалось прочитать s3://{request.s3.bucket}/{request.s3.key}: {_describe(exc)}" + return self._error(request, ErrorCode.FILE_READ_ERROR, message, None, started) + + try: + outcome: ParseOutcome = await asyncio.to_thread(self._parser.parse, data, request.file_type) + except Exception as exc: + logger.opt(exception=exc).error( + "Исключение парсера. error_category=parser_error, job_id={job_id}", job_id=request.job_id + ) + return self._error(request, ErrorCode.UNKNOWN_ERROR, f"ошибка разбора: {_describe(exc)}", None, started) + + if outcome.status is ResultStatus.ERROR: + error = outcome.error or MessageError( + code=ErrorCode.UNKNOWN_ERROR, message="парсер вернул ошибку без описания" + ) + return self._envelope( + job_id=request.job_id, + file_id=request.file_id, + status=MessageStatus.ERROR, + error=error, + metrics=self._metrics(outcome.metrics, started), + ) + + key = result_key(request.file_id, request.job_id) + try: + metrics = self._metrics(outcome.metrics, started) + result = RecognitionResult.model_validate( + { + "schema_version": CONTRACT_VERSION, + "job_id": request.job_id, + "file_id": request.file_id, + "service": self._service, + "status": ResultStatus(outcome.status), + "metrics": metrics, + "documents": list(outcome.documents), + } + ) + await self._storage.put_json(request.s3.bucket, key, result.to_wire()) + except Exception as exc: + logger.opt(exception=exc).error( + "Не удалось записать файл результата. error_category=s3_error, job_id={job_id}, key={key}", + job_id=request.job_id, + key=key, + ) + message = f"не удалось записать результат s3://{request.s3.bucket}/{key}: {_describe(exc)}" + return self._error(request, ErrorCode.UNKNOWN_ERROR, message, outcome.metrics, started) + + return self._envelope( + job_id=request.job_id, + file_id=request.file_id, + status=MessageStatus(outcome.status.value), + result_s3=S3Ref(bucket=request.s3.bucket, key=key), + metrics=metrics, + ) + + def _error( + self, request: RecognitionRequest, code: ErrorCode, message: str, metrics: Metrics | None, started: float + ) -> RecognitionResultMessage: + return self._envelope( + job_id=request.job_id, + file_id=request.file_id, + status=MessageStatus.ERROR, + error=MessageError(code=code, message=_one_line(message)), + metrics=self._metrics(metrics, started), + ) + + def _metrics(self, metrics: Metrics | None, started: float) -> Metrics: + """Метрики парсера + `duration_ms` (всегда, ≥ 0).""" + duration_ms = max(0, round((self._clock() - started) * 1000)) + fields = metrics.model_dump(exclude_unset=True) if metrics is not None else {} + return Metrics.model_validate({**fields, "duration_ms": duration_ms}) + + def _envelope( # noqa: PLR0913 - поля конверта именованные + self, + *, + job_id: object, + file_id: object, + status: MessageStatus, + error: MessageError | None = None, + result_s3: S3Ref | None = None, + metrics: Metrics | None = None, + ) -> RecognitionResultMessage: + # Необязательные поля без null в схеме передаются, только если заданы (to_wire выводит заданные) + fields: dict[str, Any] = { + "schema_version": CONTRACT_VERSION, + "job_id": job_id, + "file_id": file_id, + "status": status, + "service": self._service, + } + if result_s3 is not None: + fields["result_s3"] = result_s3 + fields["result_schema_version"] = CONTRACT_VERSION + if error is not None: + fields["error"] = error + if metrics is not None: + fields["metrics"] = metrics + return RecognitionResultMessage.model_validate(fields) + + async def _publish(self, envelope: RecognitionResultMessage, msg_id: str, job_id: str) -> None: + """Публикация конверта; исчерпанные повторы и прочие сбои — error-лог, не исключение (D-5).""" + payload = json.dumps(envelope.to_wire(), ensure_ascii=False).encode("utf-8") + try: + await self._publisher.publish(RESULT_SUBJECT, payload, msg_id) + except NatsPublishError as exc: + logger.error( + "Конверт не опубликован после всех повторов; ядро закроет задание по reply_deadline. " + "error_category=nats_error, job_id={job_id}, msg_id={msg_id}, status={status}, error={error}", + job_id=job_id, + msg_id=msg_id, + status=envelope.status.value, + error=str(exc), + ) + except Exception as exc: + logger.opt(exception=exc).error( + "Сбой публикации конверта. error_category=nats_error, job_id={job_id}, msg_id={msg_id}", + job_id=job_id, + msg_id=msg_id, + ) diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py index 6722288..eff0a80 100644 --- a/api/src/parser/__init__.py +++ b/api/src/parser/__init__.py @@ -38,6 +38,12 @@ class ParseOutcome: @runtime_checkable class Parser(Protocol): + """Разбор файла: байты + тип → `ParseOutcome`. + + Ожидаемые сбои разбора (не тот формат, нечитаемый лист и т. п.) возвращаются исходом `status = error` с + `error {code, message}`; непредвиденное исключение воркер превращает в `UNKNOWN_ERROR` (design D-5). + """ + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: ... diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py new file mode 100644 index 0000000..a85e371 --- /dev/null +++ b/api/tests/unit/test_worker.py @@ -0,0 +1,741 @@ +"""Unit-тесты воркера `src/core/worker.py` с фейками `Storage` / `Publisher` / `Parser` (design D-2, D-5, D-7). + +Сценарии spec tabular-recognition-service: ack раньше результата, повторный запрос (во время обработки и после), +заглушка `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `SCHEMA_MISMATCH` без ack, исключение парсера → `UNKNOWN_ERROR`, +подмена парсера → `done` с файлом результата, повтор публикации и `NatsPublishError`. NATS и S3 не нужны. +""" + +import asyncio +import copy +import json +from collections.abc import Iterator +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker +from loguru import logger +from referencing import Registry, Resource + +from src.contract import ( + NATS_MSG_ID_HEADER, + RESULT_SUBJECT, + ErrorCode, + FileType, + MessageError, + Metrics, + OsvDocument, + RecognitionResult, + RecognitionResultMessage, + ResultStatus, +) +from src.core.config import NATSSettings +from src.core.nats import NatsConnection, NatsPublishError +from src.core.worker import RecognitionWorker, result_key +from src.parser import NullParser, ParseOutcome + +API_DIR = Path(__file__).resolve().parents[2] +CONTRACTS_DIR = API_DIR / "contracts" +EXAMPLES_DIR = CONTRACTS_DIR / "examples" + +REQUEST = json.loads((EXAMPLES_DIR / "request-tabular.json").read_text(encoding="utf-8")) +JOB_ID = REQUEST["job_id"] +FILE_ID = REQUEST["file_id"] +BUCKET = REQUEST["s3"]["bucket"] +SOURCE_KEY = REQUEST["s3"]["key"] +RESULT_KEY = f"results/{FILE_ID}/{JOB_ID}/result.json" +SERVICE = {"name": "tabular-parser", "version": "9.9.9"} +OTHER_JOB_ID = "11111111-2222-4333-8444-555555555555" +PARSER_ROWS = 3 # metrics.rows, которые возвращает подменённый парсер + + +def _validators() -> dict[str, Draft202012Validator]: + names = { + "message": "recognition-result-message.schema.json", + "result": "recognition-result.schema.json", + } + schemas = {kind: json.loads((CONTRACTS_DIR / name).read_text(encoding="utf-8")) for kind, name in names.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 = _validators() + + +# --- фейки ------------------------------------------------------------------------------------------------------------ +class FakeStorage: + """`Storage` в памяти; пишет события в общий журнал `events`.""" + + def __init__(self, events: list[tuple[str, str]]) -> None: + self.events = events + self.objects: dict[tuple[str, str], bytes] = {} + self.json_objects: dict[tuple[str, str], Any] = {} + self.get_calls: list[tuple[str, str]] = [] + self.gate: asyncio.Event | None = None # задан — get_bytes ждёт его (обработка «в процессе») + self.get_started = asyncio.Event() + + async def get_bytes(self, bucket: str, key: str) -> bytes: + self.events.append(("s3_get", key)) + self.get_calls.append((bucket, key)) + self.get_started.set() + if self.gate is not None: + await self.gate.wait() + try: + return self.objects[(bucket, key)] + except KeyError: + raise FileNotFoundError(f"NoSuchKey: {key}") from None + + async def put_json(self, bucket: str, key: str, obj: Any) -> None: + self.events.append(("s3_put", key)) + json.dumps(obj) # объект обязан быть JSON-сериализуемым + self.json_objects[(bucket, key)] = obj + + +class FakePublisher: + """`Publisher`: копит опубликованное; `fail_subjects_msg_ids` — msg_id, публикация которых падает.""" + + def __init__(self, events: list[tuple[str, str]]) -> None: + self.events = events + self.published: list[tuple[str, dict[str, Any], str]] = [] + self.fail_msg_ids: set[str] = set() + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + if msg_id in self.fail_msg_ids: + self.events.append(("publish_failed", msg_id)) + raise NatsPublishError(f"публикация {msg_id} не удалась") + body = json.loads(payload) + self.events.append(("publish", msg_id)) + self.published.append((subject, body, msg_id)) + + +class OsvParser: + """Подмена парсера (spec «Подмена реализации в тесте»): один `osvDocument` из примера контракта.""" + + def __init__(self) -> None: + example = json.loads((EXAMPLES_DIR / "result-osv-minimal.json").read_text(encoding="utf-8")) + self.document = OsvDocument.model_validate(example["documents"][0]) + self.calls: list[tuple[bytes, FileType]] = [] + + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + self.calls.append((data, file_type)) + return ParseOutcome(status=ResultStatus.DONE, documents=(self.document,), metrics=Metrics(rows=PARSER_ROWS)) + + +class ExplodingParser: + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + raise ValueError("лист без заголовка") + + +class FakeClock: + """Монотонные часы: каждое обращение сдвигает время на `step` секунд.""" + + def __init__(self, step: float = 0.25) -> None: + self.now = 100.0 + self.step = step + + def __call__(self) -> float: + self.now += self.step + return self.now + + +def _msg(body: Any, *, raw: bytes | None = None, msg_id: str | None = None) -> SimpleNamespace: + data = raw if raw is not None else json.dumps(body).encode("utf-8") + if msg_id is None and isinstance(body, dict) and "job_id" in body: + msg_id = f"{body['job_id']}:{body.get('attempt')}" + headers = {NATS_MSG_ID_HEADER: msg_id} if msg_id else None + return SimpleNamespace(subject="recognition.tabular.request", data=data, headers=headers) + + +def _request(**overrides: Any) -> dict[str, Any]: + body = copy.deepcopy(REQUEST) + body.update(overrides) + return body + + +@pytest.fixture +def log_records() -> Iterator[list[Any]]: + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + yield records + logger.remove(handler_id) + + +class Env: + def __init__(self, parser: Any = None, *, concurrency: int = 1, done_cache_size: int = 1000) -> None: + self.events: list[tuple[str, str]] = [] + self.storage = FakeStorage(self.events) + self.storage.objects[(BUCKET, SOURCE_KEY)] = b"PK\x03\x04 xlsx bytes" + self.publisher = FakePublisher(self.events) + self.worker = RecognitionWorker( + storage=self.storage, + publisher=self.publisher, + parser=parser if parser is not None else NullParser(), + service_name=SERVICE["name"], + service_version=SERVICE["version"], + concurrency=concurrency, + done_cache_size=done_cache_size, + clock=FakeClock(), + ) + + def bodies(self) -> list[dict[str, Any]]: + return [body for _subject, body, _msg_id in self.publisher.published] + + def msg_ids(self) -> list[str]: + return [msg_id for _subject, _body, msg_id in self.publisher.published] + + +@pytest.fixture +async def env() -> Any: + environment = Env() + environment.worker.start() + yield environment + await environment.worker.stop(timeout=1) + + +async def _make_env(parser: Any = None, **kwargs: Any) -> Env: + environment = Env(parser, **kwargs) + environment.worker.start() + return environment + + +def _assert_message_valid(body: dict[str, Any]) -> None: + errors = [error.message for error in VALIDATORS["message"].iter_errors(body)] + assert errors == [], errors + RecognitionResultMessage.model_validate(body) + + +# --- ack раньше результата ------------------------------------------------------------------------------------------ +async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None: + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + assert env.events == [ + ("publish", f"ack:{JOB_ID}:1"), + ("s3_get", SOURCE_KEY), + ("publish", f"result:{JOB_ID}:1"), + ] + subjects = {subject for subject, _body, _msg_id in env.publisher.published} + assert subjects == {RESULT_SUBJECT} + ack = env.bodies()[0] + assert ack == { + "schema_version": "1.0", + "job_id": JOB_ID, + "file_id": FILE_ID, + "status": "processing", + "service": SERVICE, + } + for body in env.bodies(): + _assert_message_valid(body) + + +async def test_ack_is_published_by_on_request_itself_before_queueing(env: Env) -> None: + gate = asyncio.Event() + env.storage.gate = gate + + await env.worker.on_request(_msg(_request())) + + # on_request вернулся — ack уже опубликован, хотя обработка ещё не завершена + assert env.msg_ids() == [f"ack:{JOB_ID}:1"] + gate.set() + await env.worker.join() + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + + +# --- заглушка парсера ----------------------------------------------------------------------------------------------- +async def test_null_parser_gives_not_implemented_with_duration(env: Env) -> None: + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + result = env.bodies()[1] + _assert_message_valid(result) + assert result["status"] == "error" + assert result["job_id"] == JOB_ID + assert result["file_id"] == FILE_ID + assert result["service"] == SERVICE + assert result["error"] == {"code": "NOT_IMPLEMENTED", "message": "разбор XLS/XLSX/XLSB реализуется в Срезе 2"} + assert "result_s3" not in result + assert isinstance(result["metrics"]["duration_ms"], int) + assert result["metrics"]["duration_ms"] >= 0 + assert env.storage.json_objects == {} # при ошибке файл результата не пишется + + +# --- повторный запрос ----------------------------------------------------------------------------------------------- +async def test_repeated_request_during_processing_gets_second_ack_only(env: Env) -> None: + gate = asyncio.Event() + env.storage.gate = gate + + await env.worker.on_request(_msg(_request())) + await asyncio.wait_for(env.storage.get_started.wait(), 1) + await env.worker.on_request(_msg(_request())) # та же доставка ещё раз, пока первая в работе + gate.set() + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + assert env.storage.get_calls == [(BUCKET, SOURCE_KEY)] + + +async def test_repeated_request_while_queued_is_not_processed_twice() -> None: + environment = Env() # воркер не запущен: задание стоит в очереди + await environment.worker.on_request(_msg(_request())) + await environment.worker.on_request(_msg(_request())) + environment.worker.start() + await environment.worker.join() + await environment.worker.stop(timeout=1) + + assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + assert environment.storage.get_calls == [(BUCKET, SOURCE_KEY)] + + +async def test_repeated_request_after_done_gets_ack_with_its_own_attempt(env: Env) -> None: + await env.worker.on_request(_msg(_request())) + await env.worker.join() + await env.worker.on_request(_msg(_request(attempt=2))) + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1", f"ack:{JOB_ID}:2"] + assert env.storage.get_calls == [(BUCKET, SOURCE_KEY)] + + +async def test_failed_job_counts_as_processed(env: Env) -> None: + del env.storage.objects[(BUCKET, SOURCE_KEY)] + await env.worker.on_request(_msg(_request())) + await env.worker.join() + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1", f"ack:{JOB_ID}:1"] + assert len(env.storage.get_calls) == 1 + + +async def test_done_cache_is_bounded_lru() -> None: + environment = await _make_env(done_cache_size=1) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + await environment.worker.on_request(_msg(_request(job_id=OTHER_JOB_ID))) + await environment.worker.join() + # первый job_id вытеснен из LRU размера 1 — его повтор обрабатывается снова + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + assert len(environment.storage.get_calls) == 3 # noqa: PLR2004 - три запроса: первый, второй, повтор первого + + +# --- FILE_READ_ERROR ------------------------------------------------------------------------------------------------ +async def test_missing_s3_object_gives_file_read_error_and_worker_continues(env: Env) -> None: + del env.storage.objects[(BUCKET, SOURCE_KEY)] + other_key = f"uploads/{FILE_ID}/{OTHER_JOB_ID}/osv.xlsx" + env.storage.objects[(BUCKET, other_key)] = b"bytes" + + await env.worker.on_request(_msg(_request())) + await env.worker.join() + await env.worker.on_request(_msg(_request(job_id=OTHER_JOB_ID, s3={"bucket": BUCKET, "key": other_key}))) + await env.worker.join() + + first, second = env.bodies()[1], env.bodies()[3] + _assert_message_valid(first) + assert first["status"] == "error" + assert first["error"]["code"] == "FILE_READ_ERROR" + assert SOURCE_KEY in first["error"]["message"] + assert "result_s3" not in first + assert first["metrics"]["duration_ms"] >= 0 + # следующий запрос обработан как обычно + assert second["job_id"] == OTHER_JOB_ID + assert second["error"]["code"] == "NOT_IMPLEMENTED" + + +# --- SCHEMA_MISMATCH ------------------------------------------------------------------------------------------------ +async def test_unsupported_major_version_gives_schema_mismatch_without_ack(env: Env) -> None: + await env.worker.on_request(_msg(_request(schema_version="2.0"))) + await env.worker.join() + + assert env.msg_ids() == [f"result:{JOB_ID}:1"] + result = env.bodies()[0] + _assert_message_valid(result) + assert result["status"] == "error" + assert result["schema_version"] == "1.0" + assert result["job_id"] == JOB_ID + assert result["file_id"] == FILE_ID + assert result["error"]["code"] == "SCHEMA_MISMATCH" + assert "2.0" in result["error"]["message"] + assert result["metrics"]["duration_ms"] >= 0 + assert env.storage.get_calls == [] + + +async def test_minor_version_is_accepted(env: Env) -> None: + await env.worker.on_request(_msg(_request(schema_version="1.7", extra_field="ignored"))) + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + + +@pytest.mark.parametrize( + "mutate", + [ + pytest.param(lambda body: body["s3"].pop("key"), id="no-s3-key"), + pytest.param(lambda body: body.update(file_type="DOCX"), id="unknown-file-type"), + pytest.param(lambda body: body.pop("reply_deadline"), id="no-reply-deadline"), + ], +) +async def test_request_violating_schema_gives_schema_mismatch_without_ack(env: Env, mutate: Any) -> None: + body = _request() + mutate(body) + + await env.worker.on_request(_msg(body)) + await env.worker.join() + + assert env.msg_ids() == [f"result:{JOB_ID}:1"] + result = env.bodies()[0] + _assert_message_valid(result) + assert result["error"]["code"] == "SCHEMA_MISMATCH" + assert env.storage.get_calls == [] + + +async def test_schema_mismatch_takes_attempt_from_header_when_body_attempt_is_invalid(env: Env) -> None: + await env.worker.on_request(_msg(_request(attempt=0), msg_id=f"{JOB_ID}:3")) + await env.worker.join() + + assert env.msg_ids() == [f"result:{JOB_ID}:3"] + + +@pytest.mark.parametrize( + "message", + [ + pytest.param(_msg(None, raw=b"\xff not json"), id="not-json"), + pytest.param(_msg(None, raw=b"[1, 2]"), id="json-array"), + pytest.param(_msg(_request(job_id="not-a-uuid")), id="job-id-unreadable"), + pytest.param(_msg({k: v for k, v in REQUEST.items() if k != "file_id"}), id="no-file-id"), + pytest.param(_msg(_request(attempt="x"), msg_id="garbage"), id="attempt-unreadable"), + ], +) +async def test_unreadable_request_is_logged_and_dropped(env: Env, log_records: list[Any], message: Any) -> None: + await env.worker.on_request(message) + await env.worker.join() + + assert env.publisher.published == [] + assert env.storage.get_calls == [] + errors = [record for record in log_records if record["level"].name == "ERROR"] + assert errors, "отброшенный запрос должен попасть в лог уровня error" + assert "error_category=schema_mismatch" in errors[0]["message"] + + +# --- исключение парсера --------------------------------------------------------------------------------------------- +async def test_parser_exception_gives_unknown_error_without_traceback() -> None: + environment = await _make_env(ExplodingParser()) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + result = environment.bodies()[1] + _assert_message_valid(result) + assert result["error"]["code"] == "UNKNOWN_ERROR" + assert "лист без заголовка" in result["error"]["message"] + assert "Traceback" not in result["error"]["message"] + assert "\n" not in result["error"]["message"] + assert result["metrics"]["duration_ms"] >= 0 + + +async def test_parser_is_called_in_a_thread(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[Any] = [] + original = asyncio.to_thread + + async def spy(func: Any, /, *args: Any, **kwargs: Any) -> Any: + calls.append(func) + return await original(func, *args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", spy) + parser = OsvParser() + environment = await _make_env(parser) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + assert calls == [parser.parse] + assert parser.calls == [(b"PK\x03\x04 xlsx bytes", "XLSX")] + + +# --- подмена парсера → done ----------------------------------------------------------------------------------------- +async def test_substituted_parser_gives_done_with_result_file() -> None: + parser = OsvParser() + environment = await _make_env(parser) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + assert environment.events == [ + ("publish", f"ack:{JOB_ID}:1"), + ("s3_get", SOURCE_KEY), + ("s3_put", RESULT_KEY), + ("publish", f"result:{JOB_ID}:1"), + ] + result_file = environment.storage.json_objects[(BUCKET, RESULT_KEY)] + errors = [error.message for error in VALIDATORS["result"].iter_errors(result_file)] + assert errors == [], errors + parsed = RecognitionResult.model_validate(result_file) + assert parsed.status is ResultStatus.DONE + assert parsed.documents == [parser.document] + assert str(parsed.job_id) == JOB_ID + assert result_file["service"] == SERVICE + assert result_file["metrics"]["rows"] == PARSER_ROWS + + envelope = environment.bodies()[1] + _assert_message_valid(envelope) + assert envelope["status"] == "done" + assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY} + assert envelope["result_schema_version"] == "1.0" + assert "error" not in envelope + assert envelope["metrics"]["rows"] == PARSER_ROWS + assert envelope["metrics"]["duration_ms"] >= 0 + + +async def test_needs_check_outcome_is_propagated() -> None: + parser = OsvParser() + + class NeedsCheckParser: + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + return ParseOutcome(status=ResultStatus.NEEDS_CHECK, documents=(parser.document,)) + + environment = await _make_env(NeedsCheckParser()) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + envelope = environment.bodies()[1] + _assert_message_valid(envelope) + assert envelope["status"] == "needs_check" + assert environment.storage.json_objects[(BUCKET, RESULT_KEY)]["status"] == "needs_check" + + +async def test_error_outcome_metrics_are_kept_and_duration_added() -> None: + class ErrorWithMetricsParser: + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + return ParseOutcome( + status=ResultStatus.ERROR, + error=MessageError(code=ErrorCode.INVALID_FORMAT, message="не ОСВ"), + metrics=Metrics(warnings=["лист пуст"]), + ) + + environment = await _make_env(ErrorWithMetricsParser()) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + envelope = environment.bodies()[1] + _assert_message_valid(envelope) + assert envelope["error"] == {"code": "INVALID_FORMAT", "message": "не ОСВ"} + assert envelope["metrics"]["warnings"] == ["лист пуст"] + assert envelope["metrics"]["duration_ms"] >= 0 + + +async def test_result_file_write_failure_gives_error_without_result_s3() -> None: + environment = await _make_env(OsvParser()) + + async def broken_put(bucket: str, key: str, obj: Any) -> None: + raise ConnectionError("S3 недоступен") + + environment.storage.put_json = broken_put # type: ignore[method-assign] + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + envelope = environment.bodies()[1] + _assert_message_valid(envelope) + assert envelope["status"] == "error" + assert envelope["error"]["code"] == "UNKNOWN_ERROR" + assert "result_s3" not in envelope + + +def test_result_key_template() -> None: + assert result_key(FILE_ID, JOB_ID) == RESULT_KEY + + +# --- публикация: повтор и исчерпание попыток ------------------------------------------------------------------------ +async def test_result_publish_failure_is_logged_and_worker_continues(env: Env, log_records: list[Any]) -> None: + env.publisher.fail_msg_ids.add(f"result:{JOB_ID}:1") + env.storage.objects[(BUCKET, "uploads/other.xlsx")] = b"bytes" + + await env.worker.on_request(_msg(_request())) + await env.worker.join() + await env.worker.on_request(_msg(_request(job_id=OTHER_JOB_ID, s3={"bucket": BUCKET, "key": "uploads/other.xlsx"}))) + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"ack:{OTHER_JOB_ID}:1", f"result:{OTHER_JOB_ID}:1"] + errors = [record["message"] for record in log_records if record["level"].name == "ERROR"] + assert any(f"result:{JOB_ID}:1" in message for message in errors) + + +async def test_ack_publish_failure_is_logged_and_job_still_processed(env: Env, log_records: list[Any]) -> None: + env.publisher.fail_msg_ids.add(f"ack:{JOB_ID}:1") + + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + assert env.msg_ids() == [f"result:{JOB_ID}:1"] + assert env.storage.get_calls == [(BUCKET, SOURCE_KEY)] + errors = [record["message"] for record in log_records if record["level"].name == "ERROR"] + assert any(f"ack:{JOB_ID}:1" in message for message in errors) + + +class _FlakyNatsClient: + """Клиент nats-py для `NatsConnection`: первая публикация результата падает, остальные проходят.""" + + def __init__(self) -> None: + self.is_connected = False + self.is_closed = False + self.attempts: list[tuple[str, dict[str, str]]] = [] + self.delivered: list[tuple[str, dict[str, str]]] = [] + self._failed_once = False + + async def connect(self, *_args: Any, **_kwargs: Any) -> None: + self.is_connected = True + + async def publish(self, subject: str, payload: bytes, headers: dict[str, str]) -> None: + msg_id = headers[NATS_MSG_ID_HEADER] + self.attempts.append((msg_id, headers)) + if msg_id.startswith("result:") and not self._failed_once: + self._failed_once = True + raise ConnectionError("соединение сброшено") + self.delivered.append((msg_id, json.loads(payload))) + + async def flush(self, timeout: float) -> 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 + + +async def test_result_publish_is_retried_with_same_msg_id_through_nats_connection() -> None: + client = _FlakyNatsClient() + pauses: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + pauses.append(seconds) + + connection = NatsConnection( + NATSSettings(NATS_URL="nats://fake:4222"), + client_factory=lambda: client, + sleep=fake_sleep, + publish_retry_pause=2.0, + ) + await connection.start() + assert await connection.wait_ready(1) + events: list[tuple[str, str]] = [] + storage = FakeStorage(events) + storage.objects[(BUCKET, SOURCE_KEY)] = b"bytes" + worker = RecognitionWorker( + storage=storage, + publisher=connection, + parser=NullParser(), + service_name=SERVICE["name"], + service_version=SERVICE["version"], + ) + worker.start() + try: + await worker.on_request(_msg(_request())) + await worker.join() + finally: + await worker.stop(timeout=1) + await connection.stop() + + assert [msg_id for msg_id, _headers in client.attempts] == [ + f"ack:{JOB_ID}:1", + f"result:{JOB_ID}:1", + f"result:{JOB_ID}:1", + ] + assert [msg_id for msg_id, _body in client.delivered] == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + assert pauses == [2.0] + assert storage.get_calls == [(BUCKET, SOURCE_KEY)] # обработка не повторялась + + +# --- конкуренция и остановка ---------------------------------------------------------------------------------------- +async def test_concurrency_processes_jobs_in_parallel() -> None: + JOBS = 2 # noqa: N806 - число одновременных заданий + environment = await _make_env(concurrency=2) + gate = asyncio.Event() + environment.storage.gate = gate + started: list[str] = [] + original_get = environment.storage.get_bytes + + async def tracking_get(bucket: str, key: str) -> bytes: + started.append(key) + return await original_get(bucket, key) + + environment.storage.get_bytes = tracking_get # type: ignore[method-assign] + other_key = "uploads/other.xlsx" + environment.storage.objects[(BUCKET, other_key)] = b"bytes" + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.on_request( + _msg(_request(job_id=OTHER_JOB_ID, s3={"bucket": BUCKET, "key": other_key})) + ) + for _ in range(50): + if len(started) == JOBS: + break + await asyncio.sleep(0.01) + assert len(started) == JOBS # оба задания одновременно ждут S3 + gate.set() + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + +async def test_stop_waits_for_in_flight_job_then_rejects_new_requests() -> None: + environment = await _make_env() + gate = asyncio.Event() + environment.storage.gate = gate + await environment.worker.on_request(_msg(_request())) + await asyncio.wait_for(environment.storage.get_started.wait(), 1) + + stop_task = asyncio.create_task(environment.worker.stop(timeout=5)) + await asyncio.sleep(0) + # после начала остановки новые запросы не принимаются (без ack — ядро переотправит другой инстанции) + await environment.worker.on_request(_msg(_request(job_id=OTHER_JOB_ID))) + gate.set() + await asyncio.wait_for(stop_task, 2) + + assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + + +async def test_stop_cancels_jobs_that_do_not_finish_in_time(log_records: list[Any]) -> None: + environment = await _make_env() + environment.storage.gate = asyncio.Event() # никогда не откроется + await environment.worker.on_request(_msg(_request())) + await environment.worker.on_request(_msg(_request(job_id=OTHER_JOB_ID))) + await asyncio.wait_for(environment.storage.get_started.wait(), 1) + + await asyncio.wait_for(environment.worker.stop(timeout=0.05), 2) + + assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"ack:{OTHER_JOB_ID}:1"] + warnings = [record["message"] for record in log_records if record["level"].name == "WARNING"] + assert any("pending=1" in message for message in warnings) + + +async def test_stop_is_idempotent_and_safe_without_start() -> None: + environment = Env() + await environment.worker.stop(timeout=0.1) + await environment.worker.stop(timeout=0.1) -- 2.47.2 From a7862c1843883d2405b573b243e219bcf06b842d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:09:06 +0400 Subject: [PATCH 27/50] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=B2=D0=BE=D1=80=D0=BA=D0=B5=D1=80=D0=B0=20=D0=B2=20?= =?UTF-8?q?lifespan=20=D0=BF=D1=80=D0=B8=20NATS=5FENABLED=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B5=20nats=20=3D=20ok=20|=20down=20|=20off?= =?UTF-8?q?=20=D0=B2=20/healthcheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подписка — через on_connected NatsConnection; остановка: воркер (дорабатывает очередь) → NATS → S3. Клиент S3 остаётся и при неудачной проверке бакета, отсутствие бакета — warning (T2.1). Тест lifespan и healthcheck с фейками (T8.7); формулировки nats в README, .env.example и spec приведены к R-17. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +- api/.env.example | 2 +- api/src/app.py | 88 +++++++-- api/src/core/nats_callback.py | 19 -- api/tests/unit/test_app.py | 179 ++++++++++++++++++ .../specs/tabular-recognition-service/spec.md | 3 +- 6 files changed, 255 insertions(+), 45 deletions(-) delete mode 100644 api/src/core/nats_callback.py create mode 100644 api/tests/unit/test_app.py diff --git a/README.md b/README.md index 793221d..c070aeb 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ Stateless-сервис распознавания табличных файло ``` api/ бэкенд (все команды разработки — отсюда или через make из корня) run_server.py точка входа платформы (uvicorn; SERVER__HOST, SERVER__PORT) - src/app.py FastAPI: lifespan (подключение к MinIO и NATS), GET /healthcheck, middleware, обработчики ошибок - src/core/ config.py (настройки), clients.py (S3), logger.py, exception_handlers.py + src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок + src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер + распознавания), logger.py, exception_handlers.py src/parser/ интерфейс Parser и реализации (добавляется задачей 4.1) src/contract/ pydantic-модели контракта v1.0 (добавляется задачей 3.1) contracts/ копии JSON-схем контракта (добавляется задачей 3.1) @@ -108,8 +109,8 @@ cd api && .venv/bin/python run_server.py curl -s localhost:8000/healthcheck ``` -В ответе — `uptime`, `version` и (после добавления воркера, задача 4.3) `nats`: `ok` — подключён, `down` — включён -(`NATS_ENABLED=true`), но связи нет, `off` — выключен. +В ответе — `uptime`, `version` и `nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет, +`off` — выключен (`NATS_ENABLED=false`, воркер не запущен). Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS восстанавливается автоматически (бесконечное автопереподключение). diff --git a/api/.env.example b/api/.env.example index 17adc3a..fa55ae2 100644 --- a/api/.env.example +++ b/api/.env.example @@ -27,7 +27,7 @@ ROOT_PATH=/ VERSION=1.0.0 # `service.name` конвертов контракта и имя queue group NATS; менять не нужно, пока контракт v1.x SERVICE_NAME=tabular-parser -# false — не подключаться к NATS (воркер не стартует; /healthcheck отдаёт nats = down) +# false — не подключаться к NATS (воркер не стартует; /healthcheck отдаёт nats = off) NATS_ENABLED=true # Число заданий, обрабатываемых одновременно WORKER_CONCURRENCY=1 diff --git a/api/src/app.py b/api/src/app.py index b3e2592..1b4ca99 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,15 +1,18 @@ """Точка входа FastAPI сервиса tabular-parser. Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: nkeys JWT, пустые seed/jwt — без -аутентификации, фоновое подключение — старт не ждёт NATS), middleware (gzip, proxy headers), healthcheck, обработчики -исключений. Сервис без БД и фронта (DECISION-4). Воркер обработки `recognition.tabular.request` и поле `nats` -в healthcheck добавляются в задаче 4.3. +аутентификации, фоновое подключение — старт не ждёт NATS), воркер `src/core/worker.py` (подписка на +`recognition.tabular.request` после подключения к NATS), middleware (gzip, proxy headers), `/healthcheck` +(`nats = ok | down | off`, R-17), обработчики исключений. Сервис без БД и фронта (DECISION-4). + +При `NATS_ENABLED=false` ни NATS, ни воркер не запускаются. Остановка: воркер перестаёт принимать запросы и +дорабатывает очередь (NATS ещё нужен для публикации результатов), затем закрывается NATS (drain), затем сессия S3. """ from contextlib import asynccontextmanager from datetime import UTC, datetime -from fastapi import FastAPI +from fastapi import FastAPI, Request from loguru import logger from miniopy_async import Minio from starlette.middleware.gzip import GZipMiddleware @@ -21,6 +24,8 @@ from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers from src.core.logger import setup_logger from src.core.nats import NatsConnection +from src.core.worker import STOP_TIMEOUT, RecognitionWorker +from src.parser import get_parser settings = get_settings() @@ -30,8 +35,12 @@ UP_TIME = datetime.now(UTC) setup_logger(level="DEBUG" if settings.DEBUG else "INFO") -async def _connect_s3() -> S3Storage | None: - """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" +async def _connect_s3() -> S3Storage: + """Клиент S3 с проверкой бакета. + + Проверка — только диагностика: при недоступном MinIO или отсутствующем бакете ошибка логируется, а клиент + остаётся (соединения открываются на каждый запрос) — когда MinIO поднимется, воркер прочитает файл без рестарта. + """ minio = settings.OPENBAO.MINIO s3_client = Minio( minio.MINIO_URL, @@ -48,40 +57,79 @@ async def _connect_s3() -> S3Storage | None: error_type=type(exc).__name__, error=repr(exc), ) - await s3_client.close_session() - return None - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + else: + if bucket_exists: + logger.info("Бакет проверен. bucket={bucket}", bucket=minio.MINIO_BUCKET) + else: + logger.warning( + "Бакет не найден: чтение исходников и запись результатов будут падать. error_category=s3_error, " + "bucket={bucket}", + bucket=minio.MINIO_BUCKET, + ) return S3Storage(client=s3_client, bucket_name=minio.MINIO_BUCKET) +def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker]: + """Соединение NATS и воркер; подписка на запросы — в `on_connected` (после первого подключения).""" + + async def subscribe(conn: NatsConnection) -> None: + await conn.subscribe_requests(worker.on_request) + + nats = NatsConnection(settings.OPENBAO.NATS, on_connected=subscribe, publish_retries=settings.PUBLISH_RETRIES) + worker = RecognitionWorker( + storage=storage, + publisher=nats, + parser=get_parser(), + service_name=settings.SERVICE_NAME, + service_version=VERSION, + concurrency=settings.WORKER_CONCURRENCY, + done_cache_size=settings.DONE_CACHE_SIZE, + ) + return nats, worker + + @asynccontextmanager async def lifespan(app: FastAPI): - app.state.storage = await _connect_s3() - app.state.nats = None + storage = await _connect_s3() + nats: NatsConnection | None = None + worker: RecognitionWorker | None = None if settings.NATS_ENABLED: + nats, worker = _build_worker(storage) + worker.start() # start() не ждёт NATS: при недоступном сервере подключение продолжается в фоне (design D-6) - app.state.nats = NatsConnection(settings.OPENBAO.NATS, publish_retries=settings.PUBLISH_RETRIES) - await app.state.nats.start() + await nats.start() else: - logger.info("NATS выключен (NATS_ENABLED=false)") + logger.info("NATS выключен (NATS_ENABLED=false): воркер не запускается") + app.state.storage = storage + app.state.nats = nats + app.state.worker = worker try: logger.info("Приложение запущено") yield finally: logger.info("Приложение закрывается") - if app.state.nats: - await app.state.nats.stop() - if app.state.storage: - await app.state.storage.client.close_session() + if worker is not None: + await worker.stop(STOP_TIMEOUT) # результаты ещё публикуются: NATS закрывается после воркера + if nats is not None: + await nats.stop() + await storage.client.close_session() app = FastAPI(title="tabular-parser", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) +def _nats_state(application: FastAPI) -> str: + """`off` — NATS выключен флагом (R-17); `ok` — соединение есть; `down` — включён, но связи нет.""" + if not settings.NATS_ENABLED: + return "off" + nats = getattr(application.state, "nats", None) + return "ok" if nats is not None and nats.is_connected else "down" + + @app.get("/healthcheck", status_code=200) -async def healthcheck(): - return {"uptime": UP_TIME, "version": VERSION} +async def healthcheck(request: Request): + return {"uptime": UP_TIME, "version": VERSION, "nats": _nats_state(request.app)} app.include_router(router) diff --git a/api/src/core/nats_callback.py b/api/src/core/nats_callback.py deleted file mode 100644 index e3420b5..0000000 --- a/api/src/core/nats_callback.py +++ /dev/null @@ -1,19 +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/tests/unit/test_app.py b/api/tests/unit/test_app.py new file mode 100644 index 0000000..f89ff75 --- /dev/null +++ b/api/tests/unit/test_app.py @@ -0,0 +1,179 @@ +"""Unit-тесты lifespan и `/healthcheck` приложения без NATS и MinIO (design D-6, R-17; carry-over T8.7). + +`NatsConnection`, `_connect_s3` и `RecognitionWorker` модуля `src.app` подменяются фейками: проверяется, что при +`NATS_ENABLED=true` воркер запускается и подписывается через `on_connected`, порядок остановки +(воркер → NATS → S3) и значения `nats = ok | down | off`. +""" + +import importlib +import json +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import httpx +import pytest + +from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.worker import RecognitionWorker + +EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples" +REQUEST = json.loads((EXAMPLES_DIR / "request-tabular.json").read_text(encoding="utf-8")) +JOB_ID = REQUEST["job_id"] +app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src` + + +@asynccontextmanager +async def running_app(module: Any) -> AsyncIterator[httpx.AsyncClient]: + """Lifespan приложения + HTTP-клиент поверх ASGI (без сети).""" + application = module.app + async with application.router.lifespan_context(application): + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +class FakeStorage: + def __init__(self, events: list[str]) -> None: + self.events = events + self.client = SimpleNamespace(close_session=self._close) + self.objects = {(REQUEST["s3"]["bucket"], REQUEST["s3"]["key"]): b"xlsx"} + + async def _close(self) -> None: + self.events.append("s3.close") + + async def get_bytes(self, bucket: str, key: str) -> bytes: + return self.objects[(bucket, key)] + + async def put_json(self, bucket: str, key: str, obj: Any) -> None: + raise AssertionError("NullParser не пишет файл результата") + + +class FakeNatsConnection: + """Повторяет интерфейс `NatsConnection`, нужный lifespan и воркеру.""" + + instances: list["FakeNatsConnection"] = [] + connect_on_start = True + events: list[str] = [] # общий журнал порядка запуска и остановки (задаётся фикстурой) + + def __init__( + self, + settings: Any, + *, + on_connected: Callable[["FakeNatsConnection"], Awaitable[None]] | None = None, + publish_retries: int = 3, + ) -> None: + self.settings = settings + self.on_connected = on_connected + self.publish_retries = publish_retries + self.is_connected = False + self.handlers: list[tuple[str, Callable[[Any], Awaitable[None]]]] = [] + self.published: list[tuple[str, dict[str, Any], str]] = [] + FakeNatsConnection.instances.append(self) + + async def start(self) -> None: + self.events.append("nats.start") + if self.connect_on_start: + self.is_connected = True + if self.on_connected is not None: + await self.on_connected(self) + + async def stop(self) -> None: + self.events.append("nats.stop") + self.is_connected = False + + async def subscribe_requests(self, cb: Callable[[Any], Awaitable[None]]) -> None: + self.handlers.append(("recognition.tabular.request", cb)) + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + self.published.append((subject, json.loads(payload), msg_id)) + + +@pytest.fixture +def events() -> list[str]: + return [] + + +@pytest.fixture +def patched_app(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> Iterator[Any]: + """`src.app` с фейковыми S3, NATS и записывающим порядок остановки воркером.""" + FakeNatsConnection.instances = [] + FakeNatsConnection.connect_on_start = True + FakeNatsConnection.events = events + storage = FakeStorage(events) + + async def fake_connect_s3() -> FakeStorage: + return storage + + class RecordingWorker(RecognitionWorker): + async def stop(self, timeout: float = 10.0) -> None: + events.append("worker.stop") + await super().stop(timeout) + + monkeypatch.setattr(app_module, "_connect_s3", fake_connect_s3) + monkeypatch.setattr(app_module, "NatsConnection", FakeNatsConnection) + monkeypatch.setattr(app_module, "RecognitionWorker", RecordingWorker) + yield app_module + + +async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off( + patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str] +) -> None: + monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", False) + + async with running_app(patched_app) as client: + response = await client.get("/healthcheck") + assert patched_app.app.state.worker is None + assert patched_app.app.state.nats is None + + assert response.status_code == 200 # noqa: PLR2004 - HTTP 200 + body = response.json() + assert set(body) == {"uptime", "version", "nats"} + assert body["nats"] == "off" + assert body["version"] == patched_app.settings.VERSION + assert FakeNatsConnection.instances == [] + assert events == ["s3.close"] + + +async def test_nats_enabled_subscribes_worker_and_reports_ok( + patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str] +) -> None: + monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", True) + + async with running_app(patched_app) as client: + assert (await client.get("/healthcheck")).json()["nats"] == "ok" + (connection,) = FakeNatsConnection.instances + assert connection.publish_retries == patched_app.settings.PUBLISH_RETRIES + assert connection.handlers, "воркер должен подписаться через on_connected" + worker = patched_app.app.state.worker + assert isinstance(worker, RecognitionWorker) + + # Запрос, доставленный подписке, проходит через воркер: ack и результат NOT_IMPLEMENTED в recognition.result + _subject, handler = connection.handlers[0] + message = SimpleNamespace( + data=json.dumps(REQUEST).encode(), headers={NATS_MSG_ID_HEADER: f"{JOB_ID}:1"}, subject=_subject + ) + await handler(message) + await worker.join() + + assert [(subject, msg_id) for subject, _body, msg_id in connection.published] == [ + (RESULT_SUBJECT, f"ack:{JOB_ID}:1"), + (RESULT_SUBJECT, f"result:{JOB_ID}:1"), + ] + assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED" + assert connection.published[1][1]["service"]["name"] == "tabular-parser" + # Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3 + assert events == ["nats.start", "worker.stop", "nats.stop", "s3.close"] + + +async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(patched_app.settings, "NATS_ENABLED", True) + FakeNatsConnection.connect_on_start = False + + async with running_app(patched_app) as client: + assert (await client.get("/healthcheck")).json()["nats"] == "down" + (connection,) = FakeNatsConnection.instances + assert connection.handlers == [] # подписка — только после подключения + assert patched_app.app.state.worker is not None diff --git a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md index 51282d1..f3b8f8f 100644 --- a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md @@ -80,7 +80,8 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р ### Requirement: Соединение с NATS по правилам шаблона, без JetStream При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать -подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`). +подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`; `off` — NATS выключен флагом +`NATS_ENABLED=false`, R-17). #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса -- 2.47.2 From f7cbe968e11e5d4b960ade13692b168f594b949b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:09:06 +0400 Subject: [PATCH 28/50] =?UTF-8?q?chore:=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20core/nats=5Fcallback.py=20(R-4),=20CLAUDE.md=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D0=B0=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed6efb2..cb33e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,8 +28,8 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( `core/config.py` (pydantic-settings, `OPENBAO__MINIO__*` включая `MINIO_SECURE`, `OPENBAO__NATS__*`, `DEBUG`, `ENV_FILE`, `NATS_ENABLED`, `WORKER_CONCURRENCY`, `PUBLISH_RETRIES`, `DONE_CACHE_SIZE`), `core/clients.py` (S3), `core/logger.py`, `core/exception_handlers.py` (без SQLAlchemy), `common/exceptions.py`, `pytest.ini`, `requirements*.txt`, `raisa_config.yml`, -`setup/setup.sh` + `pip.conf` (без npm-шага). Шаблонный `core/nats_callback.py` пока на месте, удаляется задачей 4.3 — -его заменит воркер. +`setup/setup.sh` + `pip.conf` (без npm-шага). Шаблонный `core/nats_callback.py` (consumer `recognition.result` — зона +ядра) удалён задачей 4.3: его место занял воркер `core/worker.py`. **Вырезано (Срез 0):** `web/` целиком и `setup/.npmrc`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`), CORS, регистрация SMB-сессии, импорт `raisa_fastapi_protected_api`, зависимость от SQLAlchemy в обработчиках ошибок; -- 2.47.2 From cfba21b615435110d70247396148abd4ffed3f43 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:09:06 +0400 Subject: [PATCH 29/50] =?UTF-8?q?docs:=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20P-1=E2=80=93P-3=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D1=83?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=204.3;?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20Task=209=20/=20?= =?UTF-8?q?4.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../plans/2026-10-03-slice-0-skeleton.md | 2 +- .../open-questions-proposal.md | 47 +++++++++++++++++++ openspec/changes/slice-0-skeleton/tasks.md | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/slice-0-skeleton/open-questions-proposal.md diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index f158944..3bd1982 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -51,7 +51,7 @@ ### Task 9: [4.3] `src/core/worker.py` -- [ ] `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing` → очередь; рабочая корутина: S3 get → `Parser` → S3 put результата → конверт → publish; таблица ошибок D-5; `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`; `/healthcheck.nats`; проверка: unit-тесты с фейками — сценарии spec (ack раньше результата, повторный запрос, NOT_IMPLEMENTED, FILE_READ_ERROR, SCHEMA_MISMATCH без ack, подмена парсера → `done`, повтор публикации) +- [x] `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing` → очередь; рабочая корутина: S3 get → `Parser` → S3 put результата → конверт → publish; таблица ошибок D-5; `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`; `/healthcheck.nats`; проверка: unit-тесты с фейками — сценарии spec (ack раньше результата, повторный запрос, NOT_IMPLEMENTED, FILE_READ_ERROR, SCHEMA_MISMATCH без ack, подмена парсера → `done`, повтор публикации) ### Task 10: [4.4] `tests/integration/test_roundtrip.py` (маркер `integration`) 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..92a6c2b --- /dev/null +++ b/openspec/changes/slice-0-skeleton/open-questions-proposal.md @@ -0,0 +1,47 @@ +# Предложения по открытым вопросам и контракту (из реализации `slice-0-skeleton`) + +Файлы `../docs` этот репозиторий не меняет: здесь — предложения для `docs/context/07-open-questions.md` и +`docs/contracts/`; решение принимает человек / контроллер docs. + +## P-1. Невалидный запрос без читаемых `job_id` / `file_id` / `attempt` (задача 4.3) + +**Что видно в коде.** D-5 и spec требуют на запрос, не прошедший схему, ответить конвертом `error SCHEMA_MISMATCH` +«если `job_id` читаем». Но `recognition-result-message.schema.json` требует ещё `file_id`, а `Nats-Msg-Id` результата — +`result::`. Если хоть одно из трёх не читается (тело не JSON, не объект, `job_id`/`file_id` не uuid, +`attempt` нет ни в теле, ни в заголовке запроса `Nats-Msg-Id = :`), валидный конверт собрать нельзя. + +**Как сделано сейчас.** Значения не выдумываются: запрос логируется уровнем error (`error_category=schema_mismatch`, +причина, размер тела) и отбрасывается без ack и без результата. Ядро без ack повторит запрос `OUTBOX_MAX_REPUBLISH` +раз и переведёт задание в `dlq` — то есть «молчание» сервиса превращается в `dlq` на стороне ядра. +`attempt` при невалидном значении в теле берётся из заголовка запроса. + +**Предложение.** Записать это в контракт явно (раздел «Сообщение не по схеме»): «если `job_id`, `file_id` или +`attempt` не читаемы, сервис логирует ошибку и не отвечает; ядро обнаружит задание по `dlq`/`timeout`». Альтернатива — +сделать `file_id` необязательным в конверте `error` (MINOR 1.1) — выглядит избыточной: ядро всегда шлёт корректные +идентификаторы, нечитаемое тело — это сбой транспорта, а не данных. + +## P-2. Противоречие: ack при несовместимой версии запроса (`schema_version = "2.0"`) + +- `docs/openspec/changes/slice-0-skeleton/specs/recognition-contract/spec.md`, сценарий «Несовместимая версия запроса»: + «публикуется результат `SCHEMA_MISMATCH`, **запрос подтверждается (ack)**, чтобы не зациклить доставку». +- `openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md` (этот репозиторий), сценарий + «Несовместимая версия»: «публикуется конверт `SCHEMA_MISMATCH`, **ack не публикуется**»; D-5 — так же. + +**Как сделано сейчас.** По спеке этого репозитория и брифу задачи: без ack, только результат `error SCHEMA_MISMATCH`. +Зацикливания нет: ядро (`arm-core`, `recognition_exchange`) принимает результат и в статусе `published` («потерянный +ack не блокирует результат») и закрывает задание, повторов не будет. + +**Предложение.** Привести сценарий контракта в docs к «ack не публикуется; результат `SCHEMA_MISMATCH` закрывает +задание» (или наоборот — тогда правка одной ветки в `worker.py`). Нужно решение человека. + +## P-3. Повторная попытка (`attempt + 1`) того же `job_id` после ошибки (задача 4.3) + +**Как сделано сейчас.** Дедупликация — по `job_id` (spec: «уже обрабатывается или обработан этим процессом»), любой +итог (в том числе `error` и неудачная публикация результата) считается «обработан». Повторный запрос того же `job_id` +с другим `attempt` в этом процессе получает только ack `ack::`, без обработки, пока +`job_id` не вытеснен из LRU (`DONE_CACHE_SIZE`). Другая инстанция queue group обработает его как новый. + +**Вопрос.** Если ядро (Срез 3+, replay/«переотправить») будет повторять задание тем же `job_id` с `attempt + 1`, ключ +дедупликации должен стать `(job_id, attempt)`. Предложение: зафиксировать в контракте, что новая попытка — это новый +`attempt` и сервис обрабатывает её заново; тогда в воркере ключ LRU меняется на `(job_id, attempt)` (одна строка). +Пока ядро повторяет только тем же `attempt` (повтор без ack), текущее поведение корректно. diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 47cd053..0c7fba1 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -38,7 +38,7 @@ подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком -- [ ] 4.3 `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing` +- [x] 4.3 `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing` → очередь; рабочая корутина: S3 get → `Parser` → S3 put результата → конверт → publish; таблица ошибок D-5; `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`; `/healthcheck.nats`; проверка: unit-тесты с фейками — сценарии spec (ack раньше результата, повторный запрос, NOT_IMPLEMENTED, FILE_READ_ERROR, SCHEMA_MISMATCH без -- 2.47.2 From e2d31b894cd741af0b8c44b400d04fea2175fdb5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:10:59 +0400 Subject: [PATCH 30/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-18..R-26=20(=D0=B2=D0=BE=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80,=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=204.3)=20=D0=B2?= =?UTF-8?q?=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index d6d0abd..df5fb47 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -104,3 +104,12 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 22 | 2026-10-04 | R-15: фоновый supervisor подключения к NATS в `core/nats.py` (по образцу arm-core) вместо `retry_on_failed_connect`; половинные учётные данные (только seed или только jwt) — `NatsConfigError` | проверено по исходникам nats-py 2.14; повтор подключения с неполными данными бесполезен | замена на штатный параметр nats-py при его появлении | | 23 | 2026-10-04 | R-16: `PUBLISH_RETRIES=3` — это три повтора после первой попытки (до 4 попыток всего, пауза 2 с между ними); публикация при отсутствии соединения падает сразу и учитывается как неудачная попытка | дословное чтение spec «повторить её до трёх раз» и D-5; окно ≈ 6 с короче типичного рестарта NATS — результат при рестарте теряется, ядро закроет задание по `timeout` (в рамках D-5) | смена одной константы/цикла | | 24 | 2026-10-04 | R-17: `/healthcheck.nats` принимает значения `ok | down | off` (`off` — при `NATS_ENABLED=false`, как в arm-core); spec/README/`.env.example` приводятся к этому | спека определяет только `ok | down`, но выключенный по флагу NATS не является отказом | убрать `off`, отдавать `down` | +| 25 | 2026-10-04 | R-18 (D9-1, P-1): невалидный запрос без читаемых `job_id`/`file_id`/`attempt` логируется (error) и отбрасывается — без ack и результата; `attempt` берётся из тела, иначе из заголовка `Nats-Msg-Id` | схема конверта требует `file_id`, id сообщения — `attempt`; выдумывать значения нельзя | задание дойдёт до `dlq` вместо `error`; правка — одна ветка `_reject`; предложение в контракт P-1 | +| 26 | 2026-10-04 | R-19 (D9-2): MAJOR `schema_version` проверяется до pydantic-валидации; ответ `SCHEMA_MISMATCH` с понятным текстом | диагностика для аналитика/оператора | нет | +| 27 | 2026-10-04 | R-20 (D9-3, P-2): `SCHEMA_MISMATCH` для `2.0` — без ack (spec этого репозитория, D-5); спека контракта в docs говорит «с ack» | этот репозиторий — источник истины; ядро принимает результат и без ack | одна строка, если решат «с ack»; противоречие вынесено в P-2 | +| 28 | 2026-10-04 | R-21 (D9-4, P-3): завершённое задание с любым итогом считается «обработано» (LRU `DONE_CACHE_SIZE`, ключ `job_id`); повтор — только ack; `_in_progress` не вытесняется | spec: повтор с обработанным `job_id` получает ack | новая попытка (`attempt+1`) того же `job_id` в этом процессе не обрабатывается, пока он в LRU (P-3) | +| 29 | 2026-10-04 | R-22 (D9-5): неудача публикации ack после повторов — error-лог, задание всё равно обрабатывается | результат нужнее ack; ядро принимает результат без ack | при долгом отказе NATS результат тоже не опубликуется — `timeout` в ядре (D-5) | +| 30 | 2026-10-04 | R-23 (D9-6): `_connect_s3` всегда возвращает `S3Storage`, `bucket_exists=False` — warning | при недоступном MinIO на старте воркер иначе отвечал бы `FILE_READ_ERROR` до рестарта; miniopy-async открывает соединения на запрос | вернуть ветку `storage is None` | +| 31 | 2026-10-04 | R-24 (D9-7): файл результата пишется только для `done | needs_check`; сбой его записи — `UNKNOWN_ERROR` | spec: «файл результата может отсутствовать» при error; отдельного кода в D-5 нет | смена кода в одном месте | +| 32 | 2026-10-04 | R-25 (D9-8): остановка воркера — запрет приёма, ожидание очереди до `STOP_TIMEOUT=10 с`, затем отмена; NATS закрывается после воркера, S3 — последним | меньше grace-периода платформы (30 с), больше одной публикации с повторами (~6 с) | константа | +| 33 | 2026-10-04 | R-26 (D9-9): `error.message` = `Тип: текст` исключения одной строкой, до 1000 символов, без трассировки | spec: сообщение без трассировки | нет | -- 2.47.2 From 82756b8c977766ff57b409b58113f03ba4bf64a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:15:53 +0400 Subject: [PATCH 31/50] =?UTF-8?q?docs:=20D-6=20=E2=80=94=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20nats=3Doff=20=D0=B2=20he?= =?UTF-8?q?althcheck=20(R-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index df5fb47..80ad36d 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -48,7 +48,7 @@ JetStream: недоступна. ### D-6. Соединение с NATS — как у ядра, без JetStream Пустые `NATS_SEED/NATS_JWT` → без учётных данных (dev); иначе nkeys/JWT шаблона. Автопереподключение `nats-py` -(`max_reconnect_attempts=-1`); при разрыве подписка восстанавливается клиентом. `/healthcheck` отдаёт `nats = ok | down`. +(`max_reconnect_attempts=-1`); при разрыве подписка восстанавливается клиентом. `/healthcheck` отдаёт `nats = ok | down | off` (`off` — при `NATS_ENABLED=false`, R-17). Флаг `NATS_ENABLED` (по умолчанию `true`) — чтобы unit-тесты поднимали приложение без NATS. ### D-7. Тесты в два уровня -- 2.47.2 From 18276e607e012f722204f4350575254c312ef541 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:17:11 +0400 Subject: [PATCH 32/50] =?UTF-8?q?fix:=20=D0=B2=D0=BE=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=20=E2=80=94=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B5=20=D0=B2?= =?UTF-8?q?=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20ack,=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D1=8B=D0=B9=20=D0=B4=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D1=8C=20job=5Fid=20=D0=BD=D0=B5=20=D0=BF=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=BA=D1=83=D0=B5=D1=82=20SCHEMA=5FMISMATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I9.1: после ack повторная проверка приёма — warning error_category=shutdown, job_id снимается из «в работе». T9.7: невалидное тело с job_id в работе/обработанным — warning и сброс (msg-id настоящего результата не занимается). T9.5: error.message исхода парсера — одной строкой, ≤ 1000 символов. T9.6: docstring stop() про to_thread. T9.1: тест конкуренции без опроса со sleep. Co-Authored-By: Claude Fable 5.1 --- api/src/core/worker.py | 42 ++++++++++++--- api/tests/unit/test_worker.py | 98 ++++++++++++++++++++++++++++++++--- 2 files changed, 127 insertions(+), 13 deletions(-) diff --git a/api/src/core/worker.py b/api/src/core/worker.py index 4eaed96..83bb91e 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -5,7 +5,9 @@ Design D-2, D-4, D-5, D-7; spec tabular-recognition-service «Подтвержд - `on_request(msg)` — обработчик подписки NATS (queue group `tabular-parser`): разбор тела и валидация по контракту (pydantic `RecognitionRequest`, MAJOR `schema_version` = 1). Невалидный запрос — конверт `error SCHEMA_MISMATCH` - без ack (если `job_id`, `file_id` и `attempt` читаемы; иначе собрать валидный конверт нельзя — error-лог и сброс). + без ack (если `job_id`, `file_id` и `attempt` читаемы; иначе собрать валидный конверт нельзя — error-лог и сброс; + если читаемый `job_id` уже в работе или обработан — warning и сброс: иначе `SCHEMA_MISMATCH` ушёл бы под тем же + `result::`, что и настоящий результат). Валидный: `job_id` уже в работе или обработан этим процессом — только повторный ack (`ack::` этого запроса), второй обработки нет; иначе — `job_id` в множество «в работе», ack `processing` **до** чтения S3, задание во внутреннюю `asyncio.Queue`. @@ -19,7 +21,8 @@ Design D-2, D-4, D-5, D-7; spec tabular-recognition-service «Подтвержд - Дедупликация: `job_id` в работе (очередь + обрабатываемые) — множество; завершённые (любой итог, в том числе ошибка и неудачная публикация результата) — LRU размера `done_cache_size`. - Остановка: новые запросы не принимаются (без ack — ядро переотправит другой инстанции), очередь и текущие задания - дорабатываются не дольше `timeout`, остальное отменяется с warning-логом. + дорабатываются не дольше `timeout`, остальное отменяется с warning-логом. Запрос, чей ack публиковался в момент + остановки, в очередь не ставится (warning, `job_id` снимается из «в работе»). """ import asyncio @@ -153,7 +156,11 @@ class RecognitionWorker: await self._queue.join() async def stop(self, timeout: float = STOP_TIMEOUT) -> None: - """Перестаёт принимать запросы, дорабатывает очередь не дольше `timeout` секунд, затем отменяет остальное.""" + """Перестаёт принимать запросы, дорабатывает очередь не дольше `timeout` секунд, затем отменяет остальное. + + Отмена задания, которое сейчас внутри `asyncio.to_thread` (разбор), не останавливает сам поток: он доработает + в фоне, а результат будет отброшен (для заглушки безвредно; для долгого разбора — учесть в Срезе 2). + """ self._accepting = False if not self._tasks: return @@ -198,8 +205,18 @@ class RecognitionWorker: else: self._in_progress.add(job_id) await self._publish_ack(request) - if not duplicate: - self._queue.put_nowait(_Job(request)) + if duplicate: + return + if not self._accepting: + # stop() начался, пока публиковался ack: очередь уже никто не читает + self._in_progress.discard(job_id) + logger.warning( + "Воркер остановлен во время публикации ack: ack опубликован, задание не будет обработано (ядро закроет " + "его по reply_deadline). error_category=shutdown, job_id={job_id}", + job_id=job_id, + ) + return + self._queue.put_nowait(_Job(request)) def _parse_request(self, data: bytes) -> RecognitionRequest: try: @@ -227,6 +244,15 @@ class RecognitionWorker: job_id = self._readable_uuid(raw, "job_id") file_id = self._readable_uuid(raw, "file_id") attempt = self._readable_attempt(raw, msg.headers, job_id) if job_id is not None else None + if job_id is not None and (job_id.lower() in self._in_progress or job_id.lower() in self._done): + # Конверт SCHEMA_MISMATCH ушёл бы под тем же result::, что и настоящий результат + logger.warning( + "Невалидный запрос с job_id, который уже в работе или обработан: отброшен без ответа. " + "error_category=schema_mismatch, job_id={job_id}, reason={reason}", + job_id=job_id, + reason=reason, + ) + return if job_id is None or file_id is None or attempt is None: logger.error( "Запрос отброшен: не проходит контракт, а job_id/file_id/attempt не читаемы — результат не " @@ -336,8 +362,10 @@ class RecognitionWorker: return self._error(request, ErrorCode.UNKNOWN_ERROR, f"ошибка разбора: {_describe(exc)}", None, started) if outcome.status is ResultStatus.ERROR: - error = outcome.error or MessageError( - code=ErrorCode.UNKNOWN_ERROR, message="парсер вернул ошибку без описания" + error = ( + MessageError(code=outcome.error.code, message=_one_line(outcome.error.message)) + if outcome.error is not None + else MessageError(code=ErrorCode.UNKNOWN_ERROR, message="парсер вернул ошибку без описания") ) return self._envelope( job_id=request.job_id, diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index a85e371..8035b00 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -98,14 +98,21 @@ class FakeStorage: class FakePublisher: - """`Publisher`: копит опубликованное; `fail_subjects_msg_ids` — msg_id, публикация которых падает.""" + """`Publisher`: копит опубликованное; `fail_msg_ids` — msg_id, публикация которых падает; `gates` — msg_id, + публикация которых ждёт события (`waiting` выставляется, когда публикация встала на ожидание).""" def __init__(self, events: list[tuple[str, str]]) -> None: self.events = events self.published: list[tuple[str, dict[str, Any], str]] = [] self.fail_msg_ids: set[str] = set() + self.gates: dict[str, asyncio.Event] = {} + self.waiting = asyncio.Event() async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: + gate = self.gates.get(msg_id) + if gate is not None: + self.waiting.set() + await gate.wait() if msg_id in self.fail_msg_ids: self.events.append(("publish_failed", msg_id)) raise NatsPublishError(f"публикация {msg_id} не удалась") @@ -679,10 +686,13 @@ async def test_concurrency_processes_jobs_in_parallel() -> None: gate = asyncio.Event() environment.storage.gate = gate started: list[str] = [] + all_started = asyncio.Event() original_get = environment.storage.get_bytes async def tracking_get(bucket: str, key: str) -> bytes: started.append(key) + if len(started) == JOBS: + all_started.set() return await original_get(bucket, key) environment.storage.get_bytes = tracking_get # type: ignore[method-assign] @@ -693,11 +703,9 @@ async def test_concurrency_processes_jobs_in_parallel() -> None: await environment.worker.on_request( _msg(_request(job_id=OTHER_JOB_ID, s3={"bucket": BUCKET, "key": other_key})) ) - for _ in range(50): - if len(started) == JOBS: - break - await asyncio.sleep(0.01) - assert len(started) == JOBS # оба задания одновременно ждут S3 + # оба задания одновременно ждут S3 (первое не отпущено): при concurrency=1 второе не стартовало бы + await asyncio.wait_for(all_started.wait(), 1) + assert len(started) == JOBS gate.set() await environment.worker.join() finally: @@ -739,3 +747,81 @@ async def test_stop_is_idempotent_and_safe_without_start() -> None: environment = Env() await environment.worker.stop(timeout=0.1) await environment.worker.stop(timeout=0.1) + + +async def test_stop_during_ack_publish_drops_job_with_warning(log_records: list[Any]) -> None: + environment = await _make_env() + gate = asyncio.Event() + environment.publisher.gates[f"ack:{JOB_ID}:1"] = gate + request_task = asyncio.create_task(environment.worker.on_request(_msg(_request()))) + await asyncio.wait_for(environment.publisher.waiting.wait(), 1) + + # stop() начался, пока on_request публикует ack: очередь пуста, корутины отменены + await asyncio.wait_for(environment.worker.stop(timeout=1), 2) + gate.set() + await asyncio.wait_for(request_task, 1) + + assert environment.msg_ids() == [f"ack:{JOB_ID}:1"] + assert environment.storage.get_calls == [] + assert environment.worker._in_progress == set() + assert environment.worker._queue.qsize() == 0 + warnings = [record["message"] for record in log_records if record["level"].name == "WARNING"] + assert any("error_category=shutdown" in message and JOB_ID in message for message in warnings), warnings + + +# --- нормализация сообщения ошибки парсера ------------------------------------------------------------------------ +async def test_parser_error_message_is_normalised_to_one_bounded_line() -> None: + long_message = "строка 1\nстрока 2\t" + "x" * 3000 + + class VerboseErrorParser: + def parse(self, data: bytes, file_type: FileType) -> ParseOutcome: + return ParseOutcome( + status=ResultStatus.ERROR, + error=MessageError(code=ErrorCode.INVALID_FORMAT, message=long_message), + ) + + environment = await _make_env(VerboseErrorParser()) + try: + await environment.worker.on_request(_msg(_request())) + await environment.worker.join() + finally: + await environment.worker.stop(timeout=1) + + envelope = environment.bodies()[1] + _assert_message_valid(envelope) + message = envelope["error"]["message"] + assert envelope["error"]["code"] == "INVALID_FORMAT" + assert message.startswith("строка 1 строка 2 xxx") + assert "\n" not in message + assert "\t" not in message + assert len(message) <= 1000 # noqa: PLR2004 - ERROR_MESSAGE_LIMIT + + +# --- невалидное тело с job_id, который уже в работе или обработан --------------------------------------------------- +async def test_malformed_request_for_job_in_progress_is_dropped(env: Env, log_records: list[Any]) -> None: + gate = asyncio.Event() + env.storage.gate = gate + await env.worker.on_request(_msg(_request())) + await asyncio.wait_for(env.storage.get_started.wait(), 1) + + malformed = _request() + malformed["s3"].pop("key") + await env.worker.on_request(_msg(malformed)) + gate.set() + await env.worker.join() + + # SCHEMA_MISMATCH под тем же result:: не публикуется — только настоящий результат + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] + assert env.bodies()[1]["error"]["code"] == "NOT_IMPLEMENTED" + warnings = [record["message"] for record in log_records if record["level"].name == "WARNING"] + assert any("error_category=schema_mismatch" in message and JOB_ID in message for message in warnings) + + +async def test_malformed_request_for_done_job_is_dropped(env: Env) -> None: + await env.worker.on_request(_msg(_request())) + await env.worker.join() + + await env.worker.on_request(_msg(_request(job_id=JOB_ID.upper(), schema_version="2.0"))) + await env.worker.join() + + assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"] -- 2.47.2 From b7d4f0c17a03906ba18bc549c65e896e22abbe42 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:18:09 +0400 Subject: [PATCH 33/50] =?UTF-8?q?test,=20docs:=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=D0=B2=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F;=20README/CLAUDE.md=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D1=85=20=C2=AB=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=C2=BB,=20spec=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T9.2: события старта worker.start → nats.start; _connect_s3 — info/warning/error по ответу проверки бакета, клиент остаётся. T9.3: spec без «R-17». T9.4: README и CLAUDE.md описывают уже существующие файлы без отсылок к будущим задачам. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 7 +-- README.md | 18 +++--- api/tests/unit/test_app.py | 61 ++++++++++++++++++- .../specs/tabular-recognition-service/spec.md | 2 +- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb33e8e..683eff3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,11 +37,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `greenlet`, `Mako`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. -**Добавлено** (целевое состояние среза; каждый пункт появляется своей задачей `openspec/changes/slice-0-skeleton/tasks.md`, -до этого файла нет): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые +**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над книгой Excel, Срезы 2+), `api/src/contract/` (pydantic-модели `recognition-result` v1.0) и `api/contracts/` -(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/*.yaml` (словари -синонимов — в документах это `rules/*.yaml` относительно `api/`) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` +(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/` (README о назначении; +сами словари `*.yaml` — в документах это `rules/*.yaml` относительно `api/` — появятся в Срезе 2) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` (подписка queue group `tabular-parser` на `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → `recognition.result`) — 4.2–4.3, `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1, `Dockerfile`, `.env.example`, `.env.test` — 2.2. Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), diff --git a/README.md b/README.md index c070aeb..875d934 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Stateless-сервис распознавания табличных файло `arm-core`. Обмен с `arm-core` идёт по контракту **v1.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в -`api/contracts/` (добавляется задачей 3.1 среза и проверяется на идентичность в `make check`). +`api/contracts/` (идентичность с источником проверяется в `make check`). **Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта), а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с @@ -51,15 +51,15 @@ api/ бэкенд (все команды разработки src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер распознавания), logger.py, exception_handlers.py - src/parser/ интерфейс Parser и реализации (добавляется задачей 4.1) - src/contract/ pydantic-модели контракта v1.0 (добавляется задачей 3.1) - contracts/ копии JSON-схем контракта (добавляется задачей 3.1) - rules/ словари синонимов *.yaml для парсера (добавляется задачей 4.1) + src/parser/ интерфейс Parser и заглушка NullParser (разбор — Срез 2) + src/contract/ pydantic-модели контракта v1.0 + contracts/ копии JSON-схем контракта и примеры + rules/ словари синонимов *.yaml для парсера (пока только README; словари — Срез 2) tests/ pytest: unit и integration (маркер `integration`) setup/ pip.conf и setup.sh (создание venv без uv) raisa_config.yml конфигурация платформы РАИСА -Makefile команды разработки (добавляется задачей 2.1) -Dockerfile образ сервиса, python:3.11-slim (добавляется задачей 2.2) +Makefile команды разработки +Dockerfile образ сервиса, python:3.11-slim openspec/ требования и дельта текущего среза (openspec/changes/slice-0-skeleton/) ``` @@ -87,8 +87,6 @@ openspec/ требования и дельта текущего ср | `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | | `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | -Все цели `Makefile` (включая `check-full`) создаются задачей 2.1; `docker build` в `check-full` заработает после задачи 2.2 (`Dockerfile`). - ## Запуск локально против compose ```bash @@ -100,7 +98,7 @@ cd api && .venv/bin/python run_server.py ``` Значения из `api/.env.example` уже совпадают с `../docs/dev/.env` (стандартные порты и учётные данные compose); править -`api/.env` нужно, только если вы меняли порты или пароли compose. Файл `api/.env.example` создаётся задачей 2.2. +`api/.env` нужно, только если вы меняли порты или пароли compose. Подробности про compose, смену портов и сброс — [`../docs/dev/README.md`](../docs/dev/README.md). Сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Проверка: diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index f89ff75..48e34c3 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -15,8 +15,10 @@ from typing import Any import httpx import pytest +from loguru import logger from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.clients import S3Storage from src.core.worker import RecognitionWorker EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples" @@ -108,6 +110,10 @@ def patched_app(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> Iterator[ return storage class RecordingWorker(RecognitionWorker): + def start(self) -> None: + events.append("worker.start") + super().start() + async def stop(self, timeout: float = 10.0) -> None: events.append("worker.stop") await super().stop(timeout) @@ -164,8 +170,9 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok( ] assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED" assert connection.published[1][1]["service"]["name"] == "tabular-parser" + # Старт: воркер готов до подключения (on_connected сразу подписывает worker.on_request). # Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3 - assert events == ["nats.start", "worker.stop", "nats.stop", "s3.close"] + assert events == ["worker.start", "nats.start", "worker.stop", "nats.stop", "s3.close"] async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, monkeypatch: pytest.MonkeyPatch) -> None: @@ -177,3 +184,55 @@ async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, mon (connection,) = FakeNatsConnection.instances assert connection.handlers == [] # подписка — только после подключения assert patched_app.app.state.worker is not None + + +# --- проверка бакета при старте (_connect_s3) ------------------------------------------------------------------------ +@pytest.fixture +def log_records() -> Iterator[list[Any]]: + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + yield records + logger.remove(handler_id) + + +class FakeMinio: + """Подмена `miniopy_async.Minio`: `bucket_exists` отдаёт заданный ответ или бросает исключение.""" + + answer: bool | Exception = True + closed = 0 + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def bucket_exists(self, bucket: str) -> bool: + if isinstance(FakeMinio.answer, Exception): + raise FakeMinio.answer + return FakeMinio.answer + + async def close_session(self) -> None: + FakeMinio.closed += 1 + + +@pytest.mark.parametrize( + ("answer", "level", "fragment"), + [ + pytest.param(True, "INFO", "Бакет проверен", id="bucket-exists"), + pytest.param(False, "WARNING", "Бакет не найден", id="bucket-missing"), + pytest.param(ConnectionError("MinIO недоступен"), "ERROR", "Не удалось проверить", id="minio-down"), + ], +) +async def test_connect_s3_logs_bucket_check_and_keeps_client( + monkeypatch: pytest.MonkeyPatch, log_records: list[Any], answer: Any, level: str, fragment: str +) -> None: + monkeypatch.setattr(FakeMinio, "answer", answer) + monkeypatch.setattr(FakeMinio, "closed", 0) + monkeypatch.setattr(app_module, "Minio", FakeMinio) + + storage = await app_module._connect_s3() + + # Клиент остаётся в любом случае: MinIO может подняться позже, воркер прочитает файл без рестарта + assert isinstance(storage, S3Storage) + assert storage.bucket == app_module.settings.OPENBAO.MINIO.MINIO_BUCKET + assert FakeMinio.closed == 0 + messages = [record["message"] for record in log_records if record["level"].name == level] + assert any(fragment in message for message in messages), messages diff --git a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md index f3b8f8f..55fca3c 100644 --- a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md @@ -81,7 +81,7 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`; `off` — NATS выключен флагом -`NATS_ENABLED=false`, R-17). +`NATS_ENABLED=false`). #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса -- 2.47.2 From c16a7f6bf68af55eac6c2a0c2c1d4fd08812cdff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:19:59 +0400 Subject: [PATCH 34/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-27,=20R-28=20(=D1=84=D0=B8=D0=BA=D1=81-=D1=80?= =?UTF-8?q?=D0=B0=D1=83=D0=BD=D0=B4=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=204.3)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 80ad36d..8088b49 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -113,3 +113,5 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 31 | 2026-10-04 | R-24 (D9-7): файл результата пишется только для `done | needs_check`; сбой его записи — `UNKNOWN_ERROR` | spec: «файл результата может отсутствовать» при error; отдельного кода в D-5 нет | смена кода в одном месте | | 32 | 2026-10-04 | R-25 (D9-8): остановка воркера — запрет приёма, ожидание очереди до `STOP_TIMEOUT=10 с`, затем отмена; NATS закрывается после воркера, S3 — последним | меньше grace-периода платформы (30 с), больше одной публикации с повторами (~6 с) | константа | | 33 | 2026-10-04 | R-26 (D9-9): `error.message` = `Тип: текст` исключения одной строкой, до 1000 символов, без трассировки | spec: сообщение без трассировки | нет | +| 34 | 2026-10-04 | R-27 (D9-12): остановка во время публикации ack закрывается повторной проверкой `_accepting` после ack (warning `error_category=shutdown`, `job_id` убирается из `_in_progress`), без счётчика in-flight `on_request` | после проверки нет `await` до `put_nowait` — гонки нет | при смене модели остановки — переписать | +| 35 | 2026-10-04 | R-28 (D9-13): невалидный запрос с известным (в работе/обработан) `job_id` логируется (warning) и отбрасывается, `job_id` сравнивается без учёта регистра | иначе `SCHEMA_MISMATCH` ушёл бы под тем же `result::`, что и настоящий результат | снять guard | -- 2.47.2 From a62d2e24b168cf492cee24bd50939b493e63027a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:23:35 +0400 Subject: [PATCH 35/50] =?UTF-8?q?test:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20round-?= =?UTF-8?q?trip=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=B8=D0=B2=20compose-NATS=20=D0=B8=20MinIO?= =?UTF-8?q?=20(=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=204.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приложение поднимается в процессе теста через lifespan с NATS_ENABLED=True; тест в роли ядра кладёт исходник в uploads/, публикует запрос и собирает recognition.result по своему job_id: ack processing, затем error NOT_IMPLEMENTED с правильными Nats-Msg-Id и валидные по схеме конверты; повтор запроса — только ещё один ack (второго результата нет в окне тишины QUIET_WINDOW). Плюс сценарии spec: нет объекта в S3 -> FILE_READ_ERROR и сервис работает дальше; schema_version 2.0 -> один SCHEMA_MISMATCH без ack. Co-Authored-By: Claude Fable 5.1 --- api/tests/integration/test_roundtrip.py | 246 ++++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 3 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 api/tests/integration/test_roundtrip.py diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py new file mode 100644 index 0000000..5ab7241 --- /dev/null +++ b/api/tests/integration/test_roundtrip.py @@ -0,0 +1,246 @@ +"""Интеграционный round-trip сервиса против compose-NATS (без JetStream) и compose-MinIO (`.env.test`). + +Приложение поднимается в процессе теста через lifespan (`src.app`) с `NATS_ENABLED=True` — тот же код, что в проде: +`_connect_s3`, `NatsConnection` с подпиской в queue group `tabular-parser`, `RecognitionWorker` с `NullParser`. +Тест играет роль ядра: кладёт исходник в MinIO `uploads/…`, публикует запрос в `recognition.tabular.request` с +`Nats-Msg-Id = :` и собирает конверты из `recognition.result`. + +Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после +обработки: заглушка отвечает мгновенно; повтор во время обработки покрыт unit-тестами воркера), «Запрос с заглушкой +парсера», «Нет объекта в S3», «Несовместимая версия». + +Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может +работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с +таймаутом; единственная фиксированная пауза — явное окно тишины `QUIET_WINDOW` для отрицательных проверок. +""" + +import asyncio +import copy +import importlib +import io +import json +import uuid +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import pytest +from jsonschema import Draft202012Validator, FormatChecker +from miniopy_async import Minio +from nats.aio.client import Client as NATS +from nats.aio.msg import Msg + +from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT, request_msg_id +from src.core.config import get_settings + +pytestmark = pytest.mark.integration + +CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts" +EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8")) +MESSAGE_VALIDATOR = Draft202012Validator( + json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")), + format_checker=FormatChecker(), +) + +SERVICE_NAME = "tabular-parser" +READY_TIMEOUT = 5.0 # подключение к NATS и подписка приложения +MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с запасом на повторы публикации) +# Окно тишины для отрицательных проверок («второго результата нет», «ack нет»): заглушка отвечает за миллисекунды, +# поэтому лишнее сообщение, если бы оно было, пришло бы гораздо раньше конца окна +QUIET_WINDOW = 1.5 +SOURCE_BYTES = b"PK\x03\x04 round-trip: content is not read by NullParser" +XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src` + + +# --- вспомогательное -------------------------------------------------------------------------------------------------- +def _new_request(*, key_name: str | None = None, schema_version: str | None = None) -> dict[str, Any]: + """Запрос из `contracts/examples/request-tabular.json` с уникальными `job_id`/`file_id`/`s3.key`.""" + request = copy.deepcopy(EXAMPLE_REQUEST) + request["job_id"] = str(uuid.uuid4()) + request["file_id"] = str(uuid.uuid4()) + name = key_name or request["original_name"] + request["s3"]["key"] = f"uploads/{request['file_id']}/{request['job_id']}/{name}" + if schema_version is not None: + request["schema_version"] = schema_version + return request + + +def _describe(msg: Msg) -> str: + return f"Nats-Msg-Id={(msg.headers or {}).get(NATS_MSG_ID_HEADER)!r}, body={msg.data.decode('utf-8', 'replace')}" + + +def _envelope(msg: Msg, request: dict[str, Any], msg_id: str) -> dict[str, Any]: + """Общие проверки конверта: заголовок, схема recognition-result-message, `job_id`/`file_id`, `service.name`.""" + assert msg.headers is not None and msg.headers.get(NATS_MSG_ID_HEADER) == msg_id, _describe(msg) + body: dict[str, Any] = json.loads(msg.data) + errors = [f"{list(e.absolute_path)}: {e.message}" for e in MESSAGE_VALIDATOR.iter_errors(body)] + assert errors == [], f"конверт не проходит recognition-result-message.schema.json: {errors}; {_describe(msg)}" + assert body["job_id"] == request["job_id"] + assert body["file_id"] == request["file_id"] + assert body["service"]["name"] == SERVICE_NAME + return body + + +def _assert_error(msg: Msg, request: dict[str, Any], code: str) -> dict[str, Any]: + body = _envelope(msg, request, f"result:{request['job_id']}:{request['attempt']}") + assert body["status"] == "error", _describe(msg) + assert body["error"]["code"] == code, _describe(msg) + assert body["metrics"]["duration_ms"] >= 0 + return body + + +def _assert_ack(msg: Msg, request: dict[str, Any]) -> None: + body = _envelope(msg, request, f"ack:{request['job_id']}:{request['attempt']}") + assert body["status"] == "processing", _describe(msg) + + +class Core: + """Тестовый клиент в роли ядра: публикует запросы и собирает `recognition.result` по своим `job_id`.""" + + def __init__(self, client: NATS) -> None: + self.client = client + self._inboxes: dict[str, asyncio.Queue[Msg]] = {} + + async def on_result(self, msg: Msg) -> None: + try: + job_id = json.loads(msg.data).get("job_id") + except (ValueError, AttributeError): + return # чужое сообщение на общей шине + inbox = self._inboxes.get(job_id) if isinstance(job_id, str) else None + if inbox is not None: + inbox.put_nowait(msg) + + async def publish(self, request: dict[str, Any]) -> None: + self._inboxes.setdefault(request["job_id"], asyncio.Queue()) + payload = json.dumps(request, ensure_ascii=False).encode("utf-8") + headers = {NATS_MSG_ID_HEADER: request_msg_id(request["job_id"], request["attempt"])} + await self.client.publish(TABULAR_REQUEST_SUBJECT, payload, headers=headers) + await self.client.flush() + + async def next_message(self, request: dict[str, Any]) -> Msg: + try: + return await asyncio.wait_for(self._inboxes[request["job_id"]].get(), MESSAGE_TIMEOUT) + except TimeoutError: + pytest.fail( + f"нет сообщения recognition.result для job_id={request['job_id']} за {MESSAGE_TIMEOUT} с " + "(сервис не ответил; если на шине есть другой инстанс tabular-parser, запрос мог уйти ему)", + pytrace=False, + ) + + async def assert_quiet(self, request: dict[str, Any]) -> None: + """Отрицательная проверка: в окне `QUIET_WINDOW` по этому `job_id` больше ничего не приходит.""" + try: + extra = await asyncio.wait_for(self._inboxes[request["job_id"]].get(), QUIET_WINDOW) + except TimeoutError: + return + pytest.fail(f"лишнее сообщение по job_id={request['job_id']}: {_describe(extra)}", pytrace=False) + + +# --- фикстуры --------------------------------------------------------------------------------------------------------- +class Uploads: + """Исходники в MinIO `uploads/…` с уникальными ключами; удаляются после теста.""" + + def __init__(self, client: Minio) -> None: + self.client = client + self.keys: list[tuple[str, str]] = [] + + async def put(self, request: dict[str, Any]) -> None: + bucket, key = request["s3"]["bucket"], request["s3"]["key"] + data = io.BytesIO(SOURCE_BYTES) + await self.client.put_object(bucket, key, data, len(SOURCE_BYTES), content_type=XLSX_CONTENT_TYPE) + self.keys.append((bucket, key)) + + +@pytest.fixture +async def uploads() -> AsyncIterator[Uploads]: + minio = get_settings().OPENBAO.MINIO + client = Minio( + minio.MINIO_URL, + access_key=minio.MINIO_ADMIN_LOGIN, + secret_key=minio.MINIO_ADMIN_PASSWORD, + secure=minio.MINIO_SECURE, + ) + store = Uploads(client) + try: + yield store + finally: + for bucket, key in store.keys: + await client.remove_object(bucket, key) + await client.close_session() + + +@pytest.fixture +async def service(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]: + """Приложение в процессе теста: lifespan с `NATS_ENABLED=True` (в `.env.test` — false); выход — остановка.""" + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + application = app_module.app + async with application.router.lifespan_context(application): + if not await application.state.nats.wait_ready(READY_TIMEOUT): + pytest.fail( + f"NATS недоступен ({app_module.settings.OPENBAO.NATS.NATS_URL}, .env.test; make up)", pytrace=False + ) + yield + assert application.state.worker._tasks == [], "рабочие корутины воркера не остановлены" + + +@pytest.fixture +async def core(service: None) -> AsyncIterator[Core]: + """Клиент «ядра», подписанный на `recognition.result` до первой публикации запроса.""" + client = NATS() + await client.connect(get_settings().OPENBAO.NATS.NATS_URL, connect_timeout=2, max_reconnect_attempts=1) + core = Core(client) + await client.subscribe(RESULT_SUBJECT, cb=core.on_result) + await client.flush() + try: + yield core + finally: + await client.close() + + +# --- сценарии --------------------------------------------------------------------------------------------------------- +async def test_request_gets_ack_then_not_implemented_and_duplicate_gets_only_ack(core: Core, uploads: Uploads) -> None: + """«Ack раньше результата», «Запрос с заглушкой парсера», повтор запроса — только ещё один ack.""" + request = _new_request() + await uploads.put(request) + + await core.publish(request) + _assert_ack(await core.next_message(request), request) + _assert_error(await core.next_message(request), request, "NOT_IMPLEMENTED") + + await core.publish(request) # та же доставка ещё раз: тот же `Nats-Msg-Id = :` + _assert_ack(await core.next_message(request), request) + await core.assert_quiet(request) # второго результата нет + + +async def test_missing_source_object_gives_file_read_error_and_service_keeps_working( + core: Core, uploads: Uploads +) -> None: + """«Нет объекта в S3»: ack, затем `FILE_READ_ERROR`; следующий запрос обрабатывается как обычно.""" + missing = _new_request(key_name="missing.xlsx") # объект не кладётся + + await core.publish(missing) + _assert_ack(await core.next_message(missing), missing) + body = _assert_error(await core.next_message(missing), missing, "FILE_READ_ERROR") + assert missing["s3"]["key"] in body["error"]["message"] + + following = _new_request() + await uploads.put(following) + await core.publish(following) + _assert_ack(await core.next_message(following), following) + _assert_error(await core.next_message(following), following, "NOT_IMPLEMENTED") + + +async def test_unsupported_schema_version_gives_single_schema_mismatch_without_ack( + core: Core, uploads: Uploads +) -> None: + """«Несовместимая версия»: `schema_version = 2.0` → один конверт `SCHEMA_MISMATCH`, ack не публикуется.""" + request = _new_request(schema_version="2.0") + await uploads.put(request) + + await core.publish(request) + # Ack публикуется до результата тем же соединением: если бы он был, он пришёл бы первым + body = _assert_error(await core.next_message(request), request, "SCHEMA_MISMATCH") + assert "2.0" in body["error"]["message"] + await core.assert_quiet(request) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 3bd1982..055a594 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -55,7 +55,7 @@ ### Task 10: [4.4] `tests/integration/test_roundtrip.py` (маркер `integration`) -- [ ] `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать `request-tabular.json`, собрать сообщения `recognition.result`: первое — `processing`, второе — `error NOT_IMPLEMENTED` с правильными `Nats-Msg-Id`; повторная публикация запроса даёт только ещё один ack; проверка: зелёный против compose +- [x] `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать `request-tabular.json`, собрать сообщения `recognition.result`: первое — `processing`, второе — `error NOT_IMPLEMENTED` с правильными `Nats-Msg-Id`; повторная публикация запроса даёт только ещё один ack; проверка: зелёный против compose ### Task 11: [5.1] `make check-full` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 0c7fba1..077f5e1 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -43,7 +43,7 @@ `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`; `/healthcheck.nats`; проверка: unit-тесты с фейками — сценарии spec (ack раньше результата, повторный запрос, NOT_IMPLEMENTED, FILE_READ_ERROR, SCHEMA_MISMATCH без ack, подмена парсера → `done`, повтор публикации) -- [ ] 4.4 `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать +- [x] 4.4 `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать `request-tabular.json`, собрать сообщения `recognition.result`: первое — `processing`, второе — `error NOT_IMPLEMENTED` с правильными `Nats-Msg-Id`; повторная публикация запроса даёт только ещё один ack; проверка: зелёный против compose -- 2.47.2 From 5c96189146f8ef652776431de9b11214f02088ed Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:27:30 +0400 Subject: [PATCH 36/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-29..R-33=20(=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=204.4)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 8088b49..7f0671f 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -115,3 +115,8 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 33 | 2026-10-04 | R-26 (D9-9): `error.message` = `Тип: текст` исключения одной строкой, до 1000 символов, без трассировки | spec: сообщение без трассировки | нет | | 34 | 2026-10-04 | R-27 (D9-12): остановка во время публикации ack закрывается повторной проверкой `_accepting` после ack (warning `error_category=shutdown`, `job_id` убирается из `_in_progress`), без счётчика in-flight `on_request` | после проверки нет `await` до `put_nowait` — гонки нет | при смене модели остановки — переписать | | 35 | 2026-10-04 | R-28 (D9-13): невалидный запрос с известным (в работе/обработан) `job_id` логируется (warning) и отбрасывается, `job_id` сравнивается без учёта регистра | иначе `SCHEMA_MISMATCH` ушёл бы под тем же `result::`, что и настоящий результат | снять guard | +| 36 | 2026-10-04 | R-29 (D10-1): повторный запрос в интеграционном тесте шлётся после завершения обработки; случай «во время обработки» покрыт unit-тестами с gate | заглушка отвечает за миллисекунды — гонку в интеграционном тесте не воспроизвести детерминированно | нет | +| 37 | 2026-10-04 | R-30 (D10-2): `QUIET_WINDOW = 1.5 с` для отрицательных проверок (нет второго результата / нет ack) | заглушка отвечает за единицы мс; окно названо явно | +3 с к прогону; слишком короткое окно — ложный зелёный | +| 38 | 2026-10-04 | R-31 (D10-3): lifespan приложения в интеграционных тестах — function-scoped (новый воркер и LRU на тест) | изоляция LRU и простое завершение | время прогона | +| 39 | 2026-10-04 | R-32 (D10-4): `reply_deadline` из примера запроса не меняется (в прошлом) | сервис его не проверяет; контракт не требует | если сервис начнёт проверять дедлайн — править пример в тесте | +| 40 | 2026-10-04 | R-33 (D10-5): остановка воркера проверяется в teardown через приватный `worker._tasks` | дешёвая проверка чистой остановки (T10.1: проверка тавтологична — править в cleanup) | хрупкая связь с приватным полем | -- 2.47.2 From 6491fdd53876ece560d7631ac57656ad07773fd5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:29:28 +0400 Subject: [PATCH 37/50] =?UTF-8?q?test:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D0=BE=D1=80=D0=BA=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BD=D1=8F=D1=82=D1=8B=D0=BC=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC=20=D0=B8=20=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D1=8B=D0=B9=20teardown=20u?= =?UTF-8?q?ploads=20(T10.1,=20T10.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/integration/test_roundtrip.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 5ab7241..b85bc4a 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -15,6 +15,7 @@ Spec tabular-recognition-service: «Ack раньше результата», « """ import asyncio +import contextlib import copy import importlib import io @@ -166,9 +167,13 @@ async def uploads() -> AsyncIterator[Uploads]: try: yield store finally: - for bucket, key in store.keys: - await client.remove_object(bucket, key) - await client.close_session() + try: + for bucket, key in store.keys: + # сбой удаления одного объекта не должен оставлять в MinIO остальные + with contextlib.suppress(Exception): + await client.remove_object(bucket, key) + finally: + await client.close_session() @pytest.fixture @@ -176,13 +181,17 @@ async def service(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]: """Приложение в процессе теста: lifespan с `NATS_ENABLED=True` (в `.env.test` — false); выход — остановка.""" monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) application = app_module.app + tasks: list[asyncio.Task[None]] = [] async with application.router.lifespan_context(application): if not await application.state.nats.wait_ready(READY_TIMEOUT): pytest.fail( f"NATS недоступен ({app_module.settings.OPENBAO.NATS.NATS_URL}, .env.test; make up)", pytrace=False ) + tasks = list(application.state.worker._tasks) yield - assert application.state.worker._tasks == [], "рабочие корутины воркера не остановлены" + # `_tasks` обнуляется в `stop()` безусловно — проверяем сами задачи, снятые до выхода из lifespan + assert tasks, "воркер не запустил рабочие корутины" + assert all(t.done() for t in tasks), "рабочие корутины воркера не остановлены" @pytest.fixture -- 2.47.2 From 67a4311150dcb5aca4e58473c38ed938e1269c4a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:29:28 +0400 Subject: [PATCH 38/50] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B0=20=D1=81=20=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B7=D1=80=D0=B5=D0=BD=D0=B8=D1=8F=20tabular-parser,?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D0=B8=D1=82=D0=B5=D1=82=20subjects=20?= =?UTF-8?q?=D1=81=20arm-core=20(T6.1-T6.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/contract/request.py | 9 +++++---- api/src/contract/result.py | 6 ++++-- api/src/contract/subjects.py | 4 ++++ api/tests/unit/test_contract.py | 5 +++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/api/src/contract/request.py b/api/src/contract/request.py index 5165080..4877790 100644 --- a/api/src/contract/request.py +++ b/api/src/contract/request.py @@ -1,8 +1,9 @@ -"""Запрос распознавания файла (arm-core → tabular-parser / scan-gateway): `recognition-request.schema.json` v1.0. +"""Запрос распознавания файла: `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`) — не поле тела. +tabular-parser принимает этот запрос из `recognition.tabular.request` (производитель — arm-core); один файл — один job. +Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор — +`docs/context/03-architecture.md`). Subject — по `pipeline` (`request_subject`); заголовок +`Nats-Msg-Id = :` (`request_msg_id`) — не поле тела. """ from typing import Annotated, Literal diff --git a/api/src/contract/result.py b/api/src/contract/result.py index d591872..18d752c 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -1,7 +1,9 @@ """Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. -Один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). В Срезе 0 — модели и валидация примеров; -маппинг в таблицы — Срез 2. Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). +tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). +Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор — +`docs/context/03-architecture.md`). +Суммы — `Decimal` в модели и строка в JSON (`"106311116.46"`, без `float`). Нормализация счетов, резолв контрагентов и проверки — в arm-core, не здесь. """ diff --git a/api/src/contract/subjects.py b/api/src/contract/subjects.py index be58d01..7ea38d6 100644 --- a/api/src/contract/subjects.py +++ b/api/src/contract/subjects.py @@ -3,6 +3,10 @@ Источник: `docs/context/03-architecture.md`, «Контракт обмена через NATS»; схема recognition-request (`Nats-Msg-Id = :`) и recognition-result-message (`ack::` / `result::`). + +Модуль намеренно совпадает с копией в arm-core. `request_subject`, `REQUEST_SUBJECTS`, `IMAGE_REQUEST_SUBJECT` и +значение `"image"` в `Pipeline` tabular-parser не использует (он слушает только `tabular`) — это сознательный паритет +с arm-core, а не мёртвый код. """ import uuid diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 16ae4be..8aea34c 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -1,5 +1,6 @@ -"""Контракт обмена с сервисами распознавания v1.0 (spec recognition-contract, design D-7). +"""Контракт обмена с сервисами распознавания v1.0. +Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. """ @@ -598,7 +599,7 @@ def test_python_float_amount_is_rejected_with_clear_error() -> None: def test_decimal_object_input_is_accepted_and_dumped_as_string() -> None: - """Для кода, собирающего результат из `Decimal` (тесты, будущие производители): объект `Decimal` принимается.""" + """Для кода, собирающего результат из `Decimal` (тесты, парсер при сборке результата): `Decimal` принимается.""" payload = _example("result-statement-minimal.json") payload["documents"][0]["lines"][0]["values"][0]["amount"] = Decimal("1234.50") result = RecognitionResult.model_validate(payload) -- 2.47.2 From e0b2aaca9aaca06ed3a37778a9fc467e07001486 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:29:28 +0400 Subject: [PATCH 39/50] =?UTF-8?q?docs:=20=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=20queue=20group=20?= =?UTF-8?q?=D0=B2=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D1=85=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=85,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20CLAUDE.md=20(T?= =?UTF-8?q?10.3,=20T3.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 26 +++++++++++++++----------- README.md | 2 ++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 683eff3..0c22d9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,12 +21,12 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — `../docs/context/03-architecture.md`, «Соглашения шаблона команды». Репозиторий приведён к сервису **без фронта и без БД** -(change `slice-0-skeleton`, задачи 1.1–1.2 выполнены). Все команды — из `api/`. +(change `slice-0-skeleton`). Команды backend — из `api/`, `make` — из корня репозитория. **Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan: подключение к MinIO и NATS, ошибка подключения логируется и не мешает старту; `GET /healthcheck`, gzip/proxy middleware), -`core/config.py` (pydantic-settings, `OPENBAO__MINIO__*` включая `MINIO_SECURE`, `OPENBAO__NATS__*`, `DEBUG`, `ENV_FILE`, -`NATS_ENABLED`, `WORKER_CONCURRENCY`, `PUBLISH_RETRIES`, `DONE_CACHE_SIZE`), `core/clients.py` (S3), `core/logger.py`, +`core/config.py` (pydantic-settings, `SERVICE_NAME` — `service.name` конвертов и имя queue group, `OPENBAO__MINIO__*` +включая `MINIO_SECURE`, `OPENBAO__NATS__*`, `DEBUG`, `ENV_FILE`, `NATS_ENABLED`, `WORKER_CONCURRENCY`, `PUBLISH_RETRIES`, `DONE_CACHE_SIZE`), `core/clients.py` (S3), `core/logger.py`, `core/exception_handlers.py` (без SQLAlchemy), `common/exceptions.py`, `pytest.ini`, `requirements*.txt`, `raisa_config.yml`, `setup/setup.sh` + `pip.conf` (без npm-шага). Шаблонный `core/nats_callback.py` (consumer `recognition.result` — зона ядра) удалён задачей 4.3: его место занял воркер `core/worker.py`. @@ -37,11 +37,15 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `greenlet`, `Mako`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. -**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые -функции над книгой Excel, Срезы 2+), `api/src/contract/` (pydantic-модели `recognition-result` v1.0) и `api/contracts/` -(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/` (README о назначении; -сами словари `*.yaml` — в документах это `rules/*.yaml` относительно `api/` — появятся в Срезе 2) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` -(подписка queue group `tabular-parser` на `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → -`recognition.result`) — 4.2–4.3, `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1, `Dockerfile`, `.env.example`, -`.env.test` — 2.2. Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), -`make check-full` перед PR. +**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): +- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над + книгой Excel, Срезы 2+; +- `api/src/contract/` — pydantic-модели контракта v1.0 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с + `../docs/contracts` проверяется в `make check`) — 3.1; +- `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) + появятся в Срезе 2 — 4.1; +- `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на + `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → `recognition.result` — 4.2–4.3; +- `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1; `Dockerfile`, `api/.env.example`, `api/.env.test` — 2.2. + +Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR. diff --git a/README.md b/README.md index 875d934..11a0785 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ openspec/ требования и дельта текущего ср | `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | | `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | +Интеграционные тесты (`make test-integration`, `make check-full`) поднимают сервис в процессе теста и вступают в queue group `tabular-parser` на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит `NOT_IMPLEMENTED` на реальные запросы других производителей). + ## Запуск локально против compose ```bash -- 2.47.2 From 1bbcdc433e76546b26205b1d2e4a375809146b04 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:31:05 +0400 Subject: [PATCH 40/50] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=205.1=20=E2=80=94=20make=20check-full=20=D0=B7?= =?UTF-8?q?=D0=B5=D0=BB=D1=91=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два подряд прогона: ruff/mypy чисто, unit 247 passed, integration 8 passed, образ tabular-parser собран. Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 055a594..d83b6ab 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -59,7 +59,7 @@ ### Task 11: [5.1] `make check-full` -- [ ] `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` +- [x] `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` ### Task 12: [5.2] Прогон DoD diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 077f5e1..35df17a 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -49,7 +49,7 @@ ## 5. Проверки и завершение -- [ ] 5.1 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs +- [x] 5.1 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` - [ ] 5.2 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR -- 2.47.2 From 54197a07dbe48e7324f75d234ff3dfe67d49b2d5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:33:45 +0400 Subject: [PATCH 41/50] =?UTF-8?q?docs:=20README=20=E2=80=94=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB?= =?UTF-8?q?=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=200.0.0.0,=20=D0=B0=20.env.examp?= =?UTF-8?q?le=20=D1=81=D1=83=D0=B6=D0=B0=D0=B5=D1=82=20=D0=B4=D0=BE=20127.?= =?UTF-8?q?0.0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 11a0785..79624dd 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ cd api && .venv/bin/python run_server.py `api/.env` нужно, только если вы меняли порты или пароли compose. Подробности про compose, смену портов и сброс — [`../docs/dev/README.md`](../docs/dev/README.md). -Сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Проверка: +По умолчанию сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`); `api/.env.example` сужает адрес до +`127.0.0.1:8000` (только loopback). Проверка: ```bash curl -s localhost:8000/healthcheck -- 2.47.2 From a6da24ba67257b9f1caa9bd935d977e96afc9ce4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:33:45 +0400 Subject: [PATCH 42/50] =?UTF-8?q?chore:=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=205.2=20=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20DoD=20=D0=BF=D1=80=D0=BE=D0=B9=D0=B4=D0=B5=D0=BD=20(ma?= =?UTF-8?q?ke=20check-full,=20=D1=80=D1=83=D1=87=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0,=20openspec=20?= =?UTF-8?q?validate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/superpowers/plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index d83b6ab..a2f48fe 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -63,5 +63,5 @@ ### Task 12: [5.2] Прогон DoD -- [ ] Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR +- [x] Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 35df17a..ea209ff 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -51,5 +51,5 @@ - [x] 5.1 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs на ветке `feat/1-slice-0-skeleton` -- [ ] 5.2 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); +- [x] 5.2 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»); `openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR -- 2.47.2 From 71e79670c01e4d1840856bac6fb6beab00a0a9c4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:35:04 +0400 Subject: [PATCH 43/50] =?UTF-8?q?docs:=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20P-4=20(=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20tabular-parser=20=D0=B2=20docs/dev/README.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../slice-0-skeleton/open-questions-proposal.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/open-questions-proposal.md b/openspec/changes/slice-0-skeleton/open-questions-proposal.md index 92a6c2b..e29a993 100644 --- a/openspec/changes/slice-0-skeleton/open-questions-proposal.md +++ b/openspec/changes/slice-0-skeleton/open-questions-proposal.md @@ -45,3 +45,13 @@ ack не блокирует результат») и закрывает зада дедупликации должен стать `(job_id, attempt)`. Предложение: зафиксировать в контракте, что новая попытка — это новый `attempt` и сервис обрабатывает её заново; тогда в воркере ключ LRU меняется на `(job_id, attempt)` (одна строка). Пока ядро повторяет только тем же `attempt` (повтор без ack), текущее поведение корректно. + +## P-4. `docs/dev/README.md`: порт tabular-parser и копирование `.env.example` (прогон DoD, задача 5.2) + +- `../docs/dev/README.md` («Запуск сервисов из venv без Nexus») называет порт `tabular-parser` в профиле `services` 8001, а сервис + по умолчанию слушает 8000 (`SERVER__PORT`, шаблон команды); там же советует `cp ../docs/dev/.env.example api/.env` — + для этого сервиса избыточно (в нём нужны только `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `DEBUG`; свой + `api/.env.example` лежит в репозитории). +- **Предложение.** В docs либо задавать `SERVER__PORT=8001` в профиле `services` (переменная окружения контейнера), либо + привести текст README к 8000; ожидаемые поля `/healthcheck` для `tabular-parser` — `uptime`, `version`, `nats`. + Файлы в `../docs` этот change не меняет. -- 2.47.2 From ba4dc41b9e35192c91093bad6b67728339adaf39 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:43:00 +0400 Subject: [PATCH 44/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-34=20(=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0=20R?= =?UTF-8?q?-11),=20R-35=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 7f0671f..62ef6d1 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -120,3 +120,5 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 38 | 2026-10-04 | R-31 (D10-3): lifespan приложения в интеграционных тестах — function-scoped (новый воркер и LRU на тест) | изоляция LRU и простое завершение | время прогона | | 39 | 2026-10-04 | R-32 (D10-4): `reply_deadline` из примера запроса не меняется (в прошлом) | сервис его не проверяет; контракт не требует | если сервис начнёт проверять дедлайн — править пример в тесте | | 40 | 2026-10-04 | R-33 (D10-5): остановка воркера проверяется в teardown через приватный `worker._tasks` | дешёвая проверка чистой остановки (T10.1: проверка тавтологична — править в cleanup) | хрупкая связь с приватным полем | +| 41 | 2026-10-04 | R-34 (отменяет R-11, I-1 финального ревью): добавляется `.dockerignore` (исключает `api/.env`, `api/.venv`, кэши, `api/tests`, `.superpowers`), строка `.dockerignore` убирается из `.gitignore` | образ собирается `make check-full` из рабочего дерева с `api/.env` и `api/.venv` (проверено: в образе были `.env` с паролем MinIO и `.venv` 159 МБ; `SERVER__HOST=127.0.0.1` из `.env.example` сделал бы контейнер недоступным) | вернуть строку в `.gitignore`, удалить файл | +| 42 | 2026-10-04 | R-35 (M-1 финального ревью): `SERVICE_NAME` остаётся настройкой с типом `Literal["tabular-parser"]`; queue group — константа `REQUEST_QUEUE_GROUP`; формулировки «SERVICE_NAME — имя queue group» исправляются | настройка не управляла группой и могла привести к падению lifespan при другом значении | снять Literal | -- 2.47.2 From 30c3e8281a0eb5f3d52f91cebbbf47a308c9f029 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:44:27 +0400 Subject: [PATCH 45/50] =?UTF-8?q?fix:=20.dockerignore=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82=20api/.env,=20api/.venv,?= =?UTF-8?q?=20=D0=BA=D1=8D=D1=88=D0=B8=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20(I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка .dockerignore убрана из .gitignore (R-34 отменяет R-11). Образ собирается `make check-full` из рабочего дерева: раньше в /app/api были .env с паролем MinIO и .venv (175 МБ), теперь только код, контракты, правила и requirements (264 КБ). Co-Authored-By: Claude Fable 5.1 --- .dockerignore | 24 ++++++++++++++++++++++++ .gitignore | 1 - 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..04f1f74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Контекст сборки — корень репозитория; Dockerfile копирует только api/. +# Исключаем всё, что не нужно в образе: локальные секреты и окружение разработчика, кэши, тесты, документацию. +.git +.gitignore +.claude +.codegraph +.superpowers +.ruff_cache +.bounded +openspec +docs +setup + +# Окружение разработчика внутри api/ +api/.env +api/.env.test +api/.venv +api/.*_cache +api/tests + +# Байткод и кэши в любом каталоге +**/__pycache__ +**/*.pyc +**/.DS_Store diff --git a/.gitignore b/.gitignore index 8349ce7..8b6cf20 100644 --- a/.gitignore +++ b/.gitignore @@ -323,7 +323,6 @@ dist # Custom ignore .DS_Store docker-compose.yml -.dockerignore .vscode my_project_env .cursorignore -- 2.47.2 From c9cdf7592d9eb7914ab7b4177afd968a363e75fc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:45:03 +0400 Subject: [PATCH 46/50] =?UTF-8?q?docs:=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20SCHEMA=5FMISMATCH?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F;=20nats=20=3D=20off=20=D0=B2=20proposa?= =?UTF-8?q?l=20(I-2,=20M-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Требование «Ошибки транспорта превращаются в результат» приведено к коду: SCHEMA_MISMATCH публикуется, если читаемы job_id, file_id и attempt и job_id ещё не известен; иначе — лог (error/warning) без ack и результата. Добавлены два сценария (их покрывают test_unreadable_request_is_logged_and_dropped и test_malformed_request_for_{job_in_progress,done_job}_is_dropped). В proposal.md поле healthcheck nats — ok/down/off. Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/proposal.md | 6 +++-- .../specs/tabular-recognition-service/spec.md | 22 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/openspec/changes/slice-0-skeleton/proposal.md b/openspec/changes/slice-0-skeleton/proposal.md index 63f806a..95ad200 100644 --- a/openspec/changes/slice-0-skeleton/proposal.md +++ b/openspec/changes/slice-0-skeleton/proposal.md @@ -33,9 +33,11 @@ - **Парсер как интерфейс**: `src/parser/` — протокол `Parser` и `NullParser`, возвращающий `status = error`, `error.code = NOT_IMPLEMENTED` для любого файла (Срез 2 заменяет реализацией §1/§12); `api/rules/README.md` — место словарей синонимов. -- **Ошибки транспорта** всегда превращаются в результат: запрос не по схеме → `SCHEMA_MISMATCH`; файл не читается из +- **Ошибки транспорта** превращаются в результат: запрос не по схеме → `SCHEMA_MISMATCH` + (если читаемы `job_id`, `file_id`, `attempt`, иначе — лог ошибки без ответа); файл не читается из S3 → `FILE_READ_ERROR`; неожиданное исключение → `UNKNOWN_ERROR`. Ядро не ждёт таймаута, если сервис жив. -- **Healthcheck** `/healthcheck` шаблона дополняется полем `nats` (`ok`/`down`). +- **Healthcheck** `/healthcheck` шаблона дополняется полем `nats` (`ok`/`down`; `off` — NATS выключен флагом + `NATS_ENABLED=false`). - **Проверки качества** вместо CI: `make check` (ruff, mypy, pytest без Docker, идентичность контракта) и `make check-full` (+ интеграционный тест против compose, `docker build`) — по `../docs/dev/checks.md`. diff --git a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md index 55fca3c..3dec6ba 100644 --- a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md @@ -49,8 +49,15 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` в случаях: тело не проходит схему или `MAJOR` версии ≠ 1 → -`SCHEMA_MISMATCH` (если `job_id` читаем); объект S3 не читается → `FILE_READ_ERROR`; исключение парсера → -`UNKNOWN_ERROR` (сообщение без трассировки). Сервис MUST NOT падать и MUST NOT молчать по этим причинам. +`SCHEMA_MISMATCH`; объект S3 не читается → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR` (сообщение без +трассировки). Сервис MUST NOT падать по этим причинам. `SCHEMA_MISMATCH` SHALL публиковаться (без ack), когда из тела +читаемы `job_id` и `file_id` (uuid), а `attempt` — из тела или из заголовка запроса `Nats-Msg-Id = :`, +и этот `job_id` сервису ещё не известен (не в работе и не среди обработанных). Иначе валидный конверт собрать нельзя +либо он ушёл бы под тем же `Nats-Msg-Id`, что и настоящий результат: сервис SHALL записать в лог ошибку +(`error_category=schema_mismatch`, причина) — уровнем error, если идентификаторы не читаемы, уровнем warning, если +`job_id` уже известен, — и MUST NOT публиковать ни ack, ни результат; ядро обнаружит такое задание по `dlq`/`timeout` +(явная запись в контракт — открытый вопрос «невалидный запрос без читаемых `job_id`/`file_id`/`attempt`» в +`open-questions-proposal.md`). FILE_READ_ERROR и UNKNOWN_ERROR публикуются всегда: сервис MUST NOT молчать по ним. #### Scenario: Нет объекта в S3 - **WHEN** `s3.key` запроса указывает на несуществующий объект @@ -60,6 +67,17 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ - **WHEN** запрос содержит `schema_version = "2.0"` - **THEN** публикуется конверт `error.code = SCHEMA_MISMATCH`, ack не публикуется +#### Scenario: Невалидный запрос без читаемых идентификаторов +- **WHEN** тело запроса не JSON либо в нём не читаются `job_id` или `file_id`, либо `attempt` нет ни в теле, ни в + заголовке `Nats-Msg-Id` +- **THEN** в лог пишется ошибка `error_category=schema_mismatch`, ни ack, ни результат не публикуются, процесс + продолжает работу + +#### Scenario: Невалидный запрос с уже известным `job_id` +- **WHEN** запрос не проходит схему, а его `job_id` уже в работе или среди обработанных +- **THEN** в лог пишется предупреждение `error_category=schema_mismatch`, ни ack, ни результат не публикуются; + обработка или результат задания с этим `job_id` не меняются + ### Requirement: Парсер — заменяемый интерфейс Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о NATS и S3; воркер MUST NOT зависеть от конкретной реализации. В Срезе 0 реализация SHALL возвращать `NOT_IMPLEMENTED` -- 2.47.2 From bf2f60b3b589455abb11f09a3e11ac4c9fdf01c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:46:33 +0400 Subject: [PATCH 47/50] =?UTF-8?q?fix:=20NATS=20=E2=80=94=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=20=D0=B2=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=85=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE;=20dr?= =?UTF-8?q?ain=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=20?= =?UTF-8?q?10=20=D1=81=20(I-3,=20M-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nats-py 2.14 при Permissions Violation не закрывает соединение и не роняет subscribe/flush/publish, а только вызывает error_cb. Раньше это логировалось как «Нет связи с NATS, повторяем подключение» с лимитом раз в минуту. Теперь _on_error распознаёт «permissions violation» и пишет error error_category=nats_permission с subject, каждый раз и без серии ошибок подключения. Исправлены ложные утверждения docstring («ошибки прав проявляются сразу»). connect получает drain_timeout=DRAIN_TIMEOUT (10 с вместо 30 с nats-py): STOP_TIMEOUT воркера + drain укладываются в 30 с платформы. Co-Authored-By: Claude Fable 5.1 --- api/src/app.py | 3 ++- api/src/core/nats.py | 26 +++++++++++++++++++++++--- api/tests/unit/test_nats.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/api/src/app.py b/api/src/app.py index 1b4ca99..2956145 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -6,7 +6,8 @@ (`nats = ok | down | off`, R-17), обработчики исключений. Сервис без БД и фронта (DECISION-4). При `NATS_ENABLED=false` ни NATS, ни воркер не запускаются. Остановка: воркер перестаёт принимать запросы и -дорабатывает очередь (NATS ещё нужен для публикации результатов), затем закрывается NATS (drain), затем сессия S3. +дорабатывает очередь не дольше `STOP_TIMEOUT` (NATS ещё нужен для публикации результатов), затем закрывается NATS +(drain не дольше `DRAIN_TIMEOUT`), затем сессия S3 — в сумме в пределах 30 с, которые платформа даёт на завершение. """ from contextlib import asynccontextmanager diff --git a/api/src/core/nats.py b/api/src/core/nats.py index b2c1f6a..872baa5 100644 --- a/api/src/core/nats.py +++ b/api/src/core/nats.py @@ -11,17 +11,22 @@ вызывается `on_connected(conn)` (подписки воркера); если он упал, созданные в этой попытке подписки снимаются и вызов повторяется с той же паузой. Подписки после разрыва nats-py восстанавливает сам. - `subscribe_requests(cb)` — подписка на `recognition.tabular.request` в queue group `tabular-parser`: из нескольких - инстанций сообщение получает одна. После `subscribe` — `flush`: подписка считается активной, когда сервер принял SUB - (ошибки прав проявляются сразу). Исключения обработчика логируются, подписка живёт. + инстанций сообщение получает одна. После `subscribe` — `flush`: сервер обработал SUB, ошибки соединения проявляются + здесь. Отказ в правах (`Permissions Violation`) nats-py 2.14 так не проявляет: соединение остаётся открытым, + `subscribe`/`flush`/`publish` проходят, сервер лишь присылает `-ERR` в `error_cb` — он логируется отдельно уровнем + error (`error_category=nats_permission`, subject). Исключения обработчика логируются, подписка живёт. - `publish(subject, payload, msg_id)` — заголовок `Nats-Msg-Id` и `flush` (подтверждение сервером); первая попытка и до `publish_retries` повторов с тем же `msg_id` и паузой `publish_retry_pause` между попытками (D-5, R-16: 3 повтора, 2 с — всего до 4 попыток), затем `NatsPublishError` — воркер логирует ошибку, задание дойдёт до `timeout` в ядре. Нет соединения — попытка неудачна сразу (без ожидания), пауза и повтор как обычно. - Ошибки подключения логируются без шума: первая ошибка серии — warning, дальше не чаще раза в минуту. +- Остановка: `drain` ограничен `DRAIN_TIMEOUT` (у nats-py по умолчанию 30 с), чтобы вместе со `STOP_TIMEOUT` воркера + уложиться в 30 с, которые платформа даёт на завершение. """ import asyncio import base64 +import re import time from collections.abc import Awaitable, Callable from contextlib import suppress @@ -41,11 +46,14 @@ MAX_RECONNECT_ATTEMPTS = -1 # переподключаться бесконеч RECONNECT_TIME_WAIT = 2 # секунд между попытками переподключения nats-py CONNECT_TIMEOUT = 2 # секунд на одну попытку TCP-подключения FLUSH_TIMEOUT = 5 # секунд на подтверждение публикации сервером (PING/PONG) +DRAIN_TIMEOUT = 10 # секунд на drain при остановке (nats-py: 30); STOP_TIMEOUT воркера + drain ≤ 30 с платформы PUBLISH_RETRIES = 3 # повторов публикации после первой попытки (settings.PUBLISH_RETRIES, R-16) PUBLISH_RETRY_PAUSE = 2.0 # секунд между попытками публикации (D-5) BACKOFF_INITIAL = 1.0 BACKOFF_MAX = 30.0 ERROR_LOG_INTERVAL = 60.0 # не чаще раза в минуту повторяем warning об ошибках одной серии +PERMISSIONS_VIOLATION = "permissions violation" # текст `-ERR` сервера при отказе в правах (nats-py: в нижнем регистре) +_SUBJECT_IN_ERROR_RE = re.compile(r'(?:to|for)\s+"([^"]+)"') MessageHandler = Callable[[Msg], Awaitable[None]] SleepFn = Callable[[float], Awaitable[None]] @@ -213,6 +221,7 @@ class NatsConnection: max_reconnect_attempts=MAX_RECONNECT_ATTEMPTS, reconnect_time_wait=RECONNECT_TIME_WAIT, connect_timeout=CONNECT_TIMEOUT, + drain_timeout=DRAIN_TIMEOUT, **auth, ) except BaseException: @@ -287,7 +296,7 @@ class NatsConnection: subscription = await self._client.subscribe(subject, queue=queue, cb=safe_cb) self._subscriptions.append(subscription) - # Подписка активна, когда сервер обработал SUB: ошибки прав/соединения проявляются здесь, а не позже + # Сервер обработал SUB: ошибки соединения проявляются здесь; отказ в правах приходит только в error_cb await self._client.flush(timeout=self._flush_timeout) logger.info("Подписка NATS активна. subject={subject}, queue={queue}", subject=subject, queue=queue) return subscription @@ -344,6 +353,17 @@ class NatsConnection: self._last_error_log = 0.0 async def _on_error(self, exc: Exception) -> None: + text = str(exc) + if PERMISSIONS_VIOLATION in text.lower(): + # Соединение живо, повтор подключения не поможет: нужны права на subject (каждый отказ — в лог) + match = _SUBJECT_IN_ERROR_RE.search(text) + logger.error( + "NATS отказал в правах: подписка или публикация не работает. error_category=nats_permission, " + "subject={subject}, error={error}", + subject=match.group(1) if match else "-", + error=text, + ) + return self._report_error(exc) async def _on_disconnected(self) -> None: diff --git a/api/tests/unit/test_nats.py b/api/tests/unit/test_nats.py index 42bf391..8aa0b36 100644 --- a/api/tests/unit/test_nats.py +++ b/api/tests/unit/test_nats.py @@ -18,6 +18,7 @@ from loguru import logger from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT from src.core.config import NATSSettings from src.core.nats import ( + DRAIN_TIMEOUT, REQUEST_QUEUE_GROUP, NatsConfigError, NatsConnection, @@ -26,8 +27,10 @@ from src.core.nats import ( Publisher, build_nats_auth, ) +from src.core.worker import STOP_TIMEOUT JWT = "header.payload.signature" +PLATFORM_STOP_GRACE = 30.0 # секунд, которые платформа даёт процессу на завершение def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings: @@ -188,6 +191,9 @@ async def test_connect_without_credentials_infinite_reconnect_and_callbacks(): assert options["servers"] == "nats://localhost:4222" assert options["max_reconnect_attempts"] == -1 assert options["name"] == "tabular-parser" + # drain при остановке ограничен, чтобы остановка уложилась в 30 с платформы (STOP_TIMEOUT воркера + drain) + assert options["drain_timeout"] == DRAIN_TIMEOUT + assert STOP_TIMEOUT + DRAIN_TIMEOUT <= PLATFORM_STOP_GRACE for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"): assert callable(options[callback]) assert "user_jwt_cb" not in options @@ -300,6 +306,35 @@ async def test_on_closed_logs_error_when_not_stopping_and_info_on_stop(log_recor # --- подписка ----------------------------------------------------------------------------------- +async def test_permission_violation_is_logged_as_permission_error_not_reconnect(log_records: list[Any]): + """nats-py 2.14 при Permissions Violation не рвёт соединение, только вызывает error_cb (текст в нижнем регистре).""" + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld(), sleep=_RecordingSleep()) + error = Exception('nats: permissions violation for subscription to "recognition.tabular.request"') + + repeats = 2 # повтор не глушится лимитом ошибок подключения + for _ in range(repeats): + await conn._on_error(error) + + permission = [r for r in log_records if "error_category=nats_permission" in r["message"]] + assert len(permission) == repeats + assert all(r["level"].name == "ERROR" for r in permission) + assert all("subject=recognition.tabular.request" in r["message"] for r in permission) + assert not any("повторяем подключение" in r["message"] for r in log_records) + assert conn._error_streak == 0 # серия ошибок подключения не затронута + + +async def test_connection_error_in_error_cb_keeps_rate_limited_reconnect_warning(log_records: list[Any]): + conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld(), sleep=_RecordingSleep()) + + await conn._on_error(OSError("Connection refused")) + await conn._on_error(OSError("Connection refused")) + + warnings = [r for r in log_records if "повторяем подключение" in r["message"]] + assert len(warnings) == 1 # вторая ошибка серии — не чаще раза в минуту + assert warnings[0]["level"].name == "WARNING" + assert not any("nats_permission" in r["message"] for r in log_records) + + async def test_subscribe_requests_uses_tabular_subject_and_queue_group(): world = _FakeWorld() conn = await _connected(world, _RecordingSleep()) -- 2.47.2 From 8685441fcd8593ab633d9e7b2ec06d12abe09dea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:48:34 +0400 Subject: [PATCH 48/50] =?UTF-8?q?fix:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=E2=80=94=20SERVICE=5FNAME=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20tabular-parser,=20PUBLISH=5FRE?= =?UTF-8?q?TRIES=20=E2=89=A5=200=20(M-1,=20M-3,=20M-4,=20M-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SERVICE_NAME: Literal["tabular-parser"] (R-35); в config.py, .env.example, README и CLAUDE.md исправлено «имя queue group» — queue group задаёт константа REQUEST_QUEUE_GROUP в core/nats.py. - PUBLISH_RETRIES: NonNegativeInt — 0 означает одну попытку без повторов, как и принимает NatsConnection. - Удалён неиспользуемый модульный settings = get_settings() из config.py. - pyproject.toml: убран override mypy для raisa_fastapi_protected_api (зависимость удалена D-1). - tests/conftest.py: фабрика minimal_settings (Settings из временного .env с минимальным окружением, окружение процесса скрыто); test_config.py — умолчания при минимальном окружении (M-8), PUBLISH_RETRIES=0/-1, SERVICE_NAME≠tabular-parser. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 2 +- README.md | 6 +++-- api/.env.example | 5 ++-- api/pyproject.toml | 2 +- api/src/core/config.py | 11 ++++---- api/tests/conftest.py | 48 +++++++++++++++++++++++++++++++++++ api/tests/unit/test_config.py | 41 ++++++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 api/tests/unit/test_config.py diff --git a/CLAUDE.md b/CLAUDE.md index 0c22d9b..727ba05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( **Оставлено от шаблона:** `api/run_server.py` (точка входа платформы, `SERVER__HOST/PORT`), `api/src/app.py` (lifespan: подключение к MinIO и NATS, ошибка подключения логируется и не мешает старту; `GET /healthcheck`, gzip/proxy middleware), -`core/config.py` (pydantic-settings, `SERVICE_NAME` — `service.name` конвертов и имя queue group, `OPENBAO__MINIO__*` +`core/config.py` (pydantic-settings, `SERVICE_NAME` — только `service.name` конвертов (queue group — константа в `core/nats.py`), `OPENBAO__MINIO__*` включая `MINIO_SECURE`, `OPENBAO__NATS__*`, `DEBUG`, `ENV_FILE`, `NATS_ENABLED`, `WORKER_CONCURRENCY`, `PUBLISH_RETRIES`, `DONE_CACHE_SIZE`), `core/clients.py` (S3), `core/logger.py`, `core/exception_handlers.py` (без SQLAlchemy), `common/exceptions.py`, `pytest.ini`, `requirements*.txt`, `raisa_config.yml`, `setup/setup.sh` + `pip.conf` (без npm-шага). Шаблонный `core/nats_callback.py` (consumer `recognition.result` — зона diff --git a/README.md b/README.md index 79624dd..20c4885 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,11 @@ curl -s localhost:8000/healthcheck | `ENV_FILE` | `.env` | путь к файлу окружения (от `api/`) | | `NATS_ENABLED` | `true` | `false` — не подключаться к NATS, воркер не стартует | | `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно | -| `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с) | +| `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с; `0` — без повторов) | | `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация внутри процесса) | | `SERVER__HOST` | `0.0.0.0` | адрес привязки HTTP-сервера | | `SERVER__PORT` | `8000` | порт HTTP-сервера | -Имя сервиса в конвертах контракта (`service.name`) и имя queue group — `tabular-parser` (`SERVICE_NAME`, менять не нужно). +`SERVICE_NAME` — имя сервиса в конвертах контракта (`service.name`); допустимо только `tabular-parser`, иное значение — +ошибка конфигурации при старте. Queue group подписки NATS — константа `tabular-parser` в `src/core/nats.py`, настройкой +не меняется. diff --git a/api/.env.example b/api/.env.example index fa55ae2..8614ffe 100644 --- a/api/.env.example +++ b/api/.env.example @@ -25,13 +25,14 @@ OPENBAO__NATS__NATS_JWT= # Префикс путей приложения за прокси и версия в OpenAPI ROOT_PATH=/ VERSION=1.0.0 -# `service.name` конвертов контракта и имя queue group NATS; менять не нужно, пока контракт v1.x +# `service.name` конвертов контракта; допустимо только tabular-parser (иное — ошибка старта). Queue group NATS — +# константа в коде (`tabular-parser`), этой настройкой не меняется SERVICE_NAME=tabular-parser # false — не подключаться к NATS (воркер не стартует; /healthcheck отдаёт nats = off) NATS_ENABLED=true # Число заданий, обрабатываемых одновременно WORKER_CONCURRENCY=1 -# Повторы публикации ack и результата в NATS после первой попытки (3 — всего до 4 попыток, пауза 2 с) +# Повторы публикации ack и результата в NATS после первой попытки (3 — всего до 4 попыток, пауза 2 с; 0 — без повторов) PUBLISH_RETRIES=3 # Размер process-local LRU обработанных job_id (защита от дублей доставки) DONE_CACHE_SIZE=1000 diff --git a/api/pyproject.toml b/api/pyproject.toml index 048aae1..53b4910 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -20,7 +20,7 @@ explicit_package_bases = true # Сторонние пакеты без типов (или недоступные вне банка) [[tool.mypy.overrides]] -module = ["nkeys.*", "nats.*", "raisa_fastapi_protected_api", "raisa_fastapi_protected_api.*"] +module = ["nkeys.*", "nats.*"] ignore_missing_imports = true # miniopy_async: пакет типизирован с ошибкой (Minio не виден mypy при реэкспорте) — типы не подтягиваем, Minio = Any diff --git a/api/src/core/config.py b/api/src/core/config.py index 191c48e..f81a927 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,6 +1,7 @@ import os +from typing import Literal -from pydantic import PositiveInt +from pydantic import NonNegativeInt, PositiveInt from pydantic_settings import BaseSettings, SettingsConfigDict @@ -41,10 +42,11 @@ class Settings(BaseSettings): ROOT_PATH: str = "/" VERSION: str = "1.0.0" - SERVICE_NAME: str = "tabular-parser" # `service.name` конвертов контракта и имя queue group NATS + # `service.name` конвертов контракта (enum ServiceName); queue group NATS — константа REQUEST_QUEUE_GROUP в nats.py + SERVICE_NAME: Literal["tabular-parser"] = "tabular-parser" NATS_ENABLED: bool = True # false — приложение не подключается к NATS (воркер не стартует) WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно - PUBLISH_RETRIES: PositiveInt = 3 # повторы публикации ack/результата в NATS после первой попытки (до 4 попыток) + PUBLISH_RETRIES: NonNegativeInt = 3 # повторы публикации ack/результата после первой попытки (0 — без повторов) DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id model_config = SettingsConfigDict( @@ -57,6 +59,3 @@ class Settings(BaseSettings): def get_settings() -> Settings: return Settings() - - -settings = get_settings() diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e69de29..90c2304 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -0,0 +1,48 @@ +"""Общие фикстуры тестов.""" + +import os +from collections.abc import Callable +from pathlib import Path + +import pytest + +from src.core.config import Settings + +# Переменные, которые читает Settings: убираются из окружения процесса, чтобы тест видел только свой файл +_SETTINGS_ENV_PREFIXES = ( + "OPENBAO__", + "SERVER__", + "DEBUG", + "ROOT_PATH", + "VERSION", + "SERVICE_NAME", + "NATS_ENABLED", + "WORKER_CONCURRENCY", + "PUBLISH_RETRIES", + "DONE_CACHE_SIZE", +) + +# Минимальное окружение spec «Старт с минимальным окружением»: MinIO, адрес NATS и DEBUG; без seed/jwt, БД и SMB +MINIMAL_ENV = """\ +DEBUG=true +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 +""" + + +@pytest.fixture +def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callable[[str], Settings]: + """Фабрика Settings только из временного `.env` с `MINIMAL_ENV` и строками `extra` (окружение процесса скрыто).""" + for name in list(os.environ): + if name.upper().startswith(_SETTINGS_ENV_PREFIXES): + monkeypatch.delenv(name) + + def build(extra: str = "") -> Settings: + env_file = tmp_path / ".env" + env_file.write_text(MINIMAL_ENV + extra, encoding="utf-8") + return Settings(_env_file=env_file) # type: ignore[call-arg] + + return build diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py new file mode 100644 index 0000000..cbb420c --- /dev/null +++ b/api/tests/unit/test_config.py @@ -0,0 +1,41 @@ +"""Unit-тесты настроек `src/core/config.py` (spec «Старт с минимальным окружением»; R-35).""" + +from collections.abc import Callable + +import pytest +from pydantic import ValidationError + +from src.core.config import Settings + +SettingsFactory = Callable[..., Settings] + + +def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) -> None: + settings = minimal_settings() + + assert settings.DEBUG is True + assert settings.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" + assert settings.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" + assert settings.OPENBAO.NATS.NATS_SEED == "" + assert settings.OPENBAO.NATS.NATS_JWT == "" + assert settings.NATS_ENABLED is True + assert settings.SERVICE_NAME == "tabular-parser" + assert settings.WORKER_CONCURRENCY == 1 + assert settings.PUBLISH_RETRIES == 3 # noqa: PLR2004 - значение по умолчанию из spec + assert settings.DONE_CACHE_SIZE == 1000 # noqa: PLR2004 - значение по умолчанию из spec + + +def test_publish_retries_zero_is_allowed(minimal_settings: SettingsFactory) -> None: + """0 — без повторов: одна попытка публикации (NatsConnection принимает publish_retries ≥ 0).""" + assert minimal_settings("PUBLISH_RETRIES=0\n").PUBLISH_RETRIES == 0 + + +def test_publish_retries_negative_is_rejected(minimal_settings: SettingsFactory) -> None: + with pytest.raises(ValidationError, match="PUBLISH_RETRIES"): + minimal_settings("PUBLISH_RETRIES=-1\n") + + +def test_service_name_other_than_contract_value_is_rejected(minimal_settings: SettingsFactory) -> None: + """SERVICE_NAME — `service.name` конвертов контракта: другое значение — ошибка конфигурации при старте.""" + with pytest.raises(ValidationError, match="SERVICE_NAME"): + minimal_settings("SERVICE_NAME=scan-gateway\n") -- 2.47.2 From 479a76f81bc56b315e32e123e2a71ff6138ed391 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:48:34 +0400 Subject: [PATCH 49/50] =?UTF-8?q?test:=20=D1=81=D1=82=D0=B0=D1=80=D1=82=20?= =?UTF-8?q?=D1=81=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=B6=20=C2=AB?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=91=D0=94=20=D0=B8=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=C2=BB=20(M-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_app: приложение с Settings только из MinIO, адреса NATS и DEBUG стартует, /healthcheck отвечает 200 с uptime, version, nats. - test_layout: в requirements.txt нет asyncpg, SQLAlchemy, alembic, smbprotocol, argon2-cffi, raisa_fastapi_protected_api; нет каталогов web/, api/alembic/, api/src/{db,models,repositories}/. Co-Authored-By: Claude Fable 5.1 --- api/tests/unit/test_app.py | 14 ++++++++++++++ api/tests/unit/test_layout.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 api/tests/unit/test_layout.py diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index 48e34c3..77e214e 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -143,6 +143,20 @@ async def test_nats_disabled_starts_nothing_and_healthcheck_reports_off( assert events == ["s3.close"] +async def test_minimal_environment_starts_and_healthcheck_answers( + patched_app: Any, monkeypatch: pytest.MonkeyPatch, minimal_settings: Callable[..., Any] +) -> None: + """Старт с минимальным окружением: только MinIO, адрес NATS и DEBUG (без seed/jwt, БД и SMB).""" + monkeypatch.setattr(patched_app, "settings", minimal_settings()) + assert patched_app.settings.NATS_ENABLED is True + + async with running_app(patched_app) as client: + response = await client.get("/healthcheck") + + assert response.status_code == 200 # noqa: PLR2004 - HTTP 200 + assert set(response.json()) == {"uptime", "version", "nats"} + + async def test_nats_enabled_subscribes_worker_and_reports_ok( patched_app: Any, monkeypatch: pytest.MonkeyPatch, events: list[str] ) -> None: diff --git a/api/tests/unit/test_layout.py b/api/tests/unit/test_layout.py new file mode 100644 index 0000000..6a60a4b --- /dev/null +++ b/api/tests/unit/test_layout.py @@ -0,0 +1,32 @@ +"""Страж spec «Сервис не зависит от БД и фронта» (DECISION-4): ни зависимостей БД/SMB/шаблонной авторизации, ни +каталогов фронта, миграций, моделей и репозиториев.""" + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +REQUIREMENTS = REPO_ROOT / "api" / "requirements.txt" +FORBIDDEN_PACKAGES = ( + "asyncpg", + "sqlalchemy", + "alembic", + "smbprotocol", + "argon2-cffi", + "raisa_fastapi_protected_api", +) +FORBIDDEN_DIRS = ("web", "api/alembic", "api/src/db", "api/src/models", "api/src/repositories") + + +@pytest.mark.parametrize("package", FORBIDDEN_PACKAGES) +def test_requirements_have_no_database_or_template_auth_packages(package: str) -> None: + name = re.sub(r"[-_.]", "[-_.]", package) # имена пакетов сравниваются по PEP 503: -, _ и . равнозначны + pattern = re.compile(rf"^\s*{name}\s*(?:[=<>!~;\[]|$)", re.IGNORECASE) + lines = REQUIREMENTS.read_text(encoding="utf-8").splitlines() + assert not [line for line in lines if pattern.match(line)] + + +@pytest.mark.parametrize("directory", FORBIDDEN_DIRS) +def test_no_frontend_or_database_directories(directory: str) -> None: + assert not (REPO_ROOT / directory).exists() -- 2.47.2 From 6ae072e5027338c389d9ae6e9643546cb6ee7e2f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:50:22 +0400 Subject: [PATCH 50/50] =?UTF-8?q?docs:=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20R-36..R-40=20(=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B8=D0=BA=D1=81-=D1=80?= =?UTF-8?q?=D0=B0=D1=83=D0=BD=D0=B4)=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- openspec/changes/slice-0-skeleton/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index 62ef6d1..e807d7d 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -122,3 +122,8 @@ compose-NATS/MinIO — реальный запрос из примера `reques | 40 | 2026-10-04 | R-33 (D10-5): остановка воркера проверяется в teardown через приватный `worker._tasks` | дешёвая проверка чистой остановки (T10.1: проверка тавтологична — править в cleanup) | хрупкая связь с приватным полем | | 41 | 2026-10-04 | R-34 (отменяет R-11, I-1 финального ревью): добавляется `.dockerignore` (исключает `api/.env`, `api/.venv`, кэши, `api/tests`, `.superpowers`), строка `.dockerignore` убирается из `.gitignore` | образ собирается `make check-full` из рабочего дерева с `api/.env` и `api/.venv` (проверено: в образе были `.env` с паролем MinIO и `.venv` 159 МБ; `SERVER__HOST=127.0.0.1` из `.env.example` сделал бы контейнер недоступным) | вернуть строку в `.gitignore`, удалить файл | | 42 | 2026-10-04 | R-35 (M-1 финального ревью): `SERVICE_NAME` остаётся настройкой с типом `Literal["tabular-parser"]`; queue group — константа `REQUEST_QUEUE_GROUP`; формулировки «SERVICE_NAME — имя queue group» исправляются | настройка не управляла группой и могла привести к падению lifespan при другом значении | снять Literal | +| 43 | 2026-10-04 | R-36: `.dockerignore` исключает сверх перечня также `api/.env.test`, `.claude`, `setup`, `.gitignore`, `.bounded`, `**/*.pyc`, `**/.DS_Store`; `api/.env.example` остаётся в образе | документация без секретов; образ без dev-артефактов | лишний файл в образе / одна строка | +| 44 | 2026-10-04 | R-37: отказ в правах NATS логируется на каждый `-ERR` без лимита частоты (`error_category=nats_permission`) | сервер шлёт -ERR на каждую запрещённую операцию; видимость важнее шума | шум в логе (до 8 строк на задание при запрете публикации); лимит — как у ошибок подключения | +| 45 | 2026-10-04 | R-38: `DRAIN_TIMEOUT = 10 с` для NATS | 10 (STOP_TIMEOUT) + 10 (drain) + закрытие S3 < 30 с grace-периода платформы | константа | +| 46 | 2026-10-04 | R-39: тест «старт с минимальным окружением» разнесён: умолчания Settings — `test_config.py`, `/healthcheck` — `test_app.py`; фабрика Settings в `tests/conftest.py` | фикстуры фейковых NATS/S3 живут в test_app.py; тестовые модули не импортируют друг друга | перенос | +| 47 | 2026-10-04 | R-40: страж зависимостей сравнивает имена пакетов по PEP 503 | `argon2_cffi` = `argon2-cffi` | ложный пропуск при нестандартном написании | -- 2.47.2