diff --git a/.gitignore b/.gitignore index e43b0f9..e44f625 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ .DS_Store +.codegraph/ +.superpowers/ +.bounded +dev/.env +.venv/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b280266 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +# Команды проверки репозитория arm-ka-docs. Запуск из корня репозитория. +# Сервисных целей (lint/test/up...) здесь нет: описание общих целей сервисов — dev/checks.md. +VENV := .venv +# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должен быть jsonschema) +PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3) +CHANGE ?= slice-0-skeleton + +.PHONY: venv validate-contracts validate-openspec check + +# Единственная зависимость скриптов проверки контракта — jsonschema +venv: + uv venv --python 3.11 --allow-existing $(VENV) + uv pip install --python $(VENV)/bin/python jsonschema + +# Примеры contracts/examples/ против трёх схем JSON Schema 2020-12 +validate-contracts: + $(PY) scripts/validate_contracts.py + +# Спека текущего изменения OpenSpec (нужен CLI openspec в PATH) +validate-openspec: + openspec validate $(CHANGE) --strict + +check: validate-contracts validate-openspec diff --git a/context/03-architecture.md b/context/03-architecture.md index 9420f84..fa73c7f 100644 --- a/context/03-architecture.md +++ b/context/03-architecture.md @@ -28,7 +28,7 @@ (вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема. - **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**, пишет нормализованный JSON в S3 `results/`, публикует `recognition.result`. Контракт результата — - `docs/contracts/recognition-result.schema.json` (черновик; финализировать в срезе 2). Правила распознавания + `docs/contracts/recognition-result.schema.json` (контракт v1.0). Правила распознавания заголовков/группировок — `rules/*.yaml`. - **scan-gateway** — адаптер ИС Сканирование: переименовать файл `UUID_имя.pdf`, положить в «Входящие», опросить «Исходящие/UUID/UUID.json», сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), нормализовать @@ -38,23 +38,32 @@ ## Контракт обмена через NATS (DECISION-4) -Схемы — `docs/contracts/` (v1.0): `recognition-request.schema.json` и `recognition-result-message.schema.json` — тела -сообщений, `recognition-result.schema.json` — файл результата в S3; примеры — `docs/contracts/examples/`. +Схемы — `docs/contracts/` (v1.0), JSON Schema 2020-12: [`recognition-request.schema.json`](../contracts/recognition-request.schema.json) +и [`recognition-result-message.schema.json`](../contracts/recognition-result-message.schema.json) — тела сообщений, +[`recognition-result.schema.json`](../contracts/recognition-result.schema.json) — файл результата в S3; примеры — +[`docs/contracts/examples/`](../contracts/examples/). Примеры проверяются схемами: `python scripts/validate_contracts.py` +(из корня репозитория docs). Примеры — иллюстрации формы сообщений, а не готовые к исполнению задания: +схема проверяет у `reply_deadline` только формат `date-time` и не сравнивает срок с текущим временем, поэтому +`request-image.json` проходит схему при `reply_deadline` в прошлом. Срок проверяет сервис: `scan-gateway` отвечает +`error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование; `tabular-parser` срок не проверяет. +Файлы `examples/` копируются в сервисы побайтно, поэтому пример не правится ради «свежей» даты. -| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | -|---|---|---|---|---| -| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `:` | -| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `error {code, message}`, `service {name, version}`, `result_s3 {bucket, key}`, `result_schema_version`, `raw_s3_key` (только scan-gateway), `metrics` | `result::` | +| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | Схема | +|---|---|---|---|---|---| +| запрос | `recognition.tabular.request` / `recognition.image.request` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `:` | [request](../contracts/recognition-request.schema.json) | +| ack | `recognition.result` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack::` | [message](../contracts/recognition-result-message.schema.json) | +| результат | `recognition.result` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result::` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) | `group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге. -Конверт со `status = processing` — подтверждение взятия в работу (ack), `Nats-Msg-Id = ack::`. **На платформе NATS без JetStream** (ответ 03.10.2026): нет durable consumer'ов, серверного дедупа и redelivery. Поэтому гарантии доставки — на уровне приложения: сервисы подписаны queue group'ами (`tabular-parser`, `scan-gateway`), -ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении запроса публикует ack `processing`; +ядро — queue group `arm-core` на `recognition.result`; сервис сразу по получении корректного запроса публикует ack `processing`; ядро, не получив ack за `ACK_TIMEOUT_SECONDS` (30), публикует запрос повторно с тем же `Nats-Msg-Id`, не более `OUTBOX_MAX_REPUBLISH` (3) раз, затем `dlq`. Сервис держит process-local множество `job_id` в работе и на повторный -запрос отвечает ack'ом без второй обработки. Появится JetStream — меняются только publisher/subscriber. +запрос отвечает ack'ом без второй обработки. Публикация ack и результата сервисом при временной ошибке повторяется +с тем же `Nats-Msg-Id`: первая попытка плюс `PUBLISH_RETRIES` повторов (по умолчанию 3, всего до 4 попыток); повторы +исчерпаны — error-лог, задание закроет ядро (по `reply_deadline`). Появится JetStream — меняются только publisher/subscriber. Правила: - `recognition_job` создаётся в статусе `queued` **в одной транзакции** со `stored_file`; фоновый публикатор переводит в @@ -63,6 +72,18 @@ - Результат принимается подпиской queue group; `result_msg_id UNIQUE` = дедуп. Нет ack после повторов → `dlq`; потерянный результат → `reply_deadline` → `timeout`; replay = сброс в `queued` с `attempt + 1`. - Результат одного файла содержит **N документов**; ядро на каждый создаёт `document` и маппит данные. +- Три формы `Nats-Msg-Id`: запрос `:`, ack `ack::`, результат `result::`. + Ack (`processing`) сервис публикует **до чтения файла из S3**; повторная публикация запроса ядром идёт с тем же + `Nats-Msg-Id` и тем же `attempt`. Дубликат результата ядро определяет по `recognition_job.result_msg_id`. +- `schema_version` — `MAJOR.MINOR`; ядро и сервисы принимают тот же MAJOR и любой MINOR. Сервис отвергает запрос с + неизвестным MAJOR (как и запрос не по схеме) результатом `error` с кодом `SCHEMA_MISMATCH` **без предшествующего ack + `processing`**: финальный конверт сам закрывает задание в ядре (`published → error`), повторной доставки не будет. + Конверт публикуется, только если из тела извлекаются корректные `job_id` и `file_id`; для `job_id`, уже находящегося в + работе или обработанного, он не публикуется (его `Nats-Msg-Id` совпал бы с настоящим результатом). Нечитаемый запрос + (не JSON, нет `job_id`) остаётся без ответа: сервис пишет лог и отбрасывает его, ядро не получает ack, повторяет + публикацию `OUTBOX_MAX_REPUBLISH` раз и закрывает задание в `dlq` с `error_code = NO_ACK`. Новый MINOR раскатывается сначала на потребителей + (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают + незнакомые значения. - Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв контрагентов, проверки — в ядре. diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 589215d..0b1f22f 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -363,17 +363,36 @@ Policy-слой (не права): KA_RF/RKA/CLIENT_MANAGER видят толь ## §11. Маппинг JSON ИС Сканирование (FNR01/FNR02) → формы -Контракт (по ТЗ и фикстурам `aep_*.json`): `file_uid`; `error {code, message} | null` -(FILE_READ_ERROR, INVALID_FORMAT, INVALID_DOCUMENT, RECOGNITION_FAILED, UNKNOWN_ERROR); `data_ocr[]` по документам: -`label.form_name` («Бухгалтерский баланс» → BALANCE, «Отчет о финансовых результатах» → INCOME), `organization.full_name`, -`inn.inn`, `units.measurement`, `date`, `table_structure.rows[]` (`row_index`, `cells[] {column_index, value, confidence}`). -Строка `rows[0]` — заголовки колонок: cells[0] «Наименование показателя», [1] «Код», [2..] даты колонок. -`confidence` — в ТЗ доли 0..1, в реальных файлах проценты 0..100 **[открыто]** — нормализовать в `numeric(5,2)` 0–100. +Формат ответа — **по реальным файлам** `fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json` (проверено 04.10.2026; ТЗ +и первая редакция этого параграфа расходятся с ними — см. OQ-13, OQ-14, OQ-77): -Маппинг: `data_ocr[i]` → `document` (BALANCE/INCOME, `locator = pages:…`) + `financial_statement_meta` (ИНН, имя, -units, report_date, okud) + `financial_statement_column` (из `rows[0].cells[2..]`, `period_date` распарсить из -«На 30 сентября 2025 г.», `is_current` = первая) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя; только -строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`). +- Каталог ответа `/` содержит **несколько json** — по одному на распознанный документ файла (пример: `aep_3.json` — + успех, `aep_3_ofr.json` — ошибка по второму документу того же PDF). Шлюз читает все json каталога и склеивает в один ответ + (`raw/{job_id}.json`: `{job_id, files[{name, response, parse_error}]}`); ошибка одного файла не отменяет остальные. +- Корень файла: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`** (поле `error` не `null`, а + `{"code": "", "message": ""}`); ошибка — непустой `code` (`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`, + `RECOGNITION_FAILED`, `UNKNOWN_ERROR`), `data_ocr` с пустыми полями и `table_structure: []`. +- `data_ocr` — **объект** (не список): `label`, `organization`, `inn`, `units`, `date` — каждый `{form_name, confidence}` + (в `form_name` шумный OCR-текст; `date.form_name` ненадёжна, ИНН может быть мусором), `table_structure` — **список таблиц** + `{label {form_name, confidence}, rows[]}`: `label.form_name` «Бухгалтерский баланс» → BALANCE, «Отчет о финансовых + результатах» → INCOME. **Наблюдается:** в одном json может быть **несколько таблиц разных форм** (в `aep_3.json` и + `aep_6.json` — баланс и ОФР), каждая со своим `label`; тип документа задаёт `label`. Разбиение одной формы на несколько + таблиц (сегменты) в образцах **не встречалось** и не предполагается; если одна форма вдруг придёт двумя таблицами или + `label` повторится — это нештатный случай: документ не создаётся молча, а уходит в `NEEDS_CHECK` с причиной (решение — + Срез 3, OQ-77). Неизвестный `label` → `OTHER`. +- `rows[]`: `row_index`, `cells[] {column_index, value, confidence}`; `column_index` не обязательно подряд (бывают 0, 1, 2, 4, 5), + `value` — сырой OCR-текст (мусор в ячейках, переносы строк, скобки для отрицательных, пробелы-разделители тысяч). + В `aep_6.json` `rows[0]` — строка-шапка («Наименование показателя», «Код», «На 30 июня 2025 г», …); в `aep_3.json` шапки + **нет** — первая строка данных. Бывают строки без кода (расшифровки «в том числе…», OQ-36) и код-строка внутри ячейки + наименования («АКТИВ / I. Внеоборотные активы / Гудвил» с кодом 1105). +- `confidence` — **числа 0–100** (в ТЗ — доли 0..1; в реальных файлах проценты) → `numeric(5,2)` как есть. + +Маппинг (Срез 3): таблица `table_structure[i]` с `label` «Бухгалтерский баланс» / «Отчет о финансовых результатах» → +`document` (BALANCE/INCOME, `locator = pages:…`) + `financial_statement_meta` +(ИНН, имя, units, report_date, okud — из `organization/inn/units/date.form_name`, с низкой уверенностью → `NEEDS_CHECK`) + +`financial_statement_column` (из шапки `rows[0]`, если она есть; `period_date` распарсить из «На 30 июня 2025 г», `is_current` = +первая; без шапки — колонки без дат, документ `NEEDS_CHECK`) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя; +только строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`). Сверки: ИНН/имя vs `legal_entity` → `document.counterparty_check`; дата vs период версии → `period_check`. --- diff --git a/context/06-test-data.md b/context/06-test-data.md index c3f6539..3c3916b 100644 --- a/context/06-test-data.md +++ b/context/06-test-data.md @@ -146,15 +146,25 @@ fixtures/ statements-xlsx/ # ББ/ОФР в Excel (§12): лист «Отчетность» из Анализ*.xlsx, # рекомендуемые формы банка, выгрузки 1С — собрать у аналитиков ocr/ - aep_N.pdf + aep_N.json # пары вход/выход ИС Сканирование + aep_N.pdf|PDF + aep_N.json # пары вход/выход ИС Сканирование (эмулятор ИС + # в scan-gateway ищет `.json` по имени входного + # файла без расширения, регистр его расширения для + # эмулятора неважен; важен путь самого входного файла + # `aep_5.PDF`: на регистрозависимой ФС (Linux) он ищется + # буквально) golden/ Консолидация_Группа РБ.xlsx # эталон КФО по датам expected_osv_parse/*.json # ожидаемый результат парсера (зафиксировать после ревью) directories/ legal_entities.csv, company_groups.csv # тестовые справочники группы «Русский бисквит» (Срез 1) ``` -Справочники **продукта** — план счетов, правила элиминации, права и роли, состав обязательных документов — не фикстуры, -а seed ядра: `arm-core/api/seed/` (Срез 0, change `slice-0-skeleton`). Здесь остаются только тестовые данные группы. +`directories/` содержит только тестовые справочники группы (ЮЛ и ГК «Русский бисквит»); до Среза 1 в нём лежит пустой +`.gitkeep`. Справочники **продукта** — план счетов, правила элиминации, права и роли, состав обязательных документов, +региональные офисы — не фикстуры, а seed ядра: `arm-core/api/seed/core/` (обязательные данные: `roles.yaml`, +`permissions.yaml`, `role_permissions.yaml`, `regional_office.yaml`, `chart_of_accounts.csv`, `elimination_rules_v1.yaml`, +`required_document_rules.yaml`) и `arm-core/api/seed/dev/` (только `python -m scripts.seed --dev`: dev-пользователи и +примеры РФ). Загрузчик — `python -m scripts.seed [--dev]` из `arm-core/api/` (идемпотентен, upsert по натуральному ключу; +в compose его выполняет одноразовый сервис `arm-core-migrate`). Здесь остаются только тестовые данные группы. Полный план счетов 1С из бандла макета не передан — seed строится на Плане счетов 94н и типовых субсчетах 1С (OQ-47). Первый прогон парсера на `osv/**` сохранить как `expected_osv_parse/*.json` после ручной сверки итогов со строкой «Итого» — дальше это regression-набор. diff --git a/context/07-open-questions.md b/context/07-open-questions.md index 625164e..9425c2e 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -18,8 +18,8 @@ | OQ-10 | Гудвилл: входит ли стр. 1350 (добавочный капитал) в базу участия; УК из ББ (1310) или из ОСВ 80/83 | DBML, DOC-259025, FNR03 | срез 6 | допущение: 1310 из ББ; 1350 — настраиваемый флаг, по умолчанию выключен | | OQ-11 | Строка «ППА» в КФО (правило № 1, когда у А нет ОСВ 03) — что это и куда | FNR03 | срез 6 | не реализовывать | | OQ-12 | Формулы ОФР: знаки 2410/2420/2430 по ФСБУ 4/2023 vs 66н; состав 1100 (1120–1140) | DOC-221281 | срез 3 | допущение: формулы §7, строки без значения = 0 | -| OQ-13 | `confidence` от ИС Сканирование — доли 0..1 или проценты 0..100; имя поля организации (`full_name` vs `form_name`) | FNR02 vs `aep_*.json` | срез 3 (scan-gateway) | допущение: нормализовать в 0–100 по фактическим файлам | -| OQ-14 | `reporting_date` в JSON ИС Сканирование — «ждём обновлённый JSON» | FNR01 | срез 3 | допущение: парсить из заголовка «на 30 сентября 2025 г.» | +| OQ-13 | `confidence` от ИС Сканирование — доли 0..1 или проценты 0..100; имя поля организации (`full_name` vs `form_name`) | FNR02 vs `aep_*.json` | срез 3 (scan-gateway) | **закрыт для scan-gateway (04.10.2026, по `aep_3.json`, `aep_6.json`):** `confidence` — числа 0–100 (не доли); название организации — `data_ocr.organization.form_name` (шумный OCR-текст), ИНН — `inn.form_name`, единицы — `units.form_name`, дата — `date.form_name` (в `aep_6.json` мусор); `full_name`/`inn.inn`/`units.measurement` из ТЗ в реальных файлах нет. Нормализация — Срез 3 | +| OQ-14 | `reporting_date` в JSON ИС Сканирование — «ждём обновлённый JSON» | FNR01 | срез 3 | **уточнено (04.10.2026):** отдельного поля `reporting_date` в ответе нет; `date.form_name` ненадёжен (в `aep_6.json` — «Общество с ограниченной ответственностью», в `aep_3.json` — «2\|0\|2\|4\|»); даты колонок — в строке-шапке `rows[0]` таблицы («На 30 июня 2025 г» в `aep_6.json`), в `aep_3.json` шапки нет. Допущение прежнее: парсить из шапки таблицы, при её отсутствии — `NEEDS_CHECK`; решение — Срез 3 | | OQ-15 | Таймаут OCR 5 мин / 0 повторов (ТЗ 3) vs outbox с повторами (DBML) | DOC-037736 | срез 3 | решено DBML: `max_attempts` OCR = 1, парсер = 3 | | OQ-16 | Матрица прав (ТЗ сноска 29 «будет разработана»); роли макета (Аналитик ДКБ, Наблюдатель) ≠ ТЗ (ANL-034) | ТЗ 8.1, макет | срез 1 | допущение: seed по §10 правил | | OQ-17 | `user_role` M:N или одна роль на пользователя | DBML | срез 1 | допущение: M:N | @@ -50,9 +50,47 @@ | OQ-42 | «Проверка распознавания» для ОСВ (xlsx) в макете отсутствует (строки `doc-row--readonly`, `39`). Нужен ли отдельный экран проверки ОСВ (дерево строк, подсветка контрагентов) или проверка ОСВ только через сводную ОСВ (`30`)? | макет [39] | срез 2 (UI) | допущение: минимальный экран таблицы ОСВ по §1 правил без левой панели исходника | | OQ-43 | Кнопка «К отчетности» неактивна у консолидаций в статусах «Инициация» и «Верификация РФ», активна у «Консолидация РФ» и «Верифицирован» (`34`). Каково правило доступности отчёта по статусу / роли? | макет [34] | срез 4, 7 (policy) | допущение: отчёт доступен с `CONSOLIDATION_RF` и далее для всех ролей с `CONSOLIDATION_VIEW`; «Инициация» — только карточка | | OQ-44 | «Добавить версию» создаёт версию сразу без модалки (`43-01`), копируя периметр; нужно ли подтверждение и выбор сценария/подхода новой версии? | макет [43-01] | срез 7 | допущение: без модалки, периметр и сценарий копируются из предыдущей версии | -| OQ-45 | Контракт `recognition-result.schema.json` допускает `data_type = NU` (строки налогового учёта в ОСВ «БУ, НУ»), а enum DBML `osv_data_type` — только BU, CURRENCY, QUANTITY, SUM. Добавлять NU в DBML (v5.14) или не хранить строки НУ? | контракт v1.0 vs DBML | срез 2 (маппинг `osv_entry`) | допущение: контракт оставляет NU; решение по хранению — до начала Среза 2; код Среза 0 не затрагивает | -| OQ-46 | Обязательны ли все ОСВ расширенного подхода (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) при `approach = EXTENDED`, или часть — «при наличии»? | ТЗ 5.1; `required_document_rule.is_mandatory` | срез 0 (seed), срез 3 (чек-лист) | допущение: все `is_mandatory = true` для EXTENDED | -| OQ-47 | Источник полного плана счетов 1С для seed `chart_of_account`: в фикстурах файла нет, «план счетов из бандла макета» не передан | `06-test-data.md` §6, `fixtures/README.md` | срез 0 (seed) | допущение: План счетов 94н (01–99, 001–011) + типовые субсчета 1С БП 3.0, упомянутые в §4/§6; остальное добавляет администратор (ARCH-004) | +| OQ-45 | Контракт `recognition-result.schema.json` допускает `data_type = NU` (строки налогового учёта в ОСВ «БУ, НУ»), а enum DBML `osv_data_type` — только BU, CURRENCY, QUANTITY, SUM. Добавлять NU в DBML (v5.14) или не хранить строки НУ? | контракт v1.0 vs DBML | срез 2 (маппинг `osv_entry`) | допущение принято и применено в Срезе 0: контракт v1.0 оставляет `NU` (в копиях схем трёх сервисов `DataType` содержит `NU`), ORM `OsvDataType` в arm-core 1:1 с DBML v5.13 (BU, CURRENCY, QUANTITY, SUM), маппинг результата в `osv_entry` — `NoopMapper`, поэтому расхождение Срез 0 не затрагивает; решение (кандидат DBML v5.14: добавить `NU` в `osv_data_type`, либо не хранить строки НУ) — до начала Среза 2; источник: arm-core, предложения, раздел C | +| OQ-46 | Обязательны ли все ОСВ расширенного подхода (02, 03, 08, 44, 26, 91, 51, 52, 59, 80, 83, 86, 97, 001, 008, 009, 011) при `approach = EXTENDED`, или часть — «при наличии»? | ТЗ 5.1; `required_document_rule.is_mandatory` | срез 0 (seed), срез 3 (чек-лист) | допущение принято и применено в Срезе 0: все 27 строк EXTENDED в seed `arm-core/api/seed/core/required_document_rules.yaml` (маркер `ASSUMPTION(OQ-46)`) — `is_mandatory = true`; BASIC — 10 строк, все обязательные; смена правила — правка одного yaml; вопрос к методологам остаётся открытым до Среза 3 (чек-лист); источник: arm-core, предложения, раздел A | +| OQ-47 | Источник полного плана счетов 1С для seed `chart_of_account`: в фикстурах файла нет, «план счетов из бандла макета» не передан | `06-test-data.md` §6, `fixtures/README.md` | срез 0 (seed) | допущение принято и применено в Срезе 0: seed `arm-core/api/seed/core/chart_of_accounts.csv` — 122 строки (62 синтетических счёта Плана счетов 94н, 11 забалансовых, 49 субсчетов и подсубсчетов 1С БП 3.0, упомянутых в §4/§6); названия нестандартных субсчетов (66/67 `.06/.22/.24/.26`, 76.07.x, 76.08/.28/.38) выведены по их роли в §4 правил, а не взяты из справочника 1С; названия-заглушки заменить по справочнику 1С клиента, остальное добавляет администратор (ARCH-004); источник: arm-core, предложения, раздел A | +| OQ-48 | Правило элиминации № 1 (§4), сторона Б «76.07.1 − 76.07.5 − 76.07.9»: 76.07.5 и 76.07.9 не потомки 76.07.1, поэтому «вычитание» чисто информативно. Так и задумано? | `05-domain-rules.md` §4 (правило 1); seed `elimination_rules_v1.yaml`; arm-core: предл. 1 (T14.1) | срез 5 (пары ВГО) | допущение принято: исключения 76.07.5/76.07.9 на стороне Б хранятся как `is_excluded` и не влияют на сумму (76.07.1 их не содержит); алгоритм на них не опирается; подтвердить с методологами | +| OQ-49 | Правило № 1, сторона А: резервный счёт 01 лежит в одном списке с 03 и 76.07.9 — приоритета нет. Как выбирать «03, а если у А нет ОСВ 03 — 01»? Что значит «результат в строку ППА» (см. OQ-11)? | `05-domain-rules.md` §4 (правило 1: «01 — резервный»); seed `elimination_rules_v1.yaml`; arm-core: предл. 2 (T14.2) | срез 5–6 | допущение принято: в seed `accounts: ["03", "76.07.9", "01"]` без порядка; fallback «берём 01, если нет ОСВ 03» — в `comment` правила; логику приоритета реализует срез 5, до решения она не реализуется | +| OQ-50 | Составные коды статей КФО `1450/1550` (правило 1) и `1310/1350` (правило 7, гудвилл) хранятся в `elimination_rule.article_2_line` одной строкой с «/». Как их потреблять: делить по «/» и подбирать строку, которая есть в ББ Б, или хранить две строки? | `05-domain-rules.md` §4 (правила 1, 7); seed `elimination_rules_v1.yaml`; arm-core: предл. 3 (T14.3) | срез 5–6; кандидат DBML v5.14 (note колонки) | допущение принято: хранится как в §4, потребитель делит по «/»; предложение — зафиксировать это в note `article_2_line` в DBML v5.14 (версию не создавали) | +| OQ-51 | `required_document_rule`: уникальный индекс `(approach, document_type, account_id)` при nullable `account_id` допускает дубли строк для не-ОСВ документов (NULL в PostgreSQL различны). Добавить `NULLS NOT DISTINCT`? | DBML v5.13 (`required_document_rule`, Indexes); arm-core: предл. 4 (T8.3) | срез 3 (чек-лист); кандидат DBML v5.14 | допущение принято: ORM и миграция 1:1 с DBML v5.13 (без `NULLS NOT DISTINCT`); предложение v5.14: `[unique, note: 'NULLS NOT DISTINCT']`, как у `document` | +| OQ-52 | Контракт v1.0, `recognition-result-message`: `schema_version` принимает только `1.x` (`^1\.\d+$`), а `result_schema_version` — любой MAJOR (`^\d+\.\d+$`). Намеренно (файл результата версионируется отдельно) или небрежность? | `contracts/recognition-result-message.schema.json`; arm-core: предл. 5 (T20.7) | контракт v1.x; срез 2 (inbox, маппинг) | допущение принято: ядро повторяет схему буквально (любой MAJOR проходит валидацию); совместимость MAJOR результата проверяет маппинг Среза 2; уточнить у владельца контракта | +| OQ-53 | Контракт v1.0: схемы нигде не задают `additionalProperties: false` (кроме `columns` ОСВ). Считать ли неизвестные поля допустимыми расширениями? | `contracts/*.schema.json`; arm-core: предл. 6 (T20) | контракт v1.x; все три сервиса | допущение принято: модели ядра `extra = "ignore"` (строже схемы быть нельзя — ядро отвергнет совместимого пира); изменение — через MINOR контракта | +| OQ-54 | Параметры JWT РАИСА неизвестны: JWKS URL / публичный ключ, `iss`, `aud`, имя claim с email. Что в продуктивном контуре? Делать ли проверку `aud`/`iss` обязательной в проде? | ТЗ 8.1, `03-architecture.md` (SSO РАИСА); `core/auth.py`; arm-core: предл. 7 (T16.2) | срез 1+ (прод-конфигурация) | допущение принято: настраиваются `AUTH_JWKS_URL`/`AUTH_PUBLIC_KEY` (взаимоисключающие), `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM` (по умолчанию `email`); при пустых `AUTH_AUDIENCE`/`AUTH_ISSUER` `aud`/`iss` не проверяются; предложение: в проде сделать `aud` обязательным и предупреждать при старте, если пусто | +| OQ-55 | В enum `audit_action` нет действия для смены статуса задания распознавания (`recognition_job`). Добавлять ли (DBML v5.14) и нужен ли аудит обмена вообще? | DBML v5.13 `audit_action`; arm-core: предл. 8 (R-7, design #20 arm-core) | срез 2 (обмен); кандидат DBML v5.14 | допущение принято: Срез 0 не пишет `audit_event` из обмена `recognition_job`; сервис аудита (`services/audit.py`) готов и покрыт тестами; если нужно — действие в v5.14 и вызовы в `recognition_exchange.py` | +| OQ-56 | Append-only аудит обеспечен только на уровне кода (единственная точка записи, статический тест на UPDATE/DELETE по `AuditEvent`). Нужна ли защита на уровне БД (триггер или `REVOKE UPDATE, DELETE` для роли приложения)? Вопрос поднят дважды (в том числе финальным ревью ветки). | инвариант «Append-only аудит» (`../CLAUDE.md`); `services/audit.py`; arm-core: предл. 9 и дополнение к нему (задача 21) | миграции, права БД; платформа | **решено человеком 04.10.2026: осознанный техдолг.** Append-only на уровне кода и тестов; защита на уровне БД (триггер или `REVOKE UPDATE, DELETE`) — позже, вместе с платформой, до прода | +| OQ-57 | Счётчик повторных публикаций запроса (`ACK_TIMEOUT_SECONDS` × `OUTBOX_MAX_REPUBLISH`) хранится в `recognition_job.metrics.republish_count` (jsonb), колонки в DBML нет; при каждой повторной публикации `published_at` перезаписывается временем последней, время первой не сохраняется. Нужны ли колонка счётчика и время первой публикации (v5.14)? | `services/recognition_exchange.py` (design #6 arm-core); arm-core: предл. 10 (T23, T23.1); docs: O-19 (задача 4 фазы 2) | срез replay; кандидат DBML v5.14 | допущение принято: счётчик в `metrics.republish_count`, `attempt` при повторе не растёт; `published_at` — время последней публикации (`dlq` через (`OUTBOX_MAX_REPUBLISH`+1)×`ACK_TIMEOUT_SECONDS` ≈ 120 с, описано в `dev/README.md`); при будущем replay счётчик и `reply_deadline` надо сбрасывать | +| OQ-58 | Семантика сроков и ошибок обмена в ядре: (а) `reply_deadline` по умолчанию = `now + 5 мин` для обоих pipeline (`tabular`, `image`), если у задания NULL; в DBML срок есть только для OCR («OCR — 5 мин»), для парсера значения нет; (б) при невозможности собрать запрос по контракту задание уходит в `error` / `INVALID_REQUEST`; (в) при `ACK_TIMEOUT × (MAX_REPUBLISH+1) ≥ 5 мин` статус `dlq` недостижим (раньше сработает `timeout`). Верно ли? Какой срок у `tabular-parser`? | DBML `recognition_job.reply_deadline` note; `recognition_exchange.py` (`DEFAULT_REPLY_TIMEOUT`); arm-core: предл. 11 и дополнение (T23.4) | срез 2–3; настройка таймаутов | допущение принято: как описано (дефолты 30 с × 3 укладываются в 5 мин); сроки по pipeline уточнить с командами парсера и шлюза | +| OQ-59 | Email пользователя сопоставляется с `app_user.email` по `lower(email)`; при двух записях, различающихся регистром, — 403 (fail-closed). Добавить `CHECK`/уникальный индекс по `lower(email)` в DBML? | `core/auth.py`, `api/dependencies/auth.py`; arm-core: предл. 12 (R-17, design #29 arm-core) | срез 1 (админ-экран); кандидат DBML v5.14 | допущение принято: уникальность `email` регистрозависимая (как в DBML); регистронезависимая — в v5.14 | +| OQ-60 | Seed `--dev` не защищён флагом `DEBUG`: создаёт dev-пользователей (в том числе `ADMIN`) на любой БД, на которую указывает `.env`, и перезаписывает существующего `app_user` с совпадающим email. Нужна ли защита? | arm-core `scripts/seed.py`; arm-core: предл. 13 (T15.2, T15.4) | срез 0 (процесс); прод-развёртывание | допущение принято: спека защиты не требует, `--dev` помечен «только для разработки» (в compose запускается `arm-core-migrate`); предложение — отказ при `DEBUG=false` или явный флаг подтверждения | +| OQ-61 | Неверный метод на существующий API-маршрут (например `POST /api/v1/me`) отдаёт 404 вместо 405: SPA-монтирование на `/` перехватывает частичное совпадение, а `/api/*` в нём принудительно 404. Приемлемо? | arm-core `app.py` (`SPAStaticFiles`); arm-core: предл. 14 (T18.2) | API-контракт (клиенты, фронт) | допущение принято: оставлено как есть (честный 404 для несуществующих путей важнее 405); при необходимости — отдельная правка | +| OQ-62 | В образе `arm-core` нет фронта (`Dockerfile` не собирает `web/`), `GET /` отдаёт 307 на `//` и затем 404; редирект на `//` похож на дефект монтирования SPA при `ROOT_PATH=/`. Собирать `web/` в образ в Срезе 0/1 и чинить редирект? | arm-core `Dockerfile`, `app.py`; docs: O-10 (задача 3 фазы 2), `dev/README.md` | срез 0 (compose), срез 1 (фронт) | допущение принято: README фиксирует отсутствие UI в образе; редирект на `//` не исследовался (сервис не правился) — follow-up arm-core | +| OQ-63 | `web/package-lock.json`: шаблон lock не содержит, `raisa-*` из Nexus — lock вне банка отличался бы; в Срезе 0 lock в `.gitignore`. `npm install` сообщает 6 high (npm audit) в зависимостях шаблона web. Коммитить ли lock; кто обновляет зависимости? | arm-core `web/`; arm-core: предл. 15 (T25.1, R-23, design #35 arm-core) | платформа, фронт; до прода | допущение принято: lock не в репозитории, зависимости не менялись; решение — с платформенной командой (Nexus, политика audit) | +| OQ-64 | Аудит `LOGIN` / `LOGIN_FAILED` (действия есть в `audit_action`) для JWT-аутентификации: в каком срезе и что считать «входом» — первый запрос с токеном, каждый запрос, отказ проверки токена? | DBML `audit_action`; `core/auth.py`; arm-core: предл. 16 (финальное ревью) | срез 1 (вход, админ-экран); аудит | допущение принято: в Срезе 0 вход не аудируется; сервис аудита готов | +| OQ-65 | Шаблонные CORS `allow_origins=["*"]` + `allow_credentials=True` и `ProxyHeadersMiddleware(trusted_hosts=["*"])`: в проде любой клиент может подставить `X-Forwarded-For`, и `audit_event.ip_address` недостоверен. Какие origins и доверенные прокси на платформе? | arm-core `app.py` (шаблон команды); arm-core: предл. 17 (финальное ревью) | прод-конфигурация; достоверность аудита | допущение принято: оставлено как в шаблоне; перечни origins и доверенных прокси — с платформенной командой до прода | +| OQ-66 | `/docs` и `/openapi.json` FastAPI открыты без аутентификации, в том числе в проде. Закрывать их (выключать при `DEBUG=false`) или оставить? | arm-core `app.py` (шаблон); arm-core: предл. 18 (финальное ревью) | прод-конфигурация; безопасность | допущение принято: открыты, как в шаблоне; решение — с платформой и ИБ | +| OQ-67 | Inbox не обновляет `stored_file.status` (`UPLOADED → PROCESSING → PROCESSED / ERROR`) и `stored_file.current_recognition_job_id` по результату задания: после результата файл остаётся `UPLOADED`, `current_recognition_job_id` пуст. Кто и когда это делает в Срезе 2 — inbox или маппер, в одной транзакции? | DBML `stored_file`, `file_status`; `recognition_exchange.py`; arm-core: предл. 19 (финальное ревью); docs: O-18 (задача 4 фазы 2) | срез 2 (загрузка и маппинг) | допущение принято: в Срезе 0 (`NoopMapper`) файл остаётся `UPLOADED`, в `dev/README.md` записано как «не покрывается»; решение — при проектировании маппера Среза 2 | +| OQ-68 | Конверт `error` у `scan-gateway` публикуется без `metrics`, поэтому `recognition_job.metrics` = NULL (у `tabular-parser` в `metrics` есть `duration_ms`). Требовать ли `metrics` в конверте ошибки и единые поля метрик у обоих сервисов? | scan-gateway `core/worker.py`; docs: O-18 (задача 4 фазы 2, сквозная проверка) | срез 2–3 (наблюдаемость, контракт v1.x) | допущение принято: `metrics` в контракте необязательны, ядро хранит NULL; требование — решение владельца контракта | +| OQ-69 | Обработчик `recognition.result` выполняется последовательно в callback подписки NATS; длинный маппер Среза 2 задержит следующие сообщения, ack сервиса не успеет за `ACK_TIMEOUT_SECONDS` (30 с) — ложный republish, затем `dlq`. Предложение: развести быстрый inbox (фиксация сообщения и статуса) и отдельный цикл маппинга результата. | arm-core `core/nats.py` (`subscribe_results`), `recognition_exchange.py` (`handle_message`, `ResultMapper`); arm-core: предл. 20 (финальное ревью) | срез 2 (маппинг); архитектура обмена (D-6) | допущение принято: в Срезе 0 `NoopMapper` — проблема не проявляется; решение — до реализации маппера Среза 2 | +| OQ-70 | Перед чтением файла результата маппер должен проверять, что ключ начинается с `results/{file_id}/{job_id}/`, а бакет — бакет роли `results` (иначе сервис может «подсунуть» чужой объект). Закрепить это в контракте и `03-architecture.md`? | контракт `recognition-result-message` (`result_s3`); arm-core `_register_result_object` (`recognition_exchange.py`); arm-core: предл. 21 (финальное ревью) | срез 2 (маппинг); контракт v1.x | допущение принято: в Срезе 0 результат только регистрируется в `s3_object`, не читается; проверку реализует маппер Среза 2 | +| OQ-71 | Запрос без читаемых `job_id` / `file_id` / `attempt` остаётся без ответа (конверт собрать нельзя): сервис логирует ошибку и отбрасывает запрос, ядро закрывает задание `dlq` / `NO_ACK` примерно через 120 с. Расходятся и реализации: при нечитаемом `attempt` `tabular-parser` берёт его из заголовка `Nats-Msg-Id` запроса или ничего не отправляет, `scan-gateway` подставляет `attempt = 1` и публикует конверт. Оставить так, выровнять реализации или сделать `file_id` необязательным в конверте `error` (MINOR 1.1)? | `recognition-contract` spec, сценарий «Нечитаемый запрос»; tabular-parser: P-1 (задача 4.3); docs: O-24, design #35 | контракт v1.x; срез 2–3 | допущение принято: молчание сервиса (design #35); реализации выровнять в Срезе 2/3, влияние только на битые запросы | +| OQ-72 | Ack при несовместимой версии запроса: ранняя формулировка контракта и `03-architecture.md` требовали «`SCHEMA_MISMATCH` и ack», оба сервиса публикуют `error SCHEMA_MISMATCH` без ack (ядро принимает результат из `published`, ack перед ним ничего не добавляет). Спека и документ приведены к поведению сервисов — подтвердить. | tabular-parser: P-2; scan-gateway: п. 2 таблицы предложений (T12.13, R-16); docs: design #34 | контракт v1.x; срез 2 | допущение принято: без ack, результат `SCHEMA_MISMATCH` закрывает задание (design #34); если ядро начнёт требовать ack перед результатом — правка одной строки в воркерах и спеке | +| OQ-73 | Повторная попытка (`attempt + 1`) того же `job_id` после ошибки или результата: оба сервиса дедуплицируют по `job_id` (LRU `DONE_CACHE_SIZE`), поэтому replay с новым `attempt` получает только ack и не обрабатывается, на единственном инстансе задание повисает до `reply_deadline` → `timeout`. По `03-architecture.md` replay — «сброс в `queued` с `attempt + 1`». Сделать ключ дедупликации `(job_id, attempt)`; повтор во время обработки — только ack? | tabular-parser: P-3 (задача 4.3); scan-gateway: п. 17 таблицы предложений (I-3, R-17); `03-architecture.md` (replay) | срез 3+ (replay); контракт v1.x | вопрос: допущение принято на Срез 0 — дедуп по `job_id`, ядро повторяет только тем же `attempt`; при одобрении — одна строка в воркере (ключ LRU); **человеком 04.10.2026 не рассмотрен — вопрос остаётся открытым** | +| OQ-74 | `PUBLISH_RETRIES` = одна первая попытка + до 3 повторов (всего до 4) с тем же `Nats-Msg-Id`; у `scan-gateway` тип `PositiveInt` (повторы нельзя отключить настройкой), у `tabular-parser` `NonNegativeInt` (0 допустим); паузы 0,5 с и 2,0 с. Согласовать единую семантику (повторы или всего попыток) и тип? | scan-gateway: п. 1 таблицы предложений (R-13, T11.1); docs: O-25, design #36 | контракт v1.x; настройки сервисов | допущение принято: «1 + 3 повтора» (design #36), расхождение «3 против 4» обмен не ломает (дедуп по `Nats-Msg-Id`); выровнять тип — косметика конфигурации | +| OQ-75 | `reply_deadline` проверяет только `scan-gateway` (`error TIMEOUT` до отправки в ИС и потолок ожидания ответа); `tabular-parser` обрабатывает просроченный запрос. Пример `contracts/examples/request-image.json` имеет `reply_deadline` в прошлом (`2026-10-03T12:45:00Z`) и даёт `TIMEOUT` при ручном прогоне. Единая обязанность сервисов и свежая дата примера? | scan-gateway: п. 12 таблицы предложений (O-e); docs: O-26, design #37 | контракт v1.x; срез 2 | допущение принято: срок — дело сервиса, пример — иллюстрация формы (оговорка в spec и `03-architecture.md`, design #37); обновить дату можно одним PR с пересинхронизацией копий в трёх репозиториях | +| OQ-76 | Обе реальные фикстуры `fixtures/ocr` (`aep_5.json`, `aep_8_form4.json`) — ответы-ошибки ИС (`error.code = INVALID_DOCUMENT`, пустой `table_structure`). Успешный путь `image` (`done` / `needs_check`, файл в `results/…`, `s3_object` с `bucket_role = results`) сквозной проверкой не покрыт; в `scan-gateway` проверяется только синтетическим ответом. Нужны образцы успешных ответов Content AI. | scan-gateway: п. 3 таблицы предложений (O-1, R-10, design #32); docs: O-15 (задача 4 фазы 2), design #31 | срез 3 (OQ-13/14, нормализатор); fixtures | **частично закрыт человеком 04.10.2026:** добавлены реальные образцы — `aep_3.json` (успех: баланс 42 строки + ОФР 18 строк), `aep_3_ofr.json` (второй ответ ИС по тому же файлу, `INVALID_DOCUMENT`), `aep_6.json` (успех: баланс 122 строки + ОФР 49 строк); PR в `arm-ka-fixtures`. Успешный путь `image` на реальных ответах — Срез 3 (нормализатор); транспорт `scan-gateway` проверяется на них в Срезе 0 (склейка нескольких json, `NOT_IMPLEMENTED`) | +| OQ-77 | §11 `05-domain-rules.md` описывает `data_ocr[]` (список), а в фикстурах `data_ocr` — объект с полями `label/organization/inn/units/date/table_structure`. Какой формат фактический на стенде ИС (связано с OQ-13, OQ-14)? | `05-domain-rules.md` §11; scan-gateway: п. 4 таблицы предложений (O-1, T3.2) | срез 3 (нормализатор) | **закрыт человеком 04.10.2026 (по `aep_3.json`, `aep_6.json`):** `data_ocr` — **объект**, один на json-файл (`label`, `organization`, `inn`, `units`, `date` — `{form_name, confidence}`; `table_structure` — **список таблиц** `{label, rows[]}`, одна таблица = один документ БС/ОФР, в одном json их может быть несколько). Успех — `error = {code: "", message: ""}` (не `null`!), ошибка — непустой `code` и пустой `table_structure`. §11 `05-domain-rules.md` исправлен. **Уточнение человека 04.10.2026:** разбиение одной формы на несколько таблиц (сегменты) в образцах не встречалось; наблюдается только несколько таблиц **разных** форм в одном json (баланс + ОФР). Нештатный случай (повтор `label`) — `NEEDS_CHECK`, правило уточнить в Срезе 3 по реальным данным | +| OQ-78 | Шлюз читает ровно `//.json` (спека); старый код читал все файлы каталога `Output//`. Какие файлы и когда реальная ИС кладёт в `Output`, в каком порядке (момент готовности)? | scan-gateway: п. 5 таблицы предложений (T8.5) | срез 3 (стенд ИС) | **закрыт человеком 04.10.2026:** ИС кладёт **все распознанные документы одновременно** в каталог ``, по одному json на документ (пример: `aep_3.json` + `aep_3_ofr.json`); шлюз читает **все** json каталога и склеивает в один ответ (готовность — по стабилизации набора файлов, `SCAN_SETTLE_SECONDS`); ошибка одного документа не отменяет остальные (`metrics.warnings`). Реализуется в Срезе 0 (доработка PR scan-gateway #3) | +| OQ-79 | Временный файл `.<имя>.part` лежит в `Input` (атомарная запись `.part` + rename): реальная ИС без маски может забрать недописанный файл. Уточнить у владельцев ИС маску и поведение или перенести временный файл в служебный подкаталог рядом с `Input`? | scan-gateway: п. 6 таблицы предложений (R-5, T7/T8) | срез 3 (стенд ИС) | **закрыт человеком 04.10.2026:** временный файл записи уходит из `Input` в служебный каталог рядом (`SCAN_TMP_DIR`, по умолчанию `Tmp`, на той же шаре — `rename` остаётся атомарным). Реализуется в Срезе 0 (доработка PR scan-gateway #3) | +| OQ-80 | `make up` расходится с эталоном (scan-gateway и `dev/Makefile.common`): в `tabular-parser` нет `minio-init` — на свежем томе бакета `arm-ka` нет, `test_s3_integration` даёт `NoSuchBucket`, хотя README сервиса обещает бакет; в `arm-core` `up -d` без `--wait` и без `--project-name` (имя в compose фиксировано, безвредно). Также `check-full` в arm-core и tabular-parser — через пререквизиты (порядок при `make -j` не гарантирован), в scan-gateway — через `$(MAKE)`; `lint` arm-core включает `mypy scripts`. Подключить `include ../docs/dev/Makefile.common` в сервисах? | tabular-parser: `Makefile` (цель `up`); scan-gateway: п. 18 таблицы предложений (I-1); docs: O-1 и O-2 (задача 1 фазы 2), O-4 и O-5 (задача 2 фазы 2), `dev/checks.md`, `dev/Makefile.common` | срез 0 (процесс), все сервисы | допущение принято: эталон — scan-gateway, расхождения описаны в `dev/checks.md`, Makefile сервисов не правились (design #22, #24); выровнять — правкой Makefile сервисов (follow-up) | +| OQ-81 | Переменные `ARM_KA_DOCS` / `ARM_KA_FIXTURES` поддержаны неравномерно: `arm-core` — обе; `tabular-parser` — только `ARM_KA_DOCS`; `scan-gateway` — ни одной (жёстко `../docs`, `../../fixtures/ocr`). Текст причины skip («fixtures недоступен: положите репозиторий…») не содержит строки «ARM_KA_FIXTURES недоступен» из сценария «Нет фикстур» spec `quality-checks`; для docs-теста текст тоже без `ARM_KA_DOCS`. Нарушение spec — править сервисы? | scan-gateway и tabular-parser (тесты, Makefile); docs: O-3 (задача 1 фазы 2), `dev/checks.md`; spec `quality-checks` | срез 0 (процесс), все сервисы | допущение принято: расхождение описано в `dev/checks.md`; правка тестов и Makefile в scan-gateway и tabular-parser — follow-up человеку (сервисы не правились) | +| OQ-82 | `.dockerignore` в `arm-core` не исключает `api/.env.test`: файл попадает в образ (у `tabular-parser` и `scan-gateway` исключён). Исправлять? | arm-core `.dockerignore`; docs: T3.2 (задача 3 фазы 2) | срез 0 (образы), прод-сборка | допущение принято: безвредно (в файле стандартные dev-учётные данные compose, не секреты), в Срезе 0 не правилось; добавить исключение в arm-core — follow-up | +| OQ-83 | Контейнеры профиля `services` подписаны на те же subject и queue group общего NATS, что и экземпляры в интеграционных тестах сервисов (`recognition.result` / `arm-core`, `*.request`): NATS может отдать сообщение теста контейнеру и наоборот; интеграционные тесты `scan-gateway` и `arm-core` одновременно тоже делят NATS. Сделать queue group и subject-префикс настраиваемыми для тестов (например `NATS_TEST_PREFIX`) или поднимать отдельный NATS на тесты? | scan-gateway: п. 11 таблицы предложений (T14.1, T11.8); docs: O-11 (задача 3 фазы 2; подтверждено ревью для tabular-parser и scan-gateway), `dev/README.md` | срез 0 (процесс), интеграционные тесты | допущение принято: в `dev/README.md` предупреждение и команда `stop` для контейнеров профиля; не гонять `make test-integration` двух сервисов одновременно | +| OQ-84 | Запуск сервисов из venv без Nexus (`make venv` + `python run_server.py` из `api/` с `.env` из `docs/dev/.env.example`, healthcheck по `localhost`) в Срезе 0 не проверялся ни в одном из трёх сервисов. Известно по чтению конфигов (design #44): `.env.example` в нынешнем виде отвергнут `scan-gateway` (`OPENBAO__DATABASE__*`) и `tabular-parser` (`OPENBAO__DATABASE__*`, `OPENBAO__SMB__*` — `extra_forbidden`), в нём нет `OPENBAO__MINIO__MINIO_SECURE=false` (по умолчанию `true` — https к http-MinIO). Прогнать сценарий, зафиксировать набор переменных и портов (`SERVER__PORT`, `SCAN_HOTFOLDER_PATH` для `scan-gateway`), решить, нужен ли отдельный `.env.example` на сервис, и снять пометку «черновик» с раздела `dev/README.md`? | spec `dev-environment`, сценарий «Запуск из venv против compose без Nexus»; docs: design #43, `dev/README.md` («Запуск сервисов из venv без Nexus»); финальное ревью фазы 2: I-1 | срез 0 (процесс, окружение разработки); кандидат в follow-up issue | вопрос: допущение Среза 0 — раздел README остаётся черновиком, сценарий не подтверждён (проверка требовала `.venv` внутри сервисных репозиториев, в фазе 2 доступных только для чтения) | +| OQ-85 | **Возможен ли JetStream на платформе** (серверный дедуп по `Nats-Msg-Id`, durable consumer'ы, redelivery вместо ack и повторной публикации на уровне приложения)? Сейчас NATS без JetStream (ответ платформы 03.10.2026); Срез 0 строит доставку на ack `processing`, повторной публикации ядром и дедупе по `recognition_job.result_msg_id`. Если JetStream подключить можно — меняются только publisher/subscriber (`core/nats.py` в трёх сервисах), outbox/inbox и контракт остаются; исчезают потеря результата при простое ядра и риск повторной обработки. | решение человека 04.10.2026: «узнать у платформы и проверить позже»; `design.md` slice-0-skeleton (D-3, Open Questions п. 4); OQ-72, OQ-73 | инфраструктура NATS (платформа); `core/nats.py`, воркеры сервисов | **вопрос к платформе (поставить напоминание, проверить после ревью Среза 0 / до Среза 3)**: допущение — без JetStream | Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; `DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. diff --git a/context/09-delivery-plan.md b/context/09-delivery-plan.md index 19eb5bc..eda18db 100644 --- a/context/09-delivery-plan.md +++ b/context/09-delivery-plan.md @@ -19,6 +19,9 @@ платформе; ack и повторная публикация — на уровне приложения), MinIO (S3, один бакет `arm-ka`), эмулятор ИС Сканирование (часть `scan-gateway`, отдаёт `fixtures/ocr/aep_N.json`), три сервиса (профиль `services`, Python 3.11). `Makefile` с общими целями `up/test/test-integration/lint/check/check-full` (+ `migrate/seed` в ядре) в каждом репо. + Порты, запуск профиля `services` и ручная сквозная проверка обмена `recognition_job` через NATS (сценарии `tabular` → + `NOT_IMPLEMENTED`, `image` → эмулятор ИС → `INVALID_DOCUMENT`, потеря ack → `dlq`) — `docs/dev/README.md`, + разделы «Профиль `services`» и «Проверка окружения». - Контракт v1.0 — три схемы в `docs/contracts/`: `recognition-request`, `recognition-result-message` (конверты NATS) и `recognition-result` (файл результата) + `examples/`. Общий пакет `arm-ka-contracts` **не публикуется**: каждый сервис хранит копию схем в `api/contracts/` и pydantic-модели в `src/contract/`, идентичность с `docs/contracts` проверяется в `make check` diff --git a/contracts/examples/request-image.json b/contracts/examples/request-image.json new file mode 100644 index 0000000..2de9cd5 --- /dev/null +++ b/contracts/examples/request-image.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "pipeline": "image", + "file_type": "PDF", + "original_name": "bb_2025_q3.pdf", + "s3": { + "bucket": "arm-ka", + "key": "uploads/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/bb_2025_q3.pdf" + }, + "reply_deadline": "2026-10-03T12:45:00Z", + "attempt": 1 +} diff --git a/contracts/examples/request-tabular.json b/contracts/examples/request-tabular.json new file mode 100644 index 0000000..e1a26c1 --- /dev/null +++ b/contracts/examples/request-tabular.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "pipeline": "tabular", + "file_type": "XLSX", + "original_name": "osv_60_2025_q1.xlsx", + "s3": { + "bucket": "arm-ka", + "key": "uploads/a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02/3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01/osv_60_2025_q1.xlsx" + }, + "reply_deadline": "2026-10-03T12:30:00Z", + "attempt": 1 +} diff --git a/contracts/examples/result-error.json b/contracts/examples/result-error.json new file mode 100644 index 0000000..54af7cb --- /dev/null +++ b/contracts/examples/result-error.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "error", + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + }, + "documents": [] +} diff --git a/contracts/examples/result-message-done.json b/contracts/examples/result-message-done.json new file mode 100644 index 0000000..6d56f17 --- /dev/null +++ b/contracts/examples/result-message-done.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "status": "done", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "result_s3": { + "bucket": "arm-ka", + "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" + }, + "result_schema_version": "1.0", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { + "duration_ms": 18400, + "pages": 2, + "fields_total": 4, + "fields_low_confidence": 0, + "fields_unrecognized": 0, + "warnings": [] + } +} diff --git a/contracts/examples/result-message-error.json b/contracts/examples/result-message-error.json new file mode 100644 index 0000000..f5ec77a --- /dev/null +++ b/contracts/examples/result-message-error.json @@ -0,0 +1,11 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "error", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + } +} diff --git a/contracts/examples/result-message-processing.json b/contracts/examples/result-message-processing.json new file mode 100644 index 0000000..2087150 --- /dev/null +++ b/contracts/examples/result-message-processing.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "processing", + "service": { "name": "tabular-parser", "version": "0.1.0" } +} diff --git a/contracts/examples/result-osv-minimal.json b/contracts/examples/result-osv-minimal.json new file mode 100644 index 0000000..6b6d122 --- /dev/null +++ b/contracts/examples/result-osv-minimal.json @@ -0,0 +1,79 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "done", + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": "7700000000", + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "C", + "opening_credit": "D", + "turnover_debit": "E", + "turnover_credit": "F", + "closing_debit": "G", + "closing_credit": "H" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик, ИНН 7711111111", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [], + "parent_sum_violations": [] + } + } + ] +} diff --git a/contracts/examples/result-statement-minimal.json b/contracts/examples/result-statement-minimal.json new file mode 100644 index 0000000..484f222 --- /dev/null +++ b/contracts/examples/result-statement-minimal.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "status": "done", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "documents": [ + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_confidence": 97.5, + "inn": "7700000000", + "inn_confidence": 99, + "report_date_text": "30 сентября 2025 г.", + "report_date": "2025-09-30", + "units": "тыс. руб." + }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { + "line_code": "1100", + "line_code_confidence": 99, + "line_name": "Итого по разделу I", + "line_name_confidence": 96, + "values": [ + { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, + { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } + ] + }, + { + "line_code": "1600", + "line_code_confidence": 99, + "line_name": "БАЛАНС (актив)", + "line_name_confidence": 98, + "values": [ + { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, + { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } + ] + } + ] + } + ] +} diff --git a/contracts/recognition-request.schema.json b/contracts/recognition-request.schema.json new file mode 100644 index 0000000..780573c --- /dev/null +++ b/contracts/recognition-request.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-request/v1", + "title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0", + "description": "Тело сообщения на subject recognition.tabular.request или recognition.image.request; subject должен соответствовать полю pipeline (tabular → recognition.tabular.request, image → recognition.image.request). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (stored_file) → один job.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "pipeline": { "enum": ["tabular", "image"], "description": "конвейер распознавания; определяет subject запроса" }, + "file_type": { "enum": ["XLS", "XLSX", "XLSB", "PDF", "PNG", "JPG"] }, + "original_name": { "type": "string", "minLength": 1, "description": "имя файла, как загрузил пользователь" }, + "s3": { + "type": "object", + "description": "исходный файл в роли uploads", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "reply_deadline": { "type": "string", "format": "date-time", "description": "срок ответа, UTC" }, + "attempt": { "type": "integer", "minimum": 1, "description": "номер попытки, с 1" } + } +} diff --git a/contracts/recognition-result-message.schema.json b/contracts/recognition-result-message.schema.json new file mode 100644 index 0000000..1f20727 --- /dev/null +++ b/contracts/recognition-result-message.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result-message/v1", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "description": "Тело сообщения на subject recognition.result. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "status", "service"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "status": { "enum": ["processing", "done", "needs_check", "error"] }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "result_s3": { + "type": "object", + "description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { "required": ["result_s3", "result_schema_version"] } + }, + { + "if": { + "properties": { "status": { "const": "error" } }, + "required": ["status"] + }, + "then": { "required": ["error"] } + } + ] +} diff --git a/contracts/recognition-result.schema.json b/contracts/recognition-result.schema.json index 010429d..c5eea87 100644 --- a/contracts/recognition-result.schema.json +++ b/contracts/recognition-result.schema.json @@ -1,12 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway) — ЧЕРНОВИК v1, уточнить в Срезе 2", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "const": "1.0" }, + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -21,7 +22,7 @@ "error": { "type": ["object", "null"], "properties": { - "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, "message": { "type": "string" } } }, @@ -43,6 +44,13 @@ "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } } }, + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + }, "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, "osvDocument": { @@ -131,9 +139,9 @@ "properties": { "form_name": { "type": "string" }, "organization_name": { "type": ["string", "null"] }, - "organization_name_confidence": { "type": ["number", "null"] }, + "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "inn": { "type": ["string", "null"] }, - "inn_confidence": { "type": ["number", "null"] }, + "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "report_date_text": { "type": ["string", "null"] }, "report_date": { "type": ["string", "null"], "format": "date" }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, @@ -163,9 +171,9 @@ "required": ["line_code", "line_name", "values"], "properties": { "line_code": { "type": "string" }, - "line_code_confidence": { "type": ["number", "null"] }, + "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "line_name": { "type": "string" }, - "line_name_confidence": { "type": ["number", "null"] }, + "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, "values": { "type": "array", "items": { diff --git a/dev/.env.example b/dev/.env.example new file mode 100644 index 0000000..c50f3ff --- /dev/null +++ b/dev/.env.example @@ -0,0 +1,60 @@ +# Локальное окружение разработки. Скопируйте в .env: cp .env.example .env (.env в git не попадает) +# Файл читают docker compose (из этого каталога) и, блоком ниже, сервисы при запуске из venv. + +# --- Переменные docker-compose: учётные данные, порты хоста, бакет --- +# Адрес привязки портов хоста: 127.0.0.1 — только эта машина; 0.0.0.0 — доступ с других машин (пароли по умолчанию dev) +BIND_ADDR=127.0.0.1 + +# PostgreSQL 16 (схема dkb создаётся миграциями arm-core, не compose) +POSTGRES_DB=armka +POSTGRES_USER=armka +POSTGRES_PASSWORD=armka +POSTGRES_PORT=5432 + +# NATS 2.10 без JetStream: клиентский порт и порт мониторинга (/varz, /healthz) +NATS_PORT=4222 +NATS_MONITOR_PORT=8222 + +# MinIO: учётная запись администратора, порты API и консоли, единственный бакет +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 +MINIO_BUCKET=arm-ka + +# Порты хоста сервисов профиля `services` (docker compose --profile services ...). Внутри контейнера каждый сервис +# слушает 8000; эти значения меняют только порт на хосте. Настройки сервисов в контейнерах compose задаёт сам +# (docker-compose.yml, x-service-env), блок ниже на них не влияет. +ARM_CORE_PORT=8000 +TABULAR_PARSER_PORT=8001 +SCAN_GATEWAY_PORT=8002 + +# --- Настройки сервисов при запуске из venv против compose по localhost (формат шаблона команды) --- +# Значения согласованы с блоком выше: при смене порта/пароля/бакета меняйте обе части. +# Если порт хоста изменён, поправьте и порты ниже. +DEBUG=true + +# PostgreSQL (драйвер asyncpg) +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka + +# MinIO: адрес без схемы (host:port) — так ждёт miniopy-async +OPENBAO__MINIO__MINIO_URL=localhost:9000 +OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin +OPENBAO__MINIO__MINIO_ADMIN_PASSWORD=minioadmin +OPENBAO__MINIO__MINIO_BUCKET=arm-ka + +# NATS без JetStream; пустые NATS_SEED/NATS_JWT — подключение без nkeys +OPENBAO__NATS__NATS_URL=nats://localhost:4222 +OPENBAO__NATS__NATS_SEED= +OPENBAO__NATS__NATS_JWT= + +# SMB — заглушки (в Срезе 0 не используется); блок необязателен для arm-core и scan-gateway (режим local). +# Блок OPENBAO__DATABASE__* знает только arm-core: scan-gateway и tabular-parser неизвестные ключи OPENBAO__* отвергают +# (extra_forbidden) — для scan-gateway удалите блок DATABASE, для tabular-parser удалите блоки DATABASE и SMB (см. dev/README.md) +OPENBAO__SMB__SMB_HOST=localhost +OPENBAO__SMB__SMB_PATH=share +OPENBAO__SMB__SMB_LOGIN=user +OPENBAO__SMB__SMB_PASSWORD=password + +# Включает подключение arm-core к NATS (без него /healthcheck показывает nats=off) +NATS_ENABLED=true diff --git a/dev/Makefile.common b/dev/Makefile.common new file mode 100644 index 0000000..f9be8ee --- /dev/null +++ b/dev/Makefile.common @@ -0,0 +1,75 @@ +# Эталонный фрагмент общих целей разработки для сервисов АРМ КА (arm-core, tabular-parser, scan-gateway). +# Описание целей и порядок проверок перед PR — dev/checks.md. Источник эталона: Makefile scan-gateway. +# +# Подключение (два способа, смысл одинаков): +# 1. include (в workspace и в worktree .wt/<срез>/ docs лежит рядом с сервисом): +# include ../docs/dev/Makefile.common +# Ставьте include ПОСЛЕ первой собственной цели сервиса или задайте `.DEFAULT_GOAL := check`, +# иначе целью по умолчанию станет `venv` (первая цель этого файла). +# 2. копирование: скопируйте содержимое в Makefile сервиса, не меняя рецепты общих целей. +# +# Запуск — из корня репозитория сервиса; рецепты сами переходят в api/ и используют окружение api/.venv. +# Все переменные заданы через `?=`: сервис переопределяет их до include (или в командной строке: make up COMPOSE_PROJECT=x). +# +# VENV каталог окружения относительно корня сервиса (api/.venv) +# BIN каталог исполняемых файлов окружения (абсолютный путь, строится от $(CURDIR)) +# MYPY_PATHS что проверяет mypy (src; у arm-core: src scripts) +# COMPOSE_FILE compose-файл инфраструктуры (../docs/dev/docker-compose.yml) +# COMPOSE_PROJECT имя compose-проекта; общее для всех сервисов workspace (arm-ka), поэтому целей down/stop/restart нет +# IMAGE тег docker-образа (по умолчанию — имя каталога сервиса) +# CHECK_FULL_STEPS цели `check-full` по порядку; сервис дописывает свои (arm-core: check dbml-check check-infra +# test-integration docker-build web-check). Собственные цели сервиса (migrate, seed, web-check ...) +# определяются в его Makefile, не здесь. +# +# Запускайте без `make -j`: шаги check-full вызываются последовательно через $(MAKE) и при -j, но `check: lint test` — +# пререквизиты, и при -j `lint` и `test` идут параллельно, поэтому остановка на первой упавшей проверке не гарантируется. + +VENV ?= api/.venv +BIN ?= $(CURDIR)/$(VENV)/bin +MYPY_PATHS ?= src +COMPOSE_FILE ?= ../docs/dev/docker-compose.yml +COMPOSE_PROJECT ?= arm-ka +IMAGE ?= $(notdir $(CURDIR)) +CHECK_FULL_STEPS ?= check test-integration docker-build + +# Перевод строки для $(foreach ...) в рецепте: каждый шаг становится отдельной строкой рецепта (две пустые строки обязательны) +define newline + + +endef + +.PHONY: venv lint test test-integration check docker-build check-full up + +# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их +venv: + cd api && uv venv --python 3.11 --allow-existing .venv + cd api && grep -v '^raisa_' requirements.txt | uv pip install --python .venv/bin/python -r - + cd api && grep -v '^-r' requirements-dev.txt | uv pip install --python .venv/bin/python -r - + +lint: + cd api && $(BIN)/ruff check . + cd api && $(BIN)/ruff format --check . + cd api && $(BIN)/mypy $(MYPY_PATHS) + +test: + cd api && $(BIN)/pytest -m "not integration" + +test-integration: + cd api && $(BIN)/pytest -m integration + +check: lint test + +# Образ собирается из корня репозитория; тесты в образе не запускаются +docker-build: + docker build -t $(IMAGE) . + +# Полная проверка перед PR. Шаги вызываются последовательно через $(MAKE), а не как пререквизиты: +# порядок и остановка на первой ошибке гарантированы и при make -j. +check-full: + $(foreach step,$(CHECK_FULL_STEPS),$(MAKE) $(step)$(newline)) + +# Общий compose-проект workspace. --wait — только со списком долгоживущих сервисов: одноразовый minio-init +# завершается кодом 0, и compose считает это сбоем. Бакет arm-ka создаётся отдельным идемпотентным шагом. +up: + docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) up -d --wait postgres nats minio + docker compose -f $(COMPOSE_FILE) --project-name $(COMPOSE_PROJECT) run --rm minio-init diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..e46fffb --- /dev/null +++ b/dev/README.md @@ -0,0 +1,430 @@ +# Окружение разработки АРМ КА (`docs/dev/`) + +Локальная инфраструктура workspace одной командой: PostgreSQL 16, NATS 2.10 без JetStream и MinIO с бакетом `arm-ka`. +Профиль `services` добавляет к ней сервисы системы, собранные из соседних репозиториев workspace: `arm-core`, +`tabular-parser`, `scan-gateway` и эмулятор ИС Сканирование `scan-emulator` (см. «Профиль `services`»). Без профиля +поднимается только инфраструктура, а сервисы можно запускать из venv (см. «Запуск сервисов из venv»). + +## Предварительные требования + +- Docker и compose v2 (проверено на docker 28 / compose 2.34). +- `uv` и Python 3.11 — для запуска сервисов из venv (версия Python — как на платформе). +- Свободные порты хоста (по умолчанию доступны только с `127.0.0.1`, см. `BIND_ADDR`): 5432 (PostgreSQL), 4222 и 8222 (NATS и его мониторинг), 9000 и 9001 (MinIO API и консоль); + для профиля `services` ещё 8000 (`arm-core`), 8001 (`tabular-parser`), 8002 (`scan-gateway`). + Занятые порты меняются в `.env` (см. «Типичные проблемы»). +- Для профиля `services`: соседние каталоги `../../arm-core`, `../../tabular-parser`, `../../scan-gateway` и + `../../fixtures` относительно `docs/dev/` (в workspace и в зеркальном worktree `.wt/<срез>/` это одни и те же соседи + `docs`). Сборка образов ставит зависимости из PyPI (строки `raisa_*` отфильтрованы, Nexus не нужен). + +## Запуск инфраструктуры + +Команды — из каталога `docs/dev/`: + +```bash +cp .env.example .env # .env в git не попадает +docker compose up -d # вернётся сразу; healthy — примерно через 6 с +``` + +Ожидание готовности (для скриптов и `Makefile`): + +```bash +docker compose up -d && docker compose up -d --wait postgres nats minio +``` + +Голый `docker compose up -d --wait` **возвращает код 1**: одноразовый `minio-init` (создаёт бакет и завершается с +кодом 0) compose 2.34 считает сбоем (`container arm-ka-minio-init-1 exited (0)`). Поэтому `--wait` нужно давать со +списком долгоживущих сервисов — как выше. + +Проверка: + +```bash +docker compose ps # postgres, nats, minio — Up (healthy); minio-init — Exited (0) +docker compose exec postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select version()"' +curl -s localhost:8222/healthz # {"status":"ok"} +curl -s localhost:8222/varz | grep -A1 jetstream # "jetstream": {} — JetStream не включён +docker compose run --rm --entrypoint sh minio-init -c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc ls local' + # [...] 0B arm-ka/ +``` + +Консоль MinIO — (по умолчанию `minioadmin` / `minioadmin`); S3 API — `localhost:9000`. +Схема БД (`dkb`) создаётся миграциями `arm-core`, не compose. + +## Профиль `services` + +Сервисы системы в контейнерах против той же инфраструктуры. Образы собираются из `Dockerfile` в корне соседних +репозиториев (`../../arm-core`, `../../tabular-parser`, `../../scan-gateway`) и получают теги `arm-core:dev`, +`tabular-parser:dev`, `scan-gateway:dev`. Настройки сервисов задаёт сам compose (переменные `OPENBAO__*` шаблона с +адресами внутри сети compose — `postgres:5432`, `nats:4222`, `minio:9000`, `DEBUG=true`); второй блок `.env` и +`api/.env` сервисов на контейнеры не влияют. + +Команды — из каталога `docs/dev/`: + +```bash +cp .env.example .env # если ещё не сделано +docker compose --profile services up -d --build # сборка образов и запуск; первая сборка — около минуты +docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator # ждать healthy +``` + +| Сервис | Порт хоста (`.env`) | Порт в контейнере | Что делает | +|---|---|---|---| +| `arm-core` | 8000 (`ARM_CORE_PORT`) | 8000 | ядро: API, outbox/inbox `recognition_job`, подписка `recognition.result` | +| `tabular-parser` | 8001 (`TABULAR_PARSER_PORT`) | 8000 | подписка `recognition.tabular.request` (queue group `tabular-parser`) | +| `scan-gateway` | 8002 (`SCAN_GATEWAY_PORT`) | 8000 | подписка `recognition.image.request` (queue group `scan-gateway`), горячая папка — том `hotfolder` | +| `scan-emulator` | — | — | эмулятор ИС Сканирование: следит за `Input/` тома `hotfolder`, ответы — из `../../fixtures/ocr` (только чтение) | +| `arm-core-migrate` | — | — | одноразовый: `alembic upgrade head` и `python -m scripts.seed --dev`, затем `arm-core` стартует | + +Все три сервиса внутри контейнера слушают **8000** (значение по умолчанию `SERVER__PORT`); различаются только порты +хоста. Сервис, запущенный из venv, по умолчанию тоже слушает 8000 на хосте — он конфликтует с контейнером `arm-core` +(остановите контейнер или задайте `SERVER__PORT` в `api/.env`). + +Порядок старта задан `depends_on`: `arm-core-migrate` ждёт healthy `postgres`; `arm-core` — `postgres`, `nats`, `minio`, +успешного завершения `minio-init` и `arm-core-migrate`; `tabular-parser` и `scan-gateway` — `nats`, `minio` и +`minio-init`. Одноразовые `minio-init` и `arm-core-migrate` выполняются при каждом `up` профиля (миграции и seed +идемпотентны: повторный seed пишет `0 / 0 / N` — без изменений) и остаются в `ps -a` как `Exited (0)`. + +Проверка (фактический вывод на чистых томах, 2026-10-04, сокращён): + +```bash +docker compose --profile services ps -a +# arm-core Up (healthy) 127.0.0.1:8000->8000/tcp +# arm-core-migrate Exited (0) +# minio-init Exited (0) +# scan-emulator Up 8000/tcp (healthcheck нет: процесс без HTTP) +# scan-gateway Up (healthy) 127.0.0.1:8002->8000/tcp +# tabular-parser Up (healthy) 127.0.0.1:8001->8000/tcp +curl -s localhost:8000/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"} +curl -s localhost:8001/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"} +curl -s localhost:8002/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","hotfolder":"ok"} +docker compose --profile services logs arm-core-migrate # Running upgrade -> 0001_dbml_v5_13 …; Seed применён … +docker compose --profile services logs scan-emulator # … корень /data/hotfolder …; Фикстуры: /fixtures/ocr, фикстур: 2 +``` + +`/healthcheck` всех трёх сервисов **всегда отвечает 200**, поэтому healthcheck контейнеров читает поля ответа: +`nats = ok` (и `hotfolder = ok` у `scan-gateway`). Статус `healthy` означает, что сервис подключён к NATS. + +Dev-токен `arm-core` (в compose включён dev-эмитент, `AUTH_DEV_ISSUER=true`, `DEBUG=true`; пользователи — из +`seed --dev`, см. `../../arm-core/README.md`): + +```bash +curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' -d '{"email":"ka_rf@armka.local"}' +curl -s localhost:8000/api/v1/me -H "Authorization: Bearer " # email, роли KA_RF, права +``` + +Образ `arm-core` содержит только бэкенд: фронт (`web/`) в `Dockerfile` не собирается. `GET /` отвечает **307** с +редиректом на `//`, а тот — 404 (`curl -L` показывает 404 на `http://127.0.0.1:8000//`; редирект на `//` — особенность +`arm-core`, вынесена в его follow-up). UI — `npm run dev` из `../../arm-core/web` (см. README `arm-core`). + +**Ожидание готовности и коды возврата `--wait`** (compose 2.34, проверено 2026-10-04): + +| Команда | Код | Комментарий | +|---|---|---| +| `docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator` | 0 | рекомендуемая форма | +| `docker compose --profile services up -d --wait scan-gateway scan-emulator` | 0 | и на чистых томах, и на работающем окружении: `minio-init` поднимается как зависимость `scan-gateway` (`service_completed_successfully`) и его выход с кодом 0 сбоем не считается | +| `docker compose --profile services up -d --wait` (без списка) | 0 | все одноразовые контейнеры профиля — зависимости с `service_completed_successfully` | +| `docker compose up -d --wait` (без профиля и списка) | 1 | `container arm-ka-minio-init-1 exited (0)`: без профиля у `minio-init` нет зависимых сервисов | + +Для скриптов указывайте долгоживущие сервисы явно — форма не зависит от состава профиля. + +**Пересборка после изменения кода сервиса:** `docker compose --profile services up -d --build` — пересобирает образы +и пересоздаёт все контейнеры, чей образ сменился. Образ `arm-core:dev` собирает только сервис `arm-core`, образ +`scan-gateway:dev` — только `scan-gateway`; `arm-core-migrate` и `scan-emulator` используют готовый тег +(`pull_policy: never`): две сборки одного тега давали разные ID образа, и каждый следующий `up` пересоздавал контейнеры. +Поэтому `arm-core-migrate` или `scan-emulator` в одиночку (`up -d scan-emulator`) на машине без образа не стартуют — +сначала соберите его (`docker compose --profile services build scan-gateway`) или поднимайте профиль целиком. + +Форма `up -d --build <сервис>` пересоздаёт только сервис и его зависимости (проверено 2026-10-04 на образе, +пересобранном с `--no-cache`): + +| Команда | Что пересоздано | Что осталось на старом образе | +|---|---|---| +| `up -d --build arm-core` | `arm-core` и `arm-core-migrate` (он — зависимость `arm-core`, миграции нового кода выполняются) | — | +| `up -d --build scan-gateway` | только `scan-gateway` | **`scan-emulator`** (не зависимость шлюза) — до следующего `docker compose --profile services up -d`, который его пересоздаёт | + +После правок кода `scan-gateway`, затрагивающих эмулятор, пересобирайте профиль целиком. + +**Остановка и сброс профиля** — только с `--profile services`: без него compose не видит контейнеры профиля +(см. «Сброс окружения»). + +## Проверка окружения (сквозной сценарий) + +Ручная проверка обмена `recognition_job` через NATS на поднятом профиле `services`: ядро публикует задание, сервис +отвечает ack и результатом, ядро меняет статус. UI и API загрузки файлов в Срезе 0 нет, поэтому файл кладётся в MinIO +через `mc`, а строки `legal_entity` / `s3_object` / `stored_file` / `recognition_job` вставляются через `psql`. Проверено 2026-10-04 +(все три сценария ниже — фактический вывод, время UTC). + +Помощники (bash/zsh, из `docs/dev/`; `uuidgen` есть в macOS и util-linux): + +```bash +dpsql() { docker compose exec -T postgres sh -c 'psql -X -q -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" "$@"' sh "$@"; } +dmc() { docker compose run --rm -v "$PWD/../../fixtures:/fixtures:ro" --entrypoint sh minio-init \ + -c 'mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && mc "$@"' sh "$@"; } +new_job() { # new_job <файл в fixtures> — кладёт файл в uploads/ и ставит задание queued + F=$(uuidgen | tr A-Z a-z); JOB=$(uuidgen | tr A-Z a-z); O=$(uuidgen | tr A-Z a-z); N=$(basename "$3") + dmc cp -q "/fixtures/$3" "local/arm-ka/uploads/$F/$JOB/$N" >/dev/null || return 1 + dpsql < — статус задания $JOB раз в секунду, до финального статуса + for _ in $(seq "$1"); do + s=$(dpsql -At -c "select status || ' ' || coalesce(error_code, '') || ' ' || coalesce(metrics->>'republish_count', '') from dkb.recognition_job where id = '$JOB'") + [ "$s" != "$p" ] && echo "$(date -u +%T) $s" && p=$s + case $s in error*|done*|needs_check*|timeout*|dlq*) p=; return;; esac + sleep 1 + done; p= +} +``` + +Рецепт прогнан дословно на чистой БД (вставка `legal_entity` без конфликта по `inn`) и повторно при уже существующей +строке (`on conflict (inn) do nothing`); оба пути проверены. + +Ключ исходника — `uploads/{file_id}/{job_id}/{имя}` (`../context/03-architecture.md`, «Схема ключей S3»). Сообщения на +шине можно смотреть параллельно: `docker run --rm --network arm-ka_default natsio/nats-box:0.17.0 nats sub -s +nats://nats:4222 'recognition.>'` (печатает `Nats-Msg-Id` и тело каждого запроса, ack и результата). + +**1. `tabular` → `NOT_IMPLEMENTED`.** Заглушка парсера Среза 0 отвечает ошибкой на любой файл: + +```bash +new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 30 +# JOB=e585f47b-1b38-44a6-84c5-9432c7e6d194 +# 02:12:15 queued +# 02:12:20 error NOT_IMPLEMENTED +dpsql -c "select status, error_code, error_message, published_at::time(3), started_at::time(3), finished_at::time(3) from dkb.recognition_job where id = '$JOB'" +# error | NOT_IMPLEMENTED | разбор XLS/XLSX/XLSB реализуется в Срезе 2 | 02:12:18.98 | 02:12:19.021 | 02:12:19.024 +``` + +`published → processing → error` проходит за десятки миллисекунд (в прогонах 14–51 мс), опросом его не поймать; последовательность видна по +`published_at < started_at < finished_at` (`started_at` пишет ack), в логе `arm-core` («Запрос распознавания +опубликован» → «Сервис подтвердил получение запроса (ack): задание в processing» → «Результат распознавания принят … +error_code=NOT_IMPLEMENTED») и на шине: `recognition.tabular.request` (`Nats-Msg-Id: :1`) → +`recognition.result` `ack::1` (`status: processing`) → `result::1` (`status: error`). Публикатор +опрашивает outbox раз в 5 с, поэтому от вставки до публикации проходит до 5 с. + +**2. `image` → эмулятор ИС → `INVALID_DOCUMENT`, сырой ответ в `raw/`:** + +```bash +new_job image PDF ocr/aep_5.PDF && poll 30 +# JOB=46b0c90b-5a1b-4ac0-9e8a-0cdad80d857e +# 02:12:27 queued +# 02:12:29 processing +# 02:12:34 error INVALID_DOCUMENT +dmc ls local/arm-ka/raw/ +# [2026-10-04 11:23:58 UTC] 600B STANDARD 0ea16933-4cc1-4b50-bda9-a97089a918c5.json +dmc cat "local/arm-ka/raw/$JOB.json" # {"job_id": "", "files": [{"name": ".json", "response": {"file_uid": "", "error": {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."}, "data_ocr": {…}}, "parse_error": null}]} +``` + +Код `INVALID_DOCUMENT` здесь — **настоящий ответ ИС из фикстуры**: `aep_5.json` и `aep_8_form4.json` — ответы-ошибки +ИС Сканирование («Не удалось определить тип документа.», пустой `table_structure`); у `aep_3` и `aep_6` есть успешные +ответы (сценарий 2а). Эмулятор нашёл набор ответов (в логе `scan-emulator`: `_aep_5.PDF -> +/data/hotfolder/Output/`; при отсутствии набора сообщение было бы «…: нет эталонного ответа в фикстурах»). +Успешные ответы ИС есть (`aep_3.json` + `aep_3_ofr.json`, `aep_6.json`); шлюз склеивает все `*.json` каталога ответа и +отвечает `NOT_IMPLEMENTED` (нормализация — Срез 3), ошибочные файлы набора — в `metrics.warnings`. Имя файла — `aep_5.PDF` (расширение в верхнем регистре; на macOS `aep_5.pdf` тоже находится, на +Linux — нет). +`processing` виден ~10 с: задержка эмулятора 2 с, затем до `SCAN_POLL_SECONDS + SCAN_SETTLE_SECONDS` (5 + 2 с) после +первого опроса, увидевшего набор. + +**2а. `image` → успешный ответ ИС → `NOT_IMPLEMENTED`:** + +```bash +new_job image PDF ocr/aep_3.pdf && poll 60 +# 11:22:07 queued +# 11:22:08 processing +# 11:22:18 error NOT_IMPLEMENTED +dpsql -c "select status, error_code, metrics from dkb.recognition_job where id = '$JOB'" +# error | NOT_IMPLEMENTED | {"warnings": ["_ofr.json: INVALID_DOCUMENT: Не удалось определить тип документа."]} +new_job image PDF ocr/aep_6.pdf && poll 60 +# error NOT_IMPLEMENTED, metrics пусто +``` + +**3. Сервис недоступен → три повторные публикации → `dlq` / `NO_ACK`:** + +```bash +docker compose stop tabular-parser # имя сервиса указано явно — профиль не нужен +new_job tabular XLSX "osv/schastl/2025-Q3/Оборотносальдовая ведомость по счету 60 за 3 квартал 2025 г..xlsx" && poll 180 +# 02:12:43 queued +# 02:12:44 published +# 02:13:14 published 1 ← republish_count: повтор через ACK_TIMEOUT_SECONDS = 30 с, тот же Nats-Msg-Id :1 +# 02:13:45 published 2 +# 02:14:14 published 3 +# 02:14:44 dlq NO_ACK 3 ← ещё через 30 с после третьего повтора (OUTBOX_MAX_REPUBLISH = 3) +dpsql -c "select status, error_code, error_message, metrics, request_msg_id from dkb.recognition_job where id = '$JOB'" +# dlq | NO_ACK | Сервис распознавания не подтвердил получение запроса (ack) после 3 повторных публикаций | {"republish_count": 3} | :1 +docker compose --profile services up -d --wait tabular-parser # вернуть сервис; rc=0, Healthy +``` + +От первой публикации до `dlq` — ~120 с (4 × 30 с). `published_at` при каждом повторе перезаписывается (время последней +публикации), `attempt` остаётся 1, `started_at` и `service_name` пусты. Задание в `dlq` после возврата сервиса само не +перезапускается (replay — вне Среза 0). + +Что сквозная проверка **не** покрывает (Срез 0): успешные результаты (`done`/`needs_check`, регистрация файла +`results/…` в `s3_object`) — заглушка парсера и нормализатор шлюза Среза 0 (`NOT_IMPLEMENTED`) дают только ошибки, +хотя фикстуры ИС (`aep_3`, `aep_6`) успешные ответы содержат; `stored_file.status` остаётся +`UPLOADED`, `current_recognition_job_id` не заполняется (маппинг результата — `NoopMapper`, Срез 2); у ошибок +`scan-gateway` в `recognition_job.metrics` только `warnings` — по строке `<имя файла>: <код>: <сообщение>` на ошибочный +файл ответа ИС, если такие есть (`tabular-parser` присылает `duration_ms`). Тестовые строки и объекты остаются в БД и +MinIO; полная очистка — `docker compose --profile services down -v` (удаляет и общие тома `postgres-data`/`minio-data` +всех worktree: проект compose `arm-ka` общий). + +## Переменные `.env` + +Файл читают `docker compose` (из этого каталога) и, вторым блоком, сервисы при запуске из venv. Значения по умолчанию — +только для dev. + +Блок compose: + +| Переменная | По умолчанию | Для чего | +|---|---|---| +| `BIND_ADDR` | `127.0.0.1` | адрес привязки всех портов хоста; `0.0.0.0` — доступ с других машин (небезопасно, см. «Примечания») | +| `POSTGRES_DB` | `armka` | имя БД | +| `POSTGRES_USER` | `armka` | пользователь БД | +| `POSTGRES_PASSWORD` | `armka` | пароль БД | +| `POSTGRES_PORT` | `5432` | порт PostgreSQL на хосте | +| `NATS_PORT` | `4222` | клиентский порт NATS на хосте | +| `NATS_MONITOR_PORT` | `8222` | порт мониторинга NATS (`/varz`, `/healthz`) | +| `MINIO_ROOT_USER` | `minioadmin` | администратор MinIO | +| `MINIO_ROOT_PASSWORD` | `minioadmin` | пароль администратора MinIO | +| `MINIO_API_PORT` | `9000` | порт S3 API на хосте | +| `MINIO_CONSOLE_PORT` | `9001` | порт консоли MinIO на хосте | +| `MINIO_BUCKET` | `arm-ka` | единственный бакет, создаётся `minio-init` | +| `ARM_CORE_PORT` | `8000` | порт хоста `arm-core` (профиль `services`; в контейнере 8000) | +| `TABULAR_PARSER_PORT` | `8001` | порт хоста `tabular-parser` (профиль `services`; в контейнере 8000) | +| `SCAN_GATEWAY_PORT` | `8002` | порт хоста `scan-gateway` (профиль `services`; в контейнере 8000) | + +Блок сервисов (запуск из venv против compose по `localhost`, формат шаблона команды): + +| Переменная | По умолчанию | Для чего | +|---|---|---| +| `DEBUG` | `true` | dev-режим: пакеты `raisa_*` импортируются условно | +| `OPENBAO__DATABASE__DATABASE_URL` | `postgresql+asyncpg://armka:armka@localhost:5432/armka` | подключение к PostgreSQL (asyncpg) | +| `OPENBAO__MINIO__MINIO_URL` | `localhost:9000` | адрес MinIO без схемы (`host:port`, так ждёт `miniopy-async`) | +| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | `minioadmin` | логин MinIO | +| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | `minioadmin` | пароль MinIO | +| `OPENBAO__MINIO__MINIO_BUCKET` | `arm-ka` | бакет | +| `OPENBAO__NATS__NATS_URL` | `nats://localhost:4222` | адрес NATS | +| `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без nkeys | +| `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; пусто — подключение без nkeys | +| `OPENBAO__SMB__SMB_HOST` | `localhost` | заглушка SMB (в Срезе 0 не используется); блок необязателен для `arm-core` и для `scan-gateway` в режиме `SCAN_HOTFOLDER_MODE=local` (по умолчанию); `tabular-parser` его не знает и отвергнет (см. ниже) | +| `OPENBAO__SMB__SMB_PATH` | `share` | заглушка SMB | +| `OPENBAO__SMB__SMB_LOGIN` | `user` | заглушка SMB | +| `OPENBAO__SMB__SMB_PASSWORD` | `password` | заглушка SMB | +| `NATS_ENABLED` | `true` | включает подключение arm-core к NATS; без него `/healthcheck` показывает `nats=off` | + +Два блока описывают одни и те же ресурсы: **при смене порта, пароля, пользователя или бакета меняйте обе части.** + +## Сброс окружения + +```bash +docker compose --profile services down # останавливает все контейнеры проекта; тома сохраняются +docker compose --profile services down -v # полный сброс: тома postgres-data, minio-data и hotfolder (БД, бакет, горячая папка — с нуля) +``` + +Без `--profile services` команда `down` (с `-v` или без) **не трогает** контейнеры профиля: проверено — после +`docker compose down -v` контейнеры `arm-core`/`tabular-parser`/`scan-gateway`/`scan-emulator` продолжают работать, +том `hotfolder` и сеть `arm-ka_default` остаются (`Network arm-ka_default Resource is still in use`). Если профиль не +поднимался, `docker compose down [-v]` достаточно. + +Имя проекта compose фиксировано (`name: arm-ka`), поэтому тома общие для любого worktree (`.wt/<срез>/docs/dev`): +сброс из одного worktree затрагивает все. + +## Запуск сервисов из venv без Nexus (черновик) + +> Черновик: запуск сервисов из venv в Срезе 0 не проверялся (сквозная проверка шла на профиле `services`); шаги ниже +> не прогонялись целиком. Сценарий spec `dev-environment` остаётся неподтверждённым — вопрос +> [OQ-84](../context/07-open-questions.md), решение design #43. `make venv` и `make test-integration` есть во всех трёх +> сервисах. + +Пакеты `raisa_*` из Nexus банка вне банка недоступны (D-10). Поэтому `make venv` ставит `requirements.txt` без строк +`raisa_*` (фильтр `grep -v '^raisa_'`), код импортирует `raisa_*` условно (при `DEBUG=true`), а npm-пакеты `raisa-*` +в `web/package.json` вынесены в `optionalDependencies`. + +Шаги для сервиса (пути — относительно каталога его репозитория; в workspace `docs/` лежит рядом, `../docs/dev/`): + +```bash +# 1. поднять инфраструктуру (из docs/dev/, см. выше) +# 2. из корня репозитория сервиса: +cp ../docs/dev/.env.example api/.env +make venv # api/.venv: Python 3.11 + зависимости без raisa_* +cd api && .venv/bin/python run_server.py # сервис подключается к PostgreSQL/NATS/MinIO по localhost +# 3. в другом терминале: +curl -s localhost:<порт сервиса>/healthcheck +``` + +Из venv каждый сервис слушает порт из своих настроек (`SERVER__PORT`, по умолчанию **8000** у всех трёх) — порты +хоста 8001/8002 относятся только к контейнерам профиля `services` (см. таблицу портов выше). Два сервиса из venv +одновременно или сервис из venv рядом с профилем `services` требуют разных `SERVER__PORT`. Для `arm-core` в ответе +`/healthcheck` ожидаются `version` и `nats = ok` — при `NATS_ENABLED=true` (задан в `.env.example`; без него +`nats = off`). Поля `/healthcheck` в контейнерах: `tabular-parser` — `uptime`, `version`, `nats`; `scan-gateway` — ещё +`hotfolder` (шлюзу нужен `SCAN_HOTFOLDER_PATH`). Набор переменных сервисов для запуска из venv не проверялся (см. черновик выше и OQ-84). По чтению конфигов: `scan-gateway` и `tabular-parser` отвергают неизвестные ключи `OPENBAO__*`, а поле DATABASE есть только у `arm-core`. Поэтому в `.env` `scan-gateway` нужно удалить строки `OPENBAO__DATABASE__*` (SMB в режиме `local` допустим), а в `.env` `tabular-parser` — `OPENBAO__DATABASE__*` и `OPENBAO__SMB__*`; для MinIO по `http` нужен `OPENBAO__MINIO__MINIO_SECURE=false` (в `.env.example` его нет, по умолчанию `true`). + +## Интеграционные тесты сервисов против compose (черновик) + +> Черновик: состав целей и порядок проверок описаны в [`checks.md`](checks.md); открытые вопросы — OQ-83 (общий NATS +> у контейнеров и тестов) и OQ-84. + +Общий набор целей (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`) есть во всех сервисных репозиториях +(`arm-core`, `tabular-parser`, `scan-gateway`); семантика, состав `check-full`, порядок перед PR и пропуск тестов без +`docs`/`fixtures` описаны в [`checks.md`](checks.md); эталон рецептов общих целей — [`Makefile.common`](Makefile.common). Кратко: `make check` — быстрые проверки без Docker, `make check-full` — +`check` + интеграционные тесты против этого compose + образ (для `arm-core` ещё сверка с DBML, миграции и `web`); +выполняется перед созданием PR. + +Интеграционные тесты **требуют поднятой инфраструктуры**: сначала `docker compose up -d && docker compose up -d --wait +postgres nats minio`, затем `make test-integration` (или `make check-full`) в репозитории сервиса. Для `scan-gateway` +эмулятор ИС Сканирование поднимается в процессе интеграционного теста; контейнер `scan-emulator` профиля `services` +тестами не используется. Общее описание проверок — [`checks.md`](checks.md). + +> **Не запускайте интеграционные тесты `tabular-parser` и `scan-gateway` при поднятом профиле `services`.** Их +> сквозные тесты (`api/tests/integration/test_roundtrip.py`, у `scan-gateway` ещё `test_nats_minio_integration.py`) +> публикуют запросы в боевые `recognition.tabular.request` / `recognition.image.request`, а экземпляр сервиса из теста +> подписан в ту же queue group (`tabular-parser` / `scan-gateway`), что и контейнер. NATS отдаёт каждое сообщение группы +> одному подписчику — запрос теста может уйти контейнеру, и тест упадёт по таймауту ожидания ack (так прямо сказано в +> docstring теста `scan-gateway`). Риск подтверждён чтением кода тестов, тесты при поднятом профиле не запускались. +> Интеграционные тесты `arm-core` от контейнеров изолированы: временная БД на сессию (рабочая `armka` не используется) +> и уникальный subject результатов на тест. Перед тестами сервисов: +> `docker compose --profile services stop tabular-parser scan-gateway scan-emulator`; проверить, что чужих подписчиков +> нет: `curl -s 'http://127.0.0.1:8222/subsz?subs=1' | grep -o '"subject": *"recognition[^"]*"'` — строк +> `recognition.tabular.request` / `recognition.image.request` быть не должно (`recognition.result` — подписка контейнера +> `arm-core`, тестам `arm-core` не мешает). + +## Проверка контракта + +Из корня репозитория `docs`: `python scripts/validate_contracts.py` — валидирует примеры в +[`contracts/examples/`](../contracts/examples/) тремя схемами из [`contracts/`](../contracts/) (3 схемы, 8 примеров; +нужен только пакет `jsonschema`: формат `date-time` скрипт проверяет собственной функцией, поэтому `rfc3339-validator` +не требуется). Или `make venv && make check` (`make venv` создаёт `.venv` и ставит `jsonschema`; `check` использует этот +`.venv` и ещё запускает `openspec validate`), см. [`checks.md`](checks.md). + +## Примечания + +- **Образы MinIO неофициальные.** Официальные `minio/minio` и `minio/mc` недоступны (Docker Hub — denied, на quay.io нет + нужных тегов), поэтому в compose стоят `alpine/minio:RELEASE.2025-10-15T17-29-55Z` и + `bitnamilegacy/minio-client:2025.7.21`, а сервис `minio` запущен с `user: "0:0"` (иначе образ не пишет в свежий том) — + только для dev. Решения: [`design.md`](../openspec/changes/slice-0-skeleton/design.md), раздел + «Decisions made without discussion», решения №16–17. Замена на официальные образы — две строки `image:` в + `docker-compose.yml` (и удаление `user: "0:0"`). +- **Безопасность.** По умолчанию порты публикуются только на `127.0.0.1` (`BIND_ADDR`), снаружи машины они недоступны. + Чтобы открыть их для других машин, задайте в `.env` `BIND_ADDR=0.0.0.0` — но пароли по умолчанию (`armka`, + `minioadmin`) годятся только для dev, поэтому сначала смените их (в обоих блоках `.env`) и не делайте этого в + недоверенной сети. + +## Типичные проблемы + +- **Порт занят** (`address already in use`): измените `*_PORT` в `.env` и одновременно соответствующий порт в + `OPENBAO__*` (`DATABASE_URL`, `MINIO_URL`, `NATS_URL`); перезапустите `docker compose up -d`. Для портов сервисов + (`ARM_CORE_PORT`, `TABULAR_PARSER_PORT`, `SCAN_GATEWAY_PORT`) достаточно `.env`. +- **Сервис профиля не становится healthy:** `docker compose --profile services logs <сервис>`. `nats = down` — + NATS недоступен; `hotfolder = down` у `scan-gateway` — каталог `SCAN_HOTFOLDER_PATH` (том `hotfolder`) недоступен. `arm-core` не стартует, пока + `arm-core-migrate` не завершился с кодом 0: `docker compose --profile services logs arm-core-migrate`. +- **`No such image: arm-core:dev` / `scan-gateway:dev`** при запуске `arm-core-migrate` или `scan-emulator` по + отдельности: образ ещё не собран — `docker compose --profile services build` (см. «Профиль `services`»). +- **`minio-init` завис или завершился с ошибкой:** `docker compose logs minio-init`; чаще всего `minio` не стал healthy — + `docker compose logs minio`. Повторный `docker compose up -d` идемпотентен. +- **Остаточные тома от прошлых запусков** (старый пароль БД, бакет не тот): `docker compose --profile services down -v` + и запуск заново (без `--profile services` контейнеры профиля и том `hotfolder` остаются, см. «Сброс окружения»). +- **`up -d --wait` возвращает код 1:** см. раздел «Запуск инфраструктуры» — указывайте сервисы явно. diff --git a/dev/checks.md b/dev/checks.md new file mode 100644 index 0000000..37176ff --- /dev/null +++ b/dev/checks.md @@ -0,0 +1,181 @@ +# Проверки качества: цели `make` и порядок перед PR + +Конфигураций CI в репозиториях нет (в Gitea CI не используется, пайплайн платформы банка вне зоны проекта), поэтому +проверки выполняются локально одной командой перед каждым PR. Требования — spec `quality-checks` +(`openspec/changes/slice-0-skeleton/specs/quality-checks/spec.md`), решение D-8. + +Все цели запускаются **из корня репозитория сервиса** (`arm-core`, `tabular-parser`, `scan-gateway`); рецепты сами +переходят в `api/` и используют окружение `api/.venv`. Инфраструктура compose — `docs/dev/docker-compose.yml` +(см. [`README.md`](README.md)); в workspace и в worktree `.wt/<срез>/` она лежит рядом как `../docs/dev/`. + +## Общие цели + +Одинаковый набор целей во всех трёх сервисных репозиториях; семантика, описанная ниже, — эталонная (реализации +расходятся в деталях, см. «Известные расхождения реализаций»). Репозиторий вправе добавлять свои цели (см. ниже), но +не меняет смысл общих. + +| Цель | Что делает | Нужен Docker / compose | +|---|---|---| +| `make venv` | `api/.venv` на Python 3.11 через `uv`; ставит `requirements.txt` без строк `raisa_*` (пакеты Nexus банка вне банка недоступны, D-10) и `requirements-dev.txt` | нет | +| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` (в `arm-core` — `mypy src scripts`) | нет | +| `make test` | `pytest -m "not integration"`: юнит-тесты; **включает тест идентичности вендоренного контракта** `api/contracts/` с `../docs/contracts` | нет | +| `make test-integration` | `pytest -m integration` против инфраструктуры compose (PostgreSQL 16, NATS, MinIO; бакет `arm-ka` должен существовать, его создаёт `make up`) | да, `make up` | +| `make check` | `make lint` затем `make test`; останавливается на первой упавшей проверке (только при последовательном запуске: `lint` и `test` — пререквизиты, при `make -j` они идут параллельно) | нет | +| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker | +| `make up` | поднимает инфраструктуру из `../docs/dev/docker-compose.yml`; постоянные сервисы `postgres`, `nats`, `minio` | Docker | + +Сводка шагов `make check`: `ruff check .` → `ruff format --check .` → `mypy src` (в `arm-core` — `mypy src scripts`) → +`pytest -m "not integration"`. +Любой ненулевой код останавливает цель на этом шаге, следующие не выполняются. Запускайте без `-j`: при параллельной сборке шаги идут одновременно. `make check` не требует Docker, +PostgreSQL, NATS и сети. + +### `make up` + +Поднимает compose-проект `arm-ka` (имя зафиксировано в compose, поэтому тома общие для любого worktree): + +```bash +docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka up -d --wait postgres nats minio +docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm minio-init # бакет arm-ka (идемпотентно) +``` + +Почему так: голый `docker compose up -d --wait` возвращает код 1 (одноразовый `minio-init` завершается кодом 0, compose +считает это сбоем), поэтому `--wait` даётся со списком долгоживущих сервисов, а бакет создаётся отдельным шагом. Без +бакета интеграционные тесты на свежем томе падают на `NoSuchBucket`. Целей `down`/`stop`/`restart` нет намеренно: проект +`arm-ka` общий для всех сервисов; остановка и сброс — вручную (`docker compose --profile services down`, при сбросе данных `down -v` с тем же `--profile services`, +иначе контейнеры сервисов останутся), см. `README.md`. + +> Эталонная реализация `make up` — в `scan-gateway`. Реализации в `arm-core` и `tabular-parser` отличаются +> (см. «Известные расхождения реализаций»). + +### Состав `make check-full` по репозиториям + +| Репозиторий | Шаги по порядку | +|---|---| +| `scan-gateway` | `check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста | +| `tabular-parser` | `check` → `test-integration` → `docker-build` (образ `tabular-parser`) | +| `arm-core` | `check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` | + +Для `arm-core` дополнительно: + +- `dbml-check` — `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml` (путь от `api/`; + другой — `make dbml-check DBML=<путь относительно api/>`): ORM обязана совпадать с DBML; расхождения печатаются + построчно, код 1. +- Цикл миграций «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой БД, пустой + autogenerate и сверка схемы с `create_all` ORM выполняет `tests/test_migrations.py`; он входит в `test-integration`, + поэтому отдельным шагом в `check-full` не гоняется. Для быстрой проверки одних миграций: `make migration-cycle`. +- `check-infra` — TCP-проверка доступности PostgreSQL и NATS из `api/.env.test`; вместо длинного трейсбека pytest + подсказывает «поднимите инфраструктуру: `make up`». +- `web-check` — `npm install`, `biome check src`, `tsc --noEmit`, `npm run build` в `web/`. + +Дополнительные цели репозиториев (не общие): `arm-core` — `migrate`, `seed`, `seed-dev`, `dbml-check`, +`migration-cycle`, `check-infra`, `docker-build`, `web-check`; `scan-gateway` — `help`, `docker-build`, `emulator`; +`tabular-parser` — `docker-build`. + +## Эталонный фрагмент `dev/Makefile.common` + +Рецепты общих целей вынесены в [`Makefile.common`](Makefile.common) — это эталон (источник — `Makefile` `scan-gateway`), +с которым сервис сверяется `diff`ом или который подключает напрямую: + +```make +# Makefile сервиса: свои переменные и цели; цель по умолчанию задана явно, иначе ею станет первая цель include (venv) +.DEFAULT_GOAL := check +MYPY_PATHS := src scripts # arm-core; по умолчанию src +CHECK_FULL_STEPS := check dbml-check check-infra test-integration docker-build web-check # arm-core +include ../docs/dev/Makefile.common +``` + +Переменные (все `?=`): `VENV`, `BIN`, `MYPY_PATHS`, `COMPOSE_FILE`, `COMPOSE_PROJECT`, `IMAGE` (по умолчанию имя каталога +сервиса; у `scan-gateway` — `scan-gateway:dev`), `CHECK_FULL_STEPS` (по умолчанию `check test-integration docker-build`). +Определяет цели `venv`, `lint`, `test`, `test-integration`, `check`, `docker-build`, `check-full`, `up`. +Цели, специфичные для сервиса (`migrate`, `seed`, `dbml-check`, `check-infra`, `web-check`, `emulator`, `help`), +остаются в его `Makefile`. + +Проверка «`make check` вызывает одинаковые команды»: `make -n <цель>` в каждом сервисе и в Makefile, подключающем +`Makefile.common` с теми же переменными, даёт одинаковый вывод. Текущее состояние (Срез 0, сервисы не менялись): +`scan-gateway` совпадает с эталоном полностью; `arm-core` и `tabular-parser` — по `venv`, `lint`, `test`, +`test-integration`, `check`, `docker-build` и составу команд `check-full`, но не по `up` и по структуре `check-full` +(пререквизиты вместо вызовов `$(MAKE)`); см. «Известные расхождения реализаций». Сервисы переходят на `include` в +следующих срезах (решение 24 в `design.md`). + +## Внешние ресурсы: docs и fixtures опциональны + +Проверки сервисов обращаются к соседним репозиториям workspace и **не должны падать**, если их нет: тест +пропускается (`skipped`) с явной причиной, остальные выполняются, `make check` остаётся зелёным. + +| Что | Где ищется | Что пропускается без него | +|---|---|---| +| `docs` | `../docs` или каталог из `ARM_KA_DOCS` | тест идентичности контракта `api/contracts/` с `docs/contracts/`; в `arm-core` ещё сверка с DBML в тестах (`dbml-check` как цель `make` требует DBML и падает без него) | +| `fixtures` | `../fixtures` или каталог из `ARM_KA_FIXTURES` (в `arm-core`; в остальных сервисах — см. «Известные расхождения реализаций») | тесты с маркером `fixtures` (реальные данные, внутренний доступ): причина пропуска — «ARM_KA_FIXTURES недоступен …» (в `arm-core`) | + +Пример (docs и fixtures лежат не рядом): + +```bash +export ARM_KA_DOCS=/path/to/arm-ka-docs +export ARM_KA_FIXTURES=/path/to/arm-ka-fixtures +make check +``` + +Причины пропусков видно в выводе (`pytest --verbose` в `addopts`: строки `SKIPPED (…)`); полный список — +`cd api && .venv/bin/pytest -m "not integration" -rs`. + +Важно: зелёный `make check` с пропусками **не** доказывает идентичность контракта и прохождение на реальных данных. Перед +PR убедитесь, что `docs` и `fixtures` доступны (пропусков по этим причинам нет) либо явно укажите в описании PR, что +пропущено. + +> Фактическая поддержка переменных различается по репозиториям — см. «Известные расхождения реализаций». + +## Проверки этого репозитория (`docs`) + +В репозитории `arm-ka-docs` нет сервисных целей; `Makefile` в его корне содержит только проверки спеки и контракта: + +| Цель | Что делает | +|---|---| +| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` — единственной зависимостью скриптов | +| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против трёх схем JSON Schema 2020-12 (3 схемы, 8 примеров); код 1 при любой ошибке | +| `make validate-openspec` | `openspec validate slice-0-skeleton --strict` (другое изменение: `make validate-openspec CHANGE=`) | +| `make check` | обе проверки выше | + +Без `make`: `python scripts/validate_contracts.py` (нужен `jsonschema`) и `openspec validate --strict`. +Менять `contracts/**` нужно осознанно: байтовые копии схем лежат в `api/contracts/` каждого сервиса, после правки их +нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт. + +## Порядок перед PR + +1. Поднять инфраструктуру: `make up` (в репозитории сервиса). Убедиться, что `docs` и `fixtures` доступны (рядом или через + `ARM_KA_DOCS`, `ARM_KA_FIXTURES` — последняя поддерживается только в `arm-core`, см. «Известные расхождения + реализаций»). +2. `make check` — быстрые проверки. Исправлять до зелёного. +3. `make check-full` — полная проверка (интеграционные тесты, образ; для `arm-core` — ещё DBML, миграции, `web`). +4. Если менялись `contracts/`, `model/` или спека в репозитории `docs`: в нём `make check`, затем в каждом затронутом + сервисе — пересинхронизация копий контракта и пункты 2–3. +5. Итог `make check-full` (зелёный/красный, число тестов, **что пропущено и почему**) вписать в описание PR. + +Правила: PR создаётся только после зелёного `make check-full`; один OpenSpec change и один PR — на один репозиторий; +`make check-full` не заменяет ревью. + +## Типичные проблемы + +- **`make test-integration` падает на соединении** — не поднята инфраструктура: `make up`; порты заняты — поменять в + `docs/dev/.env` и в `api/.env.test`/`api/.env` сервиса (`README.md`). +- **`NoSuchBucket`** — бакета нет на свежем томе: `docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka + run --rm minio-init`. +- **`tabular-parser`: интеграционный тест получает чужие ответы** — на шине compose не должно работать другого + экземпляра сервиса из той же queue group (интеграционный тест поднимает сервис в процессе теста). +- **Нет `uv`/Python 3.11** — `make venv` их требует (версия Python — как на платформе). +- **`docker build` (и `npm install` в `web-check` у `arm-core`) падает без сети** — это шаги `check-full`, которым нужна сеть (образы, пакеты); `make check` сети не требует. + +## Известные расхождения реализаций + +Фактические реализации в `Makefile` трёх сервисов расходятся с описанной выше семантикой. Сервисы в рамках Среза 0 +не менялись; расхождения занесены как находки ревью. + +- **`make up`.** Эталон — `scan-gateway` (`--project-name arm-ka`, `--wait` со списком сервисов, затем `run --rm + minio-init`). `tabular-parser` не создаёт бакет (нет шага `minio-init`) и на свежем томе даёт `NoSuchBucket`; + `arm-core` выполняет `docker compose up -d` без `--wait`, то есть возвращается до готовности сервисов. +- **Структура `check-full`.** В `scan-gateway` шаги вызываются через `$(MAKE)` (порядок и остановка на первой ошибке + гарантированы и при `make -j`); в `arm-core` и `tabular-parser` — пререквизитами (при последовательном запуске + эквивалентно, при `make -j` порядок не гарантирован). Запускайте без `-j`. +- **`ARM_KA_DOCS`/`ARM_KA_FIXTURES`.** `arm-core` учитывает обе переменные; `tabular-parser` — только `ARM_KA_DOCS` + (тестов с маркером `fixtures` пока нет); `scan-gateway` — ни одну (ищет `../docs` и `../fixtures/ocr` по фиксированному + пути; тексты причин пропуска не содержат «ARM_KA_FIXTURES недоступен»). Пока расхождение не устранено, для + `scan-gateway` docs/fixtures нужно класть рядом. diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 0000000..c98bc3b --- /dev/null +++ b/dev/docker-compose.yml @@ -0,0 +1,236 @@ +# Инфраструктура разработки АРМ КА: PostgreSQL 16, NATS 2.10 (без JetStream, как на платформе), MinIO + бакет. +# Запуск: cp .env.example .env && docker compose up -d (из этого каталога) +# Ожидание готовности: docker compose up -d --wait postgres nats minio. Голый `up -d --wait` возвращает код 1, потому что +# одноразовый minio-init завершается (хотя и с кодом 0) — для скриптов указывайте сервисы явно. +# Все учётные данные, порты хоста и имя бакета берутся из .env (значения по умолчанию — dev). +# Порты публикуются только на 127.0.0.1; адрес привязки меняется через BIND_ADDR (см. .env.example). +# Профиль `services` — сервисы системы, собранные из соседних репозиториев workspace (../../, Dockerfile в корне): +# docker compose --profile services up -d --build +# docker compose --profile services up -d --wait arm-core tabular-parser scan-gateway scan-emulator +# Одноразовые minio-init и arm-core-migrate (миграции + seed) в список --wait не входят: они завершаются с кодом 0. +# Внутри контейнера каждый сервис слушает 8000; на хост публикуются 8000 (arm-core), 8001 (tabular-parser), 8002 (scan-gateway). + +# Фиксированное имя проекта: одинаковые тома и сети из любого worktree (.wt/<срез>/docs/dev). +name: arm-ka + +# Общие переменные сервисов профиля `services` (формат шаблона команды; адреса — внутри сети compose, не localhost). +x-service-env: &service-env + DEBUG: "true" + OPENBAO__MINIO__MINIO_URL: minio:9000 + OPENBAO__MINIO__MINIO_ADMIN_LOGIN: ${MINIO_ROOT_USER:-minioadmin} + OPENBAO__MINIO__MINIO_ADMIN_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + OPENBAO__MINIO__MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka} + OPENBAO__MINIO__MINIO_SECURE: "false" + OPENBAO__NATS__NATS_URL: nats://nats:4222 + NATS_ENABLED: "true" + +# arm-core и его одноразовые миграции: те же переменные плюс БД и dev-эмитент токенов. +x-arm-core-env: &arm-core-env + <<: *service-env + OPENBAO__DATABASE__DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-armka}:${POSTGRES_PASSWORD:-armka}@postgres:5432/${POSTGRES_DB:-armka} + # Dev-эмитент JWT (POST /api/v1/dev/token) — только при DEBUG=true; секрет публичный, только для dev. + # Порт хоста по умолчанию на 127.0.0.1 (BIND_ADDR): при BIND_ADDR=0.0.0.0 токен может получить любой в сети. + AUTH_DEV_ISSUER: "true" + AUTH_DEV_SECRET: dev-only-secret-do-not-use-in-production-0123456789 + +services: + postgres: + image: postgres:16.10-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-armka} + POSTGRES_USER: ${POSTGRES_USER:-armka} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-armka} + ports: + - "${BIND_ADDR:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 5s + + # Core NATS без JetStream: флаг --jetstream не передаётся. Мониторинг — порт 8222 (/varz, /healthz). + nats: + image: nats:2.10.29-alpine + command: ["-m", "8222"] + ports: + - "${BIND_ADDR:-127.0.0.1}:${NATS_PORT:-4222}:4222" + - "${BIND_ADDR:-127.0.0.1}:${NATS_MONITOR_PORT:-8222}:8222" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8222/healthz || exit 1"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 2s + + # Образы minio/minio и minio/mc сняты с Docker Hub и quay.io; используется сборка alpine/minio с RELEASE-тегом. + minio: + image: alpine/minio:RELEASE.2025-10-15T17-29-55Z + # Образ по умолчанию работает от пользователя minio и не может писать в свежий именованный том — в dev запускаем от root. + user: "0:0" + command: ["server", "/data", "--console-address", ":9001"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${BIND_ADDR:-127.0.0.1}:${MINIO_API_PORT:-9000}:9000" + - "${BIND_ADDR:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:9000/minio/health/live || exit 1"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 3s + + # Одноразовое создание бакета; идемпотентно (--ignore-existing), при повторном up снова завершается с кодом 0. + minio-init: + image: bitnamilegacy/minio-client:2025.7.21 + depends_on: + minio: + condition: service_healthy + restart: "no" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + MINIO_BUCKET: ${MINIO_BUCKET:-arm-ka} + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" && + mc mb --ignore-existing "local/$$MINIO_BUCKET" + + # --- Профиль `services` ------------------------------------------------------------------------------------------- + + # Одноразово: миграции arm-core (alembic upgrade head) и справочники с dev-пользователями (seed --dev). + # Оба шага идемпотентны; при каждом `up` профиля контейнер снова выполняется и завершается с кодом 0. + # Образ собирает сервис arm-core (build только у него): две сборки одного тега дают разные ID образа, и каждый + # следующий `up` пересоздавал бы arm-core-migrate и arm-core. pull_policy: never — образ локальный, не из реестра. + arm-core-migrate: + profiles: ["services"] + image: arm-core:dev + pull_policy: never + restart: "no" + depends_on: + postgres: + condition: service_healthy + environment: *arm-core-env + command: ["sh", "-c", "alembic upgrade head && python -m scripts.seed --dev"] + + arm-core: + profiles: ["services"] + build: + context: ../../arm-core + image: arm-core:dev + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + nats: + condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + arm-core-migrate: + condition: service_completed_successfully + environment: *arm-core-env + ports: + - "${BIND_ADDR:-127.0.0.1}:${ARM_CORE_PORT:-8000}:8000" + healthcheck: + # curl в python:3.11-slim нет; /healthcheck всегда отвечает 200, поэтому читаем поле nats + test: + - CMD + - python + - -c + - "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' else 1)" + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + tabular-parser: + profiles: ["services"] + build: + context: ../../tabular-parser + image: tabular-parser:dev + restart: unless-stopped + depends_on: + nats: + condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: *service-env + ports: + - "${BIND_ADDR:-127.0.0.1}:${TABULAR_PARSER_PORT:-8001}:8000" + healthcheck: + test: + - CMD + - python + - -c + - "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' else 1)" + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + # Эмулятор ИС Сканирование: тот же образ, что у шлюза (собирает сервис scan-gateway, см. arm-core-migrate); общий + # том hotfolder с шлюзом. Переменные MinIO/NATS не нужны. + # Фикстуры ответов ИС — ../../fixtures/ocr (только чтение): набор <имя>.json + <имя>_*.json для входного файла UUID_<имя>.ext. + scan-emulator: + profiles: ["services"] + image: scan-gateway:dev + pull_policy: never + command: ["python", "-m", "src.scan_emulator"] + restart: unless-stopped + environment: + SCAN_HOTFOLDER_PATH: /data/hotfolder + SCAN_FIXTURES_DIR: /fixtures/ocr + SCAN_EMULATOR_DELAY: "2" + volumes: + - hotfolder:/data/hotfolder + - ../../fixtures/ocr:/fixtures/ocr:ro + + scan-gateway: + profiles: ["services"] + build: + context: ../../scan-gateway + image: scan-gateway:dev + restart: unless-stopped + depends_on: + nats: + condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + environment: + <<: *service-env + SCAN_HOTFOLDER_MODE: local + SCAN_HOTFOLDER_PATH: /data/hotfolder + volumes: + - hotfolder:/data/hotfolder + ports: + - "${BIND_ADDR:-127.0.0.1}:${SCAN_GATEWAY_PORT:-8002}:8000" + healthcheck: + # /healthcheck всегда отвечает 200, поэтому читаем поля nats и hotfolder + test: + - CMD + - python + - -c + - "import json,sys,urllib.request as u; d=json.load(u.urlopen('http://127.0.0.1:8000/healthcheck', timeout=3)); sys.exit(0 if d['nats']=='ok' and d['hotfolder']=='ok' else 1)" + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + +volumes: + postgres-data: + minio-data: + # Общая горячая папка scan-gateway и scan-emulator (Input/, Output/) + hotfolder: diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase1.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase1.md new file mode 100644 index 0000000..177b468 --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase1.md @@ -0,0 +1,45 @@ +# Срез 0 — сквозной скелет (docs) — фаза 1: контракт и инфраструктура — план исполнения (Срез 0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/docs`). **Issue:** #1. +**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`). + +## Global Constraints + +- Репозиторий `arm-ka-docs`: документы, контракты, `docs/dev/`. `docs/source/**` не редактировать. +- Контракт v1.0: три схемы JSON Schema 2020-12 (`recognition-request`, `recognition-result-message`, `recognition-result`), `schema_version` = `"1.0"`; статусы конверта `processing | done | needs_check | error`; коды ошибок — перечень из spec `recognition-contract` (включая `NOT_IMPLEMENTED`); деньги — строки `^-?\d+(\.\d{1,2})?$`; `Nats-Msg-Id`: `:`, `ack::`, `result::`. +- Платформа: Python 3.11, PostgreSQL 16, NATS 2.10 **без JetStream**, один бакет S3 `arm-ka`; Nexus недоступен; CI нет — `make check` / `make check-full`. +- Compose собирает сервисы из `../../` относительно `docs/dev/`; версии образов фиксированы. +- Язык документов и коммитов — русский; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. + +## Задачи + +### Task 1: [1.1] [docs] `docs/contracts/recognition-result.schema.json` + +- [x] [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о версионировании, добавить `NOT_IMPLEMENTED` в `error.code`; проверить `python -m jsonschema` на файле схемы (метасхема 2020-12 валидна) + +### Task 2: [1.2] [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recogniti + +- [x] [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum, форматы uuid/date-time); проверить метасхемой + +### Task 3: [1.3] [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт + +- [x] [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done | needs_check | error; условные обязательные поля через `if/then`); проверить метасхемой + +### Task 4: [1.4] [docs] Примеры `docs/contracts/examples/` + +- [x] [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`, `result-message-processing.json`, `result-message-done.json`, `result-message-error.json`, `result-osv-minimal.json`, `result-statement-minimal.json`, `result-error.json`; скрипт `docs/scripts/validate_contracts.py` валидирует все примеры схемами и возвращает 0 + +### Task 5: [1.5] [docs] `03-architecture.md` + +- [x] [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила `Nats-Msg-Id`, ack через `processing`, повторная публикация; проверить, что текст ссылается на существующие файлы + +### Task 6: [2.1] [docs] `docs/dev/docker-compose.yml` + +- [x] [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`, `minio-init` (создаёт бакет `arm-ka`), healthcheck'и, именованные тома; `docs/dev/.env.example`; проверка: `docker compose up -d` → все healthy, `mc ls` видит бакет, `curl :8222/varz` отвечает + +### Task 7: [2.2] [docs] `docs/dev/README.md` + +- [x] [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus (`make venv` фильтрует `raisa_*`), черновик — дописывается в 5.4 + diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase2.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase2.md new file mode 100644 index 0000000..5d7ae4b --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton-phase2.md @@ -0,0 +1,45 @@ +# Срез 0 — сквозной скелет (docs) — фаза 2: проверки, профиль services, документы — план исполнения (Срез 0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/1-slice-0-skeleton` (worktree `.wt/slice-0/docs`). **Issue:** #1. +**Spec:** `openspec/changes/slice-0-skeleton/` — proposal.md, design.md, specs/*/spec.md этого change'а. Спека — единственный источник истины при рулингах; этот план сгенерирован из её `tasks.md` 1:1 (номера в скобках — исходные номера задач tasks.md; по завершении задачи отметить её `- [x]` и в `tasks.md`). + +## Global Constraints + +- Репозиторий `arm-ka-docs`: документы, контракты, `docs/dev/`. `docs/source/**` не редактировать. +- Контракт v1.0: три схемы JSON Schema 2020-12 (`recognition-request`, `recognition-result-message`, `recognition-result`), `schema_version` = `"1.0"`; статусы конверта `processing | done | needs_check | error`; коды ошибок — перечень из spec `recognition-contract` (включая `NOT_IMPLEMENTED`); деньги — строки `^-?\d+(\.\d{1,2})?$`; `Nats-Msg-Id`: `:`, `ack::`, `result::`. +- Платформа: Python 3.11, PostgreSQL 16, NATS 2.10 **без JetStream**, один бакет S3 `arm-ka`; Nexus недоступен; CI нет — `make check` / `make check-full`. +- Compose собирает сервисы из `../../` относительно `docs/dev/`; версии образов фиксированы. +- Язык документов и коммитов — русский; коммит заканчивается строкой `Co-Authored-By: Claude Fable 5.1 `. + +## Задачи + +### Task 1: [4.1] [docs] `docs/dev/checks.md` + +- [x] [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`), что делает каждая, порядок перед PR, как пропускаются тесты без фикстур/docs; проверка: цели `arm-core` соответствуют описанию + +### Task 2: [4.2] [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` се + +- [x] [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или копируется); проверка: `make check` в трёх репозиториях вызывает одинаковые команды (`diff` общих целей) + +### Task 3: [5.3] [docs] `docker-compose.yml` + +- [x] [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway` (8002), `scan-emulator` (том `hotfolder`, `../../fixtures/ocr:ro`); проверка: `--profile services up --build`, все `/healthcheck` 200 с `nats = ok` + +### Task 4: [5.4] [docs] Сквозная проверка руками по README + +- [x] [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт arm-core) → паблишер публикует → `tabular-parser` отвечает ack и `NOT_IMPLEMENTED` → `recognition_job.status` проходит `published → processing → error`, `error_code = NOT_IMPLEMENTED`; то же для `image` через эмулятор (`raw/{job_id}.json` появляется в MinIO); остановить `tabular-parser`, вставить задание — через 30 с повторная публикация, после трёх — `dlq`; зафиксировать в README как «проверка окружения» + +### Task 5: [6.1] [docs] `06-test-data.md` §6 + +- [x] [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила элиминации, права — `arm-core/api/seed/`; `09-delivery-plan.md` Срез 0: решение D-1 по пакету контрактов, ссылка на `docs/dev/`, проверки вместо CI + +### Task 6: [6.2] [docs] `07-open-questions.md` + +- [x] [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`; обязательность ОСВ расширенного подхода; источник полного плана счетов 1С) и расхождения макета из задачи B; статусы допущений + +### Task 7: [6.3] [docs] Итоговая проверка + +- [x] [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки между документами ведут на существующие файлы (`grep` по именам файлов) + diff --git a/openspec/changes/slice-0-skeleton/design.md b/openspec/changes/slice-0-skeleton/design.md index fc3dc0a..07bc89d 100644 --- a/openspec/changes/slice-0-skeleton/design.md +++ b/openspec/changes/slice-0-skeleton/design.md @@ -144,3 +144,35 @@ PostgreSQL 16, схемы создавать можно. Остаются (не | 10 | 2026-10-03 | D-9: `python:3.11-slim`, PostgreSQL 16, NATS 2.10 без JS, `node:22` до ответа | ответы пользователя; Node неизвестен | смена тега в compose/Dockerfile | | 11 | 2026-10-03 | Worktree среза — зеркальный workspace `.wt/<срез>/` с симлинками | относительные пути `../docs`, `../fixtures` и хуки должны работать в ветке | переместить worktree | | 12 | 2026-10-03 | D-10: `raisa_*` фильтруются при dev-установке, npm-пакеты `raisa-*` → `optionalDependencies` | Nexus недоступен до прода | вернуть две строки в `dependencies` | +| 13 | 2026-10-03 | Файл результата: пустой `documents` допустим только при `status = error` — выражено в схеме условием `if/then` (`minItems: 1`) | требование spec `recognition-contract` «Файл результата»; текст задачи 1.1 его не перечислял | ослабить/убрать условие в схеме, пересобрать копии контракта в сервисах | +| 14 | 2026-10-03 | Compose: фиксированное имя проекта `name: arm-ka`, порты хоста переопределяются через `.env` со значениями по умолчанию из spec | одинаковые тома/сети из любого worktree; порты 5432/4222 на машине разработчика могут быть заняты | смена имени проекта — потеря dev-томов (`down -v`), порты — правка `.env` | +| 15 | 2026-10-03 | Чекбоксы плана и `tasks.md` отмечает контроллер SDD отдельным коммитом после чистого ревью задачи | исполнитель не должен отмечать выполненным то, что ещё не прошло ревью | отметки правятся вручную | +| 16 | 2026-10-03 | Compose: MinIO берётся из неофициальных образов `alpine/minio:RELEASE.2025-10-15T17-29-55Z` и `bitnamilegacy/minio-client:2025.7.21` | `minio/minio` и `minio/mc` недоступны на Docker Hub (denied), на quay.io нужных тегов нет; для dev достаточно любого S3-совместимого MinIO нужной версии API | образы сторонние: при появлении доступа к банковскому реестру/официальным образам — заменить две строки `image:` в `dev/docker-compose.yml`; в прод эти образы не попадают | +| 17 | 2026-10-03 | Compose: сервис `minio` запускается от `user: "0:0"` | образ `alpine/minio` по умолчанию работает от пользователя `minio` и падает на свежем именованном томе | только dev; удалить строку, когда образ/том позволят писать без root | +| 18 | 2026-10-03 | `dev/.env.example` дополнен заглушками `OPENBAO__SMB__*` и `NATS_ENABLED=true` | `Settings` шаблона (arm-core) требует блок `OPENBAO.SMB`, без него сервис из venv не стартует; `NATS_ENABLED` по умолчанию `false`, и `/healthcheck` показывает `nats = off`; spec велит копировать именно `docs/dev/.env.example` | убрать две группы строк; сервисы с другим набором обязательных настроек получат лишние (игнорируемые) переменные Примечание: часть про обязательность SMB устарела, см. №44. | +| 19 | 2026-10-03 | `schema_version` во всех трёх схемах: `const "1.0"` заменён на `type: string, pattern: ^1\.\d+$` (производитель v1.0 пишет `"1.0"`); в `$comment` и в 03-architecture.md добавлено правило «новый MINOR раскатывается сначала на потребителей» | spec «Версионирование контракта»: потребитель принимает тот же MAJOR и любой MINOR, сервис «поддерживающий 1.x» принимает 1.1 и отвергает только 2.0; с `const` это неисполнимо, а копии схем уйдут в сервисы побайтно (финальное ревью I-1) | вернуть `const "1.0"` и пересобрать копии схем в сервисах; побочный риск — копии в `arm-core`/`tabular-parser`/`scan-gateway` нужно обновить (тест идентичности контракта упадёт, пока копии не обновлены) | +| 20 | 2026-10-03 | Compose: порты хоста публикуются на `${BIND_ADDR:-127.0.0.1}`, а не на 0.0.0.0 | пароли по умолчанию (`armka`, `minioadmin`); все сценарии идут через localhost; spec bind-адрес не оговаривает (финальное ревью M-6) | если нужен доступ с другой машины — `BIND_ADDR=0.0.0.0` в `.env`; для запуска профиля `services` порты хоста не нужны | +| 21 | 2026-10-04 | В корне репозитория `docs` добавлен `Makefile` с целями `venv`, `validate-contracts`, `validate-openspec`, `check` (сервисных целей нет); `jsonschema` ставится в `.venv` (в `.gitignore`) | дополнение контроллера к задаче 4.1: проверки контракта и спеки должны запускаться одной командой, как в сервисах; `check` = `scripts/validate_contracts.py` + `openspec validate slice-0-skeleton --strict` | удалить `Makefile` и строку `.venv/` в `.gitignore`; команды из `dev/checks.md` останутся запускаемыми вручную | +| 22 | 2026-10-04 | `dev/checks.md` описывает общие цели по эталону `scan-gateway` (`up` с `--project-name arm-ka`, `--wait postgres nats minio` и `run --rm minio-init`; `check-full` как последовательность шагов), расхождения `arm-core`/`tabular-parser` и неполная поддержка `ARM_KA_DOCS`/`ARM_KA_FIXTURES` в `scan-gateway` вынесены в раздел «Известные расхождения реализаций», код сервисов не менялся | spec «Одинаковый набор целей» требует единой семантики, а фактические `Makefile` расходятся; правка сервисов вне задачи 4.1 и их PR | если эталон выбран неверно — переписать раздел `make up` в `checks.md`; расхождения останутся незакрытыми, пока сервисы не выровнены | +| 23 | 2026-10-04 | В `dev/README.md` раздел «Интеграционные тесты сервисов против compose» заменён ссылкой на `checks.md`; убрано утверждение «Makefile есть только у `arm-core`» | утверждение устарело (Makefile есть у всех трёх сервисов), единый источник описания проверок — `checks.md` | вернуть старый текст раздела | +| 24 | 2026-10-04 | `dev/Makefile.common` — эталон общих целей по `scan-gateway`; различия сервисов вынесены в переменные (`MYPY_PATHS`, `IMAGE`, `COMPOSE_*`, `CHECK_FULL_STEPS`), `check-full` реализован через `$(foreach …)` с вызовом `$(MAKE)` на каждый шаг; в docs-репозитории `Makefile.common` не `include`-ится, сервисы его пока не подключают | задача 4.2 требует эталонный фрагмент и сверку `diff`ом; Makefile сервисов в Срезе 0 не правятся (код готов, PR открыты) — `include` в них можно ввести без смены поведения следующим срезом | сервисы продолжают держать копии рецептов; расхождения копий вылезают только при правке одной из них (сверка — `make -n`, см. `checks.md`) | +| 25 | 2026-10-04 | Замечания T1.1–T1.4 ревью задачи 4.1 исправлены в `dev/checks.md` (fixtures и «ARM_KA_FIXTURES» — только `arm-core`, `mypy src scripts`, «Инфраструктура compose», «одинаковая семантика» заменена ссылкой на расхождения) | прямое поручение контроллера; формулировки приведены в соответствие с фактическими Makefile и тестами | неточная документация; правится одной строкой | +| 26 | 2026-10-04 | Профиль `services` в `dev/docker-compose.yml`: переменные сервисов — общие якоря YAML (`x-service-env`, `x-arm-core-env`) с адресами внутри сети compose и `DEBUG=true`; образы собираются локально с тегами `arm-core:dev`, `tabular-parser:dev`, `scan-gateway:dev`; healthcheck контейнеров — `python -c` с чтением поля `nats` (у `scan-gateway` ещё `hotfolder`) из `/healthcheck`; порты хоста `${ARM_CORE_PORT:-8000}`/`${TABULAR_PARSER_PORT:-8001}`/`${SCAN_GATEWAY_PORT:-8002}` на `${BIND_ADDR:-127.0.0.1}`, в контейнере 8000 | spec dev-environment («`OPENBAO__*` шаблона, `DEBUG=true`»), фрагмент `scan-gateway` (`open-questions-proposal.md`) как основа; `curl` в `python:3.11-slim` нет, а `/healthcheck` всегда 200; якоря убирают тройное дублирование учётных данных MinIO/NATS | правка одного блока compose; при смене формата `/healthcheck` сервиса — правка трёх строк `test` | +| 27 | 2026-10-04 | Одноразовый сервис `arm-core-migrate` (`alembic upgrade head && python -m scripts.seed --dev`, `depends_on` postgres `service_healthy`); `arm-core` ждёт его `service_completed_successfully`; выполняется при каждом `up` профиля | дополнение контроллера к задаче 5.3; без схемы `dkb` и справочников ядро не работает, оба шага идемпотентны (повторный seed — `0 / 0 / N`); `--dev` — нужны dev-пользователи для dev-токена | лишний запуск миграций при каждом `up` (~2 с); убрать `--dev` — одна строка | +| 28 | 2026-10-04 | Образ каждого репозитория собирает один сервис: `build` только у `arm-core` и `scan-gateway`; `arm-core-migrate` и `scan-emulator` используют тег `arm-core:dev`/`scan-gateway:dev` с `pull_policy: never` (отступление от фрагмента `scan-gateway`, где `build` у обоих) | проверено: при `build` у двух сервисов с одним тегом compose 2.34 собирает образ дважды с разными ID, метка `com.docker.compose.image` контейнера расходится с тегом, и каждый следующий `up` пересоздаёт `arm-core-migrate` и `arm-core` (флип-флоп); с одной сборкой три `up` подряд — 0 пересозданий | `up -d scan-emulator` или `arm-core-migrate` в одиночку на машине без образа падает `No such image` — сначала `docker compose --profile services build`; вернуть `build` — две строки | +| 29 | 2026-10-04 | В compose у `arm-core` включён dev-эмитент токенов (`AUTH_DEV_ISSUER=true`, публичный `AUTH_DEV_SECRET` из `arm-core/api/.env.example`) | ручные проверки `/api/v1/me` и сквозной сценарий (задача 5.4) без РАИСА; маршрут существует только при `DEBUG=true`; порт хоста по умолчанию на 127.0.0.1 | при `BIND_ADDR=0.0.0.0` токен любого dev-пользователя может получить любой в сети — только dev; выключить — одна строка | +| 30 | 2026-10-04 | Сквозная проверка окружения (задача 5.4) выполняется вставкой строк `legal_entity` (синтетическое, ИНН `7700000001`, `on conflict do nothing`) / `s3_object` / `stored_file` / `recognition_job(queued)` через `psql` в контейнере `postgres` и загрузкой исходника в `uploads/{file_id}/{job_id}/{имя}` через `mc` из образа `minio-init`; рецепт — функции `dpsql`/`dmc`/`new_job`/`poll` в `dev/README.md` («Проверка окружения»); тестовые строки и объекты не удаляются | API загрузки файлов в Срезе 0 нет, тестовых скриптов `arm-core` для рабочей БД тоже (его интеграционные тесты работают во временной БД); ключ — по «Схеме ключей S3» `03-architecture.md`; рецепт проверен прогоном дословно, но при уже существовавшем `legal_entity` с этим ИНН: ветка вставки без конфликта на чистой БД не выполнялась | вставка в обход будущего API может разойтись с ним (обязательные поля `stored_file`/`document`) — рецепт придётся обновить в Срезе 1/2; мусорные строки в dev-БД — сброс `down -v` | +| 31 | 2026-10-04 | Ожидаемый итог сценария `image` в README — `error` / `INVALID_DOCUMENT` (фактический ответ), а не `NOT_IMPLEMENTED`; файл — `ocr/aep_5.PDF` (так он назван в `fixtures`, не `aep_5.pdf`) | обе пары `fixtures/ocr` (`aep_5.json`, `aep_8_form4.json`) — ответы-ошибки ИС, эмулятор отдаёт их как есть, шлюз пробрасывает код ИС; на регистрозависимой ФС `aep_5.pdf` не найдётся | успешный путь `image` (`done`/`needs_check`, `results/…`) сквозной проверкой не покрыт, пока в `fixtures/ocr` нет успешного ответа ИС | +| 32 | 2026-10-04 | Замечание T3.6 проверено эмпирически: образы `scan-gateway:dev` и `arm-core:dev` пересобраны `build --no-cache` (новые ID) на работающем профиле; после пересоздания контейнеров старые ID образов в `docker image ls -a` не остались (`No such image`) | формулировку README нужно было подтвердить, а без смены ID образа compose ничего не пересоздаёт | ~1 мин сборки и загрузка зависимостей из PyPI; на окружение не влияет (образы — те же исходники) | +| 33 | 2026-10-04 | В README для наблюдения за шиной предложен `natsio/nats-box:0.17.0` (`nats sub 'recognition.>'` в сети `arm-ka_default`) — разовый `docker run --rm`, не сервис compose | без него `published`/`processing` у `tabular` (десятки мс) не видны; образ доступен с Docker Hub (проверено), в compose не добавлялся, чтобы не менять состав окружения | при недоступности образа — только логи `arm-core` и метки времени задания; шаг опциональный | +| 34 | 2026-10-04 | Правим спеку и документ под фактическое поведение сервисов, а не код: при неизвестном MAJOR `schema_version` (и при запросе не по схеме) сервис публикует `error SCHEMA_MISMATCH` **без** отдельного ack `processing`; формулировка «и подтверждает ack'ом» (T12.13) из `spec.md` (сценарий «Несовместимая версия запроса») и `03-architecture.md` убрана. Конверт публикуется, только если извлекаются корректные `job_id`/`file_id`, и не публикуется для `job_id`, уже находящегося в работе/обработанного | `tabular-parser/api/src/core/worker.py` (`_reject`: «`SCHEMA_MISMATCH` без ack», строки 242–275) и `scan-gateway/api/src/core/worker.py` (`_reject`, R-16: строки 288–321) ведут себя одинаково; финальный конверт сам закрывает задание в ядре (`published → error` допустим, `recognition_exchange.py`: результат принимается из `queued/published/processing`), ack перед ним ничего не добавляет, а `Nats-Msg-Id` ack и результата разные; конверт для `job_id` в работе совпал бы по `result::` с настоящим результатом | если ядро когда-нибудь начнёт требовать ack перед результатом, задание с `SCHEMA_MISMATCH` останется в `published` до повторов и `dlq`; правка — одна строка в воркерах и спеке | +| 35 | 2026-10-04 | Запрос, из которого нельзя извлечь `job_id` (не JSON, не объект, нет/не uuid `job_id`), остаётся без ответа: сервис пишет лог и отбрасывает его; ядро закрывает задание по `NO_ACK` / `dlq` после `OUTBOX_MAX_REPUBLISH` повторов. Записано в `spec.md` (новый сценарий «Нечитаемый запрос») и `03-architecture.md` | собрать валидный конверт без `job_id` нельзя (схема требует uuid), а выдуманный `job_id` ядро всё равно не сопоставит с заданием; оба сервиса логируют и сбрасывают (`worker.py`: tabular строки 253–263, scan-gateway 292–299); ядро и так закрывает «молчание» (`recognition_exchange.py`: `NO_ACK`, `dlq`) | нет худшего исхода, чем 120 с до `dlq` (4 × `ACK_TIMEOUT_SECONDS`) для заведомо битого запроса, которого ядро само не порождает; сокращать задержку можно только чтением `job_id` из заголовка `Nats-Msg-Id` — вне Среза 0 | +| 36 | 2026-10-04 | `PUBLISH_RETRIES` в спеке (новое требование «Повторы публикации ответов сервисом») = **одна первая попытка + до 3 повторов** (всего не более 4 попыток) с тем же `Nats-Msg-Id` и паузой между попытками; исчерпаны — error-лог, задание закрывает ядро (`reply_deadline`). Пауза — деталь реализации (`tabular-parser` 2,0 с, `scan-gateway` 0,5 с), в спеку не включена | значения в коде: `scan-gateway/api/src/core/config.py:63` (`PUBLISH_RETRIES = 3`, «повторы после первой попытки (всего до 4)») и `core/nats.py:245–256` (`attempts_total = retry_count + 1`); `tabular-parser/api/src/core/config.py:49` (`PUBLISH_RETRIES = 3`, `NonNegativeInt`) и `core/nats.py:306` (`attempts = publish_retries + 1`); паузы различаются (`tabular-parser` — `PUBLISH_RETRY_PAUSE = 2.0` с в `core/nats.py:51`, `scan-gateway` — `RETRY_DELAY = 0.5` с в `core/nats.py:49`), семантика одна | если ядро/сервис договорятся считать `PUBLISH_RETRIES` общим числом попыток, расхождение «3 против 4» не ломает обмен (дедуп по `Nats-Msg-Id`); правка — одна фраза в спеке | +| 37 | 2026-10-04 | Пример `contracts/examples/request-image.json` с `reply_deadline` в прошлом **не меняется**; в `spec.md` (сценарий «Примеры контракта — иллюстрации») и `03-architecture.md` записано, что примеры — иллюстрации формы, схема проверяет у `reply_deadline` лишь формат `date-time`, а срок проверяет сервис (`scan-gateway` — `error TIMEOUT`, `scan-gateway/api/src/core/worker.py:469–475`; `tabular-parser` срок не проверяет: `reply_deadline` в его воркере не используется) | копии `examples/` в сервисах сверяются побайтно тестами идентичности: правка примера = пересинхронизация трёх репозиториев ради документальной оговорки; `validate_contracts.py` проходит (rc 0, 8 примеров) | если кто-то возьмёт `request-image.json` как готовое задание для `scan-gateway`, получит `TIMEOUT` — это зафиксировано оговоркой; обновить дату позже можно одним PR с пересинхронизацией | +| 38 | 2026-10-04 | `06-test-data.md` §6 и `09-delivery-plan.md` (Срез 0) приведены к факту: `fixtures/directories/` — только тестовые ЮЛ/ГК (сейчас пустой `.gitkeep`, наполнение — Срез 1); seed продукта лежит в `arm-core/api/seed/core/` (+ `seed/dev/` только для `--dev`); в плане Среза 0 добавлена ссылка на `dev/README.md` («Профиль `services`», «Проверка окружения»); `00-workspace-layout.md` не менялся (неактуальных фактов нет) | перечень файлов seed и число ролей/прав проверены по `arm-core/api/seed/` (7 ролей, 16 прав, 122 строки плана счетов); решение D-1, compose и проверки вместо CI в `09` уже были верны | только неточная ссылка в документах; правится правкой текста | +| 39 | 2026-10-04 | Реестр `07-open-questions.md`: 36 новых строк OQ-48…OQ-83 вместо ~60 пунктов трёх `open-questions-proposal.md` и находок контроллера — пересекающиеся пункты слиты в одну строку с перечнем источников: PUBLISH_RETRIES (scan-gateway п. 1 + O-25), `reply_deadline` (п. 12 + O-26), нечитаемый запрос (tabular P-1 + O-24), ack при `SCHEMA_MISMATCH` (tabular P-2 + scan-gateway п. 2), `attempt + 1` (tabular P-3 + scan-gateway п. 17), фикстуры OCR (п. 3 + O-15), `make up`/`check-full` (O-1, O-2, O-4, O-5 + scan-gateway п. 18), общий NATS (п. 11 + O-11), `stored_file.status` (arm-core предл. 19 + O-18), `republish_count` (предл. 10 + O-19), append-only аудит (предл. 9 + дополнение), срок ответа (предл. 11 + дополнение) | один вопрос — одна строка: иначе человек решает одно и то же дважды, а реестр несёт противоречивые статусы; перечень источников (репозиторий + маркер) сохраняет прослеживаемость, маркеры не менялись | если слияние оказалось неудачным, строка делится на две с новым номером; нумерация OQ-48…OQ-83 сплошная, на неё могут ссылаться только новые документы | +| 40 | 2026-10-04 | Статусы OQ-45/46/47 обновлены в существующих строках, не продублированы: все три — «допущение принято и применено в Срезе 0» с фактами (NU: контракт оставляет, ORM `OsvDataType` 1:1 с DBML v5.13, `NoopMapper`; OQ-46: 27 строк EXTENDED + 10 BASIC в seed, `is_mandatory = true`; OQ-47: 122 строки плана счетов, названия нестандартных субсчетов выведены по роли в §4). Кандидаты DBML v5.14 (`NU`, `NULLS NOT DISTINCT`, note `article_2_line`, действие `audit_action`, счётчик republish, `lower(email)`) внесены только строками реестра, файл `data-model-v5.13.dbml` не менялся | факты проверены чтением `arm-core/api/seed/core/`, `api/src/models/enums.py` и `contract/common.py`; создание DBML-версии — отдельное решение человека (CLAUDE.md: версия не меняется задним числом) | если человек решит иначе (например, хранить НУ), статус строки меняется правкой реестра, код Среза 0 не затронут | +| 41 | 2026-10-04 | В реестр **не** внесены, а оставлены в отчёте задачи 6 (кандидаты в follow-up issue при закрытии): scan-gateway п. 7 (рестарт NATS не автоматизирован), п. 8 (`stop()` не публикует конверты принятым заданиям), п. 9 (`/healthcheck` всегда 200), п. 13 (`DEBUG` не используется), п. 14 (`ResourceWarning` aiohttp), п. 15 (SMB без `connection_timeout`); tabular P-4, scan-gateway п. 10 и 16 (порты, `SCAN_GATEWAY_PORT`, устаревший README) — закрыты задачами 3–5 и `dev/README.md`; O-22 и T3.7/O-20 — процессные пункты задачи 7 | это инженерные доработки или уже исправленные расхождения документов, вопроса к человеку/аналитикам в них нет; реестр остаётся списком решений, которые принимает человек | пункты могут потеряться, если follow-up issue не будет заведена (чеклист закрытия, п. 6): они перечислены в отчёте с маркерами | +| 42 | 2026-10-04 | Формулировка статусов в новых строках: «допущение принято: …» — в коде/спеке Среза 0 действует рабочее допущение, вопрос открыт для подтверждения; «вопрос: …» — человек должен принять решение, допущение Среза 0 указано после двоеточия (аудит на уровне БД, `attempt + 1`, образцы ответов ИС, формат `data_ocr`, имя файла ответа, `.part` в `Input`) | правило файла: агент вопросы не закрывает, а помечает допущение; слово «закрыт» нигде не использовано | различение статусов — рекомендация, не контракт; при несогласии префикс правится в реестре | +| 43 | 2026-10-04 | Сценарий spec `dev-environment` «Запуск из venv против compose без Nexus» (`make venv` + `python run_server.py` + healthcheck) в Срезе 0 **не прогонялся**; раздел «Запуск сервисов из venv без Nexus» в `dev/README.md` остаётся черновиком, вопрос вынесен в OQ-84 (кандидат в follow-up issue) | проверка требует создать `.venv` внутри сервисных репозиториев (`make venv` пишет в `api/.venv`), а в фазе 2 они были доступны только для чтения (их worktree с открытыми PR не правятся); сквозная проверка шла на профиле `services` (контейнеры из тех же исходников) | сценарий spec может оказаться неработоспособным (набор переменных, `requirements.txt` без `raisa_*`, порты) — разработчик без Docker для сервисов узнает об этом при первом запуске; правится README или `.env.example` | +| 44 | 2026-10-04 | Уточнение к №18: блок `OPENBAO__SMB__*` **не обязателен**. В `arm-core` `OpenBAOSettings.SMB = SMBSettings()` с пустыми значениями по умолчанию (`api/src/core/config.py`), в `scan-gateway` блок необязателен в режиме `SCAN_HOTFOLDER_MODE=local` (по умолчанию) и обязателен целиком при `smb`, в `tabular-parser` блока нет, и `OpenBAOSettings` отвергает неизвестные ключи (`extra_forbidden`; проверено на копии его `config.py` с `.env` из `dev/.env.example`: падает на `OPENBAO.smb`, так же упадёт на `OPENBAO__DATABASE__*`) — `extra="ignore"` относится только к верхнему уровню `Settings`. Заглушки в `dev/.env.example` оставлены; комментарий и README исправлены и говорят: `.env.example` годится для `arm-core` как есть; для `scan-gateway` из него нужно убрать блок `DATABASE` (в его `OpenBAOSettings` поля нет, `extra_forbidden`), для `tabular-parser` — блоки `DATABASE` и `SMB` (уточнено 04.10 по ревью, N-1) | в момент решения №18 `Settings` arm-core требовал блок; затем в arm-core блок сделан необязательным (в Срезе 0 SMB ядру не нужен), а формулировка осталась в `.env.example` и README | ложное утверждение «обязателен» заставляло бы переносить заглушки в окружение, где они не нужны; цена низкая — у `arm-core` заглушки безвредны, а `scan-gateway` и `tabular-parser` с неотредактированным `.env.example` не стартуют (описано в README, часть OQ-84) | diff --git a/openspec/changes/slice-0-skeleton/specs/recognition-contract/spec.md b/openspec/changes/slice-0-skeleton/specs/recognition-contract/spec.md index 219679d..c7c8b10 100644 --- a/openspec/changes/slice-0-skeleton/specs/recognition-contract/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/recognition-contract/spec.md @@ -21,11 +21,33 @@ заголовком `Nats-Msg-Id` = `:` #### Scenario: Сообщение не по схеме -- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`) -- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` и не падает +- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются + корректные `job_id` и `file_id` +- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack + `processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии + реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает, + `scan-gateway` подставляет 1) — см. OQ-71 + +#### Scenario: Нечитаемый запрос +- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или + не uuid), а значит нельзя собрать валидный конверт +- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит + публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`) + +#### Scenario: Невалидный повтор запроса, уже находящегося в работе +- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом +- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result::` совпал бы с настоящим + результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается + +#### Scenario: Примеры контракта — иллюстрации +- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в + прошлом) +- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с + текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело + сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование) ### Requirement: Подтверждение взятия в работу (ack без JetStream) -Получив запрос, сервис SHALL немедленно, до чтения файла из S3, опубликовать в `recognition.result` конверт со +Получив корректный запрос (прошедший проверку схемы и `MAJOR`), сервис SHALL немедленно, до чтения файла из S3, опубликовать в `recognition.result` конверт со `status = processing`, `job_id`, `file_id`, `service {name, version}` и заголовком `Nats-Msg-Id = ack::`. Ядро SHALL перевести задание `published → processing` и записать `started_at`, `service_name`, `service_version`. Если ack не получен за `ACK_TIMEOUT_SECONDS` (по умолчанию 30), ядро SHALL опубликовать запрос повторно с тем же @@ -82,8 +104,12 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f #### Scenario: Несовместимая версия запроса - **WHEN** сервис, поддерживающий `1.x`, получает запрос `schema_version = "2.0"` -- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH`, запрос подтверждается (ack), чтобы не - зациклить доставку +- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на + запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не + будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого + запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его + отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает, + `scan-gateway` подставляет 1) — см. OQ-71 ### Requirement: Идемпотентность результатов без брокера Публикующий SHALL ставить заголовок `Nats-Msg-Id` на каждое сообщение (запрос `:`, ack @@ -95,6 +121,23 @@ v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `f - **WHEN** сервис опубликовал один и тот же результат два раза (повтор публикации после сетевой ошибки) - **THEN** задание изменено один раз, второе сообщение проигнорировано +### Requirement: Повторы публикации ответов сервисом +Сервис SHALL публиковать ack и результат с заголовком `Nats-Msg-Id` и при временной ошибке публикации (потеря соединения, +таймаут подтверждения сервера) повторять публикацию с тем же `Nats-Msg-Id`: **одна первая попытка и до +`PUBLISH_RETRIES` повторов** (значение по умолчанию 3, то есть не более 4 попыток), с паузой между попытками; пауза — +деталь реализации. Повторы исчерпаны — сервис пишет error-лог и не публикует это сообщение снова; задание закрывается +на стороне ядра (не пришёл результат — `reply_deadline` → `timeout`; не пришёл ack — повтор запроса и `dlq`, но результат, +дошедший без ack, ядро принимает и в статусе `published`). Дубликаты, появившиеся из-за повтора, +безопасны: ядро дедуплицирует их по `Nats-Msg-Id`. + +#### Scenario: Временный сбой публикации +- **WHEN** публикация результата упала с временной ошибкой, а следующая попытка успешна +- **THEN** сообщение опубликовано с прежним `Nats-Msg-Id`; ядро изменит задание один раз, даже если первая попытка тоже дошла + +#### Scenario: Повторы исчерпаны +- **WHEN** все 1 + `PUBLISH_RETRIES` попыток публикации результата неуспешны +- **THEN** сервис пишет error-лог и продолжает работу (процесс не падает), а ядро закрывает задание по `reply_deadline` + ### Requirement: Копия контракта в сервисе идентична источнику Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check` их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением). diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 9b098f5..5f710f7 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -5,26 +5,26 @@ ## 1. Контракт v1.0 (первым — от него зависят все сервисы) -- [ ] 1.1 [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о +- [x] 1.1 [docs] `docs/contracts/recognition-result.schema.json`: снять «ЧЕРНОВИК» из title, добавить `$comment` о версионировании, добавить `NOT_IMPLEMENTED` в `error.code`; проверить `python -m jsonschema` на файле схемы (метасхема 2020-12 валидна) -- [ ] 1.2 [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum, +- [x] 1.2 [docs] Новый `docs/contracts/recognition-request.schema.json` по spec `recognition-contract` (поля, enum, форматы uuid/date-time); проверить метасхемой -- [ ] 1.3 [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done | +- [x] 1.3 [docs] Новый `docs/contracts/recognition-result-message.schema.json` (конверт: `status` ∈ processing | done | needs_check | error; условные обязательные поля через `if/then`); проверить метасхемой -- [ ] 1.4 [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`, +- [x] 1.4 [docs] Примеры `docs/contracts/examples/`: `request-tabular.json`, `request-image.json`, `result-message-processing.json`, `result-message-done.json`, `result-message-error.json`, `result-osv-minimal.json`, `result-statement-minimal.json`, `result-error.json`; скрипт `docs/scripts/validate_contracts.py` валидирует все примеры схемами и возвращает 0 -- [ ] 1.5 [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила +- [x] 1.5 [docs] `03-architecture.md`: таблица «Контракт обмена через NATS» — ссылки на три файла схем, правила `Nats-Msg-Id`, ack через `processing`, повторная публикация; проверить, что текст ссылается на существующие файлы ## 2. Инфраструктура разработки -- [ ] 2.1 [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`, +- [x] 2.1 [docs] `docs/dev/docker-compose.yml`: сервисы `postgres` (16), `nats` (2.10, без `--jetstream`), `minio`, `minio-init` (создаёт бакет `arm-ka`), healthcheck'и, именованные тома; `docs/dev/.env.example`; проверка: `docker compose up -d` → все healthy, `mc ls` видит бакет, `curl :8222/varz` отвечает -- [ ] 2.2 [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus +- [x] 2.2 [docs] `docs/dev/README.md`: запуск инфраструктуры, переменные, сброс томов, запуск сервисов из venv без Nexus (`make venv` фильтрует `raisa_*`), черновик — дописывается в 5.4 ## 3. Ядро (детали — `arm-core/openspec/changes/slice-0-skeleton/tasks.md`) @@ -43,10 +43,10 @@ ## 4. Проверки качества (параллельно с 3.x, после первых тестов) -- [ ] 4.1 [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`, +- [x] 4.1 [docs] `docs/dev/checks.md`: общие цели `Makefile` (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`), что делает каждая, порядок перед PR, как пропускаются тесты без фикстур/docs; проверка: цели `arm-core` соответствуют описанию -- [ ] 4.2 [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или +- [x] 4.2 [docs] Эталонный фрагмент `docs/dev/Makefile.common` (включается в `Makefile` сервисов через `include` или копируется); проверка: `make check` в трёх репозиториях вызывает одинаковые команды (`diff` общих целей) ## 5. Сервисы распознавания (детали — в их change'ах) и профиль `services` @@ -55,10 +55,10 @@ `processing` и заглушкой `NOT_IMPLEMENTED`, Dockerfile 3.11, `make check-full` - [ ] 5.2 [scan-gateway] Вырезать фронт/БД, вендоренный контракт, горячие папки (local/smb), эмулятор, подписчик queue group `scan-gateway` с ack и таймаутом, Dockerfile 3.11, `make check-full` -- [ ] 5.3 [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway` +- [x] 5.3 [docs] `docker-compose.yml`: профиль `services` — `arm-core` (8000), `tabular-parser` (8001), `scan-gateway` (8002), `scan-emulator` (том `hotfolder`, `../../fixtures/ocr:ro`); проверка: `--profile services up --build`, все `/healthcheck` 200 с `nats = ok` -- [ ] 5.4 [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт +- [x] 5.4 [docs] Сквозная проверка руками по README: вставить `recognition_job` в БД через `psql` (или тестовый скрипт arm-core) → паблишер публикует → `tabular-parser` отвечает ack и `NOT_IMPLEMENTED` → `recognition_job.status` проходит `published → processing → error`, `error_code = NOT_IMPLEMENTED`; то же для `image` через эмулятор (`raw/{job_id}.json` появляется в MinIO); остановить `tabular-parser`, вставить задание — через 30 с повторная @@ -66,11 +66,11 @@ ## 6. Документы контекста и закрытие -- [ ] 6.1 [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила +- [x] 6.1 [docs] `06-test-data.md` §6: `fixtures/directories/` — только тестовые ЮЛ/ГК (Срез 1); план счетов, правила элиминации, права — `arm-core/api/seed/`; `09-delivery-plan.md` Срез 0: решение D-1 по пакету контрактов, ссылка на `docs/dev/`, проверки вместо CI -- [ ] 6.2 [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`; +- [x] 6.2 [docs] `07-open-questions.md`: OQ по итогам Среза 0 (контракт `data_type = NU` vs enum `osv_data_type`; обязательность ОСВ расширенного подхода; источник полного плана счетов 1С) и расхождения макета из задачи B; статусы допущений -- [ ] 6.3 [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки +- [x] 6.3 [docs] Итоговая проверка: `openspec validate slice-0-skeleton --strict` зелёный в четырёх репозиториях; ссылки между документами ведут на существующие файлы (`grep` по именам файлов) diff --git a/scripts/validate_contracts.py b/scripts/validate_contracts.py new file mode 100644 index 0000000..a34c2d6 --- /dev/null +++ b/scripts/validate_contracts.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12. + +Запуск из любого cwd: python scripts/validate_contracts.py +Зависимости: стандартная библиотека + jsonschema. +Код возврата: 0 — всё валидно, 1 — любая ошибка. +""" +import datetime +import json +import re +import sys +from pathlib import Path + +from jsonschema import Draft202012Validator, FormatChecker + +ROOT = Path(__file__).resolve().parent.parent +CONTRACTS = ROOT / "contracts" +EXAMPLES = CONTRACTS / "examples" + +SCHEMAS = { + "request": CONTRACTS / "recognition-request.schema.json", + "message": CONTRACTS / "recognition-result-message.schema.json", + "result": CONTRACTS / "recognition-result.schema.json", +} + +# Порядок важен: result-message-* проверяется раньше result-*. +PATTERNS = [ + ("request-", "request"), + ("result-message-", "message"), + ("result-", "result"), +] + +_DATE_TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$") + + +def _check_date_time(value): + # Собственная проверка вместо rfc3339-validator: нужна и дата, и время, и часовой пояс. + if not isinstance(value, str): + return True + if not _DATE_TIME_RE.match(value): + return False + datetime.datetime.fromisoformat(value.replace("z", "Z")) # ValueError -> формат не прошёл + return True + + +def make_format_checker(): + checker = FormatChecker() + checker.checks("date-time", raises=ValueError)(_check_date_time) + return checker + + +def short_path(error): + path = "/".join(str(p) for p in error.absolute_path) + return f"{path or ''}: {error.message[:120]}" + + +def main(): + failed = False + validators = {} + format_checker = make_format_checker() + + for kind, path in SCHEMAS.items(): + if not path.is_file(): + print(f"FAIL схема {path.name}: файл не найден") + failed = True + continue + try: + schema = json.loads(path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + except Exception as exc: # битый JSON или нарушение метасхемы + print(f"FAIL схема {path.name}: {str(exc).splitlines()[0][:150]}") + failed = True + continue + validators[kind] = Draft202012Validator(schema, format_checker=format_checker) + print(f"OK схема {path.name}") + + counts = {kind: 0 for kind in SCHEMAS} + for example in sorted(EXAMPLES.glob("*.json")): + kind = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None) + if kind is None: + print(f"FAIL {example.name}: имя не подходит ни под один шаблон (request-*, result-message-*, result-*)") + failed = True + continue + counts[kind] += 1 + validator = validators.get(kind) + if validator is None: + print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: схема недоступна") + failed = True + continue + try: + instance = json.loads(example.read_text(encoding="utf-8")) + except ValueError as exc: + print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: не JSON: {exc}") + failed = True + continue + errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path)) + if errors: + failed = True + print(f"FAIL {example.name} [{SCHEMAS[kind].name}]") + for error in errors: + print(f" {short_path(error)}") + else: + print(f"OK {example.name} [{SCHEMAS[kind].name}]") + + for kind, count in counts.items(): + if count == 0: + print(f"FAIL нет ни одного примера для схемы {SCHEMAS[kind].name}") + failed = True + + total = sum(counts.values()) + print(f"Итого: примеров {total}, {'есть ошибки' if failed else 'всё валидно'}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())