From 5603dd1898b7c1b1fd2c81efd071b2663502c420 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 5 Nov 2026 18:37:57 +0400 Subject: [PATCH] =?UTF-8?q?feat(#190):=20=D0=B0=D1=82=D1=80=D0=B8=D0=B1?= =?UTF-8?q?=D1=83=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81/=D0=BD=D0=B0=D1=88=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20(=C2=A714.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- api/src/domain/quality_attribution.py | 86 ++++++++++++++++++++ api/tests/test_domain_quality_attribution.py | 62 ++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 api/src/domain/quality_attribution.py create mode 100644 api/tests/test_domain_quality_attribution.py diff --git a/api/src/domain/quality_attribution.py b/api/src/domain/quality_attribution.py new file mode 100644 index 0000000..7fe1412 --- /dev/null +++ b/api/src/domain/quality_attribution.py @@ -0,0 +1,86 @@ +"""Атрибуция правки «сервис / наш разбор» (§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" diff --git a/api/tests/test_domain_quality_attribution.py b/api/tests/test_domain_quality_attribution.py new file mode 100644 index 0000000..a5897cf --- /dev/null +++ b/api/tests/test_domain_quality_attribution.py @@ -0,0 +1,62 @@ +"""Атрибуция правки «сервис / наш разбор» (§14.2).""" + +from src.domain.quality_attribution import ( + attribute_added_row, + attribute_code_fix, + attribute_header_fix, + attribute_value_fix, +) + + +def test_attribute_value_fix_ours_when_raw_matches_after_lookalikes(): + # сырой текст "1OOO.00" (O вместо 0), подтверждено 1000.00 — цифры совпадают после замены двойников → "ours" + assert attribute_value_fix("1000.00", "1OOO.00") == "ours" + + +def test_attribute_value_fix_service_when_raw_differs(): + assert attribute_value_fix("1000.00", "999.00") == "service" + + +def test_attribute_value_fix_ignores_sign_difference(): + # знак — наша зона ответственности (скобки/минус разобраны неверно), цифры сошлись → "ours" + assert attribute_value_fix("-1000.00", "(1000.00)") == "ours" + + +def test_attribute_code_fix_ours_when_code_in_raw_text(): + # код найден среди кодов склейки "1250\n1260" (две строки сырого line_code_text) → наш разбор, не сервис + # (имя теста в брифе и его ассерт противоречили таблице §14.2 — исправлено по дизайну, см. отчёт задачи) + assert attribute_code_fix("1250", "1250\n1260") == "ours" + + +def test_attribute_code_fix_service_when_code_not_in_raw_text(): + assert attribute_code_fix("1250", "9999\n9998") == "service" + + +def test_attribute_added_row_excel_always_ours(): + assert attribute_added_row("1250", raw_line_codes=["1250"], is_excel=True) == "ours" + + +def test_attribute_added_row_excel_always_ours_even_if_code_absent(): + # для Excel правило не смотрит на raw_line_codes вовсе — ошибка всегда наша (наш tabular-parser) + assert attribute_added_row("1250", raw_line_codes=[], is_excel=True) == "ours" + + +def test_attribute_added_row_pdf_ours_when_code_in_raw_tokens(): + assert attribute_added_row("1250", raw_line_codes=["1100", "1250", "1600"], is_excel=False) == "ours" + + +def test_attribute_added_row_pdf_service_when_code_absent(): + assert attribute_added_row("1250", raw_line_codes=["1100", "1600"], is_excel=False) == "service" + + +def test_attribute_header_fix_ours_when_confirmed_in_source_text(): + assert attribute_header_fix("Рога и копыта", "ООО «Рога и копыта»") == "ours" + + +def test_attribute_header_fix_service_when_confirmed_not_in_source_text(): + assert attribute_header_fix("Рога и копыта", "ООО «Другая компания»") == "service" + + +def test_attribute_header_fix_ours_after_lookalikes_for_inn(): + # ИНН — цифровое поле шапки: двойник буквы рядом с цифрой (О вместо 0) исправляется §11.3 перед сравнением + assert attribute_header_fix("7000112233", "7OOO112233") == "ours"