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
1661ec2fca
commit
7a35b79d5e
@ -34,7 +34,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
`smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса.
|
`smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса.
|
||||||
|
|
||||||
**Добавлено:** `api/src/parser/` (ОСВ §1 и формы §12 — чистые функции над книгой Excel; Срез 2), `api/src/contract/`
|
**Добавлено:** `api/src/parser/` (ОСВ §1 и формы §12 — чистые функции над книгой Excel; Срез 2), `api/src/contract/`
|
||||||
(pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в CI), `api/rules/*.yaml`
|
(pydantic-модели `recognition-result` v1.0; идентичность с `../docs/contracts` проверяется в `make check`), `api/rules/*.yaml`
|
||||||
(словари синонимов — в документах это `rules/*.yaml` относительно `api/`), `api/src/core/worker.py` (durable-подписка
|
(словари синонимов — в документах это `rules/*.yaml` относительно `api/`), `api/src/core/worker.py` (подписка queue group
|
||||||
`recognition.tabular.request` → S3 `uploads/` → парсер → S3 `results/` → `recognition.result`), `Dockerfile`,
|
`recognition.tabular.request` → S3 `uploads/` → парсер → S3 `results/` → `recognition.result`), `Dockerfile`,
|
||||||
`api/pyproject.toml` (ruff, mypy). Проверки: `pytest`, `ruff check . && ruff format --check .`, `mypy src`.
|
`api/pyproject.toml` (ruff, mypy). Проверки: `pytest`, `ruff check . && ruff format --check .`, `mypy src`.
|
||||||
|
|||||||
@ -3,14 +3,15 @@
|
|||||||
## Context
|
## Context
|
||||||
|
|
||||||
См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`124f63b`): FastAPI с lifespan (SMB, MinIO, NATS),
|
См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`124f63b`): FastAPI с lifespan (SMB, MinIO, NATS),
|
||||||
SPA-mount, Alembic, пустые пакеты `models/repositories/services`. Контракт v1.0 и правила `Nats-Msg-Id` — родительский
|
SPA-mount, Alembic, пустые пакеты `models/repositories/services`. Контракт v1.0, ack `processing` и правила `Nats-Msg-Id` —
|
||||||
change в docs. Сервис должен остаться запускаемым платформой так же, как шаблон (`api/run_server.py`, `SERVER__*`,
|
родительский change в docs. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет.
|
||||||
`raisa_config.yml`).
|
Сервис должен остаться запускаемым платформой так же, как шаблон (`api/run_server.py`, `SERVER__*`, `raisa_config.yml`).
|
||||||
|
|
||||||
## Goals / Non-Goals
|
## Goals / Non-Goals
|
||||||
|
|
||||||
**Goals:** сервис без БД/фронта; транспорт NATS ↔ S3 проверен интеграционным тестом; парсер — заменяемый интерфейс.
|
**Goals:** сервис без БД/фронта; транспорт NATS ↔ S3 проверен интеграционным тестом, включая повторный запрос; парсер —
|
||||||
**Non-Goals:** см. proposal; масштабирование (несколько инстансов — queue-семантика pull-consumer'а даёт это бесплатно, но не тестируется).
|
заменяемый интерфейс.
|
||||||
|
**Non-Goals:** см. proposal; масштабирование (несколько инстансов — queue group даёт это бесплатно, но не тестируется).
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@ -19,11 +20,14 @@ change в docs. Сервис должен остаться запускаемы
|
|||||||
запуск воркера), `/healthcheck`, обработчики ошибок, логирование. Остающиеся файлы шаблона не переименовываются —
|
запуск воркера), `/healthcheck`, обработчики ошибок, логирование. Остающиеся файлы шаблона не переименовываются —
|
||||||
чтобы diff с `arm-core` и будущие обновления шаблона были читаемы.
|
чтобы diff с `arm-core` и будущие обновления шаблона были читаемы.
|
||||||
|
|
||||||
### D-2. Воркер — pull-consumer в lifespan, одно сообщение за раз на инстанцию
|
### D-2. Воркер — подписка core NATS с queue group, ack сразу, обработка в очереди задач
|
||||||
`JetStream.pull_subscribe("recognition.tabular.request", durable="tabular-parser")`, цикл `fetch(1, timeout=5)`;
|
`nc.subscribe("recognition.tabular.request", queue="tabular-parser", cb=on_request)`. В `on_request`: валидация →
|
||||||
`ack_wait` 10 мин (большие ОСВ), `max_deliver` 3 (OQ-15). Ack — после успешной публикации конверта результата; при
|
если `job_id` уже в process-local множестве «в работе/обработано» — только ack; иначе добавить в множество, опубликовать
|
||||||
падении до публикации сообщение вернётся (повтор ≤ 3), при третьем провале ядро получит таймаут по `reply_deadline`.
|
ack `processing`, положить задание во внутреннюю `asyncio.Queue`. Рабочая корутина (одна на инстанцию, `WORKER_CONCURRENCY=1`)
|
||||||
*Альтернатива* — push-consumer с очередью: сложнее контролировать параллелизм и ack.
|
берёт задания из очереди и выполняет конвейер S3 → парсер → S3 → конверт результата. Множество обработанных `job_id`
|
||||||
|
ограничено по размеру (`DONE_CACHE_SIZE=1000`, LRU) — защита от повторов ядра после результата. *Почему ack сразу:* ядро
|
||||||
|
без JetStream публикует повторно через 30 с без ack; долгий разбор не должен вызывать повторы. *Альтернатива* — pull-семантика
|
||||||
|
JetStream: недоступна.
|
||||||
|
|
||||||
### D-3. Парсер — протокол `Parser.parse(data: bytes, file_type) -> ParseOutcome`
|
### D-3. Парсер — протокол `Parser.parse(data: bytes, file_type) -> ParseOutcome`
|
||||||
`ParseOutcome` = `status`, `documents` (модели контракта), `metrics`, `error`. `NullParser` всегда отдаёт
|
`ParseOutcome` = `status`, `documents` (модели контракта), `metrics`, `error`. `NullParser` всегда отдаёт
|
||||||
@ -36,26 +40,31 @@ change в docs. Сервис должен остаться запускаемы
|
|||||||
дополненный `get_bytes`/`put_json`.
|
дополненный `get_bytes`/`put_json`.
|
||||||
|
|
||||||
### D-5. Любая ошибка транспорта — результат, не молчание
|
### D-5. Любая ошибка транспорта — результат, не молчание
|
||||||
Таблица: запрос не проходит pydantic → `SCHEMA_MISMATCH` (ack, чтобы не зациклить); `MAJOR` версии запроса ≠ 1 →
|
Таблица: запрос не проходит pydantic → `SCHEMA_MISMATCH` (без ack — нечего подтверждать, но результат публикуется с
|
||||||
`SCHEMA_MISMATCH`; S3 `get` упал → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR` с текстом исключения
|
`job_id`, если он читаем); `MAJOR` версии запроса ≠ 1 → `SCHEMA_MISMATCH`; S3 `get` упал → `FILE_READ_ERROR`;
|
||||||
в `error.message` (без трассировки). Публикуется конверт без `result_s3`; `metrics.duration_ms` заполняется всегда.
|
исключение парсера → `UNKNOWN_ERROR` с текстом исключения в `error.message` (без трассировки). Публикуется конверт без
|
||||||
Если не удалось даже опубликовать результат — nak с задержкой 5 с.
|
`result_s3`; `metrics.duration_ms` заполняется всегда. Публикация результата повторяется до `PUBLISH_RETRIES=3` раз с
|
||||||
|
паузой 2 с; если не удалось — лог уровня error, задание дойдёт до `timeout` в ядре.
|
||||||
|
|
||||||
### D-6. Соединение с NATS — как у ядра
|
### D-6. Соединение с NATS — как у ядра, без JetStream
|
||||||
Пустые `NATS_SEED/NATS_JWT` → без учётных данных (dev); иначе nkeys/JWT шаблона. Stream `RECOGNITION` сервис **не
|
Пустые `NATS_SEED/NATS_JWT` → без учётных данных (dev); иначе nkeys/JWT шаблона. Автопереподключение `nats-py`
|
||||||
создаёт** (ответственность ядра / платформы); при отсутствии stream воркер логирует ошибку и повторяет подписку каждые 10 с,
|
(`max_reconnect_attempts=-1`); при разрыве подписка восстанавливается клиентом. `/healthcheck` отдаёт `nats = ok | down`.
|
||||||
`/healthcheck` отдаёт `nats = down`. Флаг `NATS_ENABLED` (по умолчанию `true`) — чтобы unit-тесты поднимали приложение без NATS.
|
Флаг `NATS_ENABLED` (по умолчанию `true`) — чтобы unit-тесты поднимали приложение без NATS.
|
||||||
|
|
||||||
### D-7. Тесты в два уровня
|
### D-7. Тесты в два уровня
|
||||||
Unit (`tests/unit`): модели контракта и примеры docs; обработчик сообщения с фейками `S3Storage`/`Publisher` (протоколы)
|
Unit (`tests/unit`): модели контракта и примеры docs; обработчик запроса с фейками `S3Storage`/`Publisher` (протоколы) —
|
||||||
— сценарии D-5. Интеграционные (`tests/integration`, маркер `integration`): compose-NATS/MinIO — реальный запрос из
|
сценарии D-5, повторный запрос, ack до обработки. Интеграционные (`tests/integration`, маркер `integration`):
|
||||||
примера `request-tabular.json` → результат в бакете и конверт в `recognition.result`.
|
compose-NATS/MinIO — реальный запрос из примера `request-tabular.json` → ack и результат в `recognition.result`, файл в бакете.
|
||||||
|
|
||||||
|
### D-8. Dev без Nexus, Python 3.11
|
||||||
|
`Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*` (в этом сервисе их уже нет
|
||||||
|
после D-1, но цель одинакова во всех репозиториях). Конфигураций CI нет: `make check` / `make check-full`.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- [`ack_wait` 10 мин при max_deliver 3 → до 30 мин «висящего» задания при падении инстанции] → ядро ограничивает
|
- [Повторный запрос ядра после ack при долгой обработке] → ack публикуется до чтения S3; ядро повторяет только без ack.
|
||||||
ожидание `reply_deadline`; значение `ack_wait` вынесено в настройку `NATS_ACK_WAIT_SECONDS`.
|
- [Инстанция упала после ack, до результата] → ядро закроет задание по `reply_deadline` (`timeout`), replay — Срез 3+.
|
||||||
- [`NOT_IMPLEMENTED` в проде, если Срез 2 задержится] → код ошибки честный, ядро показывает его аналитику как «формат пока не поддерживается».
|
- [`NOT_IMPLEMENTED` в проде, если Срез 2 задержится] → код ошибки честный, ядро показывает его аналитику.
|
||||||
- [Удалённые зависимости понадобятся позже] → возвращаются по одной строке в `requirements.txt`.
|
- [Удалённые зависимости понадобятся позже] → возвращаются по одной строке в `requirements.txt`.
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
@ -65,16 +74,16 @@ Unit (`tests/unit`): модели контракта и примеры docs; о
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
Общие с родительским change (Nexus, версии Python, параметры NATS банка). Специфичных нет.
|
Общие с родительским change (версия Node не нужна этому сервису; параметры NATS банка — к деплою). Специфичных нет.
|
||||||
|
|
||||||
## Decisions made without discussion
|
## Decisions made without discussion
|
||||||
|
|
||||||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | 2026-10-03 | D-1: вырезать фронт/БД/SMB, не переименовывая оставшееся | читаемый diff с arm-core | нет |
|
| 1 | 2026-10-03 | D-1: вырезать фронт/БД/SMB, не переименовывая оставшееся | читаемый diff с arm-core | нет |
|
||||||
| 2 | 2026-10-03 | D-2: pull-consumer, `fetch(1)`, `ack_wait` 10 мин, `max_deliver` 3 | OQ-15, простота контроля параллелизма | смена параметров consumer'а |
|
| 2 | 2026-10-03 | D-2: queue group `tabular-parser`, ack до обработки, одна рабочая корутина, LRU обработанных `job_id` | NATS без JetStream (ответ 03.10); ядро повторяет без ack | смена параметров; при JetStream — pull-consumer |
|
||||||
| 3 | 2026-10-03 | D-3: парсер-заглушка отвечает `NOT_IMPLEMENTED` (код добавлен в контракт родительским change'ем) | честный статус вместо маскировки под `UNSUPPORTED_FORMAT` | нет |
|
| 3 | 2026-10-03 | D-3: парсер-заглушка отвечает `NOT_IMPLEMENTED` (код добавлен в контракт родительским change'ем) | честный статус вместо маскировки под `UNSUPPORTED_FORMAT` | нет |
|
||||||
| 4 | 2026-10-03 | D-4: результат в `results/{file_id}/{job_id}/result.json` того же бакета | схема ключей `03-architecture.md`, один бакет | смена шаблона ключа в одном месте |
|
| 4 | 2026-10-03 | D-4: результат в `results/{file_id}/{job_id}/result.json` того же бакета | схема ключей `03-architecture.md`, один бакет | смена шаблона ключа в одном месте |
|
||||||
| 5 | 2026-10-03 | D-5: транспортные ошибки превращаются в результат с кодом | ядро не должно ждать таймаута, если сервис жив | нет |
|
| 5 | 2026-10-03 | D-5: транспортные ошибки превращаются в результат; публикация с 3 повторами | ядро не должно ждать таймаута, если сервис жив | нет |
|
||||||
| 6 | 2026-10-03 | D-6: stream создаёт ядро, сервис только подписывается | одна точка управления инфраструктурой NATS | добавить `ensure_stream` и в сервис |
|
| 6 | 2026-10-03 | D-8: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега |
|
||||||
| 7 | 2026-10-03 | Каталог словарей — `api/rules/` (относительно рабочего каталога `api/`) | все команды шаблона идут из `api/` | перенос каталога |
|
| 7 | 2026-10-03 | Каталог словарей — `api/rules/` (относительно рабочего каталога `api/`) | все команды шаблона идут из `api/` | перенос каталога |
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
# Proposal: Срез 0 — скелет tabular-parser
|
# Proposal: Срез 0 — скелет tabular-parser
|
||||||
|
|
||||||
**Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** tabular-parser. **Issue:** RSHB/arm-tabular-parser#1.
|
**Срез:** 0 «Скелет» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** tabular-parser. **Issue:** RSHB/arm-tabular-parser#1.
|
||||||
**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, CI-шаблон).
|
**Родительский change:** `../docs/openspec/changes/slice-0-skeleton` (контракт v1.0, compose, проверки качества).
|
||||||
**Соседние:** `../arm-core/openspec/changes/slice-0-skeleton`, `../scan-gateway/openspec/changes/slice-0-skeleton`.
|
**Соседние:** `../arm-core/openspec/changes/slice-0-skeleton`, `../scan-gateway/openspec/changes/slice-0-skeleton`.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Репозиторий сейчас — полная копия каркаса `arm-core` с фронтом, Alembic и моделями, то есть противоречит DECISION-4
|
Репозиторий сейчас — полная копия каркаса `arm-core` с фронтом, Alembic и моделями, то есть противоречит DECISION-4
|
||||||
(сервис без БД, видит только NATS и S3). Прежде чем в Срезе 2 писать парсер ОСВ, нужен сервис, который уже умеет
|
(сервис без БД, видит только NATS и S3). Прежде чем в Срезе 2 писать парсер ОСВ, нужен сервис, который уже умеет
|
||||||
принять запрос из NATS, прочитать файл из S3, положить результат и ответить по контракту v1.0 — чтобы парсер стал
|
принять запрос из NATS, подтвердить его, прочитать файл из S3, положить результат и ответить по контракту v1.0 — чтобы
|
||||||
чистой функцией «байты → документы», а транспорт был проверен end-to-end вместе с ядром.
|
парсер стал чистой функцией «байты → документы», а транспорт был проверен end-to-end вместе с ядром на той платформе,
|
||||||
|
что есть: NATS без JetStream, Python 3.11, без Nexus.
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
@ -18,41 +19,46 @@
|
|||||||
`repositories/`, `alembic/`, `alembic.ini`; настройки `OPENBAO.DATABASE`, `OPENBAO.SMB`; зависимости `asyncpg`,
|
`repositories/`, `alembic/`, `alembic.ini`; настройки `OPENBAO.DATABASE`, `OPENBAO.SMB`; зависимости `asyncpg`,
|
||||||
`SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md`.
|
`SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; шаблонный `README.md`.
|
||||||
- **Инструменты**: `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + инструменты,
|
- **Инструменты**: `api/pyproject.toml` (ruff, mypy), `requirements-dev.txt` = `-r requirements.txt` + инструменты,
|
||||||
`Dockerfile` (строка убрана из `.gitignore`), `Makefile` (`up / test / lint`), `.env.example`, `.env.test`;
|
`Dockerfile` (`python:3.11-slim`; строка убрана из `.gitignore`), `Makefile` с общими целями `up / test /
|
||||||
`Settings` читает `ENV_FILE`; `SERVICE_NAME = "tabular-parser"`.
|
test-integration / lint / check / check-full` + `venv`, `.env.example`, `.env.test`; `Settings` читает `ENV_FILE`;
|
||||||
- **Контракт**: вендоренные схемы `api/contracts/` + pydantic-модели `src/contract/` (запрос, конверт результата, файл
|
`SERVICE_NAME = "tabular-parser"`.
|
||||||
результата); тест идентичности с `../docs/contracts`.
|
- **Контракт**: вендоренные схемы `api/contracts/` + pydantic-модели `src/contract/` (запрос, конверт со статусами
|
||||||
- **Воркер** `src/core/worker.py`: durable pull-consumer `tabular-parser` на `recognition.tabular.request`
|
`processing | done | needs_check | error`, файл результата); тест идентичности с `../docs/contracts`.
|
||||||
(`max_deliver` 3 — OQ-15), чтение исходника из S3 `uploads/…`, вызов парсера, запись `results/{file_id}/{job_id}/result.json`,
|
- **Воркер** `src/core/worker.py` (NATS без JetStream): подписка на `recognition.tabular.request` в queue group
|
||||||
публикация конверта `recognition.result` с `Nats-Msg-Id = result:<job_id>:<attempt>`, ack после публикации.
|
`tabular-parser`; по получении — сразу ack: конверт `status = processing` в `recognition.result`
|
||||||
|
(`Nats-Msg-Id = ack:<job_id>:<attempt>`); затем чтение исходника из S3 `uploads/…`, вызов парсера, запись
|
||||||
|
`results/{file_id}/{job_id}/result.json`, публикация конверта результата (`Nats-Msg-Id = result:<job_id>:<attempt>`).
|
||||||
|
Process-local множество `job_id` в работе: повторный запрос (ядро публикует повторно, не получив ack) получает ack и не
|
||||||
|
обрабатывается второй раз.
|
||||||
- **Парсер как интерфейс**: `src/parser/` — протокол `Parser` и `NullParser`, возвращающий `status = error`,
|
- **Парсер как интерфейс**: `src/parser/` — протокол `Parser` и `NullParser`, возвращающий `status = error`,
|
||||||
`error.code = NOT_IMPLEMENTED` для любого файла (Срез 2 заменяет реализацией §1/§12); `api/rules/README.md` —
|
`error.code = NOT_IMPLEMENTED` для любого файла (Срез 2 заменяет реализацией §1/§12); `api/rules/README.md` —
|
||||||
место словарей синонимов.
|
место словарей синонимов.
|
||||||
- **Ошибки транспорта** всегда превращаются в результат: запрос не по схеме → `SCHEMA_MISMATCH`; файл не читается из
|
- **Ошибки транспорта** всегда превращаются в результат: запрос не по схеме → `SCHEMA_MISMATCH`; файл не читается из
|
||||||
S3 → `FILE_READ_ERROR`; неожиданное исключение → `UNKNOWN_ERROR`. Ядро не ждёт таймаута, если сервис жив.
|
S3 → `FILE_READ_ERROR`; неожиданное исключение → `UNKNOWN_ERROR`. Ядро не ждёт таймаута, если сервис жив.
|
||||||
- **Healthcheck** `/healthcheck` шаблона дополняется полем `nats` (`ok`/`down`).
|
- **Healthcheck** `/healthcheck` шаблона дополняется полем `nats` (`ok`/`down`).
|
||||||
- **CI**: `.gitea/workflows/ci.yml` из шаблона docs (ruff, mypy, pytest c NATS/MinIO, идентичность контракта, сборка образа).
|
- **Проверки качества** вместо CI: `make check` (ruff, mypy, pytest без Docker, идентичность контракта) и
|
||||||
|
`make check-full` (+ интеграционный тест против compose, `docker build`) — по `../docs/dev/checks.md`.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
### New Capabilities
|
### New Capabilities
|
||||||
- `tabular-recognition-service`: stateless-сервис, который принимает задания из NATS, обменивается файлами через S3 и
|
- `tabular-recognition-service`: stateless-сервис, который принимает задания из NATS без JetStream, подтверждает их,
|
||||||
отвечает по контракту v1.0; в Срезе 0 — с парсером-заглушкой.
|
обменивается файлами через S3 и отвечает по контракту v1.0; в Срезе 0 — с парсером-заглушкой.
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
<!-- openspec/specs пуст -->
|
<!-- openspec/specs пуст -->
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
- **Таблицы DBML:** сервис не пишет в БД. Косвенно: `recognition_job` ядра получает `status = error`,
|
- **Таблицы DBML:** сервис не пишет в БД. Косвенно: `recognition_job` ядра получает `status = processing`
|
||||||
`error_code = NOT_IMPLEMENTED`, `service_name = tabular-parser` из конверта.
|
(`started_at`), затем `error`, `error_code = NOT_IMPLEMENTED`, `service_name = tabular-parser` из конвертов.
|
||||||
- **Правила:** §1.0/§12 — только как контракт будущего парсера (интерфейс `Parser`), §9.4 — статусы задания.
|
- **Правила:** §1.0/§12 — только как контракт будущего парсера (интерфейс `Parser`), §9.4 — статусы задания.
|
||||||
- **Зависимости:** удаляются БД/SMB/фронт; добавляются `ruff`, `mypy`, `pytest*`, `jsonschema` (валидация примеров).
|
- **Зависимости:** удаляются БД/SMB/фронт; добавляются `ruff`, `mypy`, `pytest*`, `jsonschema` (валидация примеров).
|
||||||
- **Внешние системы:** NATS JetStream (stream `RECOGNITION`), MinIO/S3 (бакет `arm-ka`).
|
- **Внешние системы:** NATS без JetStream (subjects `recognition.*`), MinIO/S3 (бакет `arm-ka`).
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- Разбор ОСВ и форм ББ/ОФР (§1, §12) — Срез 2; property-тесты на `../fixtures/osv/**` — Срез 2.
|
- Разбор ОСВ и форм ББ/ОФР (§1, §12) — Срез 2; property-тесты на `../fixtures/osv/**` — Срез 2.
|
||||||
- Словари `rules/*.yaml` — Срез 2 (здесь только каталог и README).
|
- Словари `rules/*.yaml` — Срез 2 (здесь только каталог и README).
|
||||||
- Heartbeat `processing` (DBML: «опц. heartbeat») — не реализуется.
|
- Периодический heartbeat `processing` — не реализуется (один ack при получении).
|
||||||
- Любая работа с БД, SMB, фронтом.
|
- Любая работа с БД, SMB, фронтом; конфигурации CI.
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Stateless-сервис разбора табличных файлов (XLS/XLSX/XLSB): принимает задания из NATS, читает исходник из S3, отдаёт
|
Stateless-сервис разбора табличных файлов (XLS/XLSX/XLSB): принимает задания из NATS без JetStream, подтверждает их,
|
||||||
результат в S3 и конверт в NATS по контракту v1.0, не имея доступа к БД и фронта. В Срезе 0 разбор заменён заглушкой.
|
читает исходник из S3, отдаёт результат в S3 и конверт в NATS по контракту v1.0, не имея доступа к БД и фронта.
|
||||||
|
В Срезе 0 разбор заменён заглушкой.
|
||||||
|
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
@ -16,35 +17,48 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/
|
|||||||
- **WHEN** сервис запускается с `.env`, содержащим только `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `DEBUG=true`
|
- **WHEN** сервис запускается с `.env`, содержащим только `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `DEBUG=true`
|
||||||
- **THEN** `GET /healthcheck` отвечает 200 с полями `uptime`, `version`, `nats`
|
- **THEN** `GET /healthcheck` отвечает 200 с полями `uptime`, `version`, `nats`
|
||||||
|
|
||||||
### Requirement: Приём задания и ответ по контракту
|
### Requirement: Подтверждение запроса до обработки
|
||||||
Сервис SHALL потреблять `recognition.tabular.request` durable pull-consumer'ом `tabular-parser`; для каждого запроса,
|
Сервис SHALL быть подписан на `recognition.tabular.request` в queue group `tabular-parser`. Получив запрос, проходящий
|
||||||
проходящего `recognition-request.schema.json`, SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер,
|
`recognition-request.schema.json`, сервис SHALL до чтения файла из S3 опубликовать в `recognition.result` конверт
|
||||||
записать файл результата (по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json`, затем
|
`status = processing` с `job_id`, `file_id`, `service {name: "tabular-parser", version}` и заголовком
|
||||||
опубликовать конверт в `recognition.result` (по `recognition-result-message.schema.json`) с заголовком
|
`Nats-Msg-Id = ack:<job_id>:<attempt>`. Повторный запрос с `job_id`, который уже обрабатывается или обработан этим
|
||||||
`Nats-Msg-Id = result:<job_id>:<attempt>` и `service.name = "tabular-parser"`, и только после публикации подтвердить запрос.
|
процессом, SHALL получить повторный ack и MUST NOT обрабатываться второй раз.
|
||||||
|
|
||||||
|
#### Scenario: Ack раньше результата
|
||||||
|
- **WHEN** опубликован запрос из `docs/contracts/examples/request-tabular.json`
|
||||||
|
- **THEN** первым сообщением сервиса в `recognition.result` является конверт `processing`, и только затем — результат
|
||||||
|
|
||||||
|
#### Scenario: Повторный запрос во время обработки
|
||||||
|
- **WHEN** тот же запрос доставлен второй раз, пока первый обрабатывается
|
||||||
|
- **THEN** публикуется второй ack `processing`, файл из S3 повторно не читается, результат публикуется один раз
|
||||||
|
|
||||||
|
### Requirement: Результат по контракту
|
||||||
|
После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата
|
||||||
|
(по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json` и опубликовать конверт результата (по
|
||||||
|
`recognition-result-message.schema.json`) с заголовком `Nats-Msg-Id = result:<job_id>:<attempt>`. При неудаче публикации
|
||||||
|
сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку.
|
||||||
|
|
||||||
#### Scenario: Запрос с заглушкой парсера (Срез 0)
|
#### Scenario: Запрос с заглушкой парсера (Срез 0)
|
||||||
- **WHEN** опубликован запрос из `docs/contracts/examples/request-tabular.json` с существующим объектом в `uploads/`
|
- **WHEN** запрос подтверждён и объект существует в `uploads/`
|
||||||
- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`,
|
- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id`
|
||||||
`job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать; запрос подтверждён
|
из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать
|
||||||
|
|
||||||
#### Scenario: Ack только после результата
|
#### Scenario: Повтор публикации
|
||||||
- **WHEN** публикация конверта завершается ошибкой
|
- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно
|
||||||
- **THEN** запрос не подтверждается (nak с задержкой), следующая доставка повторяет обработку; дубликат конверта
|
- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется
|
||||||
отбрасывается по `Nats-Msg-Id`
|
|
||||||
|
|
||||||
### Requirement: Ошибки транспорта превращаются в результат
|
### Requirement: Ошибки транспорта превращаются в результат
|
||||||
Сервис SHALL отвечать конвертом `status = error` и подтверждать запрос в случаях: тело не проходит схему или `MAJOR`
|
Сервис SHALL отвечать конвертом `status = error` в случаях: тело не проходит схему или `MAJOR` версии ≠ 1 →
|
||||||
версии ≠ 1 → `SCHEMA_MISMATCH`; объект S3 не читается → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR`
|
`SCHEMA_MISMATCH` (если `job_id` читаем); объект S3 не читается → `FILE_READ_ERROR`; исключение парсера →
|
||||||
(сообщение без трассировки). Сервис MUST NOT падать и MUST NOT молчать по этим причинам.
|
`UNKNOWN_ERROR` (сообщение без трассировки). Сервис MUST NOT падать и MUST NOT молчать по этим причинам.
|
||||||
|
|
||||||
#### Scenario: Нет объекта в S3
|
#### Scenario: Нет объекта в S3
|
||||||
- **WHEN** `s3.key` запроса указывает на несуществующий объект
|
- **WHEN** `s3.key` запроса указывает на несуществующий объект
|
||||||
- **THEN** публикуется конверт `error.code = FILE_READ_ERROR`, запрос подтверждён, процесс продолжает работу
|
- **THEN** публикуется конверт `error.code = FILE_READ_ERROR`, процесс продолжает работу
|
||||||
|
|
||||||
#### Scenario: Несовместимая версия
|
#### Scenario: Несовместимая версия
|
||||||
- **WHEN** запрос содержит `schema_version = "2.0"`
|
- **WHEN** запрос содержит `schema_version = "2.0"`
|
||||||
- **THEN** публикуется конверт `error.code = SCHEMA_MISMATCH`, запрос подтверждён
|
- **THEN** публикуется конверт `error.code = SCHEMA_MISMATCH`, ack не публикуется
|
||||||
|
|
||||||
### Requirement: Парсер — заменяемый интерфейс
|
### Requirement: Парсер — заменяемый интерфейс
|
||||||
Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о
|
Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о
|
||||||
@ -57,16 +71,17 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р
|
|||||||
|
|
||||||
### Requirement: Копия контракта идентична источнику
|
### Requirement: Копия контракта идентична источнику
|
||||||
`api/contracts/*.schema.json` SHALL быть байт в байт равны файлам `../docs/contracts/`; pydantic-модели SHALL валидировать
|
`api/contracts/*.schema.json` SHALL быть байт в байт равны файлам `../docs/contracts/`; pydantic-модели SHALL валидировать
|
||||||
все `../docs/contracts/examples/*.json` и порождать JSON, проходящий схему.
|
все `../docs/contracts/examples/*.json` и порождать JSON, проходящий схему. Проверка входит в `make check`.
|
||||||
|
|
||||||
#### Scenario: CI сервиса
|
#### Scenario: `make check`
|
||||||
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
|
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
|
||||||
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением
|
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением
|
||||||
|
|
||||||
### Requirement: Соединение с NATS по правилам шаблона
|
### Requirement: Соединение с NATS по правилам шаблона, без JetStream
|
||||||
При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Отсутствие stream
|
При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Сервис MUST NOT
|
||||||
`RECOGNITION` SHALL приводить к повторным попыткам подписки каждые 10 с и `nats = down` в `/healthcheck`, а не к падению.
|
требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать
|
||||||
|
подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`).
|
||||||
|
|
||||||
#### Scenario: Stream появился позже
|
#### Scenario: NATS перезапущен
|
||||||
- **WHEN** сервис стартовал раньше ядра, создавшего stream
|
- **WHEN** контейнер NATS перезапущен во время работы сервиса
|
||||||
- **THEN** в течение 20 с после создания stream воркер подписан, `nats = ok`
|
- **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`, новый запрос обрабатывается
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Tasks: Срез 0 — скелет tabular-parser
|
# Tasks: Срез 0 — скелет tabular-parser
|
||||||
|
|
||||||
Репозиторий — tabular-parser; команды — из `api/`. Зависимости: 3.1 ждёт контракт v1.0 (`docs` 1.1–1.4);
|
Репозиторий — tabular-parser; команды — из `api/`. Зависимости: 3.1 ждёт контракт v1.0 (`docs` 1.1–1.4);
|
||||||
интеграционные тесты 4.x — compose инфраструктуры (`docs` 2.1). Каждая задача ≤ половины дня.
|
интеграционные тесты 4.x — compose инфраструктуры (`docs` 2.1). Каждая задача ≤ половины дня. CI нет — проверки локальные.
|
||||||
|
|
||||||
## 1. Вырезать фронт и БД из шаблона
|
## 1. Вырезать фронт и БД из шаблона
|
||||||
|
|
||||||
@ -10,45 +10,46 @@
|
|||||||
только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2)
|
только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2)
|
||||||
- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`;
|
- [ ] 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`,
|
`src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`,
|
||||||
`SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `NATS_ACK_WAIT_SECONDS=600`, `NATS_MAX_DELIVER=3`;
|
`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`;
|
`requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`;
|
||||||
проверка: `pip install -r requirements.txt` в чистом venv, `python run_server.py` стартует c `.env.example`, `/healthcheck` 200
|
проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200
|
||||||
- [ ] 1.3 `README.md` — описание сервиса (назначение, команды, env, запуск против compose), `CLAUDE.md` — актуализировать
|
- [ ] 1.3 `README.md` — описание сервиса (назначение, команды `make`, env, запуск против compose), `CLAUDE.md` — актуализировать
|
||||||
раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют
|
раздел «Соглашения шаблона» по факту вырезанного; проверка: все упомянутые файлы существуют
|
||||||
|
|
||||||
## 2. Инструменты
|
## 2. Инструменты
|
||||||
|
|
||||||
- [ ] 2.1 `api/pyproject.toml` (ruff: line-length 120, E,F,I,UP,B,PL; mypy с плагином pydantic), `requirements-dev.txt`
|
- [ ] 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; проверка: `ruff check . &&
|
= `-r requirements.txt` + ruff, mypy, pytest, pytest-asyncio, pytest-env, httpx, jsonschema; `Makefile`: `venv`
|
||||||
ruff format --check . && mypy src` зелёные
|
(python3.11), `lint`, `test`, `test-integration`, `check`, `check-full`, `up`; проверка: `make venv && make lint` зелёные
|
||||||
- [ ] 2.2 `Dockerfile` (python:3.12-slim), `Makefile` (`up` — compose docs, `test`, `test-integration`, `lint`),
|
- [ ] 2.2 `Dockerfile` (`python:3.11-slim`), `.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен
|
||||||
`.env.example`, `.env.test`; проверка: `docker build -t tabular-parser .` успешен
|
|
||||||
|
|
||||||
## 3. Контракт
|
## 3. Контракт
|
||||||
|
|
||||||
- [ ] 3.1 `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели
|
- [ ] 3.1 `api/contracts/` — копии трёх схем + `examples/` из `../docs/contracts`; `src/contract/` — pydantic-модели
|
||||||
(`RecognitionRequest`, `RecognitionResultMessage`, `RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги
|
(`RecognitionRequest`, `RecognitionResultMessage` со статусами `processing | done | needs_check | error`,
|
||||||
как `Decimal` ↔ строка); `tests/unit/test_contract.py` — идентичность с docs (skip без docs), валидация примеров,
|
`RecognitionResult`, `OsvDocument`, `StatementDocument`, деньги как `Decimal` ↔ строка); `tests/unit/test_contract.py`
|
||||||
round-trip модели → JSON → схема; проверка: тесты зелёные
|
— идентичность с docs (skip без docs), валидация примеров, round-trip модели → JSON → схема; проверка: тесты зелёные
|
||||||
|
|
||||||
## 4. Воркер и парсер-заглушка
|
## 4. Воркер и парсер-заглушка
|
||||||
|
|
||||||
- [ ] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`;
|
- [ ] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`;
|
||||||
`tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты
|
`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`:
|
- [ ] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`:
|
||||||
подключение (с/без nkeys), `subscribe_requests()` с повтором при отсутствии stream, `publish_result(envelope,
|
подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`,
|
||||||
msg_id)`; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS
|
`publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест
|
||||||
- [ ] 4.3 `src/core/worker.py`: цикл `fetch(1)` → `handle(msg)`: валидация запроса → S3 get → `Parser` → S3 put результата →
|
подключения к compose-NATS без JetStream и получения сообщения с заголовком
|
||||||
конверт → publish → ack; таблица ошибок D-5; `metrics.duration_ms`; запуск/остановка в lifespan при `NATS_ENABLED`;
|
- [ ] 4.3 `src/core/worker.py`: `on_request(msg)`: валидация → множество `job_id` в работе/обработано (LRU) → ack `processing`
|
||||||
`/healthcheck.nats`; проверка: unit-тесты `handle` с фейками — сценарии spec (NOT_IMPLEMENTED, FILE_READ_ERROR,
|
→ очередь; рабочая корутина: S3 get → `Parser` → S3 put результата → конверт → publish; таблица ошибок D-5;
|
||||||
SCHEMA_MISMATCH, подмена парсера → `done`, ошибка публикации → nak)
|
`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/…`, опубликовать
|
- [ ] 4.4 `tests/integration/test_roundtrip.py` (маркер `integration`): положить файл в MinIO `uploads/…`, опубликовать
|
||||||
`request-tabular.json`, дождаться конверта в `recognition.result`; проверить `Nats-Msg-Id`, `error.code = NOT_IMPLEMENTED`,
|
`request-tabular.json`, собрать сообщения `recognition.result`: первое — `processing`, второе — `error NOT_IMPLEMENTED`
|
||||||
ack запроса (consumer info: `num_ack_pending = 0`); проверка: зелёный против compose
|
с правильными `Nats-Msg-Id`; повторная публикация запроса даёт только ещё один ack; проверка: зелёный против compose
|
||||||
|
|
||||||
## 5. CI и завершение
|
## 5. Проверки и завершение
|
||||||
|
|
||||||
- [ ] 5.1 `.gitea/workflows/ci.yml` из `../docs/dev/ci/python-service.yml` (блок `env`: tabular-parser, порт 8001, без шагов
|
- [ ] 5.1 `make check-full`: `make check` + `pytest -m integration` + `docker build`; проверка: зелёный против compose docs
|
||||||
arm-core); проверка: зелёный run на ветке `feat/1-slice-0-skeleton`
|
на ветке `feat/1-slice-0-skeleton`
|
||||||
- [ ] 5.2 Прогон DoD: `make up` → `make test` → `make test-integration` → ручная проверка из `docs/dev/README.md`
|
- [ ] 5.2 Прогон DoD: `make up` → `make check-full` → ручная проверка из `docs/dev/README.md` («проверка окружения»);
|
||||||
(«проверка окружения»); `openspec validate slice-0-skeleton --strict`; результаты — в описание PR
|
`openspec validate slice-0-skeleton --strict`; вывод `make check-full` — в описание PR
|
||||||
|
|||||||
@ -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