"""Атрибуция правки «сервис / наш разбор» (§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"