Тест идентичности копии контракта по MAJOR (шаг 0 docs#116) #51

Merged
aep merged 1 commits from feat/50-contract-identity-window into main 2026-10-18 17:11:46 +03:00
7 changed files with 355 additions and 15 deletions

View File

@ -42,8 +42,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`):
- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 — - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 —
Срез 2b; Срез 2b;
- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с - `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность по правилу MAJOR (S-53),
`../docs/contracts` проверяется в `make check`) — 3.1; `tests/contract_identity.py`, с `../docs/contracts` проверяется в `make check`) — 3.1;
- `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`)
появились в Срезе 2a — 4.1; появились в Срезе 2a — 4.1;
- `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на - `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на
@ -89,7 +89,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23). лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы **Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы
в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`). в `api/contracts/` идентична `../docs/contracts/` по правилу MAJOR (S-53, `tests/contract_identity.py`; проверяется в `make check`).
**Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden, **Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden,
property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция

View File

@ -0,0 +1,109 @@
"""Правило сверки копии контракта `api/contracts/` с `docs/contracts/` на окне перехода v1 → v2 (S-53).
Правило — из спеки docs `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»:
схема копии `<имя>.schema.json` с `$id` `…/v<M>` сверяется с `<имя>.v<M>.schema.json` docs, если такой файл есть,
иначе с основным `<имя>.schema.json`; пример `examples/<имя>.json` — с `examples/v<M>/<имя>.json`, если он есть
(`M` — MAJOR схемы копии вида примера), иначе с `examples/<имя>.json`; прочие файлы копии (`.v<N>`-схемы,
`examples/v<N>/`) — с одноимённым файлом docs. Файл побайтно одинаков в arm-core, tabular-parser и scan-gateway
(`api/tests/contract_identity.py`); правка — во всех трёх, сверка — `shasum -a 256`.
"""
import json
import re
from pathlib import Path
# Порядок значим: «result-message-» начинается с «result-», берётся первый подходящий префикс.
KIND_BY_PREFIX: tuple[tuple[str, str], ...] = (
("request-", "recognition-request"),
("result-message-", "recognition-result-message"),
("result-", "recognition-result"),
)
_EXAMPLE_DEPTH = 2 # examples/<файл>.json
_ID_MAJOR = re.compile(r"/v(\d+)$")
_WINDOW_SCHEMA = re.compile(r"\.v(\d+)\.schema\.json$")
_WINDOW_EXAMPLE = re.compile(r"^examples/v(\d+)/[^/]+\.json$")
def schema_major(schema_path: Path) -> int:
"""MAJOR схемы из её `$id` (`…/v<M>`)."""
schema = json.loads(schema_path.read_text(encoding="utf-8"))
schema_id = schema.get("$id") if isinstance(schema, dict) else None
match = _ID_MAJOR.search(schema_id) if isinstance(schema_id, str) else None
if match is None:
raise ValueError(f"{schema_path.name}: в $id нет MAJOR (…/v<M>)")
return int(match.group(1))
def example_kind(file_name: str) -> str:
"""Имя схемы вида примера по префиксу имени файла."""
for prefix, kind in KIND_BY_PREFIX:
if file_name.startswith(prefix):
return kind
raise ValueError(f"{file_name}: вид примера не определён по префиксу (request-, result-message-, result-)")
def docs_pair(copy_rel: str, copy_dir: Path, docs_dir: Path) -> str:
"""Относительный POSIX-путь файла docs, с которым сверяется файл копии `copy_rel`."""
parts = copy_rel.split("/")
if len(parts) == 1 and copy_rel.endswith(".schema.json") and not _WINDOW_SCHEMA.search(copy_rel):
major = schema_major(copy_dir / copy_rel)
candidate = copy_rel.removesuffix(".schema.json") + f".v{major}.schema.json"
return candidate if (docs_dir / candidate).is_file() else copy_rel
if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples" and copy_rel.endswith(".json"):
kind = example_kind(parts[1])
kind_schema = copy_dir / f"{kind}.schema.json"
if not kind_schema.is_file():
raise ValueError(f"{copy_rel}: нет схемы {kind}.schema.json в копии")
candidate = f"examples/v{schema_major(kind_schema)}/{parts[1]}"
return candidate if (docs_dir / candidate).is_file() else copy_rel
return copy_rel
def held_window_majors(copy_rels: set[str]) -> set[int]:
"""MAJOR, которые копия держит файлами `.v<N>` (хотя бы один файл — держит все)."""
majors: set[int] = set()
for rel in copy_rels:
match = _WINDOW_SCHEMA.search(rel) if "/" not in rel else _WINDOW_EXAMPLE.match(rel)
if match:
majors.add(int(match.group(1)))
return majors
def _json_files(root: Path) -> set[str]:
return {p.relative_to(root).as_posix() for p in root.rglob("*.json")}
def _is_required_in_copy(docs_rel: str, held: set[int]) -> bool:
parts = docs_rel.split("/")
if len(parts) == 1 and docs_rel.endswith(".schema.json"):
match = _WINDOW_SCHEMA.search(docs_rel)
return match is None or int(match.group(1)) in held
if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples":
return True
match = _WINDOW_EXAMPLE.match(docs_rel)
return match is not None and int(match.group(1)) in held
def identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]:
"""Все расхождения копии с docs; каждое начинается с относительного пути файла. Пустой список — идентична."""
copy_files = _json_files(copy_dir)
docs_files = _json_files(docs_dir)
problems: list[str] = []
for copy_rel in sorted(copy_files):
try:
pair = docs_pair(copy_rel, copy_dir, docs_dir)
except ValueError as exc:
problems.append(f"{copy_rel}: {exc}")
continue
if pair not in docs_files:
problems.append(f"{copy_rel}: нет пары в docs/contracts ({pair})")
elif (copy_dir / copy_rel).read_bytes() != (docs_dir / pair).read_bytes():
problems.append(
f"{copy_rel}: отличается от docs/contracts/{pair} — скопируйте заново (cp), не правьте руками"
)
held = held_window_majors(copy_files)
for docs_rel in sorted(docs_files - copy_files):
if _is_required_in_copy(docs_rel, held):
problems.append(f"{docs_rel}: есть в docs/contracts, нет в api/contracts")
return sorted(problems, key=lambda p: p.split(":", 1)[0])

View File

@ -1,8 +1,9 @@
"""Контракт обмена с сервисами распознавания v1.1. """Контракт обмена с сервисами распознавания v1.1.
Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`. Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`.
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53);
JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же вердикт на негативных кейсах. каждый пример проходит и JSON Schema, и pydantic-модель (с round-trip); модели и схемы выносят один и тот же
вердикт на негативных кейсах.
""" """
import copy import copy
@ -34,6 +35,7 @@ from src.contract import (
request_subject, request_subject,
result_msg_id, result_msg_id,
) )
from tests.contract_identity import identity_problems
API_DIR = Path(__file__).resolve().parents[2] API_DIR = Path(__file__).resolve().parents[2]
CONTRACTS_DIR = API_DIR / "contracts" CONTRACTS_DIR = API_DIR / "contracts"
@ -116,16 +118,8 @@ def test_vendored_contracts_are_identical_to_docs() -> None:
pytest.skip( pytest.skip(
"docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)" "docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)"
) )
docs_files = {p.relative_to(docs_dir).as_posix() for p in docs_dir.rglob("*.json")} problems = identity_problems(CONTRACTS_DIR, docs_dir)
own_files = {p.relative_to(CONTRACTS_DIR).as_posix() for p in CONTRACTS_DIR.rglob("*.json")} assert problems == [], "\n".join(problems)
assert own_files == docs_files, (
f"набор файлов контракта разошёлся: только в api/contracts: {sorted(own_files - docs_files)}, "
f"только в docs/contracts: {sorted(docs_files - own_files)}"
)
for name in sorted(docs_files):
assert (CONTRACTS_DIR / name).read_bytes() == (docs_dir / name).read_bytes(), (
f"файл {name} в api/contracts отличается от docs/contracts: скопируйте заново (cp), не правьте руками"
)
def test_example_table_covers_all_example_files() -> None: def test_example_table_covers_all_example_files() -> None:

View File

@ -0,0 +1,190 @@
"""Правило теста идентичности копии контракта (S-53, спека recognition-contract). Файл одинаков в arm-core,
tabular-parser и scan-gateway; правка — сразу во всех трёх."""
import json
from pathlib import Path
import pytest
from tests.contract_identity import docs_pair, example_kind, identity_problems, schema_major
REQ, MSG, RES = "recognition-request", "recognition-result-message", "recognition-result"
def _schema(name: str, major: int) -> str:
return json.dumps({"$id": f"arm-ka/{name}/v{major}"})
def _example(major: int, mark: str) -> str:
return json.dumps({"schema_version": f"{major}.0", "mark": mark})
def _v1_copy() -> dict[str, str]: # копия сервиса сегодня = docs main до #117
return {
f"{REQ}.schema.json": _schema(REQ, 1),
f"{MSG}.schema.json": _schema(MSG, 1),
f"{RES}.schema.json": _schema(RES, 1),
"examples/request-image.json": _example(1, "request"),
"examples/result-message-done.json": _example(1, "message"),
"examples/result-statement-excel.json": _example(1, "result"),
}
def _v2_main() -> dict[str, str]: # копия сервиса после шага 5 = docs после #117 без файлов окна; запрос остаётся v1
return {
f"{REQ}.schema.json": _schema(REQ, 1),
f"{MSG}.schema.json": _schema(MSG, 2),
f"{RES}.schema.json": _schema(RES, 2),
"examples/request-image.json": _example(1, "request"),
"examples/result-message-done.json": _example(2, "message"),
"examples/result-statement-excel.json": _example(2, "result"),
}
def _docs_window() -> dict[str, str]: # docs после #117 = копия ядра после шага 3
v1 = _v1_copy()
return {
**_v2_main(),
f"{MSG}.v1.schema.json": v1[f"{MSG}.schema.json"],
f"{RES}.v1.schema.json": v1[f"{RES}.schema.json"],
"examples/v1/result-message-done.json": v1["examples/result-message-done.json"],
"examples/v1/result-statement-excel.json": v1["examples/result-statement-excel.json"],
}
def _dirs(tmp_path: Path, copy: dict[str, str], docs: dict[str, str]) -> tuple[Path, Path]:
"""Пишет файлы в tmp_path/copy и tmp_path/docs (подкаталоги создаёт), возвращает (copy_dir, docs_dir)."""
result = []
for name, files in (("copy", copy), ("docs", docs)):
root = tmp_path / name
root.mkdir()
for rel, text in files.items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(text, encoding="utf-8")
result.append(root)
return result[0], result[1]
def _without(files: dict[str, str], rel: str) -> dict[str, str]:
return {k: v for k, v in files.items() if k != rel}
def test_step0_before_docs_merge_passes(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _v1_copy(), _v1_copy())
assert identity_problems(c, d) == []
assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json"
def test_stale_copy_fails_naming_file(tmp_path: Path) -> None:
docs = _v1_copy()
docs[f"{RES}.schema.json"] = json.dumps({"$id": "arm-ka/recognition-result/v1", "new": 1})
c, d = _dirs(tmp_path, _v1_copy(), docs)
problems = identity_problems(c, d)
assert len(problems) == 1
assert problems[0].startswith(f"{RES}.schema.json")
assert "отличается" in problems[0]
def test_service_on_v1_after_v2_merge_passes(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _v1_copy(), _docs_window())
assert identity_problems(c, d) == []
assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.v1.schema.json"
assert docs_pair(f"{MSG}.schema.json", c, d) == f"{MSG}.v1.schema.json"
def test_request_v1_pairs_with_main_after_v2_merge(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _v1_copy(), _docs_window())
assert docs_pair(f"{REQ}.schema.json", c, d) == f"{REQ}.schema.json"
assert docs_pair("examples/request-image.json", c, d) == "examples/request-image.json"
def test_previous_major_examples_pair_with_v1_dir(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _v1_copy(), _docs_window())
for name in ("result-statement-excel.json", "result-message-done.json"):
assert docs_pair(f"examples/{name}", c, d) == f"examples/v1/{name}"
def test_service_on_v2_passes_and_ignores_v1_files(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _v2_main(), _docs_window())
assert identity_problems(c, d) == []
assert docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json"
assert docs_pair("examples/result-statement-excel.json", c, d) == "examples/result-statement-excel.json"
def test_core_window_copy_passes(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _docs_window(), _docs_window())
assert identity_problems(c, d) == []
assert docs_pair(f"{RES}.v1.schema.json", c, d) == f"{RES}.v1.schema.json"
assert docs_pair("examples/v1/result-statement-excel.json", c, d) == "examples/v1/result-statement-excel.json"
def test_core_window_copy_requires_every_v1_docs_file(tmp_path: Path) -> None:
copy = _without(_docs_window(), "examples/v1/result-message-done.json")
c, d = _dirs(tmp_path, copy, _docs_window())
assert "examples/v1/result-message-done.json: есть в docs/contracts, нет в api/contracts" in identity_problems(c, d)
def test_window_copy_without_docs_pair_fails(tmp_path: Path) -> None:
c, d = _dirs(tmp_path, _docs_window(), _v2_main())
problems = identity_problems(c, d)
for rel in (f"{RES}.v1.schema.json", "examples/v1/result-statement-excel.json"):
found = [p for p in problems if p.startswith(rel)]
assert found, rel
assert "нет пары" in found[0]
def test_top_level_docs_file_missing_in_copy_fails(tmp_path: Path) -> None:
copy = _without(_v1_copy(), "examples/request-image.json")
c, d = _dirs(tmp_path, copy, _v1_copy())
assert "examples/request-image.json: есть в docs/contracts, нет в api/contracts" in identity_problems(c, d)
def test_extra_copy_file_fails(tmp_path: Path) -> None:
copy = {**_v1_copy(), "examples/result-new.json": _example(1, "result")}
c, d = _dirs(tmp_path, copy, _v1_copy())
problems = identity_problems(c, d)
assert len(problems) == 1
assert problems[0].startswith("examples/result-new.json")
assert "нет пары" in problems[0]
def test_new_top_level_docs_example_is_reported(tmp_path: Path) -> None:
docs = {**_docs_window(), "examples/result-statement-ocr.json": _example(2, "result")}
c, d = _dirs(tmp_path, _v1_copy(), docs)
problems = identity_problems(c, d)
assert len(problems) == 1
assert problems[0].startswith("examples/result-statement-ocr.json")
def test_unknown_example_prefix_fails(tmp_path: Path) -> None:
files = {**_v1_copy(), "examples/foo.json": _example(1, "x")}
c, d = _dirs(tmp_path, files, files)
problems = identity_problems(c, d)
assert len(problems) == 1
assert problems[0].startswith("examples/foo.json")
assert "вид примера" in problems[0]
with pytest.raises(ValueError, match="вид примера"):
example_kind("foo.json")
def test_schema_without_major_fails(tmp_path: Path) -> None:
copy = {**_v1_copy(), f"{RES}.schema.json": json.dumps({"$id": "arm-ka/recognition-result"})}
c, d = _dirs(tmp_path, copy, _v1_copy())
problems = identity_problems(c, d)
assert any(p.startswith(f"{RES}.schema.json") and "$id" in p for p in problems)
with pytest.raises(ValueError):
schema_major(c / f"{RES}.schema.json")
def test_example_kind_by_longest_prefix(tmp_path: Path) -> None:
copy = {
**_v1_copy(),
f"{RES}.schema.json": _schema(RES, 2),
"examples/result-statement-excel.json": _example(2, "result"),
}
docs = {**copy, "examples/v1/result-message-done.json": _example(1, "message")}
c, d = _dirs(tmp_path, copy, docs)
assert example_kind("result-message-done.json") == MSG
assert docs_pair("examples/result-message-done.json", c, d) == "examples/v1/result-message-done.json"
assert docs_pair("examples/result-statement-excel.json", c, d) == "examples/result-statement-excel.json"

View File

@ -0,0 +1,22 @@
## Why
Окно перехода контракта v1 → v2 (docs change `unified-statement-normalization`, шаг 0 выкладки): после слияния docs PR #117
основные схемы в `docs/contracts/` станут v2.0, а прежние v1 лягут рядом файлами `*.v1.schema.json` и `examples/v1/`.
Текущий тест идентичности копии сверяет наборы файлов целиком и покраснеет сразу после слияния. Правило S-53 спеки docs
`recognition-contract` сверяет копию с файлом docs своего MAJOR.
## What Changes
- Тест идентичности копии `api/contracts/` использует правило MAJOR (`api/tests/contract_identity.py`, побайтно одинаковый
файл в arm-core, tabular-parser и scan-gateway) вместо сравнения наборов файлов целиком.
- Требование «Копия контракта идентична источнику» уточнено под окно перехода.
## Non-goals
- Копии `api/contracts/` и pydantic-модели не меняются; переход сервиса на контракт 2.0 — шаг 5 выкладки, отдельный change.
## Impact
- `api/tests/` (тест идентичности, правило, сценарии), `CLAUDE.md`.
- Issue: RSHB/arm-tabular-parser#50; план —
`docs/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md`.

View File

@ -0,0 +1,22 @@
## MODIFIED Requirements
### Requirement: Копия контракта идентична источнику
Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts`
каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе
идентична источнику»: схема с `$id` `…/v<M>` — с `<имя>.v<M>.schema.json`, если такой файл есть в docs, иначе с
основным; пример — с `examples/v<M>/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого
`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать
JSON, проходящий схему копии. Проверка входит в `make check`.
#### Scenario: `make check`
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением
#### Scenario: Окно перехода на контракт 2.0
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и
`examples/v1/`, а копия сервиса — v1.2
- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит
#### Scenario: Копия устарела
- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет
- **THEN** тест идентичности падает, сообщение называет файл

View File

@ -0,0 +1,3 @@
## 1. Тест идентичности по правилу MAJOR
- [x] 1.1 Выполнить задачи плана 0 (`docs`, `openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md`) для этого репозитория: правило, сценарии, реальный тест, проверка `make check` на docs main и на ветке `feat/116-unified-normalization`.