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 collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
from typing import Literal
|
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__ = [
|
__all__ = [
|
||||||
"attribute_added_row",
|
"attribute_added_row",
|
||||||
"attribute_code_fix",
|
"attribute_code_fix",
|
||||||
|
"attribute_header_date_fix",
|
||||||
"attribute_header_fix",
|
"attribute_header_fix",
|
||||||
"attribute_value_fix",
|
"attribute_value_fix",
|
||||||
]
|
]
|
||||||
@ -84,3 +86,16 @@ def attribute_header_fix(confirmed: str, header_source_text: str) -> Attribution
|
|||||||
rules = default_rules()
|
rules = default_rules()
|
||||||
normalized_source = fix_lookalikes(header_source_text, rules.lookalikes)
|
normalized_source = fix_lookalikes(header_source_text, rules.lookalikes)
|
||||||
return "ours" if confirmed in normalized_source else "service"
|
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")
|
_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")
|
_LINE_FIELDS = ("row_index", "line_code", "line_code_text", "line_status", "needs_review")
|
||||||
_VALUE_FIELDS = (
|
_VALUE_FIELDS = (
|
||||||
"line_id",
|
|
||||||
"column_id",
|
|
||||||
"amount",
|
"amount",
|
||||||
"string_value",
|
"string_value",
|
||||||
"source_text",
|
"source_text",
|
||||||
@ -59,13 +57,14 @@ def build_intake_payload( # noqa: PLR0913 - сигнатура снимка ф
|
|||||||
if osv_row_count is not None:
|
if osv_row_count is not None:
|
||||||
payload["row_count"] = osv_row_count
|
payload["row_count"] = osv_row_count
|
||||||
return payload
|
return payload
|
||||||
|
line_refs = {getattr(line, "id", None): _line_ref(line) for line in lines or ()}
|
||||||
header = _pick(document, _HEADER_FIELDS)
|
header = _pick(document, _HEADER_FIELDS)
|
||||||
if header_source is not None:
|
if header_source is not None:
|
||||||
header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()}
|
header["header_source"] = {key: _plain_mapping(entry) for key, entry in header_source.items()}
|
||||||
return {
|
return {
|
||||||
"header": header,
|
"header": header,
|
||||||
"lines": [_line(line) for line in lines or ()],
|
"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],
|
"issues": [_pick(issue, _ISSUE_FIELDS) for issue in issues],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,10 +99,16 @@ def build_outcome_payload( # noqa: PLR0913 - сигнатура снимка ф
|
|||||||
return payload
|
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]:
|
def _line(line: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": _plain(getattr(line, "id", None)),
|
"id": _plain(getattr(line, "id", None)),
|
||||||
"ref": _plain(getattr(line, "source_ref", None)),
|
"ref": _line_ref(line),
|
||||||
**_pick(line, _LINE_FIELDS),
|
**_pick(line, _LINE_FIELDS),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,8 +120,18 @@ def _plain_mapping(entry: Any) -> Any:
|
|||||||
return _plain(entry)
|
return _plain(entry)
|
||||||
|
|
||||||
|
|
||||||
def _value(value: Any) -> dict[str, Any]:
|
def _value(value: Any, line_refs: Mapping[Any, str]) -> dict[str, Any]:
|
||||||
return _pick(value, _VALUE_FIELDS)
|
"""Значение по контракту: `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]:
|
def _pick(obj: Any, fields: Sequence[str]) -> dict[str, Any]:
|
||||||
@ -128,7 +143,7 @@ def _plain(value: Any) -> Any:
|
|||||||
if isinstance(value, Enum):
|
if isinstance(value, Enum):
|
||||||
return value.value
|
return value.value
|
||||||
if isinstance(value, Decimal):
|
if isinstance(value, Decimal):
|
||||||
return str(value)
|
return format(value, "f")
|
||||||
if isinstance(value, UUID):
|
if isinstance(value, UUID):
|
||||||
return str(value)
|
return str(value)
|
||||||
if isinstance(value, datetime | date):
|
if isinstance(value, datetime | date):
|
||||||
|
|||||||
@ -27,6 +27,7 @@ from src.domain.quality_attribution import (
|
|||||||
Attribution,
|
Attribution,
|
||||||
attribute_added_row,
|
attribute_added_row,
|
||||||
attribute_code_fix,
|
attribute_code_fix,
|
||||||
|
attribute_header_date_fix,
|
||||||
attribute_header_fix,
|
attribute_header_fix,
|
||||||
attribute_value_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); не импортируется оттуда во избежание цикла
|
# `OUTCOME_FACT_DOCUMENT_TYPES` в `services/auto_confirm.py` (Task 6); не импортируется оттуда во избежание цикла
|
||||||
# `documents → auto_confirm → statements → documents` (то же решение, что в Task 6)
|
# `documents → auto_confirm → statements → documents` (то же решение, что в Task 6)
|
||||||
OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV})
|
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 «Шапка»
|
# поле шапки → (атрибут «правилось», имя поля в `financial_statement_meta`); порядок — как в §14.2 «Шапка»
|
||||||
_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = (
|
_HEADER_FIX_FIELDS: tuple[tuple[str, str], ...] = (
|
||||||
("inn_modified", "inn"),
|
("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`).
|
"""Правка суммы (§14.2 «Сумма»): `None` — значение не правилось (`is_modified` не `True`).
|
||||||
|
|
||||||
Нет сырого текста для сравнения (`source_text is None`) — классифицировать нельзя; считаем правку нашей, не
|
Нет сырого текста для сравнения (`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)
|
"ours" if value.source_text is None else attribute_value_fix(confirmed=confirmed, raw_text=value.source_text)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"kind": "value",
|
"field": "value",
|
||||||
"line_id": str(value.line_id),
|
"line_ref": line_ref,
|
||||||
"attribution": attribution,
|
"column": str(value.column_id),
|
||||||
"before": str(value.original_amount) if value.original_amount is not None else None,
|
"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,
|
"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)
|
else attribute_code_fix(confirmed_code=confirmed_code, raw_text=line.line_code_text)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"kind": "code",
|
"field": "code",
|
||||||
"line_id": str(line.id),
|
"line_ref": _line_ref(line),
|
||||||
"attribution": attribution,
|
"source": attribution,
|
||||||
"before": line.line_code_original_value,
|
"rule": RULE_CODE,
|
||||||
|
"before_raw": line.line_code_text,
|
||||||
|
"before_recognized": line.line_code_original_value,
|
||||||
"after": confirmed_code,
|
"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:
|
if line.row_index is not None:
|
||||||
return None
|
return None
|
||||||
attribution: Attribution = attribute_added_row(line.line_code or "", (), is_excel=True) if is_excel else "ours"
|
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:
|
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)
|
confirmed = _header_confirmed_value(meta, field)
|
||||||
source_entry = header_source.get(field) if isinstance(header_source, Mapping) else None
|
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
|
source_text = source_entry.get("text") if isinstance(source_entry, Mapping) else None
|
||||||
attribution: Attribution = (
|
confirmed_date = meta.report_date if field == "report_date" else None
|
||||||
"ours" if source_text is None else attribute_header_fix(confirmed=confirmed, header_source_text=source_text)
|
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)
|
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:
|
def _primary_cause(counters: Mapping[str, int], *, checks_accepted: bool) -> QualityCause:
|
||||||
@ -458,7 +497,7 @@ class DocumentService:
|
|||||||
if fix is None:
|
if fix is None:
|
||||||
return
|
return
|
||||||
fixes.append(fix)
|
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
|
counters[f"fixes_{prefix}_{suffix}"] += 1
|
||||||
|
|
||||||
if document.document_type is DocumentType.OSV:
|
if document.document_type is DocumentType.OSV:
|
||||||
@ -476,14 +515,28 @@ class DocumentService:
|
|||||||
]
|
]
|
||||||
if document.is_modified:
|
if document.is_modified:
|
||||||
counters["fixes_osv_period_account"] = 1
|
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)
|
payload = build_outcome_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count, fixes=fixes)
|
||||||
elif meta is not None:
|
elif meta is not None:
|
||||||
_, pairs = await statements_repo.load_form(self._session, meta.id)
|
_, pairs = await statements_repo.load_form(self._session, meta.id)
|
||||||
lines = [line for line, _ in pairs]
|
lines = [line for line, _ in pairs]
|
||||||
values = [value for _, line_values in pairs for value in line_values]
|
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:
|
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:
|
for line in lines:
|
||||||
_record(_code_fix(line), "code")
|
_record(_code_fix(line), "code")
|
||||||
_record(_added_row_fix(line, is_excel=is_excel), "added_row")
|
_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,
|
make_stored_file,
|
||||||
)
|
)
|
||||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||||
|
from tests.quality_payload_contract import assert_outcome_payload_valid
|
||||||
|
|
||||||
HTTP_OK = 200
|
HTTP_OK = 200
|
||||||
HTTP_FORBIDDEN = 403
|
HTTP_FORBIDDEN = 403
|
||||||
@ -622,9 +623,11 @@ async def _break_2200_and_accept(
|
|||||||
|
|
||||||
|
|
||||||
async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact:
|
async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact:
|
||||||
return (
|
fact = (
|
||||||
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.fixtures
|
@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"), # наш разбор тысячного разделителя ошибся
|
original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся
|
||||||
confirmed_amount=Decimal("1234.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)
|
client = await auth_client(KA_RF)
|
||||||
|
|
||||||
response = await client.post(f"/documents/{income.id}/confirm")
|
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.fixes_value_ours, fact.fixes_value_service) == (1, 0)
|
||||||
assert fact.payload["fixes"] == [
|
assert fact.payload["fixes"] == [
|
||||||
{
|
{
|
||||||
"kind": "value",
|
"field": "value",
|
||||||
"line_id": str(fixed_line_id),
|
"line_ref": fixed_line_ref,
|
||||||
"attribution": "ours",
|
"column": str(fixed_column_id),
|
||||||
"before": "1234000.00",
|
"source": "ours",
|
||||||
|
"rule": "Сумма",
|
||||||
|
"before_raw": "1 234",
|
||||||
|
"before_recognized": "1234000.00",
|
||||||
"after": "1234.00",
|
"after": "1234.00",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -740,6 +749,94 @@ async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted(
|
|||||||
assert fact.payload["fixes"] == []
|
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
|
@pytest.mark.fixtures
|
||||||
async def test_disclosure_confirm_has_no_outcome_fact(
|
async def test_disclosure_confirm_has_no_outcome_fact(
|
||||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
|
|||||||
@ -59,6 +59,7 @@ from tests.factories import (
|
|||||||
make_stored_file,
|
make_stored_file,
|
||||||
)
|
)
|
||||||
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
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_NO_CONTENT = 204
|
||||||
HTTP_FORBIDDEN = 403
|
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:
|
async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact:
|
||||||
return (
|
fact = (
|
||||||
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
||||||
).scalar_one()
|
).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]:
|
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.conftest import AuthClientFactory
|
||||||
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
|
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.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
|
||||||
|
from tests.quality_payload_contract import assert_outcome_payload_valid
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
@ -241,6 +242,7 @@ async def test_auto_confirm_outcome_fact_osv_payload_shape(seeded: AsyncSession)
|
|||||||
fact = (
|
fact = (
|
||||||
await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id))
|
await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document.id))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
assert_outcome_payload_valid(fact.payload)
|
||||||
assert "lines" not in fact.payload
|
assert "lines" not in fact.payload
|
||||||
assert "values" not in fact.payload
|
assert "values" not in fact.payload
|
||||||
assert fact.payload["osv_warnings"] == ["OSV_ROUNDING"]
|
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))
|
await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == income.id))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert fact.outcome == QualityOutcome.AUTO_CONFIRMED
|
assert fact.outcome == QualityOutcome.AUTO_CONFIRMED
|
||||||
|
assert_outcome_payload_valid(fact.payload)
|
||||||
assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION
|
assert fact.primary_cause == QualityCause.WITHOUT_PARTICIPATION
|
||||||
assert fact.report_date == income.reporting_date
|
assert fact.report_date == income.reporting_date
|
||||||
assert fact.payload["fixes"] == []
|
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
|
assert disclosure.status is DocumentStatus.CONFIRMED
|
||||||
|
|
||||||
facts = (
|
facts = (
|
||||||
await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))
|
(await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id)))
|
||||||
).scalars().all()
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
assert facts == []
|
assert facts == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"""Атрибуция правки «сервис / наш разбор» (§14.2)."""
|
"""Атрибуция правки «сервис / наш разбор» (§14.2)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from src.domain.quality_attribution import (
|
from src.domain.quality_attribution import (
|
||||||
attribute_added_row,
|
attribute_added_row,
|
||||||
attribute_code_fix,
|
attribute_code_fix,
|
||||||
|
attribute_header_date_fix,
|
||||||
attribute_header_fix,
|
attribute_header_fix,
|
||||||
attribute_value_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():
|
def test_attribute_header_fix_ours_after_lookalikes_for_inn():
|
||||||
# ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением
|
# ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением
|
||||||
assert attribute_header_fix("7000112233", "7OOO112233") == "ours"
|
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"] == [
|
assert payload["values"] == [
|
||||||
{
|
{
|
||||||
"line_id": str(line_id),
|
"line_ref": "row:3",
|
||||||
"column_id": str(column_id),
|
"column": str(column_id),
|
||||||
"amount": "1500.00",
|
"amount": "1500.00",
|
||||||
"string_value": None,
|
"string_value": None,
|
||||||
"source_text": "1 500",
|
"source_text": "1 500",
|
||||||
"confidence": "95.00",
|
"confidence": 95.0,
|
||||||
"is_recognized": True,
|
"is_recognized": True,
|
||||||
"needs_review": False,
|
"needs_review": False,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,6 +89,7 @@ from tests.factories import (
|
|||||||
make_stored_file,
|
make_stored_file,
|
||||||
make_version,
|
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
|
from tests.test_recognition_inbox import envelope, fake_msg
|
||||||
|
|
||||||
API_DIR = Path(__file__).resolve().parents[1]
|
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 def _outcome_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityOutcomeFact:
|
||||||
async with maker() as session:
|
async with maker() as session:
|
||||||
return (
|
fact = (
|
||||||
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
async def test_supersede_previous_writes_superseded_fact_for_dropped_document(
|
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 def _intake_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityIntakeFact:
|
||||||
async with maker() as session:
|
async with maker() as session:
|
||||||
return (
|
fact = (
|
||||||
await session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id))
|
await session.execute(select(QualityIntakeFact).where(QualityIntakeFact.document_id == document_id))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
assert_intake_payload_valid(fact.payload) # payload соответствует контракту quality-facts
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
async def test_map_candidate_writes_intake_fact_for_statement_document(
|
async def test_map_candidate_writes_intake_fact_for_statement_document(
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы
|
Отдельная схема PostgreSQL (имя — настройка, по умолчанию `dkb_metrics`), миграции — общей цепочкой Alembic. Таблицы
|
||||||
плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие
|
плоские: измерения — обычные столбцы, подробности — `jsonb` с версией схемы (`schema_version`). Без FK на рабочие
|
||||||
таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта.
|
таблицы; идентификаторы документов, файлов, заданий, ЮЛ, ГК — просто `uuid`, имена ЮЛ и ГК копируются на момент факта.
|
||||||
Контракт столбцов и `payload` — `../docs/contracts/quality-facts.schema.json`. Записи только добавляются.
|
Контракт столбцов и `payload` — `../docs/contracts/metrics/quality-facts.schema.json`. Записи только добавляются.
|
||||||
|
|
||||||
### 4.1 `quality_intake_fact` — документ «как распознано»
|
### 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-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-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-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`, признак скана, право), правила
|
- **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.
|
- **arm-core** (этот change): запись фактов, атрибуция правок, расчёт метрик, API, экран, право в seed.
|
||||||
- scan-gateway и tabular-parser не меняются.
|
- scan-gateway и tabular-parser не меняются.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user