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
2 changed files with 470 additions and 3 deletions
Showing only changes of commit bb1714b096 - Show all commits

View File

@ -10,24 +10,48 @@
перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5).
"""
import datetime
import uuid
from collections.abc import Mapping, Sequence
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, NotFoundError
from src.domain.file_types import PIPELINES
from src.domain.policy import VisibilityScope
from src.domain.quality_attribution import (
Attribution,
attribute_added_row,
attribute_code_fix,
attribute_header_fix,
attribute_value_fix,
)
from src.domain.quality_facts import build_outcome_payload
from src.models.access import AppUser
from src.models.audit_ex import Comment
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, IssueSeverity
from src.models.enums import (
AuditAction,
AuditSection,
DocumentIssueKind,
DocumentStatus,
DocumentType,
FileStatus,
IssueSeverity,
IssueSource,
QualityCause,
QualityOutcome,
RecognitionPipeline,
)
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementMeta
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
from src.repositories import files as files_repo
from src.repositories import statements as statements_repo
from src.repositories.quality_facts import insert_outcome_fact
from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut
from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut
from src.schemas.files import DocumentBrief, FileOut, FileWarningOut, JobBrief
@ -45,6 +69,124 @@ DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE"
PERIOD_REQUIRED = "PERIOD_REQUIRED"
CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED"
CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK})
# §14.2-§14.4 (design quality-metrics §4.2, §5, §6; AURORA-1759): факт исхода `CONFIRMED` пишется только для этих
# типов — тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES` в `services/result_mapping.py` (Task 5) и
# `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})
# поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка»
_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = (
("inn_modified", "inn"),
("report_date_modified", "report_date"),
("units_modified", "units"),
("organization_name_modified", "organization_name"),
)
_FIX_COUNTER_KEYS: tuple[str, ...] = (
"fixes_value_service",
"fixes_value_ours",
"fixes_code_service",
"fixes_code_ours",
"fixes_added_row_service",
"fixes_added_row_ours",
"fixes_header_service",
"fixes_header_ours",
"fixes_osv_period_account",
)
_SERVICE_COUNTER_KEYS: tuple[str, ...] = (
"fixes_value_service",
"fixes_code_service",
"fixes_added_row_service",
"fixes_header_service",
)
_OURS_COUNTER_KEYS: tuple[str, ...] = (
"fixes_value_ours",
"fixes_code_ours",
"fixes_added_row_ours",
"fixes_header_ours",
"fixes_osv_period_account",
)
def _value_fix(value: FinancialStatementValue) -> dict[str, Any] | None:
"""Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`).
Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; по решению design §5
«спорное — в наш разбор» (decision log 27.10.2026: анти-цель — не приписывать сервису наши недоработки)
считаем правку нашей, не пропускаем (решение задачи 7, см. отчёт)."""
if not value.is_modified:
return None
confirmed = str(value.amount) if value.amount is not None else ""
attribution: Attribution = (
"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,
"after": confirmed,
}
def _code_fix(line: FinancialStatementLine) -> dict[str, Any] | None:
"""Правка кода строки (§14.2 «Код строки»): `None` — код не правился. Нет сырого текста — «наш разбор»
(то же решение, что у правки суммы)."""
if not line.line_code_modified:
return None
confirmed_code = line.line_code or ""
attribution: Attribution = (
"ours"
if line.line_code_text is 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,
"after": confirmed_code,
}
def _added_row_fix(line: FinancialStatementLine, *, is_excel: bool) -> dict[str, Any] | None:
"""Добавленная строка (§14.2 «Добавленная строка», `row_index IS NULL` — v5.19). Для PDF — известный пробел:
сырые четырёхзначные токены ответа ИС Сканирование Task 2/5 не извлекали, поэтому `raw_line_codes` всегда
пуст и результат для PDF всегда «сервис» (design §11 «оставлено исполнителю»; задача 7 это не чинит — вне
рамок, см. отчёт). Для Excel — всегда «наш разбор» (`attribute_added_row` сам это решает по `is_excel`)."""
if line.row_index is not None:
return None
attribution = attribute_added_row(line.line_code or "", (), is_excel=is_excel)
return {"kind": "added_row", "line_id": str(line.id), "attribution": attribution, "after": line.line_code}
def _header_confirmed_value(meta: FinancialStatementMeta, field: str) -> str:
value = getattr(meta, field)
if isinstance(value, datetime.date):
return value.isoformat()
return str(value) if value is not None else ""
def _header_fix(field: str, *, meta: FinancialStatementMeta, header_source: Mapping[str, Any] | None) -> dict[str, Any]:
"""Правка поля шапки (§14.2 «Шапка»); вызывается только когда `meta.<field>_modified` уже `True`. Нет сырого
текста поля в `header_source` — «наш разбор» (то же решение, что у правки суммы/кода)."""
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)
)
return {"kind": "header", "field": field, "attribution": attribution, "after": confirmed}
def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause:
"""Приоритет §14.3 для `CONFIRMED` (design §5, §6): сервис > наш разбор > документ клиента > как есть."""
if any(counters[key] > 0 for key in _SERVICE_COUNTER_KEYS):
return QualityCause.SERVICE_ERROR
if any(counters[key] > 0 for key in _OURS_COUNTER_KEYS):
return QualityCause.OUR_ERROR
if checks_accepted:
return QualityCause.CLIENT_DOCUMENT
return QualityCause.AS_IS
def _metrics(job: RecognitionJob | None) -> dict[str, Any]:
@ -289,6 +431,81 @@ class DocumentService:
raise ConflictError(blocker.code, blocker.message, **blocker.extra)
return meta
async def _write_confirm_outcome_fact(
self, document: Document, file: StoredFile, meta: FinancialStatementMeta | None, now: datetime.datetime
) -> None:
"""Факт исхода `CONFIRMED` с атрибуцией правок (§14.2-§14.4; design quality-metrics §4.2, §5, §6;
AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у факта приёма/
`AUTO_CONFIRMED`, Task 5/6). Правки ищутся по ЖИВЫМ колонкам `*_modified`/`original_*`/`source_text`
(первичка не мутируется) — не по JSON `quality_intake_fact.payload` (решение задачи 7, см. отчёт: тот
payload собран для другой цели и его форма — не для этого сравнения). Пишется в транзакции вызывающего
(`confirm`), без `flush`/`commit`.
"""
if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES:
return
is_excel = PIPELINES[file.file_type] is RecognitionPipeline.tabular
issues = await statements_repo.issues(self._session, document.id)
fixes: list[dict[str, Any]] = []
counters: dict[str, int] = dict.fromkeys(_FIX_COUNTER_KEYS, 0)
def _record(fix: dict[str, Any] | None, prefix: str) -> None:
if fix is None:
return
fixes.append(fix)
suffix = "service" if fix["attribution"] == "service" else "ours"
counters[f"fixes_{prefix}_{suffix}"] += 1
if document.document_type is DocumentType.OSV:
# ОСВ: без построчного снимка сумм (правок сумм ОСВ в ядре нет, design §5) — только предупреждения
# сервиса и число строк; правка периода/счёта — document.is_modified, всегда «наш разбор» (Excel)
row_count = (
await self._session.execute(
select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id)
)
).scalar_one()
osv_warnings = [
issue.code
for issue in issues
if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
]
if document.is_modified:
counters["fixes_osv_period_account"] = 1
fixes.append({"kind": "osv_period_account", "attribution": "ours"})
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]
for value in values:
_record(_value_fix(value), "value")
for line in lines:
_record(_code_fix(line), "code")
_record(_added_row_fix(line, is_excel=is_excel), "added_row")
for modified_attr, field_name in _HEADER_FIX_FIELDS:
if getattr(meta, modified_attr):
_record(_header_fix(field_name, meta=meta, header_source=meta.header_source), "header")
payload = build_outcome_payload(
document, lines=lines, values=values, issues=issues, header_source=meta.header_source, fixes=fixes
)
else:
# BALANCE/INCOME без шапки (в текущем маппинге не возникает, см. Task 5/6) — снимок без построчных данных
payload = build_outcome_payload(document, issues=issues, fixes=fixes)
checks_accepted = meta is not None and meta.checks_accepted_by is not None
await insert_outcome_fact(
self._session,
document_id=document.id,
outcome=QualityOutcome.CONFIRMED,
outcome_at=now,
primary_cause=_primary_cause(counters, checks_accepted=checks_accepted),
report_date=document.reporting_date,
confirmed_by=self._audit.user_id,
issue_count_at_outcome=len(issues),
checks_accepted=checks_accepted,
payload=payload,
**counters,
)
async def confirm(self, document_id: uuid.UUID) -> DocumentOut:
"""`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`.
@ -338,4 +555,5 @@ class DocumentService:
before_value={"status": before.value},
after_value={"status": DocumentStatus.CONFIRMED.value},
)
await self._write_confirm_outcome_fact(document, file, meta, now)
return await self._card(document, file)

View File

@ -10,6 +10,7 @@ import asyncio
import datetime
import json
import uuid
from decimal import Decimal
from pathlib import Path
from typing import Any
@ -25,12 +26,19 @@ from src.models.enums import (
AuditSection,
CounterpartyMatchKind,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
QualityCause,
QualityOutcome,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry
from src.models.quality_facts import QualityOutcomeFact
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
from src.services.statement_recompute import recompute_form
from src.services.statements_engine import default_engine
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
@ -39,6 +47,7 @@ from tests.factories import (
make_legal_entity,
make_stored_file,
)
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
HTTP_OK = 200
HTTP_FORBIDDEN = 403
@ -61,8 +70,12 @@ LOCK_WAIT_SECONDS = 10.0
pytestmark = pytest.mark.integration
async def _rostov_office(session: AsyncSession) -> RegionalOffice:
return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
office = await _rostov_office(session)
group = await make_company_group(session, name=f"ГК {name}", office=office)
return await make_legal_entity(session, name=name, group=group)
@ -512,3 +525,239 @@ async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client:
assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"}
assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service)
assert body["status"] == "NEEDS_CHECK"
# --- факт исхода CONFIRMED (§14.2-14.4, design quality-metrics §4.2/§5/§6, AURORA-1759) -------------------------------
INCOME_FORM = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
SYNTHETIC_DISCLOSURE = GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json"
async def _golden_owner(session: AsyncSession, path: Path, *, office: RegionalOffice, name: str) -> LegalEntity:
"""Владелец с ИНН шапки golden-снимка `path` (совпадает с шапкой — `counterparty_check = MATCH`)."""
data = json.loads(path.read_text("utf-8"))
inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
return await make_legal_entity(session, name=name, inn=inn, office=office)
async def _income_form(
session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice
) -> tuple[Document, FinancialStatementMeta]:
"""Чистая (confirmable) форма ОФР из golden-снимка — база для тестов факта исхода `CONFIRMED`."""
path = fixtures_dir / INCOME_FORM
owner = await _golden_owner(session, path, office=office, name="ООО Владелец Исход")
(income,) = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None)
assert income.document_type is DocumentType.INCOME
meta = (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
return income, meta
async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document:
"""RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (та же фикстура, что в test_auto_confirm.py)."""
path = fixtures_dir / SYNTHETIC_DISCLOSURE
owner = await _golden_owner(session, path, office=office, name="ООО Синтетика Документы")
documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None)
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
assert disclosure.status is DocumentStatus.RECOGNIZED
return disclosure
async def _set_value_fix(
session: AsyncSession,
income: Document,
*,
source_text: str,
original_amount: Decimal,
confirmed_amount: Decimal,
) -> FinancialStatementValue:
"""Правит одно значение формы напрямую (ORM, минуя `PATCH .../statement`) — `is_modified=True` и `source_text`
для проверки отнесения правки (§14.2 «Сумма»)."""
meta = (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id))
).scalar_one()
value = (
await session.execute(
select(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta.id)
.limit(1)
)
).scalar_one()
value.is_modified = True
value.source_text = source_text
value.original_amount = original_amount
value.amount = confirmed_amount
await session.flush()
return value
async def _break_2200_and_accept(
session: AsyncSession,
document: Document,
meta: FinancialStatementMeta,
*,
user_id: uuid.UUID,
now: datetime.datetime,
) -> None:
"""Ломает итог 2200 (как `test_form_confirmable.py::_break_2200`) и сразу акцептует нарушение — документ
остаётся подтверждаемым (`form_blocker` не видит нарушения при акцепте), но `checks_accepted_by` задан."""
values = (
await session.execute(
select(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200")
)
).scalars()
for value in values:
value.amount = (value.amount or 0) + 1000
await session.flush()
job = await session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
meta.checks_accepted_by = user_id
meta.checks_accepted_at = now
meta.checks_accepted_comment = "акцептовано для теста факта исхода"
await session.flush()
async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact:
return (
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
).scalar_one()
@pytest.mark.fixtures
async def test_confirm_writes_outcome_fact_as_is_when_no_fixes(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""§14.3 п. 6: ручное подтверждение чистой формы без правок и без акцепта — `AS_IS`."""
office = await _rostov_office(seeded)
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
user_id = await _user_id(seeded, KA_RF)
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.outcome == QualityOutcome.CONFIRMED
assert fact.primary_cause == QualityCause.AS_IS
assert fact.checks_accepted is False
assert fact.confirmed_by == user_id
assert all(
getattr(fact, key) == 0
for key in (
"fixes_value_service",
"fixes_value_ours",
"fixes_code_service",
"fixes_code_ours",
"fixes_added_row_service",
"fixes_added_row_ours",
"fixes_header_service",
"fixes_header_ours",
"fixes_osv_period_account",
)
)
assert fact.payload["fixes"] == []
@pytest.mark.fixtures
async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""§14.2 «Сумма»: верный ответ есть в сыром тексте сервиса (разбор ошибся мы) — `OUR_ERROR`."""
office = await _rostov_office(seeded)
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
fixed = await _set_value_fix(
seeded,
income,
source_text="1 234", # сервис прочитал верно (тысячи через пробел)
original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся
confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте
)
fixed_line_id = fixed.line_id # до HTTP-запроса: после него объекты сессии теста могут устареть
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.primary_cause == QualityCause.OUR_ERROR
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",
"after": "1234.00",
}
]
@pytest.mark.fixtures
async def test_confirm_writes_outcome_fact_service_error_when_fix_not_in_raw_text(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""§14.2 «Сумма»: верного ответа в сыром тексте нет — сервис прочитал не те цифры — `SERVICE_ERROR`."""
office = await _rostov_office(seeded)
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
await _set_value_fix(
seeded,
income,
source_text="99", # сервис прочитал совсем другие цифры
original_amount=Decimal("99.00"),
confirmed_amount=Decimal("1234.00"),
)
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.primary_cause == QualityCause.SERVICE_ERROR
assert (fact.fixes_value_service, fact.fixes_value_ours) == (1, 0)
@pytest.mark.fixtures
async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""§14.3/§14.4: подтверждение с акцептом нарушенного жёсткого соотношения §7, без правок — `CLIENT_DOCUMENT`."""
office = await _rostov_office(seeded)
income, meta = await _income_form(seeded, fixtures_dir, office=office)
user_id = await _user_id(seeded, KA_RF)
await _break_2200_and_accept(seeded, income, meta, user_id=user_id, now=datetime.datetime.now(datetime.UTC))
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.checks_accepted is True
assert fact.primary_cause == QualityCause.CLIENT_DOCUMENT
assert fact.payload["fixes"] == []
@pytest.mark.fixtures
async def test_disclosure_confirm_has_no_outcome_fact(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Гейт типа документа (тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES`/`OUTCOME_FACT_DOCUMENT_TYPES`, Task 5/6):
`DISCLOSURE` подтверждается (симметрично `test_recognized_disclosure_is_manually_confirmable`,
test_auto_confirm.py), но факт исхода `CONFIRMED` не пишется."""
office = await _rostov_office(seeded)
disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{disclosure.id}/confirm")
assert response.status_code == HTTP_OK, response.text
assert response.json()["status"] == "CONFIRMED"
facts = (
(await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)))
.scalars()
.all()
)
assert facts == []