From 947d2a6a51f7f90f3606fbcdfb34d0f1157a5c03 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 11:57:22 +0400 Subject: [PATCH] =?UTF-8?q?contract:=20v1.1=20=E2=80=94=20OsvRow.extra,=20?= =?UTF-8?q?CONTRACT=5FVERSION=201.1,=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 +- .../examples/result-osv-minimal.json | 5 +- .../examples/result-osv-needs-check.json | 99 +++++++++++++++++++ api/contracts/recognition-result.schema.json | 7 +- api/src/contract/__init__.py | 2 +- api/src/contract/common.py | 4 +- api/src/contract/result.py | 3 +- api/tests/unit/test_contract.py | 22 ++++- api/tests/unit/test_worker.py | 6 +- .../plans/2026-10-05-slice-2a-osv-parser.md | 6 +- openspec/changes/slice-2a-osv-parser/tasks.md | 2 +- 11 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 api/contracts/examples/result-osv-needs-check.json diff --git a/CLAUDE.md b/CLAUDE.md index 727ba05..4dbe354 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над книгой Excel, Срезы 2+; -- `api/src/contract/` — pydantic-модели контракта v1.0 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с +- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с `../docs/contracts` проверяется в `make check`) — 3.1; - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) появятся в Срезе 2 — 4.1; diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json index 6b6d122..f883a8d 100644 --- a/api/contracts/examples/result-osv-minimal.json +++ b/api/contracts/examples/result-osv-minimal.json @@ -1,5 +1,5 @@ { - "schema_version": "1.0", + "schema_version": "1.1", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "service": { "name": "tabular-parser", "version": "0.1.0" }, @@ -66,7 +66,8 @@ "turnover_debit": "500.00", "turnover_credit": "200.50", "closing_debit": "0.00", - "closing_credit": "700.50" + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } } ], "checks": { diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json new file mode 100644 index 0000000..b30baf7 --- /dev/null +++ b/api/contracts/examples/result-osv-needs-check.json @@ -0,0 +1,99 @@ +{ + "schema_version": "1.1", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "needs_check", + "metrics": { "rows": 3, "warnings": ["hierarchy=outline", "balance identity violated at rows [3]"] }, + "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" + }, + { + "row_number": 3, + "level": 1, + "parent_row_number": 2, + "row_value": "ООО Одуванчик, ИНН 7722222222", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Одуванчик", + "counterparty_inn": "7722222222", + "data_type": "NU", + "opening_debit": "0.00", + "opening_credit": "400.00", + "turnover_debit": "100.00", + "turnover_credit": "50.00", + "closing_debit": "0.00", + "closing_credit": "400.00", + "extra": { "КПП": "352801001" } + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [3], + "parent_sum_violations": [] + } + } + ] +} diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json index c5eea87..5d329a8 100644 --- a/api/contracts/recognition-result.schema.json +++ b/api/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.1", "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.1 пишет \"1.1\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -114,7 +114,8 @@ "turnover_debit": { "$ref": "#/$defs/money" }, "turnover_credit": { "$ref": "#/$defs/money" }, "closing_debit": { "$ref": "#/$defs/money" }, - "closing_credit": { "$ref": "#/$defs/money" } + "closing_credit": { "$ref": "#/$defs/money" }, + "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } } } } }, diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py index 79922fd..bd042b7 100644 --- a/api/src/contract/__init__.py +++ b/api/src/contract/__init__.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`). +"""Контракт обмена с сервисами распознавания v1.1 (схемы — `api/contracts/`, копии `docs/contracts/`). Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений. """ diff --git a/api/src/contract/common.py b/api/src/contract/common.py index d0e27a8..8fa3581 100644 --- a/api/src/contract/common.py +++ b/api/src/contract/common.py @@ -1,4 +1,4 @@ -"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. +"""Общие основы моделей контракта v1.1: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — @@ -27,7 +27,7 @@ from pydantic import ( model_validator, ) -CONTRACT_VERSION = "1.0" +CONTRACT_VERSION = "1.1" # Шаблоны версий — буквально из схем SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 18d752c..4a89716 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -1,4 +1,4 @@ -"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0. +"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.1. tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее). Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор — @@ -102,6 +102,7 @@ class OsvRow(ContractModel): turnover_credit: Money = None closing_debit: Money = None closing_credit: Money = None + extra: dict[str, str | None] | None = None # нераспознанные колонки таблицы: заголовок → текст ячейки (§1.2) class OsvChecks(ContractModel): diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 8aea34c..bce77dc 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -1,4 +1,4 @@ -"""Контракт обмена с сервисами распознавания v1.0. +"""Контракт обмена с сервисами распознавания v1.1. Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и @@ -56,9 +56,10 @@ EXAMPLE_KIND = { "result-message-processing.json": "message", "result-error.json": "result", "result-osv-minimal.json": "result", + "result-osv-needs-check.json": "result", "result-statement-minimal.json": "result", } -EXAMPLES_COUNT = 8 +EXAMPLES_COUNT = 9 def _load(path: Path) -> Any: @@ -453,6 +454,21 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [ False, ), ("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True), + ( + "result/osv-row-extra-strings", + "result-osv-minimal.json", + _set("documents.0.rows.0.extra", {"КПП": "1", "Н": None}), + True, + ), + ("result/osv-row-extra-null", "result-osv-minimal.json", _set("documents.0.rows.0.extra", None), True), + ("result/osv-row-extra-absent", "result-osv-minimal.json", _drop("documents.0.rows.1.extra"), True), + ( + "result/osv-row-extra-non-string-value", + "result-osv-minimal.json", + _set("documents.0.rows.0.extra", {"КПП": 1}), + False, + ), + ("result/osv-row-extra-not-object", "result-osv-minimal.json", _set("documents.0.rows.0.extra", ["КПП"]), False), ("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False), ("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False), ( @@ -588,7 +604,7 @@ def test_message_status_enum_values() -> None: def test_contract_version_constant() -> None: - assert CONTRACT_VERSION == "1.0" + assert CONTRACT_VERSION == "1.1" def test_python_float_amount_is_rejected_with_clear_error() -> None: diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index 8035b00..46a27eb 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -231,7 +231,7 @@ async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None: assert subjects == {RESULT_SUBJECT} ack = env.bodies()[0] assert ack == { - "schema_version": "1.0", + "schema_version": "1.1", "job_id": JOB_ID, "file_id": FILE_ID, "status": "processing", @@ -368,7 +368,7 @@ async def test_unsupported_major_version_gives_schema_mismatch_without_ack(env: result = env.bodies()[0] _assert_message_valid(result) assert result["status"] == "error" - assert result["schema_version"] == "1.0" + assert result["schema_version"] == "1.1" assert result["job_id"] == JOB_ID assert result["file_id"] == FILE_ID assert result["error"]["code"] == "SCHEMA_MISMATCH" @@ -503,7 +503,7 @@ async def test_substituted_parser_gives_done_with_result_file() -> None: _assert_message_valid(envelope) assert envelope["status"] == "done" assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY} - assert envelope["result_schema_version"] == "1.0" + assert envelope["result_schema_version"] == "1.1" assert "error" not in envelope assert envelope["metrics"]["rows"] == PARSER_ROWS assert envelope["metrics"]["duration_ms"] >= 0 diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 04f562c..5d81c24 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -57,9 +57,9 @@ - `CONTRACT_VERSION = "1.1"` — воркер пишет его в `schema_version` файла и `result_schema_version` конверта (уже так). - Пример `result-osv-needs-check.json`: статус `needs_check`, один документ, `checks.balance_identity_violations = [3]`, `metrics.warnings = ["hierarchy=outline", "balance identity violated at rows [3]"]`, `schema_version "1.1"`, у строки 3 `extra: {"КПП": "352801001"}`. -- [ ] **Step 1 (docs):** внести правки схемы и примеров; проверить `python -c "import json,jsonschema; ..."` — оба примера `result-osv-*.json` валидны по новой схеме, старый `result-osv-minimal.json` без `extra` тоже валиден; `openspec validate slice-2a-osv-parser --strict` в docs → valid. Commit в docs: `contracts: результат v1.1 — rows[].extra (§1.2), пример needs_check`. -- [ ] **Step 2 (здесь):** скопировать схему, добавить поле модели и версию; `pytest tests/unit/test_contract.py tests/unit/test_worker.py -v` → сначала падает на версии/идентичности, затем PASS после правок ожиданий (`"1.1"`). -- [ ] **Step 3:** `make check` → PASS. Commit: `contract: v1.1 — OsvRow.extra, CONTRACT_VERSION 1.1, копия схемы`. +- [x] **Step 1 (docs):** внести правки схемы и примеров; проверить `python -c "import json,jsonschema; ..."` — оба примера `result-osv-*.json` валидны по новой схеме, старый `result-osv-minimal.json` без `extra` тоже валиден; `openspec validate slice-2a-osv-parser --strict` в docs → valid. Commit в docs: `contracts: результат v1.1 — rows[].extra (§1.2), пример needs_check`. +- [x] **Step 2 (здесь):** скопировать схему, добавить поле модели и версию; `pytest tests/unit/test_contract.py tests/unit/test_worker.py -v` → сначала падает на версии/идентичности, затем PASS после правок ожиданий (`"1.1"`). +- [x] **Step 3:** `make check` → PASS. Commit: `contract: v1.1 — OsvRow.extra, CONTRACT_VERSION 1.1, копия схемы`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index bdb7927..2496002 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -10,7 +10,7 @@ `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурой `osv_files` (все файлы `../fixtures/osv/**` кроме `synthetic`, параметризация по относительному пути); маркер `fixtures` в деле; `test_layout` не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров. -- [ ] 1.2 Контракт v1.1: скопировать `../docs/contracts/recognition-result.schema.json` v1.1 в `api/contracts/`, `OsvRow.extra: +- [x] 1.2 Контракт v1.1: скопировать `../docs/contracts/recognition-result.schema.json` v1.1 в `api/contracts/`, `OsvRow.extra: dict[str, str | None] | None`, `CONTRACT_VERSION = "1.1"`; примеры docs проходят; тест идентичности зелёный; воркер пишет `result_schema_version = "1.1"` (`test_worker` обновлён). Проверка: `make check`. - [ ] 1.3 `grid.py` и читатели: `Cell`, `Sheet`, `MergedRange`, `column_letter`; `readers/xlsx.py` (openpyxl, полное чтение,