feat(#190): метрики качества первички (AURORA-1759) #229

Merged
aep merged 45 commits from feat/190-ocr-metrics into main 2026-11-07 07:50:00 +03:00
13 changed files with 410 additions and 41 deletions
Showing only changes of commit 8c7ce7c172 - Show all commits

View File

@ -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 отверг бы его как совпавший с обоими)."
}

View File

@ -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"

View File

@ -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):

View File

@ -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")

View File

@ -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]

View File

@ -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

View File

@ -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]:

View File

@ -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 == []

View File

@ -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"

View File

@ -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,
}

View File

@ -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(

View File

@ -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) |

View File

@ -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 не меняются.