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 159 additions and 3 deletions
Showing only changes of commit 6390b36188 - Show all commits

View File

@ -16,9 +16,10 @@ audit-trail «События файлов и документов»; design D-4)
уборка таких объектов — ретеншн бакета (OQ-22).
"""
import datetime
import hashlib
import uuid
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Mapping, Sequence
from dataclasses import dataclass
from typing import Literal
@ -37,6 +38,7 @@ from src.core.config import Settings
from src.core.storage import ObjectNotFoundError, ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
from src.domain.quality_facts import build_outcome_payload
from src.models.consolidation import Consolidation
from src.models.directories import LegalEntity
from src.models.enums import (
@ -44,15 +46,18 @@ from src.models.enums import (
AuditSection,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
QualityOutcome,
RecognitionStatus,
S3BucketRole,
)
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.repositories import consolidations as consolidations_repo
from src.repositories import directories as directories_repo
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.common import PageParams
from src.schemas.files import FileOut
from src.services import consolidation_documents
@ -73,6 +78,12 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу
FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND"
FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS"
FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS"
# §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` пишется только
# для этих типов — тот же набор, что `OUTCOME_FACT_DOCUMENT_TYPES` в services/auto_confirm.py (Task 6) и
# services/documents.py (Task 7); не импортируется оттуда во избежание цикла (то же решение, что в Task 6/7)
OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV})
# статусы документа, у которого исход подтверждения ещё не было: удаление пишет `DELETED`, а не `DELETED_AFTER_CONFIRM`
_NEVER_CONFIRMED_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK})
class StorageUnavailableError(DomainError):
@ -304,6 +315,50 @@ class PrimaryFilesService:
media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream")
return _chain(first, iterator), media_type, header
async def _write_delete_outcome_facts(
self,
documents: Sequence[Document],
issue_counts: Mapping[uuid.UUID, statements_repo.IssueCounts],
now: datetime.datetime,
) -> None:
"""Факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` на документ удаляемого файла (§14.3; design quality-metrics
§4.2, §6; AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у `CONFIRMED`/
`AUTO_CONFIRMED`, Task 6/7).
Документ `CONFIRMED`, дошедший до этой точки, — ВСЕГДА автоподтверждённый: вручную подтверждённый
(`confirmed_by IS NOT NULL`) даёт 409 `FILE_HAS_CONFIRMED_DOCUMENTS` раньше в `delete` (см. docstring метода),
поэтому здесь — `DELETED_AFTER_CONFIRM`. `RECOGNIZED`/`NEEDS_CHECK` — документ без исхода подтверждения —
`DELETED`. Иной статус (`ERROR`) факта не получает: у него не было осмысленного состояния «как распознано»,
которое стоило бы фиксировать как удалённый исход (решение задачи 8, design.md).
`primary_cause=None` явно — §14.3 правило 1: `DELETED*` вне матрицы причин. `payload` — минимальный снимок
без построчных данных (`build_outcome_payload(document, fixes=())`): это событие удаления, не разбор правок,
и строки формы/ОСВ документа всё равно исчезнут за этим же вызовом (решение задачи 8). Пишется в транзакции
вызывающего (`delete`), без `flush`/`commit`.
"""
for document in documents:
if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES:
continue
if document.status is DocumentStatus.CONFIRMED:
outcome = QualityOutcome.DELETED_AFTER_CONFIRM
elif document.status in _NEVER_CONFIRMED_STATUSES:
outcome = QualityOutcome.DELETED
else:
continue
counts = issue_counts.get(document.id)
issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0
await insert_outcome_fact(
self._session,
document_id=document.id,
outcome=outcome,
outcome_at=now,
primary_cause=None,
report_date=document.reporting_date,
confirmed_by=document.confirmed_by,
issue_count_at_outcome=issue_count_at_outcome,
payload=build_outcome_payload(document, fixes=()),
)
async def delete(self, file_id: uuid.UUID) -> None:
"""Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10).
@ -319,7 +374,13 @@ class PrimaryFilesService:
и `stored_file`; `s3_object` файла и результатов заданий
остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и
`results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
ретеншн бакета. `before_value` аудита — карточка файла."""
ретеншн бакета. `before_value` аудита — карточка файла.
§14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ файла типа BALANCE/INCOME/OSV ДО
физического удаления строк пишется факт исхода `DELETED` (документ не подтверждался) или
`DELETED_AFTER_CONFIRM` (автоподтверждённый — вручную подтверждённый сюда не доходит, см. 409 выше);
`_write_delete_outcome_facts`.
"""
visible = await files_repo.get_file(self._session, self._scope, file_id)
if visible is None:
raise NotFoundError(FILE_NOT_FOUND)
@ -356,6 +417,7 @@ class PrimaryFilesService:
)
before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json")
object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)]
await self._write_delete_outcome_facts(documents, issue_counts, utc_now())
await files_repo.delete_file_rows(self._session, file)
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
owned_prefixes = [

View File

@ -27,12 +27,15 @@ from src.models.enums import (
AuditSection,
ConsolidationStatus,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
QualityOutcome,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from src.models.quality_facts import QualityOutcomeFact
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
@ -88,6 +91,17 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]:
return list(rows.scalars())
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()
async def _outcome_facts(session: AsyncSession, document_id: uuid.UUID) -> list[QualityOutcomeFact]:
rows = await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
return list(rows.scalars())
# --- успешное удаление ----------------------------------------------------------------------------------------------
@ -570,3 +584,83 @@ async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth
assert response.status_code == HTTP_NO_CONTENT, response.text
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": []}
# --- факты исхода DELETED/DELETED_AFTER_CONFIRM (задача 8, §14.3, AURORA-1759) ----------------------------------------
async def test_delete_file_writes_deleted_fact_for_unconfirmed_document(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Документ `NEEDS_CHECK` — подтверждения не было вовсе: удаление файла пишет факт исхода `DELETED`,
`primary_cause` не входит в матрицу причин (§14.3 правило 1) и остаётся `NULL`."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK)
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
fact = await _outcome_fact(seeded, document_id)
assert fact.outcome == QualityOutcome.DELETED
assert fact.primary_cause is None
assert fact.confirmed_by is None
assert fact.report_date == document.reporting_date
assert fact.payload["fixes"] == []
async def test_delete_file_writes_deleted_after_confirm_for_auto_confirmed_document(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Документ уже auto_confirm'нут (`confirmed_by IS NULL`), без привязок к версиям консолидации: ручного
подтверждения (OQ-157/409 `FILE_HAS_CONFIRMED_DOCUMENTS`) здесь нет, файл удаляется — факт исхода
`DELETED_AFTER_CONFIRM`."""
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) # confirmed_by NULL
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
fact = await _outcome_fact(seeded, document_id)
assert fact.outcome == QualityOutcome.DELETED_AFTER_CONFIRM
assert fact.primary_cause is None
assert fact.confirmed_by is None
async def test_delete_other_document_writes_no_outcome_fact(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Гейт типа документа (тот же набор `OUTCOME_FACT_DOCUMENT_TYPES`, что у `CONFIRMED`/`AUTO_CONFIRMED`,
Task 6/7): `OTHER` удаляется, как прежде, но факта исхода не получает."""
entity = await _rostov_entity(seeded)
file, _job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
now = utc_now()
document = Document(
stored_file_id=file.id,
legal_entity_id=file.legal_entity_id,
document_type=DocumentType.OTHER,
file_ordinal=0,
status=DocumentStatus.NEEDS_CHECK,
created_at=now,
updated_at=now,
)
seeded.add(document)
await seeded.flush()
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _outcome_facts(seeded, document_id) == []