test: golden v2 и Review Focus на реальных ответах ИС (#46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:18:00 +04:00
parent d7bef3e08e
commit 22ca529bc6
3 changed files with 174 additions and 11 deletions

View File

@ -6,10 +6,13 @@
import json
import sys
import uuid
from pathlib import Path
from typing import Any
from arm_statements import default_rules
from src.contract import ResultStatus, ServiceInfo, ServiceName
from src.core.worker import _outcome_metrics, build_result
from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import merge_answers
from src.normalizer import NormalizeOutcome, OcrNormalizer
@ -18,6 +21,9 @@ API_DIR = Path(__file__).resolve().parents[1]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
GOLDEN_DIR = REAL_FIXTURES / "expected_normalize"
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
JOB_ID = uuid.UUID(JOB)
FILE_ID = uuid.UUID("3f1c2b7a-0000-4000-8000-0000000000f1")
SERVICE = ServiceInfo(name=ServiceName.SCAN_GATEWAY, version="golden")
GOLDEN_VERSION = "0" * 40 # фиксированная версия копии: golden от раскладки копии не зависит
@ -42,14 +48,34 @@ def real_set(name: str) -> dict[str, Any]:
return dict(merge_answers(JOB, files))
def full_payload(outcome: NormalizeOutcome) -> dict[str, Any]:
"""Полный файл результата (только успех) с фиктивными идентификаторами: им проверяется схема `result`."""
result = build_result(
outcome,
job_id=JOB_ID,
file_id=FILE_ID,
service=SERVICE,
raw_s3_key="raw/golden.json",
metrics=_outcome_metrics(outcome),
)
return dict(result.to_wire())
def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]:
return {
"status": outcome.status.value,
"warnings": [w.to_wire() for w in outcome.warnings],
"metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None,
"error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None,
"documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents],
}
"""Эталон golden v2 (P5-14): `schema_version`, `status`, `metrics`, `documents`; для ошибки — ещё `error`.
`job_id`, `file_id`, `service`, `raw_s3_key` не пишутся: `statements_version` меняется при каждой раскладке."""
metrics = _outcome_metrics(outcome)
if outcome.status is ResultStatus.ERROR:
return {
"schema_version": "2.0",
"status": "error",
"error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None,
"metrics": metrics.model_dump(mode="json", exclude_none=True) if metrics else None,
"documents": [],
}
wire = full_payload(outcome)
return {key: wire[key] for key in ("schema_version", "status", "metrics", "documents") if key in wire}
def main() -> int:
@ -65,7 +91,7 @@ def main() -> int:
)
print(
f"{name}: {payload['status']}, документов {len(payload['documents'])}, "
f"предупреждений {len(payload['warnings'])}"
f"предупреждений {len((payload.get('metrics') or {}).get('warnings', []))}"
)
return 0

View File

@ -9,8 +9,18 @@ from pathlib import Path
import pytest
from arm_statements import default_rules
from src.contract import ResultStatus
from src.normalizer import OcrNormalizer
from tests.golden_update import GOLDEN_DIR, GOLDEN_VERSION, REAL_FIXTURES, golden_cases, outcome_to_json, real_set
from tests.golden_update import (
GOLDEN_DIR,
GOLDEN_VERSION,
REAL_FIXTURES,
full_payload,
golden_cases,
outcome_to_json,
real_set,
)
from tests.unit.test_contract import VALIDATORS
API_DIR = Path(__file__).resolve().parents[3]
RULES = default_rules()
@ -25,9 +35,11 @@ def _cases() -> list[str]:
def test_matches_golden(name: str) -> None:
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
pytest.skip("golden v2 пересобирается в задаче 6 (эталоны в fixtures пока прежнего формата)")
expected_path = GOLDEN_DIR / f"{name}.json"
assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff"
expected = json.loads(expected_path.read_text(encoding="utf-8"))
actual = outcome_to_json(OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name)))
outcome = OcrNormalizer(RULES, version=GOLDEN_VERSION).normalize(real_set(name))
if outcome.status is not ResultStatus.ERROR:
VALIDATORS["result"].validate(full_payload(outcome))
actual = outcome_to_json(outcome)
assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures"

View File

@ -0,0 +1,125 @@
"""Review Focus плана 5: правила пакета arm_statements на реальных ответах ИС (`fixtures/ocr`).
Утверждения не ослабляются и не подгоняются под файл (Р-31/P5-17): расхождение — находка для пакета."""
from decimal import Decimal
from typing import Any
import pytest
from arm_statements import default_rules
from src.contract import ErrorCode, ResultStatus
from src.contract.result import StatementDocument, StatementLine, StatementValue
from src.normalizer import NormalizeOutcome, OcrNormalizer
from tests.golden_update import JOB, REAL_FIXTURES, real_set
pytestmark = [
pytest.mark.fixtures,
pytest.mark.skipif(not REAL_FIXTURES.is_dir(), reason="fixtures недоступен: положите репозиторий fixtures рядом"),
]
NORMALIZER = OcrNormalizer(default_rules(), version="0" * 40)
def run(name: str) -> NormalizeOutcome:
return NORMALIZER.normalize(real_set(name))
def lines(doc: StatementDocument) -> dict[str, StatementLine]:
refs = [ln.ref for ln in doc.lines]
assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines)))
return {ln.ref: ln for ln in doc.lines}
def value(line: StatementLine, column_index: int) -> StatementValue:
return next(v for v in line.values if v.column_index == column_index)
def _holders(out: NormalizeOutcome) -> list[Any]:
"""Что удерживает `done`: значения и строки с needs_review, строки не ok/detail, предупреждения error."""
found: list[Any] = []
for doc in out.documents:
for ln in doc.lines:
if ln.line_status not in ("ok", "detail"):
found.append((doc.locator, ln.ref, "line_status", ln.line_status, ln.line_code_text))
for v in ln.values:
if v.needs_review:
found.append((doc.locator, ln.ref, v.column_index, v.text, v.confidence))
if ln.needs_review:
found.append((doc.locator, ln.ref, "line", ln.line_code_text, None))
found.extend(("warning", w.code, w.severity, w.locator, w.message) for w in out.warnings if w.severity == "error")
return found
def test_aep_3_junk_codes_and_dirty_amounts() -> None: # Review Focus 1, 4
out = run("aep_3")
assert out.status is ResultStatus.NEEDS_CHECK
bal, inc = out.documents
assert [(d.document_type, d.locator, d.status) for d in out.documents] == [
("BALANCE", f"file:{JOB}.json;table:0", "needs_check"),
("INCOME", f"file:{JOB}.json;table:1", "needs_check"),
]
assert [c.column_index for c in bal.columns] == [2, 4, 5]
b = lines(bal)
assert (b["row:0"].line_status, b["row:0"].line_code_text) == ("missing", "НИ")
assert (b["row:2"].line_status, b["row:2"].parent_ref) == ("detail", "row:1")
assert (b["row:3"].line_status, b["row:3"].line_code) == ("ok", "1140")
assert {b["row:12"].line_status, b["row:31"].line_status} == {"unreadable"}
assert (b["row:17"].line_status, b["row:17"].parent_ref) == ("detail", "row:16")
i = lines(inc)
revenue = value(i["row:0"], 2)
assert i["row:0"].line_status == "missing"
assert (revenue.amount, revenue.string_value, revenue.is_recognized) == (None, "211(1 |", False)
cost = value(i["row:1"], 2)
assert (i["row:1"].line_code, cost.amount, cost.is_recognized) == ("2120", Decimal("-7744536"), False)
assert value(i["row:8"], 2).amount is None
[failed] = [w for w in out.warnings if w.code == "JSON_FAILED"]
assert (failed.severity, failed.locator) == ("error", f"file:{JOB}_ofr.json")
assert failed.message == "INVALID_DOCUMENT: Не удалось определить тип документа."
def test_aep_6_garbage_code_and_repeats() -> None: # Review Focus 2
bal, inc = run("aep_6").documents
b = lines(bal)
assert (b["row:6"].line_status, b["row:6"].line_code, b["row:6"].line_code_text) == (
"unreadable",
None,
"44 30\nI |",
)
assert "row:6#1" not in b and all(v.amount is None for v in b["row:6"].values)
assert [ln.line_code for ln in bal.lines].count("1220") == 2
assert [ln.line_code for ln in bal.lines].count("1520") == 2
i = lines(inc)
assert [ln.line_code for ln in inc.lines].count("2410") == 2 and i["row:4"].line_status == "detail"
assert bal.status == "needs_check"
@pytest.mark.parametrize(
("name", "row", "amounts"),
[
("БО 1 кв 2025", 73, (Decimal("43274"), Decimal("198622"))),
("Бухгалтерская отчетность 1 кв.25", 93, (Decimal("917775"), Decimal("2371333"))),
],
)
def test_q1_2025_split_repeats_and_done(name: str, row: int, amounts: tuple[Decimal, Decimal]) -> None: # RF 3
out = run(name)
bal, _ = out.documents
b = lines(bal)
first, second = b[f"row:{row}#1"], b[f"row:{row}#2"]
assert (first.line_code, second.line_code) == ("1500", "1700")
assert (value(first, 2).amount, value(second, 2).amount) == amounts
assert any(w.code == "ROW_SPLIT" and w.severity == "info" for w in bal.warnings)
codes = [ln.line_code for ln in bal.lines]
assert codes.count("1220") == 2 and codes.count("1520") == 2
# Р-23: расшифровки не держат документ; расхождение — находка для пакета (P5-17), не правка шлюза
assert [d.status for d in out.documents] == ["done", "done"], _holders(out)
assert out.status is ResultStatus.DONE
@pytest.mark.parametrize("name", ["aep_5", "aep_8_form4"])
def test_all_json_failed_is_error(name: str) -> None: # Review Focus 4, 5
out = run(name)
assert (out.status, out.documents) == (ResultStatus.ERROR, [])
assert out.error is not None and out.error.code is ErrorCode.INVALID_DOCUMENT
assert out.error.message == "Не удалось определить тип документа."
assert [(w.code, w.locator) for w in out.warnings] == [("JSON_FAILED", f"file:{JOB}.json")]