docs: change slice-0-skeleton под факты платформы — NATS без JetStream, Python 3.11, без CI и Nexus
Обмен с ядром на core NATS: подписка queue group, ack status=processing сразу по получении, process-local LRU job_id против повторных запросов, публикация результата с повторами; удалены durable consumer / max_deliver / ack_wait. Dockerfile python:3.11-slim; make check / make check-full вместо CI; raisa_* фильтруются при dev-установке. Обновлены proposal, design (решения без обсуждения), specs, tasks, CLAUDE.md, openspec/config.yaml. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f12ce87346
commit
43cbfe8e38
10
CLAUDE.md
10
CLAUDE.md
@ -27,12 +27,16 @@
|
|||||||
- **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), Redux Toolkit (`store/slices/`), react-router 7;
|
- **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), Redux Toolkit (`store/slices/`), react-router 7;
|
||||||
`layouts/` — страницы, `widgets/` — блоки, `components/` — примитивы; env только `REACT_APP_*` через `conf/config.ts`;
|
`layouts/` — страницы, `widgets/` — блоки, `components/` — примитивы; env только `REACT_APP_*` через `conf/config.ts`;
|
||||||
запросы — `${Config.API_URL}/api/v1/...`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка.
|
запросы — `${Config.API_URL}/api/v1/...`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка.
|
||||||
- **Сборка** — платформа запускает `api/run_server.py` (`SERVER__HOST/PORT`), ресурсы — `raisa_config.yml`; SPA:
|
- **Сборка** — платформа (Python 3.11) запускает `api/run_server.py` (`SERVER__HOST/PORT`), ресурсы — `raisa_config.yml`;
|
||||||
`web/dist` → `api/web/`. `Dockerfile` в корне — только для локального compose и CI (убран из `.gitignore` шаблона).
|
SPA: `web/dist` → `api/web/`. `Dockerfile` (`python:3.11-slim`) в корне — только для локального compose (убран из
|
||||||
|
`.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus: `make venv` ставит зависимости
|
||||||
|
без `raisa_*`, npm-пакеты `raisa-*` — `optionalDependencies`.
|
||||||
|
- **NATS без JetStream**: подписка queue group `arm-core` на `recognition.result`; ack сервисов — конверт `processing`;
|
||||||
|
запрос без ack публикуется повторно (30 с × 3) → `dlq`; дедуп результатов — `recognition_job.result_msg_id`.
|
||||||
|
|
||||||
## Зависимости от соседей
|
## Зависимости от соседей
|
||||||
- Контракты: `../docs/contracts/recognition-result.schema.json` (вход из парсера/шлюза), `ex-payload-v1.schema.json`.
|
- Контракты: `../docs/contracts/recognition-result.schema.json` (вход из парсера/шлюза), `ex-payload-v1.schema.json`.
|
||||||
- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); в CI — checkout `arm-ka-fixtures`.
|
- Фикстуры: `../fixtures/` (env `ARM_KA_FIXTURES`); при отсутствии тесты с маркером `fixtures` пропускаются с предупреждением.
|
||||||
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
|
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
|
||||||
|
|
||||||
## Правила репозитория
|
## Правила репозитория
|
||||||
|
|||||||
@ -8,13 +8,16 @@
|
|||||||
с `OPENBAO__*`, схема `dkb`, Alembic с URL из настроек, DI ручными `Depends`, loguru, обработчики ошибок `{"detail": …}`,
|
с `OPENBAO__*`, схема `dkb`, Alembic с URL из настроек, DI ручными `Depends`, loguru, обработчики ошибок `{"detail": …}`,
|
||||||
SPA из `api/web/`, `DEBUG=true` отключает NATS/РАИСА.
|
SPA из `api/web/`, `DEBUG=true` отключает NATS/РАИСА.
|
||||||
- DBML v5.13 — источник имён и структур; поля вне DBML не вводятся.
|
- DBML v5.13 — источник имён и структур; поля вне DBML не вводятся.
|
||||||
- Контракт v1.0 — родительский change в `docs` (конверты сообщений, файл результата, `Nats-Msg-Id`).
|
- Контракт v1.0 — родительский change в `docs` (конверты сообщений, ack `processing`, файл результата, `Nats-Msg-Id`).
|
||||||
|
- Платформа (ответы 03.10.2026): Python **3.11**, PostgreSQL **16**, **NATS без JetStream**, Nexus недоступен до прода,
|
||||||
|
CI в Gitea нет — пайплайн платформы вне репозитория, параметры JWT РАИСА неизвестны.
|
||||||
- Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через `recognition_job`.
|
- Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через `recognition_job`.
|
||||||
|
|
||||||
## Goals / Non-Goals
|
## Goals / Non-Goals
|
||||||
|
|
||||||
**Goals:** рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox, доказанные интеграционным
|
**Goals:** рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox на core NATS, доказанные
|
||||||
тестом; CI, который ловит расхождение ORM ↔ DBML и невозвратные миграции.
|
интеграционным тестом (включая потерю запроса и повторную публикацию); `make check-full`, который ловит расхождение
|
||||||
|
ORM ↔ DBML и невозвратные миграции.
|
||||||
|
|
||||||
**Non-Goals:** см. proposal. Дополнительно: не оптимизируем производительность outbox (одна инстанция, 5-секундный
|
**Non-Goals:** см. proposal. Дополнительно: не оптимизируем производительность outbox (одна инстанция, 5-секундный
|
||||||
опрос достаточно для Среза 0–3); не проектируем multi-tenant.
|
опрос достаточно для Среза 0–3); не проектируем multi-tenant.
|
||||||
@ -37,9 +40,9 @@ native_enum=True, values_callable=lambda e: [m.value for m in e])`, значен
|
|||||||
`alembic/versions/0001_dbml_v5_13.py`: `CREATE SCHEMA IF NOT EXISTS dkb` (в `env.py`, до миграций), enum'ы → таблицы →
|
`alembic/versions/0001_dbml_v5_13.py`: `CREATE SCHEMA IF NOT EXISTS dkb` (в `env.py`, до миграций), enum'ы → таблицы →
|
||||||
FK → индексы. Циклическая связь `stored_file.current_recognition_job_id ↔ recognition_job.file_id` — FK с `use_alter=True`.
|
FK → индексы. Циклическая связь `stored_file.current_recognition_job_id ↔ recognition_job.file_id` — FK с `use_alter=True`.
|
||||||
Частичные уникальные индексы — `Index(..., unique=True, postgresql_where=text("is_current"))`; `document` —
|
Частичные уникальные индексы — `Index(..., unique=True, postgresql_where=text("is_current"))`; `document` —
|
||||||
`Index(..., unique=True, postgresql_nulls_not_distinct=True)` (PostgreSQL ≥ 15). `downgrade()` удаляет индексы, таблицы,
|
`Index(..., unique=True, postgresql_nulls_not_distinct=True)` (PostgreSQL ≥ 15; на платформе 16). `downgrade()` удаляет
|
||||||
enum'ы (`DROP TYPE`) и оставляет пустую схему. Автогенерация используется как черновик, итог правится руками и
|
индексы, таблицы, enum'ы (`DROP TYPE`) и оставляет пустую схему. Автогенерация используется как черновик, итог правится
|
||||||
проверяется CI-шагом «upgrade → downgrade → upgrade» и `check_dbml_vs_orm.py`.
|
руками и проверяется шагом «upgrade → downgrade → upgrade» и `check_dbml_vs_orm.py` в `make check-full`.
|
||||||
|
|
||||||
### D-3. `check_dbml_vs_orm.py` — pydbml + сравнение метаданных, notes читаются по шаблонам
|
### D-3. `check_dbml_vs_orm.py` — pydbml + сравнение метаданных, notes читаются по шаблонам
|
||||||
Парсер — библиотека `pydbml` (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице
|
Парсер — библиотека `pydbml` (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице
|
||||||
@ -47,7 +50,7 @@ enum'ы (`DROP TYPE`) и оставляет пустую схему. Автог
|
|||||||
упорядоченный список значений). Notes интерпретируются только по трём шаблонам: `partial UNIQUE WHERE <expr>`,
|
упорядоченный список значений). Notes интерпретируются только по трём шаблонам: `partial UNIQUE WHERE <expr>`,
|
||||||
`NULLS NOT DISTINCT`, `[pk]` на составных индексах; остальное — текст. Вывод — список расхождений `таблица.колонка: ожидалось /
|
`NULLS NOT DISTINCT`, `[pk]` на составных индексах; остальное — текст. Вывод — список расхождений `таблица.колонка: ожидалось /
|
||||||
фактически`, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (`tests/test_dbml_parity.py`).
|
фактически`, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (`tests/test_dbml_parity.py`).
|
||||||
*Если `pydbml` недоступен в Nexus* — fallback: собственный парсер подмножества DBML (Table/Enum/Indexes/Ref), ~200 строк.
|
*Если `pydbml` окажется неустановим* — fallback: собственный парсер подмножества DBML (Table/Enum/Indexes/Ref), ~200 строк.
|
||||||
|
|
||||||
### D-4. Seed — данные в файлах, upsert по натуральному ключу, разделение prod/dev
|
### D-4. Seed — данные в файлах, upsert по натуральному ключу, разделение prod/dev
|
||||||
`api/seed/core/`: `regional_office.yaml` (только ГО), `roles.yaml`, `permissions.yaml`, `role_permissions.yaml`,
|
`api/seed/core/`: `regional_office.yaml` (только ГО), `roles.yaml`, `permissions.yaml`, `role_permissions.yaml`,
|
||||||
@ -66,25 +69,35 @@ enum'ы (`DROP TYPE`) и оставляет пустую схему. Автог
|
|||||||
`require(code)` (403, если `code ∉ effective_permissions`). `effective_permissions` — один запрос по `user_role ⋈
|
`require(code)` (403, если `code ∉ effective_permissions`). `effective_permissions` — один запрос по `user_role ⋈
|
||||||
role_permission ⋈ permission`. Dev-эмитент — `POST /api/v1/dev/token {email}` → HS256 JWT на 8 часов; роутер подключается
|
role_permission ⋈ permission`. Dev-эмитент — `POST /api/v1/dev/token {email}` → HS256 JWT на 8 часов; роутер подключается
|
||||||
только при `AUTH_DEV_ISSUER=true` и `DEBUG=true` (в проде невозможен). Шаблонный middleware `raisa_fastapi_protected_api`
|
только при `AUTH_DEV_ISSUER=true` и `DEBUG=true` (в проде невозможен). Шаблонный middleware `raisa_fastapi_protected_api`
|
||||||
остаётся как в шаблоне (импорт при `DEBUG=false`) — он защищает периметр, наш слой извлекает личность. *Альтернатива* —
|
остаётся как в шаблоне (импорт при `DEBUG=false`; пакет недоступен вне банка до прода) — он защищает периметр, наш слой
|
||||||
только middleware РАИСА: отвергнуто, пакет недоступен вне банка и его API неизвестен.
|
извлекает личность. *Альтернатива* — только middleware РАИСА: отвергнуто, пакет недоступен и его API неизвестен.
|
||||||
|
|
||||||
### D-6. Outbox/inbox — фоновые задачи в lifespan, JetStream через nats-py
|
### D-6. Outbox/inbox на core NATS — фоновые задачи в lifespan, ack и повторы на уровне приложения
|
||||||
`src/core/nats.py`: подключение (пустые `NATS_SEED/NATS_JWT` → без учётных данных; иначе nkeys/JWT как в шаблоне),
|
JetStream на платформе нет: нет durable consumer'ов, серверного дедупа, redelivery, `max_deliver`. Схема:
|
||||||
`ensure_stream()` при `NATS_MANAGE_STREAM=true` (stream `RECOGNITION`, subjects `recognition.>`, `duplicate_window` 2 мин),
|
- `src/core/nats.py`: подключение (пустые `NATS_SEED/NATS_JWT` → без учётных данных; иначе nkeys/JWT как в шаблоне),
|
||||||
durable pull-consumer `arm-core-results` (`recognition.result`, `max_deliver` 5, `ack_wait` 60 с).
|
`publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` с queue group `arm-core`
|
||||||
`src/services/recognition_exchange.py`: `publish_pending()` — `SELECT … FROM recognition_job WHERE status='queued'
|
(несколько инстанций ядра получают результат по одному разу). Stream'ов и consumer'ов нет; переподключение — встроенное
|
||||||
FOR UPDATE SKIP LOCKED LIMIT 50`, сборка запроса из `stored_file ⋈ s3_object`, `js.publish(subject, payload,
|
в `nats-py` (`max_reconnect_attempts=-1`, `reconnect_time_wait=2`).
|
||||||
headers={"Nats-Msg-Id": f"{job_id}:{attempt}"})`, при ack → `published`, `published_at`, `request_msg_id`, `request_subject`;
|
- `src/services/recognition_exchange.py`:
|
||||||
при ошибке — остаётся `queued` (повтор следующим циклом). `handle_result(msg)` — конверт → pydantic; job не найден → лог +
|
- `publish_pending()` — `SELECT … FROM recognition_job WHERE status='queued' OR (status='published' AND published_at <
|
||||||
ack; `result_msg_id == Nats-Msg-Id` → ack (дубликат); иначе `s3_object` (роль `results`, `created_by = service.name`) →
|
now() - ACK_TIMEOUT_SECONDS) FOR UPDATE SKIP LOCKED LIMIT 50`; для `queued` — первая публикация (`published`,
|
||||||
обновление job (`status`, `service_*`, `finished_at`, `metrics`, `error_*`, `result_schema_version`, `result_s3_object_id`,
|
`published_at`, `request_msg_id = <job_id>:<attempt>`, `request_subject`); для `published` без ack — повторная
|
||||||
`result_msg_id`) → commit → ack; ошибка БД → nak; `num_delivered ≥ max_deliver` → `status = dlq` + ack. `expire_deadlines()` —
|
публикация с тем же `Nats-Msg-Id` и обновлением `published_at`, счётчик повторов — в `metrics.republish_count`
|
||||||
`published|processing` с `reply_deadline < now()` → `timeout`. Период опроса `OUTBOX_POLL_SECONDS=5`, вахтёр — 30 с.
|
(jsonb, поле контракта метрик ядра); при `republish_count ≥ OUTBOX_MAX_REPUBLISH` → `status = dlq`,
|
||||||
Запуск — `asyncio.create_task` в lifespan при `DEBUG=false` **или** `NATS_ENABLED=true` (в dev NATS нужен, шаблонный
|
`error_code = NO_ACK`. `attempt` при этом не растёт — обработка не начиналась.
|
||||||
`DEBUG` его выключал — добавлен явный флаг). Маппинг документов — протокол `ResultMapper` с реализацией `NoopMapper`
|
- `handle_message(msg)` — конверт → pydantic; `status = processing` → задание `published → processing`, `started_at`,
|
||||||
(Срез 2 заменит). *Альтернатива* — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает
|
`service_name/version` (повторный ack игнорируется; ack для задания не в `published` — лог); иначе результат: job не
|
||||||
многоинстансность безопасной позже без изменений).
|
найден → лог; `result_msg_id == Nats-Msg-Id` → дубликат, игнор; иначе `s3_object` (роль `results`, `created_by =
|
||||||
|
service.name`) → обновление job (`status`, `finished_at`, `metrics`, `error_*`, `result_schema_version`,
|
||||||
|
`result_s3_object_id`, `result_msg_id`) → commit. Ошибка БД — лог и повтор по следующему сообщению не предусмотрен
|
||||||
|
(брокер не переотправит): задание дойдёт до `timeout` по `reply_deadline` и будет replay'нуто — осознанный компромисс
|
||||||
|
без JetStream.
|
||||||
|
- `expire_deadlines()` — `processing` (и `published`) с `reply_deadline < now()` → `timeout`.
|
||||||
|
- Период опроса `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`, вахтёр — 30 с.
|
||||||
|
- Запуск — `asyncio.create_task` в lifespan при `NATS_ENABLED=true` (в dev NATS нужен, шаблонный `DEBUG` его выключал —
|
||||||
|
добавлен явный флаг). Маппинг документов — протокол `ResultMapper` с реализацией `NoopMapper` (Срез 2 заменит).
|
||||||
|
*Альтернатива* — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает многоинстансность
|
||||||
|
безопасной позже без изменений). *Если JetStream появится* — `publish` и подписка переезжают на JS API, остальное без изменений.
|
||||||
|
|
||||||
### D-7. Тесты — PostgreSQL/NATS/MinIO из compose, маркеры `integration` и `fixtures`
|
### D-7. Тесты — PostgreSQL/NATS/MinIO из compose, маркеры `integration` и `fixtures`
|
||||||
`tests/conftest.py`: engine на `OPENBAO__DATABASE__DATABASE_URL` из `.env.test`; схема создаётся миграциями один раз на
|
`tests/conftest.py`: engine на `OPENBAO__DATABASE__DATABASE_URL` из `.env.test`; схема создаётся миграциями один раз на
|
||||||
@ -92,34 +105,42 @@ ack; `result_msg_id == Nats-Msg-Id` → ack (дубликат); иначе `s3_o
|
|||||||
(нужны NATS/MinIO), `fixtures` (нужен `ARM_KA_FIXTURES`; пропуск с причиной). SQLite не используется: enum'ы, partial
|
(нужны NATS/MinIO), `fixtures` (нужен `ARM_KA_FIXTURES`; пропуск с причиной). SQLite не используется: enum'ы, partial
|
||||||
unique, jsonb, `NULLS NOT DISTINCT`.
|
unique, jsonb, `NULLS NOT DISTINCT`.
|
||||||
|
|
||||||
### D-8. Линтеры: ruff + mypy в `api/pyproject.toml`
|
### D-8. Линтеры и проверки: ruff + mypy в `api/pyproject.toml`, `make check` / `make check-full`
|
||||||
`ruff`: `line-length = 120`, правила `E,F,I,UP,B,PL` (подмножество pylint), формат по умолчанию (black-совместимый).
|
`ruff`: `line-length = 120`, правила `E,F,I,UP,B,PL` (подмножество pylint), формат по умолчанию (black-совместимый).
|
||||||
`mypy`: `strict = false` на старте, `check_untyped_defs = true`, плагин pydantic; `src/models` и `src/domain` — `strict = true`.
|
`mypy`: `strict = false` на старте, `check_untyped_defs = true`, плагин pydantic; `src/models` и `src/domain` — `strict = true`.
|
||||||
Шаблон декларировал black/isort/pylint без конфигов; откат — замена файла конфига и шага CI.
|
Шаблон декларировал black/isort/pylint без конфигов; откат — замена файла конфига. CI в Gitea нет: `make check` (без
|
||||||
|
Docker) и `make check-full` (compose, миграции, DBML, `web`) — по `../docs/dev/checks.md`; `make check-full` перед PR.
|
||||||
|
|
||||||
### D-9. План счетов для seed — стандартный 94н + типовые субсчета 1С БП 3.0
|
### D-9. План счетов для seed — стандартный 94н + типовые субсчета 1С БП 3.0
|
||||||
Полный план конкретной конфигурации клиента недоступен (fixtures README: «план счетов из бандла макета» отсутствует).
|
Полный план конкретной конфигурации клиента недоступен (fixtures README: «план счетов из бандла макета» отсутствует).
|
||||||
Seed содержит синтетические счета 01–99 и забалансовые 001–011 (Приказ 94н) с `kind` по плану и все субсчета, на которые
|
Seed содержит синтетические счета 01–99 и забалансовые 001–011 (Приказ 94н) с `kind` по плану и все субсчета, на которые
|
||||||
ссылаются §4 (правила), §6 (шахматка) и `required_document_rule`; `row_type` — по глубине кода; `parent_code` — префикс.
|
ссылаются §4 (правила), §6 (шахматка) и `required_document_rule`; `row_type` — по глубине кода; `parent_code` — префикс.
|
||||||
Редкие субсчета клиентов добавляет администратор (ARCH-004, v5.6). Проверка: все счета `elimination_rules_v1.yaml` и
|
Редкие субсчета клиентов добавляет администратор (ARCH-004, v5.6). Проверка: все счета `elimination_rules_v1.yaml` и
|
||||||
`required_document_rules.yaml` резолвятся в `chart_of_account`.
|
`required_document_rules.yaml` резолвятся в `chart_of_account` (OQ-47).
|
||||||
|
|
||||||
### D-10. API — префикс `/api/v1`, формат ошибок шаблона
|
### D-10. API — префикс `/api/v1`, формат ошибок шаблона
|
||||||
Все новые роутеры под `APIRouter(prefix="/api/v1")` в `api/router.py`; ответы ошибок — `{"detail": …}` по-русски, как
|
Все новые роутеры под `APIRouter(prefix="/api/v1")` в `api/router.py`; ответы ошибок — `{"detail": …}` по-русски, как
|
||||||
в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права.
|
в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права.
|
||||||
|
|
||||||
|
### D-11. Dev без Nexus: Python 3.11, фильтр `raisa_*`, `optionalDependencies`
|
||||||
|
`Dockerfile` — `python:3.11-slim`; `make venv` — `python3.11 -m venv` + `grep -v '^raisa_' requirements.txt | pip install -r
|
||||||
|
/dev/stdin` + `requirements-dev.txt`; `Dockerfile` ставит так же. `requirements.txt` для платформы не меняется.
|
||||||
|
`web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies` (с Nexus ставятся, без — пропускаются).
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- [PostgreSQL банка < 15 → `NULLS NOT DISTINCT` недоступен] → миграция падает явно; fallback — уникальный индекс по
|
- [Без JetStream результат, опубликованный при простое ядра, теряется] → `reply_deadline` → `timeout` → replay (Срез 3+);
|
||||||
`COALESCE(osv_account_code, '')` (одно место, помечено комментарием в миграции).
|
для Среза 0 это задокументированный компромисс, покрыт интеграционным тестом «ядро не слушало».
|
||||||
- [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; CI-шаг
|
- [Повторная публикация без ack дублирует запрос работающему сервису] → сервисы держат множество `job_id` в работе и
|
||||||
«upgrade → downgrade → upgrade» и сверка с DBML.
|
отвечают повторным ack; тот же `Nats-Msg-Id`.
|
||||||
|
- [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; шаг
|
||||||
|
«upgrade → downgrade → upgrade» и сверка с DBML в `make check-full`.
|
||||||
- [`pydbml` не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге
|
- [`pydbml` не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге
|
||||||
задачи 2.4; при провале — собственный парсер (D-3).
|
задачи 2.5; при провале — собственный парсер (D-3).
|
||||||
- [Dev-эмитент попадёт в прод] → роутер подключается только при `AUTH_DEV_ISSUER=true and DEBUG=true`; тест проверяет,
|
- [Dev-эмитент попадёт в прод] → роутер подключается только при `AUTH_DEV_ISSUER=true and DEBUG=true`; тест проверяет,
|
||||||
что при `DEBUG=false` маршрута нет.
|
что при `DEBUG=false` маршрута нет.
|
||||||
- [Consumer ack'ает до commit'а] → порядок: commit → ack; при падении между ними — повтор и дедуп по `result_msg_id`.
|
|
||||||
- [Шаблон создаёт `settings` при импорте] → тесты всегда идут с `.env.test`; `ENV_FILE` читается в `Settings.model_config`.
|
- [Шаблон создаёт `settings` при импорте] → тесты всегда идут с `.env.test`; `ENV_FILE` читается в `Settings.model_config`.
|
||||||
|
- [Нет CI — проверки можно забыть] → `make check-full` в DoD каждой задачи и в описании PR.
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
@ -128,9 +149,8 @@ Seed содержит синтетические счета 01–99 и заба
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
Все — в родительском change (`../docs/openspec/changes/slice-0-skeleton/design.md`, «Open Questions»): версии
|
Остались (в родительском change): версия Node, параметры JWT РАИСА (нужны к первому деплою; до этого dev-эмитент),
|
||||||
Python/PostgreSQL/NATS, параметры JWT РАИСА, доступ к Nexus, кто создаёт stream. Ни один не меняет структуру задач:
|
появится ли JetStream (меняет только `core/nats.py`). Ни один не меняет структуру задач.
|
||||||
до ответов работает dev-эмитент и `NATS_MANAGE_STREAM=true`.
|
|
||||||
|
|
||||||
## Decisions made without discussion
|
## Decisions made without discussion
|
||||||
|
|
||||||
@ -141,10 +161,11 @@ Python/PostgreSQL/NATS, параметры JWT РАИСА, доступ к Nexus
|
|||||||
| 3 | 2026-10-03 | D-3: сверка через `pydbml`, notes по трём шаблонам | писать полный парсер дорого | собственный парсер ~200 строк |
|
| 3 | 2026-10-03 | D-3: сверка через `pydbml`, notes по трём шаблонам | писать полный парсер дорого | собственный парсер ~200 строк |
|
||||||
| 4 | 2026-10-03 | D-4: коды прав `<OBJECT>_<ACTION>`, `EXPORT` для совпадения; `valid_from` правил = 2024-01-01; seed разделён на core/dev | предсказуемость кодов; фикстуры 2024 г. | переименование seed-данных до первого деплоя бесплатно |
|
| 4 | 2026-10-03 | D-4: коды прав `<OBJECT>_<ACTION>`, `EXPORT` для совпадения; `valid_from` правил = 2024-01-01; seed разделён на core/dev | предсказуемость кодов; фикстуры 2024 г. | переименование seed-данных до первого деплоя бесплатно |
|
||||||
| 5 | 2026-10-03 | D-5: dev-эмитент HS256 за двумя флагами; РАИСА — конфигурируемый JWKS-каркас | пакет РАИСА недоступен, параметры неизвестны | дописать `RaisaVerifier` под реальные claims |
|
| 5 | 2026-10-03 | D-5: dev-эмитент HS256 за двумя флагами; РАИСА — конфигурируемый JWKS-каркас | пакет РАИСА недоступен, параметры неизвестны | дописать `RaisaVerifier` под реальные claims |
|
||||||
| 6 | 2026-10-03 | D-6: фоновые задачи в lifespan, pull-consumer, флаг `NATS_ENABLED` для dev | шаблонный `DEBUG` выключал NATS целиком | вынести в отдельный процесс — перенос двух функций |
|
| 6 | 2026-10-03 | D-6: core NATS — queue group `arm-core`, ack `processing`, повторная публикация без ack 30 с × 3 → `dlq` с `error_code = NO_ACK`, счётчик в `metrics.republish_count`, `attempt` не растёт; ошибка БД при приёме результата не ретраится (доходит до `timeout`) | JetStream нет (ответ 03.10); DBML не имеет колонки для счётчика повторов | при JetStream — замена publisher/subscriber; при необходимости — колонка в DBML v5.14 |
|
||||||
| 7 | 2026-10-03 | D-6: `stored_file.status` в Срезе 0 не меняется, только `recognition_job` | жизненный цикл файла — Срез 2 | нет |
|
| 7 | 2026-10-03 | D-6: `stored_file.status` в Срезе 0 не меняется, только `recognition_job` | жизненный цикл файла — Срез 2 | нет |
|
||||||
| 8 | 2026-10-03 | D-7: тесты только на PostgreSQL, транзакция с откатом | SQLite не воспроизводит схему | нет |
|
| 8 | 2026-10-03 | D-7: тесты только на PostgreSQL 16, транзакция с откатом | SQLite не воспроизводит схему | нет |
|
||||||
| 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README | один инструмент, black-совместимый формат | замена конфига и шага CI |
|
| 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README; `make check`/`make check-full` вместо CI | один инструмент, black-совместимый формат; CI в Gitea нет | замена конфига; YAML под пайплайн платформы при необходимости |
|
||||||
| 10 | 2026-10-03 | D-9: план счетов — 94н + типовые субсчета 1С, не полный план клиента | полного плана нет; ARCH-004 | дозагрузка CSV администратором или новым seed |
|
| 10 | 2026-10-03 | D-9: план счетов — 94н + типовые субсчета 1С, не полный план клиента | полного плана нет; ARCH-004 | дозагрузка CSV администратором или новым seed |
|
||||||
| 11 | 2026-10-03 | ASSUMPTION(OQ-46): все ОСВ расширенного подхода `is_mandatory = true` | иначе EXTENDED ничем не отличается от BASIC в чек-листе | правка одного yaml |
|
| 11 | 2026-10-03 | ASSUMPTION(OQ-46): все ОСВ расширенного подхода `is_mandatory = true` | иначе EXTENDED ничем не отличается от BASIC в чек-листе | правка одного yaml |
|
||||||
| 12 | 2026-10-03 | `Settings` читает `ENV_FILE`; `requirements-dev.txt` → `-r requirements.txt` + инструменты | шаблонный pytest.ini уже ссылается на `ENV_FILE`; dev-пины конфликтовали с prod | нет |
|
| 12 | 2026-10-03 | `Settings` читает `ENV_FILE`; `requirements-dev.txt` → `-r requirements.txt` + инструменты | шаблонный pytest.ini уже ссылается на `ENV_FILE`; dev-пины конфликтовали с prod | нет |
|
||||||
|
| 13 | 2026-10-03 | D-11: Python 3.11, фильтр `raisa_*` при dev-установке, npm `raisa-*` → `optionalDependencies` | ответы пользователя: 3.11, Nexus недоступен до прода | вернуть две строки в `dependencies` |
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Proposal: Срез 0 — скелет ядра arm-core
|
# Proposal: Срез 0 — скелет ядра arm-core
|
||||||
|
|
||||||
**Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** arm-core. **Issue:** RSHB/arm-core#1.
|
**Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** arm-core. **Issue:** RSHB/arm-core#1.
|
||||||
**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, CI-шаблон).
|
**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, проверки качества).
|
||||||
**Соседние:** `../tabular-parser/openspec/changes/slice-0-skeleton`, `../scan-gateway/openspec/changes/slice-0-skeleton`.
|
**Соседние:** `../tabular-parser/openspec/changes/slice-0-skeleton`, `../scan-gateway/openspec/changes/slice-0-skeleton`.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@ -14,9 +14,12 @@ DBML v5.13, прав как данных и работающего outbox/inbox,
|
|||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- **Инструменты шаблона**: `api/pyproject.toml` (ruff — формат, совместимый с black, и lint; mypy), `requirements-dev.txt`
|
- **Инструменты шаблона**: `api/pyproject.toml` (ruff — формат, совместимый с black, и lint; mypy), `requirements-dev.txt`
|
||||||
переписан как `-r requirements.txt` + dev-инструменты (пины шаблона были рассинхронизированы), `Dockerfile` в корне
|
переписан как `-r requirements.txt` + dev-инструменты (пины шаблона были рассинхронизированы), `Dockerfile`
|
||||||
(строка `Dockerfile` убрана из `.gitignore`), `Makefile` с целями `up / migrate / test / lint / seed`, `.env.example`,
|
(`python:3.11-slim` — версия платформы; строка `Dockerfile` убрана из `.gitignore`), `Makefile` с общими целями
|
||||||
`.env.test`; `Settings` читает путь к env-файлу из `ENV_FILE` (шаблонный `pytest.ini` уже задаёт его, но `config.py` игнорировал).
|
`up / test / test-integration / lint / check / check-full` и своими `venv / migrate / seed`, `.env.example`, `.env.test`;
|
||||||
|
`Settings` читает путь к env-файлу из `ENV_FILE` (шаблонный `pytest.ini` уже задаёт его, но `config.py` игнорировал).
|
||||||
|
Без Nexus: `make venv` ставит `requirements.txt` без строк `raisa_*`; в `web/package.json` `raisa-ui` и
|
||||||
|
`raisa-fastapi-protected-web` → `optionalDependencies`.
|
||||||
- **Модель данных**: ORM 1:1 с `../docs/model/data-model-v5.13.dbml` — 43 таблицы, 27 enum'ов, все FK, уникальные и
|
- **Модель данных**: ORM 1:1 с `../docs/model/data-model-v5.13.dbml` — 43 таблицы, 27 enum'ов, все FK, уникальные и
|
||||||
частичные индексы (`related_group_member` WHERE is_current, `consolidation_lock` WHERE released_at IS NULL,
|
частичные индексы (`related_group_member` WHERE is_current, `consolidation_lock` WHERE released_at IS NULL,
|
||||||
`document` NULLS NOT DISTINCT), схема `dkb`. Миграция `0001_dbml_v5_13` создаёт всё с нуля и полностью откатывается.
|
`document` NULLS NOT DISTINCT), схема `dkb`. Миграция `0001_dbml_v5_13` создаёт всё с нуля и полностью откатывается.
|
||||||
@ -30,14 +33,16 @@ DBML v5.13, прав как данных и работающего outbox/inbox,
|
|||||||
`AUTH_DEV_ISSUER`; каркас `RaisaVerifier` под JWKS/публичный ключ, параметры — вопрос к человеку), поиск `app_user`
|
`AUTH_DEV_ISSUER`; каркас `RaisaVerifier` под JWKS/публичный ключ, параметры — вопрос к человеку), поиск `app_user`
|
||||||
по email, зависимости `current_user` и `require(permission_code)`, `GET /api/v1/me`, `POST /api/v1/dev/token`
|
по email, зависимости `current_user` и `require(permission_code)`, `GET /api/v1/me`, `POST /api/v1/dev/token`
|
||||||
(только при включённом флаге). Policy-слой (филиал/ЗО/статус) — не здесь (Срез 1+).
|
(только при включённом флаге). Policy-слой (филиал/ЗО/статус) — не здесь (Срез 1+).
|
||||||
- **Обмен через `recognition_job`** (DECISION-4): вендоренные схемы контракта `api/contracts/` + pydantic-модели
|
- **Обмен через `recognition_job`** (DECISION-4, **NATS без JetStream**): вендоренные схемы контракта `api/contracts/` +
|
||||||
`src/contract/`; outbox-паблишер (`queued` → NATS → `published`, `Nats-Msg-Id = <job_id>:<attempt>`), durable
|
pydantic-модели `src/contract/`; outbox-паблишер (`queued` → NATS → `published`, `Nats-Msg-Id = <job_id>:<attempt>`);
|
||||||
consumer `recognition.result` (дедуп по `result_msg_id`, обновление статуса/метрик, регистрация файла результата в
|
подписка queue group `arm-core` на `recognition.result`: ack сервиса (`status = processing`) → `processing`/`started_at`,
|
||||||
`s3_object`, `dlq` после `max_deliver`), вахтёр `reply_deadline` → `timeout`. Маппинг документов — точка расширения,
|
результат → дедуп по `result_msg_id`, обновление статуса/метрик, регистрация файла результата в `s3_object`;
|
||||||
реализуется в Срезе 2.
|
повторная публикация запроса без ack (`ACK_TIMEOUT_SECONDS` = 30, до `OUTBOX_MAX_REPUBLISH` = 3 раз) → затем `dlq`;
|
||||||
|
вахтёр `reply_deadline` → `timeout`. Маппинг документов — точка расширения, реализуется в Срезе 2.
|
||||||
- **Аудит**: `services/audit.py` — единственный способ записать `audit_event` (append-only, `before/after` jsonb).
|
- **Аудит**: `services/audit.py` — единственный способ записать `audit_event` (append-only, `before/after` jsonb).
|
||||||
- **CI**: `.gitea/workflows/ci.yml` из шаблона docs: ruff, mypy, pytest на PostgreSQL/NATS/MinIO, миграции
|
- **Проверки качества** (CI в Gitea нет, пайплайн платформы вне репозитория): `make check` — ruff, mypy, pytest без
|
||||||
upgrade → downgrade → upgrade, `check_dbml_vs_orm`, сборка образа; job `web`: biome, tsc, rsbuild build.
|
Docker, идентичность контракта; `make check-full` — плюс интеграционные тесты против compose, миграции
|
||||||
|
upgrade → downgrade → upgrade, `check_dbml_vs_orm`, сборка образа, `web`: biome, tsc, rsbuild build.
|
||||||
- **Документация репозитория**: `README.md` заменяется описанием сервиса (команды, env, как поднять), `CLAUDE.md` —
|
- **Документация репозитория**: `README.md` заменяется описанием сервиса (команды, env, как поднять), `CLAUDE.md` —
|
||||||
раздел «Соглашения шаблона» (уже в ветке).
|
раздел «Соглашения шаблона» (уже в ветке).
|
||||||
|
|
||||||
@ -62,10 +67,10 @@ DBML v5.13, прав как данных и работающего outbox/inbox,
|
|||||||
Новых полей и таблиц нет.
|
Новых полей и таблиц нет.
|
||||||
- **Параграфы правил:** §4 (seed правил элиминации), §9.4 (жизненный цикл `recognition_status`), §10 (матрица прав).
|
- **Параграфы правил:** §4 (seed правил элиминации), §9.4 (жизненный цикл `recognition_status`), §10 (матрица прав).
|
||||||
- **Зависимости (новые):** `PyJWT[crypto]`, `pydbml` (dev, сверка DBML), `ruff`, `mypy`, `pytest-cov`, `types-*`;
|
- **Зависимости (новые):** `PyJWT[crypto]`, `pydbml` (dev, сверка DBML), `ruff`, `mypy`, `pytest-cov`, `types-*`;
|
||||||
`nats-py` уже есть (JetStream API), `miniopy-async` уже есть.
|
`nats-py` уже есть (core NATS, заголовки), `miniopy-async` уже есть.
|
||||||
- **Внешние системы:** NATS JetStream (stream `RECOGNITION`), MinIO/S3 (бакет `arm-ka`), PostgreSQL 15 (`CREATE SCHEMA dkb`,
|
- **Внешние системы:** NATS без JetStream (subjects `recognition.*`), MinIO/S3 (бакет `arm-ka`), PostgreSQL 16
|
||||||
`CREATE TYPE`).
|
(`CREATE SCHEMA dkb`, `CREATE TYPE`).
|
||||||
- **Фронт:** без изменений кода; добавляется только CI-job.
|
- **Фронт:** код не меняется; `raisa-*` в `package.json` → `optionalDependencies`; проверки `web` входят в `make check-full`.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ WHERE `is_current`, `consolidation_lock` WHERE `released_at IS NULL`) и `NULLS
|
|||||||
|
|
||||||
### Requirement: Миграции применяются с нуля и откатываются
|
### Requirement: Миграции применяются с нуля и откатываются
|
||||||
Единственная ревизия `0001` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения и индексы на пустой
|
Единственная ревизия `0001` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения и индексы на пустой
|
||||||
базе PostgreSQL 15; `downgrade` SHALL удалять всё созданное, оставляя пустую схему; повторный `upgrade` после `downgrade`
|
базе PostgreSQL 16 (требуется ≥ 15); `downgrade` SHALL удалять всё созданное, оставляя пустую схему; повторный `upgrade` после `downgrade`
|
||||||
SHALL проходить без ошибок.
|
SHALL проходить без ошибок.
|
||||||
|
|
||||||
#### Scenario: Полный цикл
|
#### Scenario: Полный цикл
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует
|
Единственная точка обмена ядра с сервисами распознавания — таблица `recognition_job` (DECISION-4): outbox публикует
|
||||||
запросы в NATS, inbox принимает результаты идемпотентно, таймауты и DLQ отражаются в статусе задания, файл результата
|
запросы в NATS, inbox принимает подтверждения и результаты идемпотентно, потеря сообщений без JetStream компенсируется
|
||||||
регистрируется в `s3_object`.
|
повторной публикацией и таймаутами, файл результата регистрируется в `s3_object`.
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
Фоновый публикатор SHALL с периодом `OUTBOX_POLL_SECONDS` (по умолчанию 5) выбирать задания `status = queued`
|
Фоновый публикатор SHALL с периодом `OUTBOX_POLL_SECONDS` (по умолчанию 5) выбирать задания `status = queued`
|
||||||
(с блокировкой `FOR UPDATE SKIP LOCKED`), формировать сообщение по `recognition-request.schema.json` v1.0 из
|
(с блокировкой `FOR UPDATE SKIP LOCKED`), формировать сообщение по `recognition-request.schema.json` v1.0 из
|
||||||
`recognition_job ⋈ stored_file ⋈ s3_object` и публиковать в subject по `pipeline` с заголовком
|
`recognition_job ⋈ stored_file ⋈ s3_object` и публиковать в subject по `pipeline` с заголовком
|
||||||
`Nats-Msg-Id = <job_id>:<attempt>`. После подтверждения JetStream задание SHALL получить `status = published`,
|
`Nats-Msg-Id = <job_id>:<attempt>`. После успешной публикации задание SHALL получить `status = published`,
|
||||||
`published_at`, `request_msg_id`, `request_subject`; при ошибке публикации задание SHALL остаться `queued`.
|
`published_at`, `request_msg_id`, `request_subject`; при ошибке публикации задание SHALL остаться `queued`.
|
||||||
|
|
||||||
#### Scenario: Публикация запроса парсеру
|
#### Scenario: Публикация запроса парсеру
|
||||||
@ -22,17 +22,40 @@
|
|||||||
|
|
||||||
#### Scenario: NATS недоступен
|
#### Scenario: NATS недоступен
|
||||||
- **WHEN** публикация завершается ошибкой соединения
|
- **WHEN** публикация завершается ошибкой соединения
|
||||||
- **THEN** задание остаётся `queued`, ошибка залогирована, следующий цикл повторяет публикацию; дубликат после
|
- **THEN** задание остаётся `queued`, ошибка залогирована, следующий цикл повторяет публикацию
|
||||||
восстановления отбрасывается JetStream по `Nats-Msg-Id`
|
|
||||||
|
### Requirement: Ack сервиса переводит задание в `processing`
|
||||||
|
Получив в `recognition.result` конверт со `status = processing` для задания в `published`, ядро SHALL установить
|
||||||
|
`status = processing`, `started_at`, `service_name`, `service_version`. Повторный ack для задания в `processing` SHALL
|
||||||
|
игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание.
|
||||||
|
|
||||||
|
#### Scenario: Сервис подтвердил
|
||||||
|
- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания
|
||||||
|
- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser`
|
||||||
|
|
||||||
|
### Requirement: Запрос без ack публикуется повторно, затем — `dlq`
|
||||||
|
Если задание находится в `published` дольше `ACK_TIMEOUT_SECONDS` (по умолчанию 30) без ack, публикатор SHALL
|
||||||
|
опубликовать запрос повторно с тем же `Nats-Msg-Id` и тем же `attempt`, обновив `published_at` и увеличив счётчик
|
||||||
|
`metrics.republish_count`. После `OUTBOX_MAX_REPUBLISH` (по умолчанию 3) повторов без ack задание SHALL получить
|
||||||
|
`status = dlq`, `error_code = NO_ACK`, `finished_at`.
|
||||||
|
|
||||||
|
#### Scenario: Сервис недоступен
|
||||||
|
- **WHEN** ни один инстанс `tabular-parser` не подписан, задание опубликовано
|
||||||
|
- **THEN** запрос публикуется повторно примерно каждые 30 с; после трёх повторов задание имеет `status = dlq`,
|
||||||
|
`error_code = NO_ACK`, `request_msg_id` не изменился
|
||||||
|
|
||||||
|
#### Scenario: Сервис появился после первого повтора
|
||||||
|
- **WHEN** сервис подписался между первой и второй публикацией и прислал ack
|
||||||
|
- **THEN** задание переходит в `processing`, дальнейшие повторы не выполняются
|
||||||
|
|
||||||
### Requirement: Inbox принимает результат идемпотентно
|
### Requirement: Inbox принимает результат идемпотентно
|
||||||
Durable consumer `arm-core-results` на `recognition.result` SHALL валидировать конверт по
|
Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по
|
||||||
`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен
|
`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен
|
||||||
`Nats-Msg-Id` сообщения — подтвердить без изменений; иначе зарегистрировать файл результата в `s3_object`
|
`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`,
|
||||||
(`bucket_role = results`, `created_by = <service.name>`), обновить задание (`status` ∈ {done, needs_check, error},
|
`created_by = <service.name>`; только при наличии `result_s3`), обновить задание (`status`, `service_name`,
|
||||||
`service_name`, `service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`,
|
`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`,
|
||||||
`result_s3_object_id`, `result_msg_id`), зафиксировать транзакцию и только затем подтвердить сообщение. Неизвестный
|
`result_msg_id`) и зафиксировать транзакцию. Неизвестный `job_id` SHALL быть залогирован. Результат для задания, уже
|
||||||
`job_id` SHALL быть залогирован и подтверждён. Сообщение с `num_delivered ≥ max_deliver` SHALL перевести задание в `dlq`.
|
находящегося в финальном статусе (`done`, `error`, `timeout`, `dlq`), SHALL логироваться и не применяться.
|
||||||
|
|
||||||
#### Scenario: Успешный результат
|
#### Scenario: Успешный результат
|
||||||
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
|
- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results/<file_id>/<job_id>/result.json}` и
|
||||||
@ -41,34 +64,38 @@ Durable consumer `arm-core-results` на `recognition.result` SHALL валиди
|
|||||||
строку `s3_object` с этим ключом, `finished_at` заполнен
|
строку `s3_object` с этим ключом, `finished_at` заполнен
|
||||||
|
|
||||||
#### Scenario: Повторная доставка
|
#### Scenario: Повторная доставка
|
||||||
- **WHEN** то же сообщение доставляется второй раз
|
- **WHEN** то же сообщение получено второй раз
|
||||||
- **THEN** строка `recognition_job` и `s3_object` не меняются, сообщение подтверждено
|
- **THEN** строка `recognition_job` и `s3_object` не меняются
|
||||||
|
|
||||||
#### Scenario: Ошибка сервиса
|
#### Scenario: Ошибка сервиса
|
||||||
- **WHEN** конверт содержит `status = error`, `error = {NOT_IMPLEMENTED, …}` без `result_s3`
|
- **WHEN** конверт содержит `status = error`, `error = {NOT_IMPLEMENTED, …}` без `result_s3`
|
||||||
- **THEN** задание получает `status = error`, `error_code = NOT_IMPLEMENTED`, `error_message` из конверта, `result_s3_object_id` пуст
|
- **THEN** задание получает `status = error`, `error_code = NOT_IMPLEMENTED`, `error_message` из конверта, `result_s3_object_id` пуст
|
||||||
|
|
||||||
#### Scenario: Отравленное сообщение
|
#### Scenario: Результат пришёл после таймаута
|
||||||
- **WHEN** обработка одного и того же сообщения падает ошибкой БД пять раз подряд (`max_deliver = 5`)
|
- **WHEN** задание уже `timeout`, и приходит конверт `done`
|
||||||
- **THEN** на пятой доставке задание переводится в `dlq`, сообщение подтверждено, повторов больше нет
|
- **THEN** задание не меняется, событие залогировано
|
||||||
|
|
||||||
### Requirement: Таймаут ожидания результата
|
### Requirement: Таймаут ожидания результата
|
||||||
Фоновый вахтёр SHALL с периодом 30 с переводить задания `published` или `processing` с `reply_deadline < now()` в
|
Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в
|
||||||
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`. Replay задания (новая попытка) — вне Среза 0.
|
`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`. Replay задания (новая попытка, `attempt + 1`) — вне Среза 0.
|
||||||
|
|
||||||
#### Scenario: Сервис не ответил
|
#### Scenario: Сервис взял, но не ответил
|
||||||
- **WHEN** задание опубликовано с `reply_deadline` в прошлом и результата нет
|
- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата
|
||||||
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`
|
- **THEN** не позднее чем через 30 с задание имеет `status = timeout`
|
||||||
|
|
||||||
### Requirement: Соединение с NATS по шаблону, stream — по флагу
|
### Requirement: Соединение с NATS по шаблону, без JetStream
|
||||||
Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных,
|
Ядро SHALL подключаться к NATS с nkeys-seed и JWT, если заданы `OPENBAO__NATS__NATS_SEED/NATS_JWT`, и без учётных данных,
|
||||||
если они пусты; при `NATS_MANAGE_STREAM=true` SHALL создать stream `RECOGNITION` (subjects `recognition.>`,
|
если они пусты; подписываться на `recognition.result` в queue group `arm-core`; переподключаться автоматически.
|
||||||
окно дубликатов 2 мин) и свой consumer, если их нет. Фоновые задачи SHALL запускаться при `NATS_ENABLED=true`.
|
Stream'ы и consumer'ы JetStream MUST NOT требоваться. Фоновые задачи SHALL запускаться при `NATS_ENABLED=true`.
|
||||||
|
|
||||||
#### Scenario: Dev-окружение compose
|
#### Scenario: Dev-окружение compose
|
||||||
- **WHEN** ядро стартует с `DEBUG=true`, `NATS_ENABLED=true`, `NATS_MANAGE_STREAM=true` и пустыми учётными данными
|
- **WHEN** ядро стартует с `DEBUG=true`, `NATS_ENABLED=true` и пустыми учётными данными против NATS без JetStream
|
||||||
- **THEN** stream `RECOGNITION` и consumer `arm-core-results` существуют, в логах нет ошибок подключения
|
- **THEN** подписка активна, `/healthcheck` отдаёт `nats = ok`, в логах нет ошибок подключения
|
||||||
|
|
||||||
|
#### Scenario: Два инстанса ядра
|
||||||
|
- **WHEN** запущены два инстанса с `NATS_ENABLED=true` и опубликован один результат
|
||||||
|
- **THEN** результат обработан ровно одним инстансом (queue group), строка задания изменена один раз
|
||||||
|
|
||||||
#### Scenario: NATS выключен
|
#### Scenario: NATS выключен
|
||||||
- **WHEN** `NATS_ENABLED=false`
|
- **WHEN** `NATS_ENABLED=false`
|
||||||
- **THEN** приложение стартует, фоновые задачи не запускаются, `/healthcheck` отвечает 200
|
- **THEN** приложение стартует, фоновые задачи не запускаются, `/healthcheck` отвечает 200 с `nats = off`
|
||||||
|
|||||||
@ -1,25 +1,29 @@
|
|||||||
# Tasks: Срез 0 — скелет ядра arm-core
|
# Tasks: Срез 0 — скелет ядра arm-core
|
||||||
|
|
||||||
Репозиторий — arm-core, если не указано иное; все команды — из `api/`. Порядок — по зависимостям. Каждая задача ≤ половины
|
Репозиторий — arm-core, если не указано иное; все команды — из `api/`. Порядок — по зависимостям. Каждая задача ≤ половины
|
||||||
дня, с указанием файлов и способа проверки. Зависимости от других репозиториев: 1.1 ждёт контракт v1.0
|
дня, с указанием файлов и способа проверки. Зависимости от других репозиториев: 5.1 ждёт контракт v1.0
|
||||||
(`docs` задачи 1.1–1.4); 2.x нужна инфраструктура compose (`docs` 2.1).
|
(`docs` задачи 1.1–1.4); 2.x нужна инфраструктура compose (`docs` 2.1). CI нет — проверка каждой задачи локальная.
|
||||||
|
|
||||||
## 1. Инструменты и окружение шаблона
|
## 1. Инструменты и окружение шаблона
|
||||||
|
|
||||||
- [ ] 1.1 `api/pyproject.toml` (ruff: line-length 120, правила E,F,I,UP,B,PL; mypy с плагином pydantic),
|
- [ ] 1.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, pytest-cov, httpx,
|
`requirements-dev.txt` = `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, pytest-cov, httpx,
|
||||||
PyJWT[crypto], pydbml, PyYAML, types-PyYAML; проверка: `pip install -r requirements-dev.txt` без конфликтов,
|
PyJWT[crypto], pydbml, PyYAML, types-PyYAML; `Makefile`: `venv` (python3.11, `requirements.txt` без строк `raisa_*`,
|
||||||
`ruff check . && ruff format --check . && mypy src` зелёные на текущем каркасе
|
затем dev), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`, `migrate`, `seed`, `seed-dev`;
|
||||||
|
проверка: `make venv && make lint` зелёные на текущем каркасе без доступа к Nexus
|
||||||
- [ ] 1.2 `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля
|
- [ ] 1.2 `src/core/config.py`: `Settings.model_config` читает `env_file` из `ENV_FILE` (по умолчанию `.env`); новые поля
|
||||||
`AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`,
|
`AUTH_DEV_ISSUER=false`, `AUTH_DEV_SECRET`, `AUTH_JWKS_URL`, `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`,
|
||||||
`AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `NATS_MANAGE_STREAM=false`, `OUTBOX_POLL_SECONDS=5`;
|
`AUTH_EMAIL_CLAIM="email"`, `NATS_ENABLED=false`, `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`,
|
||||||
`.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config import settings"` читает тестовые значения
|
`OUTBOX_MAX_REPUBLISH=3`; `.env.example`, `.env.test`; проверка: `ENV_FILE=.env.test python -c "from src.core.config
|
||||||
- [ ] 1.3 `Dockerfile` (python:3.12-slim, `pip install -r api/requirements.txt`, `CMD python run_server.py`), убрать строку
|
import settings"` читает тестовые значения
|
||||||
`Dockerfile` из `.gitignore`, `Makefile` (`up`, `migrate`, `seed`, `test`, `lint`, `dbml-check`); проверка:
|
- [ ] 1.3 `Dockerfile` (`python:3.11-slim`, установка `requirements.txt` без `raisa_*`, `CMD python run_server.py`), убрать
|
||||||
`docker build -t arm-core .` успешен, `make lint` зелёный
|
строку `Dockerfile` из `.gitignore`; `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` →
|
||||||
|
`optionalDependencies`; проверка: `docker build -t arm-core .` успешен без Nexus; `npm install` в `web/` проходит,
|
||||||
|
`npm run build` собирает
|
||||||
- [ ] 1.4 `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из
|
- [ ] 1.4 `src/app.py`: NATS — пустые `NATS_SEED/NATS_JWT` → подключение без nkeys (перенести `nkeys.from_seed` из
|
||||||
модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`; `api/router.py` — `APIRouter(prefix="/api/v1")`;
|
модуля в функцию), подключение при `NATS_ENABLED=true` независимо от `DEBUG`, `/healthcheck` отдаёт `nats`
|
||||||
проверка: `python run_server.py` стартует против compose без учётных данных, `/healthcheck` 200
|
(`ok`/`down`/`off`); `api/router.py` — `APIRouter(prefix="/api/v1")`; проверка: `python run_server.py` стартует
|
||||||
|
против compose без учётных данных, `/healthcheck` 200 с `nats = ok`
|
||||||
|
|
||||||
## 2. Модель данных (spec `data-model`)
|
## 2. Модель данных (spec `data-model`)
|
||||||
|
|
||||||
@ -41,11 +45,11 @@
|
|||||||
добавленная колонка даёт код 1 и понятное сообщение
|
добавленная колонка даёт код 1 и понятное сообщение
|
||||||
- [ ] 2.6 `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик
|
- [ ] 2.6 `alembic/env.py`: `CREATE SCHEMA IF NOT EXISTS dkb` перед миграциями; ревизия `0001_dbml_v5_13.py` (черновик
|
||||||
autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`);
|
autogenerate → ручная правка: enum'ы, partial-индексы, `NULLS NOT DISTINCT`, `use_alter`, полный `downgrade`);
|
||||||
проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL;
|
проверка: `alembic upgrade head && alembic downgrade base && alembic upgrade head` на compose-PostgreSQL 16;
|
||||||
`alembic revision --autogenerate` после — пустая ревизия
|
`alembic revision --autogenerate` после — пустая ревизия
|
||||||
- [ ] 2.7 `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры
|
- [ ] 2.7 `tests/conftest.py`: engine из `.env.test`, миграции один раз на сессию, транзакция с откатом на тест, маркеры
|
||||||
`integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из
|
`integration`/`fixtures` (skip с причиной при отсутствии `ARM_KA_FIXTURES`); проверка: тест чтения `Decimal` из
|
||||||
`osv_entry.closing_debit` проходит; `pytest -m "not integration"` зелёный
|
`osv_entry.closing_debit` проходит; `make check` зелёный
|
||||||
|
|
||||||
## 3. Seed (spec `reference-data-seed`)
|
## 3. Seed (spec `reference-data-seed`)
|
||||||
|
|
||||||
@ -72,36 +76,39 @@
|
|||||||
- [ ] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается
|
- [ ] 4.3 `api/endpoints/me.py` (`GET /api/v1/me`), `api/endpoints/dev_auth.py` (`POST /api/v1/dev/token`, подключается
|
||||||
только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10;
|
только при `AUTH_DEV_ISSUER and DEBUG`); проверка: тесты — ответ `/me` для `ka_rf@armka.local` равен правам §10;
|
||||||
при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401
|
при `DEBUG=false` маршрут `dev/token` → 404 и dev-токен → 401
|
||||||
- [ ] 4.4 `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, получение dev-токена,
|
- [ ] 4.4 `README.md` репозитория: замена шаблонного текста — назначение, команды (`make`), env, работа без Nexus,
|
||||||
запуск против compose; проверка: новый разработчик проходит README до зелёного `/me` (самопроверка по шагам)
|
получение dev-токена, запуск против compose; проверка: новый разработчик проходит README до зелёного `/me`
|
||||||
|
|
||||||
## 5. Обмен через `recognition_job` и аудит (spec `recognition-job-exchange`, `audit-trail`)
|
## 5. Обмен через `recognition_job` и аудит (spec `recognition-job-exchange`, `audit-trail`)
|
||||||
|
|
||||||
- [ ] 5.1 `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели
|
- [ ] 5.1 `api/contracts/` — копии трёх схем из `../docs/contracts/` + `examples/`; `src/contract/` — pydantic-модели
|
||||||
запроса, конверта результата, файла результата (Decimal как строка); `tests/test_contract.py` — идентичность с
|
запроса, конверта (статусы `processing | done | needs_check | error`), файла результата (Decimal как строка);
|
||||||
`../docs/contracts` (skip с предупреждением без docs), валидация примеров; проверка: тесты зелёные
|
`tests/test_contract.py` — идентичность с `../docs/contracts` (skip с предупреждением без docs), валидация примеров;
|
||||||
|
проверка: тесты зелёные
|
||||||
- [ ] 5.2 `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`,
|
- [ ] 5.2 `src/services/audit.py` — `AuditService.record(...)` в текущей сессии; тест: системное событие с `user_id = NULL`,
|
||||||
откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent`
|
откат транзакции не оставляет записи; статический тест — в `src/` нет `update()/delete()` по `AuditEvent`
|
||||||
- [ ] 5.3 `src/core/nats.py`: подключение (с nkeys/JWT или без), `ensure_stream` (`RECOGNITION`, `recognition.>`, дубликаты
|
- [ ] 5.3 `src/core/nats.py`: подключение (с nkeys/JWT или без; автопереподключение), `publish(subject, payload, msg_id)`
|
||||||
2 мин), durable pull-consumer `arm-core-results` (`max_deliver` 5, `ack_wait` 60 с); проверка: интеграционный тест
|
с заголовком `Nats-Msg-Id`, подписка `recognition.result` в queue group `arm-core`; проверка: интеграционный тест
|
||||||
против compose-NATS создаёт stream и consumer, повторный вызов идемпотентен
|
против compose-NATS (без JetStream) — публикация и получение сообщения с заголовком; два подписчика в одной группе
|
||||||
- [ ] 5.4 `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED, сборка запроса, `Nats-Msg-Id`,
|
получают сообщение один раз
|
||||||
переход `published`), `expire_deadlines()`; фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный
|
- [ ] 5.4 `src/services/recognition_exchange.py`: `publish_pending()` (SKIP LOCKED; первая публикация `queued →
|
||||||
тест — вставка `s3_object` + `stored_file` + `recognition_job(queued)` → сообщение получено тестовым подписчиком и
|
published`; повторная публикация `published` без ack через `ACK_TIMEOUT_SECONDS` с тем же `Nats-Msg-Id`,
|
||||||
проходит схему → строка `published`; задание с прошедшим `reply_deadline` → `timeout`
|
`metrics.republish_count`; после `OUTBOX_MAX_REPUBLISH` → `dlq`, `error_code = NO_ACK`), `expire_deadlines()`;
|
||||||
- [ ] 5.5 `handle_result()` + `ResultMapper`/`NoopMapper` + регистрация `s3_object` результата + аудит
|
фоновые задачи в lifespan при `NATS_ENABLED`; проверка: интеграционный тест — вставка `s3_object` + `stored_file` +
|
||||||
`CONSOLIDATION_STATUS_CHANGE`? — нет: для заданий аудит не предусмотрен enum'ом, пишем только лог; обработка
|
`recognition_job(queued)` → сообщение получено тестовым подписчиком и проходит схему → строка `published`; без ack
|
||||||
дубликатов, неизвестного `job_id`, `dlq`; проверка: интеграционный тест — `done` обновляет задание и создаёт
|
(с укороченными таймаутами) — три повтора с одним `Nats-Msg-Id` и `dlq`; задание с прошедшим `reply_deadline` → `timeout`
|
||||||
`s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; пятикратное падение → `dlq`
|
- [ ] 5.5 `handle_message()`: ack `processing` → `processing`/`started_at`/`service_*`; результат → `ResultMapper`/`NoopMapper`
|
||||||
|
+ регистрация `s3_object` + обновление задания; дубликат по `result_msg_id`, неизвестный `job_id`, результат для
|
||||||
|
финального статуса — лог без изменений; проверка: интеграционный тест полного цикла queued → published → processing
|
||||||
|
→ done с `s3_object`; повтор не меняет строки; `error NOT_IMPLEMENTED` → `error_code`; `done` после `timeout` игнорируется
|
||||||
|
|
||||||
## 6. CI и завершение
|
## 6. Проверки и завершение
|
||||||
|
|
||||||
- [ ] 6.1 `.gitea/workflows/ci.yml` из `../docs/dev/ci/python-service.yml` (блок `env`: arm-core, шаги `migrations`,
|
- [ ] 6.1 `make check-full`: `make check` + `pytest -m integration` + цикл миграций + `check_dbml_vs_orm` + `docker build`
|
||||||
`dbml-check` включены) + job `web` (`npm ci`, `biome check src`, `tsc --noEmit`, `rsbuild build`); проверка: зелёный
|
+ `web`: `npm install`, `npx biome check src`, `npx tsc --noEmit`, `npm run build`; проверка: цель зелёная против
|
||||||
run на ветке `feat/1-slice-0-skeleton`
|
compose docs на ветке `feat/1-slice-0-skeleton`
|
||||||
- [ ] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make test` (включая `integration`) →
|
- [ ] 6.2 Прогон DoD: `make up` (compose docs) → `make migrate` → `make seed-dev` → `make check-full` → `curl /api/v1/me`
|
||||||
`curl /api/v1/me` с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное,
|
с dev-токеном возвращает права; `openspec validate slice-0-skeleton --strict`; проверка: всё зелёное, вывод
|
||||||
результаты вписаны в описание PR
|
`make check-full` вписан в описание PR
|
||||||
- [ ] 6.3 Обновить `openspec/specs/` не требуется до архивации; дополнить `CLAUDE.md` (команды `make`), зафиксировать
|
- [ ] 6.3 Дополнить `CLAUDE.md` (команды `make`), зафиксировать новые допущения в `../docs/context/07-open-questions.md`
|
||||||
новые допущения в `../docs/context/07-open-questions.md` через PR в docs (OQ-46 и всё, что всплыло); проверка:
|
через PR в docs (OQ-46 и всё, что всплыло); проверка: ссылки из README/CLAUDE.md ведут на существующие файлы
|
||||||
ссылки из README/CLAUDE.md ведут на существующие файлы
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ context: |
|
|||||||
../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4).
|
../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4).
|
||||||
Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md.
|
Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md.
|
||||||
Фикстуры — ../fixtures (реальные данные, не спецификация формата).
|
Фикстуры — ../fixtures (реальные данные, не спецификация формата).
|
||||||
Стек: Python 3.12, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL, pydantic v2, NATS JetStream, S3 (MinIO),
|
Стек: Python 3.11, FastAPI, SQLAlchemy 2 async + Alembic, PostgreSQL 16, pydantic v2, NATS без JetStream (ack и повторы — на уровне приложения), S3 (MinIO, miniopy-async),
|
||||||
loguru; фронт — React + TypeScript, rsbuild, antd, Redux Toolkit, biome. Язык документов и коммитов — русский,
|
loguru; фронт — React + TypeScript, rsbuild, antd, Redux Toolkit, biome. Язык документов и коммитов — русский,
|
||||||
идентификаторы — английские, как в DBML.
|
идентификаторы — английские, как в DBML.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user