arm-core/api/src/domain/quality_attribution.py
Andrey Epifancev 5603dd1898 feat(#190): атрибуция правки сервис/наш разбор (§14.2)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-05 18:37:57 +04:00

87 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Атрибуция правки «сервис / наш разбор» (§14.2 `05-domain-rules.md`; design `quality-metrics` §5).
Правка — отличие подтверждённого аналитиком значения от того, что пришло на вход (снимок `quality_intake_fact`).
Правило одно: верный ответ есть в сыром тексте, который распознал сервис (ИС Сканирование / `tabular-parser`), —
ошибка наша (мы неверно разобрали то, что сервис прочитал правильно); верного ответа в сыром тексте нет — ошибка
сервиса. Для Excel сервис разбора — наш же `tabular-parser`, поэтому там любая ошибка распознавания — «наш разбор»
(design §5); это решает вызывающий, не передавая Excel-документы сюда, а не отдельная ветка в этих функциях.
Разбор сырого текста — тем же кодом, что разбирал сервис: публичным API `arm_statements`
(`fix_lookalikes`, `parse_amount`, `parse_code`, `default_rules`). Правила читаются один раз на вызов
(`default_rules()` — чистая и дешёвая фабрика пакета, копия правил per-call не кэшируется: это чистый домен без
состояния на процесс, кэш — не его забота).
"""
from collections.abc import Sequence
from typing import Literal
from arm_statements import Source, default_rules, fix_lookalikes, parse_amount, parse_code
__all__ = [
"attribute_added_row",
"attribute_code_fix",
"attribute_header_fix",
"attribute_value_fix",
]
Attribution = Literal["service", "ours"]
def _digits_only(text: str) -> str:
return "".join(char for char in text if char.isdigit())
def attribute_value_fix(confirmed: str, raw_text: str) -> Attribution:
"""§14.2 «Сумма»: «наш разбор», если цифры подтверждённой суммы (без знака) совпадают с цифрами, которые
извлекает из сырого текста `parse_amount` после замены двойников §11.3 — сервис прочитал верно, а мы
неверно разобрали (знак, скобки, разряды, мусор по краям); иначе — «сервис» (сервис сам прочитал не те цифры).
`parse_amount` требует `Source`; здесь он не определяет исход («О»→«0» и выделение числового ядра в тексте
работают одинаково для PDF и Excel), поэтому берём `Source.OCR` — распознавание различий между источниками
в `parse_amount` касается только неоднозначных разделителей тысяч, а их чтение в эту цифровую проверку не
входит (решение задачи 3, см. отчёт).
"""
rules = default_rules()
parsed = parse_amount(raw_text, Source.OCR, rules)
if parsed.amount is None:
return "service"
return "ours" if _digits_only(str(abs(parsed.amount))) == _digits_only(confirmed) else "service"
def attribute_code_fix(confirmed_code: str, raw_text: str) -> Attribution:
"""§14.2 «Код строки»: «наш разбор», если подтверждённый код есть среди кодов, которые `parse_code` извлекает
из строк сырого `line_code_text` (в т. ч. из одной из частей склейки `1250\\n1260` — каждая строка разбирается
отдельно, как это делает сам пакет для многострочных ячеек); иначе — «сервис».
"""
rules = default_rules()
codes: set[str] = set()
for line in raw_text.splitlines():
if not line.strip():
continue
parsed = parse_code(line, rules)
if parsed.code is not None:
codes.add(parsed.code)
return "ours" if confirmed_code in codes else "service"
def attribute_added_row(code: str, raw_line_codes: Sequence[str], *, is_excel: bool) -> Attribution:
"""§14.2 «Добавленная строка» (`row_index IS NULL`): для Excel всегда «наш разбор» (сервис разбора Excel — наш
`tabular-parser`, `raw_line_codes` не смотрим вовсе); для PDF — «наш разбор», если код добавленной строки есть
среди четырёхзначных токенов сырого ответа ИС Сканирование (`raw_line_codes` извлекаются раньше, при приёме, —
не задача этого модуля); иначе — «сервис» (сервис вообще не увидел такой код).
"""
if is_excel:
return "ours"
return "ours" if code in raw_line_codes else "service"
def attribute_header_fix(confirmed: str, header_source_text: str) -> Attribution:
"""§14.2 «Шапка» (период, ИНН, единицы, наименование) и «Дата колонки»: «наш разбор», если подтверждённое
значение можно найти в сыром тексте источника после замены двойников §11.3 — `header_source[*].text` для полей
шапки или подпись колонки (`label`) для даты колонки; какой именно текст передать — решает вызывающий, функция
одна и та же для обоих случаев (design §5). Иначе — «сервис».
"""
rules = default_rules()
normalized_source = fix_lookalikes(header_source_text, rules.lookalikes)
return "ours" if confirmed in normalized_source else "service"