diff --git a/api/contracts/metrics/quality-facts.schema.json b/api/contracts/metrics/quality-facts.schema.json new file mode 100644 index 0000000..ff63bcf --- /dev/null +++ b/api/contracts/metrics/quality-facts.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/quality-facts/v1", + "title": "Payload фактов качества распознавания первички (AURORA-1759)", + "description": "Структура jsonb-столбца payload таблиц quality_intake_fact и quality_outcome_fact (model/data-model-v5.25.dbml, context/05-domain-rules.md §14). quality_failure_fact не описан здесь — у него нет payload, все поля плоские. НЕ валидируется make check / scripts/validate_contracts.py: этот скрипт жёстко завязан на три сообщения NATS-контракта распознавания (request-/result-message-/result- примеры с дедупом и версией сообщения); payload фактов не ходит по NATS и не требует той же идемпотентности. Схему проверяет тест в arm-core, где payload формируется (см. openspec/changes/quality-metrics/design.md §2 этого репозитория).", + "$defs": { + "money": { + "type": ["string", "null"], + "pattern": "^-?\\d+(\\.\\d{1,2})?$", + "description": "Decimal как строка (как contracts/recognition-result.schema.json#/$defs/money) — деньги не float" + }, + "intake-payload": { + "type": "object", + "description": "Снимок документа на момент приёма результата (до подтверждения). Для OSV — без lines/values, только osv_warnings (взаимоисключение — см. dependentSchemas ниже).", + "dependentSchemas": { + "osv_warnings": { + "not": { "anyOf": [{ "required": ["lines"] }, { "required": ["values"] }] } + } + }, + "properties": { + "header": { + "type": "object", + "description": "header_source и распознанные поля шапки (period, inn, units, organization_name, …)", + "additionalProperties": true + }, + "lines": { + "type": "array", + "description": "Отсутствует для document_type = OSV", + "items": { + "type": "object", + "required": ["ref", "line_status"], + "properties": { + "ref": { "type": "string" }, + "row_index": { "type": ["integer", "null"] }, + "line_code": { "type": ["string", "null"] }, + "line_code_text": { "type": ["string", "null"] }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "needs_review": { "type": "boolean" } + } + } + }, + "values": { + "type": "array", + "description": "Отсутствует для document_type = OSV", + "items": { + "type": "object", + "required": ["line_ref", "column"], + "properties": { + "line_ref": { "type": "string" }, + "column": { "type": "string" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "source_text": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "is_recognized": { "type": "boolean" }, + "needs_review": { "type": "boolean" } + } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { "type": "string" }, + "level": { "enum": ["relation", "soft", "warning", "info"] } + } + } + }, + "raw_line_codes": { + "type": "array", + "description": "Только PDF: четырёхзначные токены сырого ответа ИС, стоящие отдельной строкой — вход для §14.2 (добавленная строка)", + "items": { "type": "string", "pattern": "^\\d{4}$" } + }, + "osv_warnings": { + "type": "array", + "description": "Только для document_type = OSV, вместо lines/values", + "items": { "type": "string" } + } + } + }, + "outcome-payload": { + "type": "object", + "description": "Подтверждённое состояние (то же строение header/lines/values, что и intake-payload) плюс список правок", + "properties": { + "header": { "$ref": "#/$defs/intake-payload/properties/header" }, + "lines": { "$ref": "#/$defs/intake-payload/properties/lines" }, + "values": { "$ref": "#/$defs/intake-payload/properties/values" }, + "fixes": { + "type": "array", + "items": { + "type": "object", + "required": ["field", "after", "source", "rule"], + "properties": { + "field": { "enum": ["value", "code", "added_row", "header", "osv_period_account"], "description": "header покрывает и правку даты колонки — §14.2 считает их вместе" }, + "before_raw": { "type": ["string", "null"], "description": "сырой текст сервиса на этом поле, если был" }, + "before_recognized": { "description": "как было распознано до правки" }, + "after": { "description": "подтверждённое значение" }, + "source": { "enum": ["service", "ours"], "description": "§14.2 — только два источника правки" }, + "rule": { "type": "string", "description": "строка таблицы §14.2, которая сработала" } + } + } + } + } + } + }, + "$comment": "Корневая схема не инстанцируется напрямую: тест в arm-core валидирует quality_intake_fact.payload против #/$defs/intake-payload и quality_outcome_fact.payload против #/$defs/outcome-payload (не oneOf — оба $defs допускают пустой объект, и oneOf отверг бы его как совпавший с обоими)." +} diff --git a/api/src/domain/quality_attribution.py b/api/src/domain/quality_attribution.py index 7fe1412..3979a3c 100644 --- a/api/src/domain/quality_attribution.py +++ b/api/src/domain/quality_attribution.py @@ -13,13 +13,15 @@ """ from collections.abc import Sequence +from datetime import date from typing import Literal -from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code +from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code, parse_date __all__ = [ "attribute_added_row", "attribute_code_fix", + "attribute_header_date_fix", "attribute_header_fix", "attribute_value_fix", ] @@ -84,3 +86,16 @@ def attribute_header_fix(confirmed: str, header_source_text: str) -> Attribution rules = default_rules() normalized_source = fix_lookalikes(header_source_text, rules.lookalikes) return "ours" if confirmed in normalized_source else "service" + + +def attribute_header_date_fix(confirmed: date, header_source_text: str) -> Attribution: + """§14.2 «Шапка», отчётная дата: сырой текст сервиса (`31.03.2025`, «на 31 марта 2025 г.») разбирается тем же + `parse_date` пакета `arm_statements` (после замены двойников §11.3) и сравнивается с подтверждённой датой как + дата, а не как подстрока ISO-строки. Совпала — «наш разбор» (сервис прочитал верно); не совпала — «сервис». + Текст не разобрался — «наш разбор» (анти-цель design §2: спорное не приписываем сервису). + """ + rules = default_rules() + parsed = parse_date(fix_lookalikes(header_source_text, rules.lookalikes), rules) + if parsed is None: + return "ours" + return "ours" if parsed == confirmed else "service" diff --git a/api/src/domain/quality_facts.py b/api/src/domain/quality_facts.py index a702689..3f3671c 100644 --- a/api/src/domain/quality_facts.py +++ b/api/src/domain/quality_facts.py @@ -25,8 +25,6 @@ __all__ = ["build_intake_payload", "build_outcome_payload"] _HEADER_FIELDS = ("document_type", "period_year", "period_quarter", "is_cumulative", "reporting_date") _LINE_FIELDS = ("row_index", "line_code", "line_code_text", "line_status", "needs_review") _VALUE_FIELDS = ( - "line_id", - "column_id", "amount", "string_value", "source_text", @@ -59,13 +57,14 @@ def build_intake_payload( # noqa: PLR0913 - сигнатура снимка ф if osv_row_count is not None: payload["row_count"] = osv_row_count return payload + line_refs = {getattr(line, "id", None): _line_ref(line) for line in lines or ()} header = _pick(document, _HEADER_FIELDS) if header_source is not None: header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()} return { "header": header, "lines": [_line(line) for line in lines or ()], - "values": [_value(value) for value in values or ()], + "values": [_value(value, line_refs) for value in values or ()], "issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues], } @@ -100,10 +99,16 @@ def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка ф return payload +def _line_ref(line: Any) -> str: + """Ссылка на строку в payload: `source_ref`, а при его отсутствии — `str(id)` (контракт: `ref` — всегда строка).""" + source_ref = getattr(line, "source_ref", None) + return str(source_ref) if source_ref is not None else str(getattr(line, "id", None)) + + def _line(line: Any) -> dict[str, Any]: return { "id": _plain(getattr(line, "id", None)), - "ref": _plain(getattr(line, "source_ref", None)), + "ref": _line_ref(line), **_pick(line, _LINE_FIELDS), } @@ -115,8 +120,18 @@ def _plain_mapping(entry: Any) -> Any: return _plain(entry) -def _value(value: Any) -> dict[str, Any]: - return _pick(value, _VALUE_FIELDS) +def _value(value: Any, line_refs: Mapping[Any, str]) -> dict[str, Any]: + """Значение по контракту: `line_ref` — `ref` строки из `lines[]` (нет строки в снимке — `str(line_id)`), + `column` — `str(column_id)`, `confidence` — число (не деньги, float допустим), остальное — как в БД.""" + line_id = getattr(value, "line_id", None) + column_id = getattr(value, "column_id", None) + confidence = getattr(value, "confidence", None) + return { + "line_ref": line_refs.get(line_id, str(line_id)), + "column": str(column_id), + **_pick(value, _VALUE_FIELDS), + "confidence": float(confidence) if confidence is not None else None, + } def _pick(obj: Any, fields: Sequence[str]) -> dict[str, Any]: @@ -128,7 +143,7 @@ def _plain(value: Any) -> Any: if isinstance(value, Enum): return value.value if isinstance(value, Decimal): - return str(value) + return format(value, "f") if isinstance(value, UUID): return str(value) if isinstance(value, datetime | date): diff --git a/api/src/services/documents.py b/api/src/services/documents.py index ab604d3..c3fe6bd 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -27,6 +27,7 @@ from src.domain.quality_attribution import ( Attribution, attribute_added_row, attribute_code_fix, + attribute_header_date_fix, attribute_header_fix, attribute_value_fix, ) @@ -74,6 +75,12 @@ CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEED # `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла # `documents → auto_confirm → statements → documents` (то же решение, что в Task 6) OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# строки таблицы §14.2 05-domain-rules.md → `fixes[].rule` в payload (контракт quality-facts) +RULE_VALUE = "Сумма" +RULE_CODE = "Код строки" +RULE_ADDED_ROW = "Добавленная строка" +RULE_HEADER = "Шапка" +RULE_OSV_PERIOD_ACCOUNT = "Период / счёт ОСВ" # поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка» _HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = ( ("inn_modified", "inn"), @@ -107,7 +114,7 @@ _OURS_COUNTER_KEYS: tuple[str, ...] = ( ) -def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: +def _value_fix(value: FinancialStatementValue, line_ref: str) -> dict[str, Any] | None: """Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`). Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; считаем правку нашей, не @@ -120,10 +127,13 @@ def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None: "ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text) ) return { - "kind": "value", - "line_id": str(value.line_id), - "attribution": attribution, - "before": str(value.original_amount) if value.original_amount is not None else None, + "field": "value", + "line_ref": line_ref, + "column": str(value.column_id), + "source": attribution, + "rule": RULE_VALUE, + "before_raw": value.source_text, + "before_recognized": format(value.original_amount, "f") if value.original_amount is not None else None, "after": confirmed, } @@ -140,10 +150,12 @@ def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None: else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text) ) return { - "kind": "code", - "line_id": str(line.id), - "attribution": attribution, - "before": line.line_code_original_value, + "field": "code", + "line_ref": _line_ref(line), + "source": attribution, + "rule": RULE_CODE, + "before_raw": line.line_code_text, + "before_recognized": line.line_code_original_value, "after": confirmed_code, } @@ -161,7 +173,20 @@ def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, if line.row_index is not None: return None attribution: Attribution = attribute_added_row(line.line_code or "", (), is_excel=True) if is_excel else "ours" - return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code} + return { + "field": "added_row", + "line_ref": _line_ref(line), + "source": attribution, + "rule": RULE_ADDED_ROW, + "before_raw": None, + "before_recognized": None, + "after": line.line_code, + } + + +def _line_ref(line: FinancialStatementLine) -> str: + """Тот же `ref`, что в `lines[]` payload (`source_ref`, иначе `str(id)`).""" + return str(line.source_ref) if line.source_ref is not None else str(line.id) def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str: @@ -177,11 +202,25 @@ def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapp confirmed = _header_confirmed_value(meta, field) source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None - attribution: Attribution = ( - "ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text) - ) + confirmed_date = meta.report_date if field == "report_date" else None + attribution: Attribution + if source_text is None: + attribution = "ours" + elif confirmed_date is not None: + # отчётная дата: сырой текст разбирается `parse_date`, сравниваются даты, а не подстрока ISO (I-5) + attribution = attribute_header_date_fix(confirmed=confirmed_date, header_source_text=source_text) + else: + attribution = attribute_header_fix(confirmed=confirmed, header_source_text=source_text) before = getattr(meta, f"{field}_original_value", None) - return {"kind": "header", "field": field, "attribution": attribution, "before": before, "after": confirmed} + return { + "field": "header", + "header_field": field, + "source": attribution, + "rule": RULE_HEADER, + "before_raw": source_text, + "before_recognized": before, + "after": confirmed, + } def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause: @@ -458,7 +497,7 @@ class DocumentService: if fix is None: return fixes.append(fix) - suffix = "service" if fix["attribution"] == "service" else "ours" + suffix = "service" if fix["source"] == "service" else "ours" counters[f"fixes_{prefix}_{suffix}"] += 1 if document.document_type is DocumentType.OSV: @@ -476,14 +515,28 @@ class DocumentService: ] if document.is_modified: counters["fixes_osv_period_account"] = 1 - fixes.append({"kind": "osv_period_account", "attribution": "ours"}) + fixes.append( + { + "field": "osv_period_account", + "source": "ours", + "rule": RULE_OSV_PERIOD_ACCOUNT, + "before_raw": None, + "before_recognized": None, + "after": { + "period_year": document.period_year, + "period_quarter": document.period_quarter.value if document.period_quarter else None, + "osv_account_code": document.osv_account_code, + }, + } + ) payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes) elif meta is not None: _, pairs = await statements_repo.load_form(self._session, meta.id) lines = [line for line, _ in pairs] values = [value for _, line_values in pairs for value in line_values] + refs = {line.id: _line_ref(line) for line in lines} for value in values: - _record(_value_fix(value), "value") + _record(_value_fix(value, refs.get(value.line_id, str(value.line_id))), "value") for line in lines: _record(_code_fix(line), "code") _record(_added_row_fix(line, is_excel=is_excel), "added_row") diff --git a/api/tests/quality_payload_contract.py b/api/tests/quality_payload_contract.py new file mode 100644 index 0000000..a20f8de --- /dev/null +++ b/api/tests/quality_payload_contract.py @@ -0,0 +1,40 @@ +"""Проверка payload фактов качества по контракту `contracts/metrics/quality-facts.schema.json` (JSON Schema 2020-12). + +Контракт живёт в docs, копия — `api/contracts/metrics/` (идентичность проверяет `test_contract.py`). Корневая схема +не инстанцируется: `quality_intake_fact.payload` валидируется против `#/$defs/intake-payload`, `quality_outcome_fact. +payload` — против `#/$defs/outcome-payload`. +""" + +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + +SCHEMA_PATH = Path(__file__).resolve().parents[1] / "contracts" / "metrics" / "quality-facts.schema.json" +_SCHEMA = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _validator(def_name: str) -> Draft202012Validator: + schema = {"$schema": _SCHEMA["$schema"], "$defs": _SCHEMA["$defs"], "$ref": f"#/$defs/{def_name}"} + return Draft202012Validator(schema, format_checker=FormatChecker()) + + +_INTAKE = _validator("intake-payload") +_OUTCOME = _validator("outcome-payload") + + +def _errors(validator: Draft202012Validator, payload: dict[str, Any]) -> list[str]: + return [f"{'/'.join(map(str, e.absolute_path))}: {e.message}" for e in validator.iter_errors(payload)] + + +def assert_intake_payload_valid(payload: dict[str, Any]) -> None: + """Payload факта приёма соответствует `$defs/intake-payload`.""" + problems = _errors(_INTAKE, payload) + assert not problems, problems[:5] + + +def assert_outcome_payload_valid(payload: dict[str, Any]) -> None: + """Payload факта исхода соответствует `$defs/outcome-payload`.""" + problems = _errors(_OUTCOME, payload) + assert not problems, problems[:5] diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 1c700bc..43038db 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -48,6 +48,7 @@ from tests.factories import ( make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid HTTP_OK = 200 HTTP_FORBIDDEN = 403 @@ -622,9 +623,11 @@ async def _break_2200_and_accept( async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact @pytest.mark.fixtures @@ -676,7 +679,10 @@ async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте ) - fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть + fixed_column_id = fixed.column_id # до HTTP-запроса: после него объекты сессии теста могут устареть + fixed_line = await seeded.get(FinancialStatementLine, fixed.line_id) + assert fixed_line is not None + fixed_line_ref = fixed_line.source_ref or str(fixed_line.id) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") @@ -687,10 +693,13 @@ async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0) assert fact.payload["fixes"] == [ { - "kind": "value", - "line_id": str(fixed_line_id), - "attribution": "ours", - "before": "1234000.00", + "field": "value", + "line_ref": fixed_line_ref, + "column": str(fixed_column_id), + "source": "ours", + "rule": "Сумма", + "before_raw": "1 234", + "before_recognized": "1234000.00", "after": "1234.00", } ] @@ -740,6 +749,94 @@ async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted( assert fact.payload["fixes"] == [] +@pytest.mark.fixtures +async def test_confirm_outcome_fact_report_date_fix_compares_parsed_dates( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """I-5: правка отчётной даты шапки сравнивается как дата (`parse_date`), а не как подстрока ISO в сыром тексте: + «31.03.2025» и подтверждённая 2025-03-31 — «наш разбор» (сервис прочитал верно).""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + meta.report_date = datetime.date(2025, 3, 31) + meta.report_date_modified = True + meta.report_date_original_value = "2024-03-31" + meta.header_source = {"report_date": {"text": "31.03.2025", "confidence": "99.00"}} + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + assert (fact.fixes_header_ours, fact.fixes_header_service) == (1, 0) + assert fact.payload["fixes"] == [ + { + "field": "header", + "header_field": "report_date", + "source": "ours", + "rule": "Шапка", + "before_raw": "31.03.2025", + "before_recognized": "2024-03-31", + "after": "2025-03-31", + } + ] + + +@pytest.mark.fixtures +async def test_confirm_outcome_fact_code_fix_matches_contract( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Правка кода строки: запись `fixes[]` по контракту (`field`/`source`/`rule`/`before_raw`/`before_recognized`), + ссылка `line_ref` совпадает с `ref` строки в `lines[]`.""" + office = await _rostov_office(seeded) + income, meta = await _income_form(seeded, fixtures_dir, office=office) + line = ( + ( + await seeded.execute( + select(FinancialStatementLine).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.row_index.is_not(None) + ) + ) + ) + .scalars() + .first() + ) + assert line is not None + line.line_code_modified = True + line.line_code_original_value = "9999" + line.line_code_text = "9999\n" + (line.line_code or "") + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{income.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, income.id) + (fix,) = fact.payload["fixes"] + assert (fix["field"], fix["source"], fix["rule"]) == ("code", "ours", "Код строки") + assert fix["before_recognized"] == "9999" + assert fix["before_raw"] == line.line_code_text + assert fix["line_ref"] in {entry["ref"] for entry in fact.payload["lines"]} + + +async def test_confirm_osv_outcome_fact_period_account_fix_matches_contract( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """ОСВ с уточнённым периодом/счётом: правка `osv_period_account` — «наш разбор», payload по контракту.""" + _, _, document = await _document(seeded) + document.is_modified = True + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + fact = await _outcome_fact(seeded, document.id) + (fix,) = fact.payload["fixes"] + assert (fix["field"], fix["source"], fix["rule"]) == ("osv_period_account", "ours", "Период / счёт ОСВ") + assert fix["after"]["osv_account_code"] == "60" + + @pytest.mark.fixtures async def test_disclosure_confirm_has_no_outcome_fact( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 500007b..7ae6f56 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -59,6 +59,7 @@ from tests.factories import ( make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid HTTP_NO_CONTENT = 204 HTTP_FORBIDDEN = 403 @@ -95,9 +96,11 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]: async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def _outcome_facts(session: AsyncSession, document_id: uuid.UUID) -> list[QualityOutcomeFact]: diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index d9635c9..b128e1a 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -43,6 +43,7 @@ from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, tr from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot +from tests.quality_payload_contract import assert_outcome_payload_valid pytestmark = pytest.mark.integration @@ -241,6 +242,7 @@ async def test_auto_confirm_outcome_fact_osv_payload_shape(seeded: AsyncSession) fact = ( await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) assert "lines" not in fact.payload assert "values" not in fact.payload assert fact.payload["osv_warnings"] == ["OSV_ROUNDING"] @@ -270,6 +272,7 @@ async def test_auto_confirm_outcome_fact_for_form_has_lines(seeded: AsyncSession await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == income.id)) ).scalar_one() assert fact.outcome == QualityOutcome.AUTO_CONFIRMED + assert_outcome_payload_valid(fact.payload) assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION assert fact.report_date == income.reporting_date assert fact.payload["fixes"] == [] @@ -402,8 +405,10 @@ async def test_recognized_disclosure_is_auto_confirmed_without_outcome_fact( assert disclosure.status is DocumentStatus.CONFIRMED facts = ( - await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)) - ).scalars().all() + (await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))) + .scalars() + .all() + ) assert facts == [] diff --git a/api/tests/test_domain_quality_attribution.py b/api/tests/test_domain_quality_attribution.py index a5897cf..f2341ac 100644 --- a/api/tests/test_domain_quality_attribution.py +++ b/api/tests/test_domain_quality_attribution.py @@ -1,8 +1,11 @@ """Атрибуция правки «сервис / наш разбор» (§14.2).""" +from datetime import date + from src.domain.quality_attribution import ( attribute_added_row, attribute_code_fix, + attribute_header_date_fix, attribute_header_fix, attribute_value_fix, ) @@ -60,3 +63,21 @@ def test_attribute_header_fix_service_when_confirmed_not_in_source_text(): def test_attribute_header_fix_ours_after_lookalikes_for_inn(): # ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением assert attribute_header_fix("7000112233", "7OOO112233") == "ours" + + +def test_attribute_header_date_fix_ours_when_text_date_equals_confirmed(): + # ISO-строка «2025-03-31» в «31.03.2025» подстрокой не ищется, но даты равны → сервис прочитал верно → "ours" + assert attribute_header_date_fix(date(2025, 3, 31), "31.03.2025") == "ours" + + +def test_attribute_header_date_fix_ours_for_text_form_of_date(): + assert attribute_header_date_fix(date(2025, 3, 31), "на 31 марта 2025 г.") == "ours" + + +def test_attribute_header_date_fix_service_when_text_date_differs(): + assert attribute_header_date_fix(date(2025, 3, 31), "30.06.2025") == "service" + + +def test_attribute_header_date_fix_ours_when_text_unparsable(): + # спорное / не разобралось → «наш разбор» (анти-цель design §2) + assert attribute_header_date_fix(date(2025, 3, 31), "дата не указана") == "ours" diff --git a/api/tests/test_domain_quality_facts.py b/api/tests/test_domain_quality_facts.py index 796cd9f..7da30c3 100644 --- a/api/tests/test_domain_quality_facts.py +++ b/api/tests/test_domain_quality_facts.py @@ -87,12 +87,12 @@ def test_build_intake_payload_statement_serializes_line_value_and_issue_fields() ] assert payload["values"] == [ { - "line_id": str(line_id), - "column_id": str(column_id), + "line_ref": "row:3", + "column": str(column_id), "amount": "1500.00", "string_value": None, "source_text": "1 500", - "confidence": "95.00", + "confidence": 95.0, "is_recognized": True, "needs_review": False, } diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 7f8bcfe..2f1b1e0 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -89,6 +89,7 @@ from tests.factories import ( make_stored_file, make_version, ) +from tests.quality_payload_contract import assert_intake_payload_valid, assert_outcome_payload_valid from tests.test_recognition_inbox import envelope, fake_msg API_DIR = Path(__file__).resolve().parents[1] @@ -1547,9 +1548,11 @@ async def test_supersede_previous_reports_dropped_documents_without_changing_rep async def _outcome_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityOutcomeFact: async with maker() as session: - return ( + fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() + assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def test_supersede_previous_writes_superseded_fact_for_dropped_document( @@ -2244,9 +2247,11 @@ STATEMENT_VALUE_COUNT = 4 # 2 строки × 2 колонки async def _intake_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityIntakeFact: async with maker() as session: - return ( + fact = ( await session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id)) ).scalar_one() + assert_intake_payload_valid(fact.payload) # payload соответствует контракту quality-facts + return fact async def test_map_candidate_writes_intake_fact_for_statement_document( diff --git a/openspec/changes/quality-metrics/design.md b/openspec/changes/quality-metrics/design.md index 58a4643..2080733 100644 --- a/openspec/changes/quality-metrics/design.md +++ b/openspec/changes/quality-metrics/design.md @@ -57,7 +57,7 @@ Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта. -Контракт столбцов и `payload` — `../docs/contracts/quality-facts.schema.json`. Записи только добавляются. +Контракт столбцов и `payload` — `../docs/contracts/metrics/quality-facts.schema.json`. Записи только добавляются. ### 4.1 `quality_intake_fact` — документ «как распознано» @@ -252,3 +252,9 @@ PDF, точный вид выгрузки, разбиение на задачи. | 07.11.2026 | R-13 (final review I-3, рулинг владельца): `stored_file.is_scan` и `recognition_job.raw_s3_object_id` — последние колонки таблиц, и в ORM, и в DBML v5.25 (docs); версия DBML не меняется. Тест миграции 0006 проверяет «последняя колонка» на ревизии `0006_dbml_v5_18`, а не на head | миграция 0012 добавляет колонки `ADD COLUMN` (в конец), `test_migrated_schema_equals_orm_create_all` и 0006-тест требуют порядок ORM = порядок миграции; прецедент v5.18 `consolidation_id` | порядок колонок в DBML v5.25 не совпадает с черновиками аналитики; смена порядка в ORM требует правки docs PR #203 (merge вместе) | | 07.11.2026 | R-14 (final review I-4): при удалении файла, у которого есть `quality_intake_fact` с `file_id`, объект `uploads` файла не получает `expires_at` (исключён из `object_ids` и из префикса `uploads/{file_id}/`); определение — один запрос `has_intake_fact_for_file` по `file_id`, а не сравнение `upload_s3_key` | spec quality-metrics-model: объект нужен эталонному прогону (OQ-179); сравнение по `file_id` дешевле и покрывает все факты файла; объект `raw` в `object_ids` не входит и раньше | если у файла несколько документов и факт есть не у всех, объект сохраняется целиком (бакет растёт на размер файла); если `file_id` в факте ошибочен — объект экспирится и эталон потеряется | | 07.11.2026 | R-15 (final review M-3): `has_text_layer` вызывается через `asyncio.to_thread` и разбирает не больше `MAX_PAGES_CHECKED = 10` первых страниц | pypdf синхронен и работал в event loop под advisory-lock загрузки; текстовый слой, если есть, виден на первых страницах формы отчётности (ББ/ОФР — 1-3 листа), 10 — запас | PDF, у которого текст только после 10-й страницы (титулы-сканы), будет помечен `is_scan = true` — метрика «скан» чуть завысит долю сканов | +| 07.11.2026 | R-16 (final review I-2, рулинг владельца-сессии): Контракт `quality-facts.schema.json` перенесён в docs из `docs/contracts/` в `docs/contracts/metrics/` (git mv, коммит в docs-ветке feat/202); копия в arm-core — `api/contracts/metrics/quality-facts.schema.json` (cp, байт-в-байт); ссылки в docs (DBML-заметки, design/proposal/spec/план change) и arm-core обновлены. `contract_identity.py` НЕ менялся | для пути глубины 2 не из `examples/` `_is_required_in_copy` копию не требует, а `docs_pair` сверяет копию arm-core по тому же относительному пути; `tabular-parser` и `scan-gateway` остаются зелёными без копии (проверено в arm-core: `test_contract*` 296 passed с копией). Схема не привязана к NATS-контракту распознавания, подкаталог это подчёркивает | если docs PR #203 сольётся без переноса, тесты идентичности в двух сервисах покраснеют на `quality-facts.schema.json` в корне; обратный перенос потребует правки ссылок в обоих репозиториях | +| 07.11.2026 | R-17 (I-2): Payload `values[]` по контракту: `line_id`/`column_id` → `line_ref` (= `ref` строки в `lines[]`; нет строки в снимке — `str(line_id)`) и `column` (= `str(column_id)`); `confidence` — числом (`float(Decimal)`, не деньги); `lines[].ref` = `source_ref`, при `null` — `str(id)`; `Decimal` сериализуется `format(d, 'f')` (без экспоненты, как требует `$defs/money`) | схема требует `line_ref`/`column`/`ref` строкой и `confidence` number; `column` — идентификатор колонки, а не её подпись/индекс: у значения есть только `column_id`, подпись требовала бы дополнительной выборки колонок в каждом хуке | `column` — UUID, а не читаемая дата колонки: аналитик по payload колонку не опознаёт без join; смена на индекс колонки потребует передавать колонки во все хуки | +| 07.11.2026 | R-18 (I-2): Запись `fixes[]` по контракту: `kind`→`field`, `attribution`→`source`, новые `rule` (строка таблицы §14.2: «Сумма», «Код строки», «Добавленная строка», «Шапка», «Период / счёт ОСВ»), `before_raw` (сырой текст сервиса: `source_text` / `line_code_text` / `header_source[field].text`), `before_recognized` (бывшее `before`), `line_id`→`line_ref` (для значения ещё `column`), для шапки поле — `header_field` (в `field` — `header`, как требует enum). `after` у ОСВ — `{period_year, period_quarter, osv_account_code}` | схема требует `field`/`after`/`source`/`rule` и допускает дополнительные свойства: `line_ref`, `column`, `header_field` — для разбора правки без join; `after` у `osv_period_account` обязателен, а «значение до» в `document` не хранится (`before_*` = null) | `header_field` и `line_ref`/`column` вне обязательных свойств схемы — потребитель не может на них полагаться как на контракт; у ОСВ нет «до» значения периода/счёта | +| 07.11.2026 | R-19 (I-2): Схема контракта НЕ менялась | все поля контракта заполняются; `raw_line_codes` необязателен и по-прежнему не пишется (R-5b, deferred) | — | +| 07.11.2026 | R-20 (I-2): Тест валидации: хелпер `api/tests/quality_payload_contract.py` (jsonschema Draft 2020-12, `$defs/intake-payload` и `$defs/outcome-payload`); хелперы чтения фактов в интеграционных тестах (`_outcome_fact`, `_intake_fact` в test_api_documents/test_api_files_delete/test_result_mapping) валидируют каждый прочитанный payload, test_auto_confirm — явно. Покрыты: приём ББ/ОФР и ОСВ, AUTO_CONFIRMED (форма и ОСВ), CONFIRMED с правками суммы/кода/шапки/ОСВ, DELETED(_AFTER_CONFIRM), SUPERSEDED(_AFTER_CONFIRM) | payload строится реальными сервисными функциями на PostgreSQL, не ручными литералами; `jsonschema` уже в `requirements-dev.txt` | валидация — в тестах, не в рантайме: выход payload за контракт в проде не отсекается | +| 07.11.2026 | R-21 (I-5): Правка отчётной даты шапки (`report_date`): сырой текст `header_source['report_date'].text` разбирается `parse_date` пакета `arm_statements` (после `fix_lookalikes`, `default_rules()`), даты сравниваются как даты (`attribute_header_date_fix`): равны — «наш разбор», различаются — «сервис», текст не разобрался — «наш разбор». Прочие поля шапки — прежнее сравнение подстрокой | ISO `2025-03-31` как подстрока `31.03.2025` не находился → почти любая правка даты шла в «сервис», вопреки анти-цели §2 («спорное — в наш разбор»); §14.2 и design §5 называют `parse_date` | если `parse_date` ошибётся в формате, дата различится — правка уйдёт в «сервис» (завышение доли ошибок сервиса); правка «Дата колонки» по-прежнему не учитывается (R-5a) | diff --git a/openspec/changes/quality-metrics/proposal.md b/openspec/changes/quality-metrics/proposal.md index e296000..009c3a9 100644 --- a/openspec/changes/quality-metrics/proposal.md +++ b/openspec/changes/quality-metrics/proposal.md @@ -24,7 +24,7 @@ ## Затронутые репозитории - **arm-ka-docs** (отдельный change): DBML v5.24 (таблицы фактов, роль бакета `raw`, признак скана, право), правила - подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/quality-facts.schema.json`. + подсчёта — новый раздел `05-domain-rules.md` §14, контракт фактов `docs/contracts/metrics/quality-facts.schema.json`. - **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed. - scan-gateway и tabular-parser не меняются.