feat(#190): метрики качества первички (AURORA-1759) #229
86
api/src/domain/quality_attribution.py
Normal file
86
api/src/domain/quality_attribution.py
Normal 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"
|
||||
62
api/tests/test_domain_quality_attribution.py
Normal file
62
api/tests/test_domain_quality_attribution.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user