fix(#190): I-2 payload фактов по контракту quality-facts, вендоринг схемы и тест валидации; I-5 дата шапки через parse_date
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a6c3641c1
commit
8c7ce7c172
109
api/contracts/metrics/quality-facts.schema.json
Normal file
109
api/contracts/metrics/quality-facts.schema.json
Normal 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 отверг бы его как совпавший с обоими)."
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
40
api/tests/quality_payload_contract.py
Normal file
40
api/tests/quality_payload_contract.py
Normal 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]
|
||||
@ -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
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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 == []
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) |
|
||||
|
||||
@ -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 не меняются.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user