feat(#190): метрики качества первички (AURORA-1759) #229

Merged
aep merged 45 commits from feat/190-ocr-metrics into main 2026-11-07 07:50:00 +03:00
2 changed files with 148 additions and 0 deletions
Showing only changes of commit 5603dd1898 - Show all commits

View File

@ -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"

View File

@ -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"