fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml
- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false); confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0 - F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check - F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно» - №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N» - I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes - T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим - гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
22025ecabe
commit
c1288d08fb
@ -23,7 +23,7 @@ FULL_CONFIDENCE = 100.0
|
||||
|
||||
class OcrNormalizer:
|
||||
"""Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по
|
||||
одному на таблицу; статус — `done` только когда всё распознано (D-6)."""
|
||||
одному на таблицу; статус — `done` только когда всё распознано и ни один файл набора не ошибочен (D-6, №28)."""
|
||||
|
||||
def __init__(self, rules: OcrRules) -> None:
|
||||
self._rules = rules
|
||||
@ -43,15 +43,17 @@ class OcrNormalizer:
|
||||
documents: list[StatementDocument] = []
|
||||
fully_recognized = True
|
||||
succeeded = 0
|
||||
failed = 0
|
||||
first_error = None
|
||||
for index, entry in enumerate(files):
|
||||
error = file_error(index, entry)
|
||||
if error is not None:
|
||||
warnings.append(error.warning)
|
||||
first_error = first_error or error
|
||||
failed += 1
|
||||
continue
|
||||
succeeded += 1
|
||||
assert isinstance(entry, dict) # noqa: S101 — проверено _file_error
|
||||
assert isinstance(entry, dict) # проверено _file_error
|
||||
file_documents, file_warnings, file_recognized = self._normalize_file(entry)
|
||||
documents.extend(file_documents)
|
||||
warnings.extend(file_warnings)
|
||||
@ -60,7 +62,8 @@ class OcrNormalizer:
|
||||
return error_outcome(first_error.code, first_error.summary, warnings)
|
||||
if not documents:
|
||||
return error_outcome(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE, warnings)
|
||||
status = ResultStatus.DONE if fully_recognized else ResultStatus.NEEDS_CHECK
|
||||
# №28: потерянный ошибочный файл (например, ОФР при успешном балансе) не остаётся под `done`
|
||||
status = ResultStatus.DONE if fully_recognized and not failed else ResultStatus.NEEDS_CHECK
|
||||
rows = sum(len(document.lines) for document in documents)
|
||||
return outcome_cls(status=status, documents=documents, metrics=Metrics(rows=rows), warnings=warnings)
|
||||
|
||||
@ -73,8 +76,10 @@ class OcrNormalizer:
|
||||
return [], [f"{name}: data_ocr отсутствует или не объект"], False
|
||||
header = parse_file_header(data_ocr, self._rules.lookalikes)
|
||||
warnings: list[str] = []
|
||||
if header.inn is None or header.inn_confidence != FULL_CONFIDENCE:
|
||||
if header.inn is None:
|
||||
warnings.append(f"{name}: ИНН не распознан")
|
||||
elif header.inn_confidence != FULL_CONFIDENCE:
|
||||
warnings.append(f"{name}: ИНН распознан неуверенно")
|
||||
if header.report_date is None:
|
||||
warnings.append(f"{name}: дата отчёта не разобрана")
|
||||
tables = data_ocr.get("table_structure")
|
||||
@ -84,7 +89,7 @@ class OcrNormalizer:
|
||||
prefix = f"{name}: table:{index}: "
|
||||
try:
|
||||
built = build_document(table if isinstance(table, Mapping) else {}, index, name, header, self._rules)
|
||||
except Exception as exc: # noqa: BLE001 — §11.1 п. 9: ошибка одной таблицы не роняет файл
|
||||
except Exception as exc: # §11.1 п. 9: ошибка одной таблицы не роняет файл
|
||||
warnings.append(f"{prefix}BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}")
|
||||
recognized = False
|
||||
continue
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра без изменений:
|
||||
запятая → точка, пробелы долой, содержимое круглых скобок — отрицательное, прочие символы отбрасываются, несколько
|
||||
точек — разделители тысяч (остаётся последняя). `verified` — разбор не отбрасывал символов."""
|
||||
"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием —
|
||||
порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`).
|
||||
|
||||
Что делает разбор: запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма
|
||||
отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки
|
||||
(`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`).
|
||||
`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого
|
||||
(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`.
|
||||
|
||||
Количество знаков после запятой здесь не проверяется: `1,234` → `1.234`; ограничение `Money` (не больше двух знаков) —
|
||||
в `document._value` (решение №30)."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@ -23,8 +31,8 @@ def parse_amount(raw: str) -> ParsedAmount:
|
||||
"""Парсит сумму из распознанного текста OCR-ячейки.
|
||||
|
||||
Заменяет запятую на точку, удаляет пробелы, извлекает содержимое круглых скобок
|
||||
(означает отрицательное число), удаляет непотребные символы, обрабатывает множественные
|
||||
точки как разделители тысяч (оставляет последнюю).
|
||||
(означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями
|
||||
тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456).
|
||||
|
||||
verified=True только если в исходной строке не пришлось отбрасывать символы
|
||||
(после удаления скобок).
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
"""Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA
|
||||
(проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после
|
||||
`text.fix_lookalikes`; замена двойников здесь не выполняется."""
|
||||
`text.fix_lookalikes`; замена двойников здесь не выполняется.
|
||||
|
||||
Единственное отличие от источника (решение №31 design.md): тексты длиннее `MAX_DATE_TEXT_LENGTH` символов не
|
||||
разбираются — регулярки квартала и полугодия дают кубическое backtracking на длинных строках (`"1" + " " * 4000 + "b"` —
|
||||
десятки секунд при удерживаемом GIL), а дата занимает не более десятков символов."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import date, timedelta
|
||||
|
||||
__all__ = ["parse_date"]
|
||||
__all__ = ["MAX_DATE_TEXT_LENGTH", "is_date_sized", "parse_date"]
|
||||
|
||||
MAX_DATE_TEXT_LENGTH = 200
|
||||
|
||||
|
||||
_ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "1V": 4, "11": 2, "111": 3}
|
||||
@ -184,12 +190,18 @@ def _year(y: int) -> date | None:
|
||||
return date(y, 12, 31) if 1990 <= y <= 2100 else None # noqa: PLR2004
|
||||
|
||||
|
||||
def is_date_sized(text: str) -> bool:
|
||||
"""Текст не длиннее `MAX_DATE_TEXT_LENGTH` (по краям пробелы не считаются): только такой разбирается как дата.
|
||||
Вызывающие проверяют это ДО `fix_lookalikes`, чтобы не гонять замену двойников по длинному тексту."""
|
||||
return len(text.strip()) <= MAX_DATE_TEXT_LENGTH
|
||||
|
||||
|
||||
def parse_date(value: object) -> date | None:
|
||||
"""Вернуть дату или None, если это не дата (название компании и т.п.)."""
|
||||
"""Вернуть дату или None, если это не дата (название компании и т.п.) или текст длиннее `MAX_DATE_TEXT_LENGTH`."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value.strip()
|
||||
if not s:
|
||||
if not s or not is_date_sized(s):
|
||||
return None
|
||||
# текстовые формы ищем по всей строке, числовые — по первой (дальше мусор)
|
||||
return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " "))
|
||||
|
||||
@ -5,11 +5,12 @@ D-6, D-7): тип по label или по кодам, колонки из рас
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue
|
||||
from src.normalizer.ocr.amounts import is_blank, parse_amount
|
||||
from src.normalizer.ocr.dates import parse_date
|
||||
from src.normalizer.ocr.dates import is_date_sized, parse_date
|
||||
from src.normalizer.ocr.header import FileHeader
|
||||
from src.normalizer.ocr.layout import Cell, cell_text
|
||||
from src.normalizer.ocr.rows import ParsedRow, ParsedTable, RowKind, parse_table_rows, table_type_by_codes
|
||||
@ -20,6 +21,7 @@ __all__ = ["BuiltDocument", "build_document"]
|
||||
|
||||
FULL_CONFIDENCE = 100.0
|
||||
CAPPED_CONFIDENCE = 99.0
|
||||
MONEY_DECIMAL_PLACES = 2
|
||||
DocumentType = Literal["BALANCE", "INCOME", "OTHER"]
|
||||
|
||||
|
||||
@ -78,7 +80,8 @@ def _columns(parsed: ParsedTable, lookalikes: Mapping[str, str], build: _Build)
|
||||
for position, source_idx in enumerate(header.layout.value_idxs):
|
||||
cell = header.cells.get(source_idx)
|
||||
label = collapse_whitespace(cell.value) if cell is not None else ""
|
||||
period = parse_date(fix_lookalikes(label, lookalikes)) if label else None
|
||||
# решение №31: подпись длиннее даты не разбирается и не проходит `fix_lookalikes`
|
||||
period = parse_date(fix_lookalikes(label, lookalikes)) if label and is_date_sized(label) else None
|
||||
if period is None and not parsed.synthetic_header:
|
||||
build.warn(f"колонка {position} без даты: {label}")
|
||||
columns.append(StatementColumn(column_index=position, label=label, period_date=period))
|
||||
@ -109,6 +112,11 @@ def _choose_edition(
|
||||
return edition
|
||||
|
||||
|
||||
def _decimal_places(amount: Decimal) -> int:
|
||||
exponent = amount.as_tuple().exponent
|
||||
return -exponent if isinstance(exponent, int) and exponent < 0 else 0
|
||||
|
||||
|
||||
def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValue:
|
||||
if cell is None:
|
||||
build.fully_recognized = False
|
||||
@ -117,14 +125,20 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu
|
||||
)
|
||||
parsed = parse_amount(cell.value)
|
||||
text = collapse_whitespace(cell.value)
|
||||
amount = parsed.amount
|
||||
verified = parsed.verified
|
||||
if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES:
|
||||
# решение №30: `Money` контракта — не больше двух знаков; «1,234» (пробел тысяч как запятая) — артефакт OCR,
|
||||
# сумма не принимается, текст остаётся в string_value, иначе ValidationError ронял бы всю таблицу
|
||||
amount, verified = None, False
|
||||
# DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана
|
||||
string_value = text if parsed.amount is None and text and not is_blank(text) else None
|
||||
recognized = cell.confidence == FULL_CONFIDENCE and parsed.verified
|
||||
string_value = text if amount is None and text and not is_blank(text) else None
|
||||
recognized = cell.confidence == FULL_CONFIDENCE and verified
|
||||
if not recognized:
|
||||
build.fully_recognized = False
|
||||
return StatementValue(
|
||||
column_index=column_index,
|
||||
amount=parsed.amount,
|
||||
amount=amount,
|
||||
string_value=string_value,
|
||||
confidence=cell.confidence,
|
||||
is_recognized=recognized,
|
||||
@ -205,6 +219,11 @@ def build_document(
|
||||
data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER]
|
||||
if any(len(row.layout.value_idxs) != len(columns) for row in data_rows):
|
||||
build.warn("повторная шапка изменила число колонок значений") # одно предупреждение на таблицу
|
||||
# №29: строки, отброшенные разбором молча (мусорный код; пустой код при наличии сумм) — по одному предупреждению
|
||||
if parsed.skipped_non_numeric_code:
|
||||
build.warn(f"строк с нечисловым кодом пропущено: {parsed.skipped_non_numeric_code}")
|
||||
if parsed.skipped_no_code_with_amounts:
|
||||
build.warn(f"строк без кода с суммами пропущено: {parsed.skipped_no_code_with_amounts}")
|
||||
codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows]
|
||||
edition = _choose_edition(header, columns, codes, rules, build)
|
||||
lines: list[StatementLine] = []
|
||||
|
||||
@ -5,8 +5,8 @@ from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from src.normalizer.ocr.dates import parse_date
|
||||
from src.normalizer.ocr.text import clean_value, collapse_whitespace, digits_only, fix_lookalikes
|
||||
from src.normalizer.ocr.dates import is_date_sized, parse_date
|
||||
from src.normalizer.ocr.text import clamp_confidence, clean_value, collapse_whitespace, digits_only, fix_lookalikes
|
||||
|
||||
__all__ = ["FileHeader", "parse_file_header"]
|
||||
|
||||
@ -31,10 +31,8 @@ def _field(data_ocr: Mapping[str, object], name: str) -> tuple[str, float | None
|
||||
if not isinstance(raw, Mapping):
|
||||
return "", None
|
||||
text = raw.get("form_name")
|
||||
confidence = raw.get("confidence")
|
||||
text_str = str(text) if isinstance(text, str) else ""
|
||||
conf_float = float(confidence) if isinstance(confidence, (int, float)) else None
|
||||
return (text_str, conf_float)
|
||||
return (text_str, clamp_confidence(raw.get("confidence")))
|
||||
|
||||
|
||||
def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader:
|
||||
@ -49,7 +47,10 @@ def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, s
|
||||
inn_confidence = None
|
||||
elif inn_confidence is not None and inn_raw.strip() != inn:
|
||||
inn_confidence = min(inn_confidence, CAPPED_CONFIDENCE)
|
||||
report_date = parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() else None
|
||||
# решение №31: длинный текст не разбирается и не проходит `fix_lookalikes`
|
||||
report_date = (
|
||||
parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() and is_date_sized(date_raw) else None
|
||||
)
|
||||
return FileHeader(
|
||||
organization_name=organization_name,
|
||||
organization_confidence=organization_confidence if organization_name is not None else None,
|
||||
|
||||
@ -5,7 +5,7 @@ from collections.abc import Collection, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.normalizer.ocr.amounts import is_blank, parse_amount
|
||||
from src.normalizer.ocr.text import is_code_header, is_value_header, normalize_header
|
||||
from src.normalizer.ocr.text import clamp_confidence, is_code_header, is_value_header, normalize_header
|
||||
|
||||
__all__ = ["Cell", "Cells", "ColumnLayout", "build_layout", "cell_text", "cells_by_index", "infer_layout"]
|
||||
|
||||
@ -31,7 +31,7 @@ class ColumnLayout:
|
||||
def cells_by_index(row: Mapping[str, object]) -> Cells:
|
||||
"""Нормализует набор ячеек из распознанной строки в словарь по индексам колонок.
|
||||
|
||||
value → str(value or ""), confidence → float(confidence or 0);
|
||||
value → str(value or ""), confidence → число из [0, 100] (`clamp_confidence`; не число → 0);
|
||||
ячейка без column_index пропускается.
|
||||
"""
|
||||
cells: Cells = {}
|
||||
@ -41,9 +41,9 @@ def cells_by_index(row: Mapping[str, object]) -> Cells:
|
||||
for raw in raw_cells:
|
||||
if not isinstance(raw, Mapping) or not isinstance(raw.get("column_index"), int):
|
||||
continue
|
||||
confidence = raw.get("confidence")
|
||||
confidence = clamp_confidence(raw.get("confidence"))
|
||||
cells[int(raw["column_index"])] = Cell(
|
||||
str(raw.get("value") or ""), float(confidence) if isinstance(confidence, int | float) else 0.0
|
||||
str(raw.get("value") or ""), confidence if confidence is not None else 0.0
|
||||
)
|
||||
return cells
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from src.normalizer.ocr.amounts import parse_amount
|
||||
from src.normalizer.ocr.layout import Cell, Cells, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout
|
||||
|
||||
__all__ = ["ParsedRow", "ParsedTable", "RowKind", "classify_row", "parse_table_rows", "table_type_by_codes"]
|
||||
@ -37,6 +38,10 @@ class ParsedTable:
|
||||
|
||||
rows: list[ParsedRow]
|
||||
synthetic_header: bool
|
||||
# решение №29: строки, молча отбрасываемые разбором (сам разбор — дословный перенос, он их не меняет);
|
||||
# шапки (в том числе повторные) в счёт не идут
|
||||
skipped_non_numeric_code: int = 0 # код непустой и не целое число (`classify_row` → HEADER)
|
||||
skipped_no_code_with_amounts: int = 0 # код пуст, но в колонках значений раскладки строки есть разобранная сумма
|
||||
|
||||
|
||||
def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None:
|
||||
@ -62,6 +67,22 @@ def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | N
|
||||
return ParsedRow(cells, layout, kind, ordinal)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Skipped:
|
||||
non_numeric_code: int = 0
|
||||
no_code_with_amounts: int = 0
|
||||
|
||||
|
||||
def _count_skipped(cells: Cells, layout: ColumnLayout, skipped: _Skipped) -> None:
|
||||
"""Строка отброшена `_data_row`: считаем её, если потеряна возможная строка данных (с кодом-мусором или с суммами);
|
||||
пустой код без сумм — заголовок группы, «в том числе:» — не считается."""
|
||||
kind = classify_row(cells, layout)
|
||||
if kind is RowKind.HEADER:
|
||||
skipped.non_numeric_code += 1
|
||||
elif kind is None and any(parse_amount(cell_text(cells, idx)).amount is not None for idx in layout.value_idxs):
|
||||
skipped.no_code_with_amounts += 1
|
||||
|
||||
|
||||
def _synthetic_header_cells(layout: ColumnLayout) -> Cells:
|
||||
"""Ячейки восстановленного заголовка: код и наименование, тексты периодов пустые (ячеек под значениями нет)."""
|
||||
cells: Cells = {layout.code_idx: Cell(HEADER_CODE, FULL_CONFIDENCE)}
|
||||
@ -83,18 +104,26 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co
|
||||
before_header = rows_cells[:first_header]
|
||||
inferred = infer_layout([cells for _, cells in before_header], known_codes) if before_header else None
|
||||
leading_rows: list[ParsedRow] = []
|
||||
skipped = _Skipped()
|
||||
if inferred is not None:
|
||||
for ordinal, cells in before_header:
|
||||
parsed = _data_row(cells, inferred, ordinal)
|
||||
if parsed is not None:
|
||||
leading_rows.append(parsed)
|
||||
else:
|
||||
_count_skipped(cells, inferred, skipped)
|
||||
|
||||
if first_header == len(rows_cells):
|
||||
if not leading_rows or inferred is None:
|
||||
return None
|
||||
header_ordinal = min(p.ordinal for p in leading_rows) - 1
|
||||
header = ParsedRow(_synthetic_header_cells(inferred), inferred, RowKind.HEADER, header_ordinal)
|
||||
return ParsedTable([header, *leading_rows], synthetic_header=True)
|
||||
return ParsedTable(
|
||||
[header, *leading_rows],
|
||||
synthetic_header=True,
|
||||
skipped_non_numeric_code=skipped.non_numeric_code,
|
||||
skipped_no_code_with_amounts=skipped.no_code_with_amounts,
|
||||
)
|
||||
|
||||
header_row_index, header_cells = rows_cells[first_header]
|
||||
layout = build_layout(header_cells)
|
||||
@ -110,7 +139,14 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co
|
||||
parsed = _data_row(cells, layout, ordinal)
|
||||
if parsed is not None:
|
||||
parsed_rows.append(parsed)
|
||||
return ParsedTable(parsed_rows, synthetic_header=False)
|
||||
else:
|
||||
_count_skipped(cells, layout, skipped)
|
||||
return ParsedTable(
|
||||
parsed_rows,
|
||||
synthetic_header=False,
|
||||
skipped_non_numeric_code=skipped.non_numeric_code,
|
||||
skipped_no_code_with_amounts=skipped.no_code_with_amounts,
|
||||
)
|
||||
|
||||
|
||||
def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None:
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
`_keep_digits_only`, `_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header`
|
||||
старого ядра; алгоритмы без изменений."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
__all__ = [
|
||||
"clamp_confidence",
|
||||
"clean_value",
|
||||
"collapse_whitespace",
|
||||
"digits_only",
|
||||
@ -38,6 +40,20 @@ def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def clamp_confidence(raw: object) -> float | None:
|
||||
"""Уверенность OCR как число из [0, 100]: не число → `None`; NaN, бесконечность и целое вне диапазона float → 0;
|
||||
остальное зажимается в [0, 100] (решение №30: `json.loads` принимает `NaN`/`Infinity`, контракт требует 0..100)."""
|
||||
if not isinstance(raw, int | float):
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except OverflowError: # целое больше диапазона float
|
||||
return 0.0
|
||||
if not math.isfinite(value):
|
||||
return 0.0
|
||||
return min(max(value, 0.0), 100.0)
|
||||
|
||||
|
||||
def clean_value(text: str) -> str:
|
||||
"""Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются."""
|
||||
cleaned = "".join(
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование;
|
||||
`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа.
|
||||
Битый файл — `ValueError` с именем файла (сервис не стартует).
|
||||
Битый файл — `ValueError` с именем файла (сервис не стартует); повторяющиеся ключи yaml (код строки дважды) — тоже
|
||||
ошибка (№33).
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
@ -110,6 +111,26 @@ class OcrRules(BaseModel):
|
||||
return [edition.id for edition in self.editions if code in edition.lines]
|
||||
|
||||
|
||||
class _UniqueKeyLoader(yaml.SafeLoader):
|
||||
"""`SafeLoader`, отклоняющий повторяющиеся ключи отображений (№33): `safe_load` молча оставляет последнее значение,
|
||||
и повторный код в справочнике терялся бы без следа."""
|
||||
|
||||
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]:
|
||||
seen: set[object] = set()
|
||||
for key_node, _ in node.value:
|
||||
key = self.construct_object(key_node, deep=True)
|
||||
try:
|
||||
duplicate = key in seen
|
||||
seen.add(key)
|
||||
except TypeError: # нехешируемый ключ — его отклонит базовый `construct_mapping`
|
||||
continue
|
||||
if duplicate:
|
||||
raise yaml.constructor.ConstructorError(
|
||||
"при разборе отображения", node.start_mark, f"повторяющийся ключ {key!r}", key_node.start_mark
|
||||
)
|
||||
return super().construct_mapping(node, deep=deep)
|
||||
|
||||
|
||||
def _read_yaml(directory: Path, name: str) -> object:
|
||||
path = directory / name
|
||||
try:
|
||||
@ -117,7 +138,7 @@ def _read_yaml(directory: Path, name: str) -> object:
|
||||
except OSError as exc:
|
||||
raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc
|
||||
try:
|
||||
return yaml.safe_load(text)
|
||||
return yaml.load(text, Loader=_UniqueKeyLoader) # SafeLoader с проверкой дублей
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc
|
||||
|
||||
|
||||
@ -556,7 +556,7 @@ async def test_real_fixture_aep_3_two_files_merged_needs_check_with_documents(ro
|
||||
# успешный файл даёт предупреждения нормализации
|
||||
warnings = result.body["metrics"]["warnings"]
|
||||
assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"
|
||||
assert f"{job_id}.json: ИНН не распознан" in warnings
|
||||
assert f"{job_id}.json: ИНН распознан неуверенно" in warnings # в aep_3 ИНН извлечён, но confidence < 100
|
||||
assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings
|
||||
expected = _golden("aep_3", job_id)
|
||||
if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом
|
||||
|
||||
@ -21,6 +21,19 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount
|
||||
("", None, True),
|
||||
("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL)
|
||||
(":", None, False),
|
||||
# несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified
|
||||
("1.234,56", Decimal("123456"), False),
|
||||
("1,234.56", Decimal("123456"), False),
|
||||
(
|
||||
"1,234",
|
||||
Decimal("1.234"),
|
||||
True,
|
||||
), # знаков после запятой больше двух — `Money` отсекает `document._value` (№30)
|
||||
# №13: скобки без содержимого — не сумма, но и не «неразобранный текст»
|
||||
("()", None, True),
|
||||
("( )", None, True),
|
||||
("(.)", None, True),
|
||||
("(+5)", Decimal("-5"), True), # в скобках знак заменяется минусом
|
||||
],
|
||||
)
|
||||
def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None:
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"""Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`."""
|
||||
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.dates import parse_date
|
||||
from src.normalizer.ocr.dates import MAX_DATE_TEXT_LENGTH, is_date_sized, parse_date
|
||||
from src.normalizer.ocr.text import fix_lookalikes
|
||||
|
||||
LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"}
|
||||
@ -68,3 +69,35 @@ def test_non_string_is_none() -> None:
|
||||
|
||||
def test_year_out_of_range_is_none() -> None:
|
||||
assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100
|
||||
|
||||
|
||||
class TestLengthCap:
|
||||
"""Решение №31: текст длиннее `MAX_DATE_TEXT_LENGTH` не разбирается; патологические строки не вешают процесс."""
|
||||
|
||||
PATHOLOGICAL = {
|
||||
"spaces-then-letter": "1" + " " * 4000 + "b",
|
||||
"newlines": "1" + "\n" * 4000, # после strip — «1»: короткий текст, отсекается не длиной
|
||||
"newlines-inside": "1" + "\n" * 4000 + "b",
|
||||
"lookalikes-after-digit": "1" + "О" * 5000,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("raw", PATHOLOGICAL.values(), ids=PATHOLOGICAL.keys())
|
||||
def test_pathological_text_is_fast_and_none(self, raw: str) -> None:
|
||||
start = time.perf_counter()
|
||||
assert parse_date(raw) is None
|
||||
assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None if is_date_sized(raw) else True
|
||||
assert time.perf_counter() - start < 1.0
|
||||
|
||||
def test_long_text_is_not_date_sized(self) -> None:
|
||||
assert not is_date_sized("1" + " " * 4000 + "b") and not is_date_sized("1" + "О" * 5000)
|
||||
|
||||
def test_boundary(self) -> None:
|
||||
date_text = "на 30 сентября 2025 г."
|
||||
padded = date_text + " " * 300 # пробелы по краям не считаются
|
||||
assert is_date_sized(padded) and parse_date(padded) == date(2025, 9, 30)
|
||||
at_limit = date_text + "x" * (MAX_DATE_TEXT_LENGTH - len(date_text))
|
||||
assert len(at_limit) == MAX_DATE_TEXT_LENGTH and is_date_sized(at_limit)
|
||||
assert parse_date(at_limit) == date(2025, 9, 30) # на пределе дата ещё разбирается
|
||||
over = date_text + "x" * (MAX_DATE_TEXT_LENGTH + 1 - len(date_text))
|
||||
assert len(over) == MAX_DATE_TEXT_LENGTH + 1 and not is_date_sized(over)
|
||||
assert parse_date(over) is None # одним символом больше — не разбирается, хотя дата в начале есть
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
@ -422,3 +424,163 @@ class TestKnownCodeConfidence:
|
||||
assert line.line_code == "1150" and line.line_name == "Основные средства"
|
||||
assert line.line_code_confidence == 99 and line.line_name_confidence == 99
|
||||
assert built.fully_recognized is False
|
||||
|
||||
|
||||
class TestMoneyPrecision:
|
||||
"""Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу."""
|
||||
|
||||
@pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"])
|
||||
def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
|
||||
)
|
||||
built = _build(table)
|
||||
value = built.document.lines[0].values[0]
|
||||
assert value.amount is None and value.string_value == text and value.is_recognized is False
|
||||
assert value.confidence == 100
|
||||
assert built.fully_recognized is False
|
||||
|
||||
def test_neighbours_stay_intact_and_document_is_built(self) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)),
|
||||
_row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)),
|
||||
)
|
||||
built = _build(table)
|
||||
first, second = built.document.lines
|
||||
assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234")
|
||||
assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True)
|
||||
assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True)
|
||||
assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True)
|
||||
built.document.to_wire() # контрактная модель сериализуется без ValidationError
|
||||
|
||||
@pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"])
|
||||
def test_up_to_two_decimals_is_kept(self, text: str) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)),
|
||||
)
|
||||
value = _build(table).document.lines[0].values[0]
|
||||
assert value.amount is not None and value.string_value is None and value.is_recognized is True
|
||||
|
||||
|
||||
class TestConfidenceRange:
|
||||
"""Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)]
|
||||
)
|
||||
def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)),
|
||||
)
|
||||
built = _build(table)
|
||||
assert built.document.lines[0].values[0].confidence == expected
|
||||
built.document.to_wire()
|
||||
|
||||
def test_header_with_nan_confidence_builds_document(self) -> None:
|
||||
header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0)
|
||||
built = _build(CLEAN_BALANCE, header)
|
||||
assert built.document.header.inn_confidence == 0 and built.fully_recognized is False
|
||||
|
||||
|
||||
class TestSkippedRowsWarnings:
|
||||
"""Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check."""
|
||||
|
||||
def test_both_warnings_and_not_fully_recognized(self) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
|
||||
_row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)),
|
||||
_row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)),
|
||||
_row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)),
|
||||
_row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)),
|
||||
_row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта
|
||||
)
|
||||
built = _build(table)
|
||||
assert [line.line_code for line in built.document.lines] == ["1150"]
|
||||
assert built.warnings == [
|
||||
"строк с нечисловым кодом пропущено: 2",
|
||||
"строк без кода с суммами пропущено: 2",
|
||||
]
|
||||
assert built.fully_recognized is False
|
||||
|
||||
def test_group_titles_without_amounts_are_silent(self) -> None:
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
CLEAN_BALANCE["rows"][0],
|
||||
_row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)),
|
||||
_row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)),
|
||||
_row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
|
||||
)
|
||||
built = _build(table)
|
||||
assert built.warnings == [] and built.fully_recognized is True
|
||||
|
||||
def test_aep_like_table_with_synthetic_header(self) -> None:
|
||||
"""Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10»."""
|
||||
table = _table(
|
||||
"Отчет о финансовых результатах",
|
||||
_row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)),
|
||||
_row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)),
|
||||
_row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)),
|
||||
_row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)),
|
||||
)
|
||||
built = _build(table)
|
||||
assert [line.line_code for line in built.document.lines] == ["2120", "2100"]
|
||||
assert "строк с нечисловым кодом пропущено: 1" in built.warnings
|
||||
assert "строк без кода с суммами пропущено: 1" in built.warnings
|
||||
assert "шапка синтезирована: колонки без дат" in built.warnings
|
||||
|
||||
|
||||
@pytest.mark.fixtures
|
||||
class TestSkippedRowsRealFixtures:
|
||||
@pytest.mark.parametrize(
|
||||
("name", "index", "expected"),
|
||||
[
|
||||
("aep_3.json", 0, ["строк с нечисловым кодом пропущено: 8", "строк без кода с суммами пропущено: 2"]),
|
||||
("aep_3.json", 1, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2"]),
|
||||
("aep_6.json", 0, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 19"]),
|
||||
("aep_6.json", 1, ["строк с нечисловым кодом пропущено: 1", "строк без кода с суммами пропущено: 2"]),
|
||||
],
|
||||
)
|
||||
def test_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None:
|
||||
real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name
|
||||
if not real.is_file():
|
||||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||||
data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"]
|
||||
built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES)
|
||||
assert built is not None
|
||||
skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))]
|
||||
assert skipped == expected
|
||||
assert built.fully_recognized is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label",
|
||||
[
|
||||
"на 2024" + " " * 4000 + "b",
|
||||
"на 2024" + "\n" * 4000 + "b",
|
||||
"на 2024" + "О" * 5000,
|
||||
], # «на » — признак колонки значений
|
||||
ids=["spaces", "nl", "look"],
|
||||
)
|
||||
def test_long_column_label_is_fast_and_has_no_date(label: str) -> None:
|
||||
"""Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного
|
||||
роста через `_columns`)."""
|
||||
table = _table(
|
||||
"Бухгалтерский баланс",
|
||||
_head(label),
|
||||
_row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)),
|
||||
)
|
||||
start = time.perf_counter()
|
||||
built = _build(table)
|
||||
assert time.perf_counter() - start < 1.0
|
||||
assert built.document.columns[0].period_date is None and built.fully_recognized is False
|
||||
assert any(w.startswith("колонка 0 без даты") for w in built.warnings)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Шапка документа из `data_ocr` (§11.1 п. 8) на реальных `aep_3.json` (мусор) и `aep_6.json` (чисто)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
@ -52,6 +53,29 @@ def test_noisy_inn_caps_confidence() -> None:
|
||||
assert header.organization_name is None and header.organization_confidence is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "expected"), [(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0)])
|
||||
def test_header_confidence_is_clamped(raw: float, expected: float) -> None:
|
||||
"""Решение №30: уверенность полей шапки — в [0, 100], NaN → 0 (контракт требует 0..100)."""
|
||||
header = parse_file_header(
|
||||
{"organization": _field("ООО Ромашка", raw), "inn": _field("7700000000", raw), "date": _field("2024", raw)},
|
||||
LOOKALIKES,
|
||||
)
|
||||
assert header.organization_confidence == expected and header.inn_confidence == expected
|
||||
assert header.report_date_recognized is (expected == 100.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw", ["1" + " " * 4000 + "b", "1" + "\n" * 4000, "1" + "О" * 5000], ids=["spaces", "nl", "look"]
|
||||
)
|
||||
def test_pathological_report_date_is_fast(raw: str) -> None:
|
||||
"""Решение №31: длинный текст даты не разбирается и не проходит `fix_lookalikes` (иначе — секунды и минуты)."""
|
||||
start = time.perf_counter()
|
||||
header = parse_file_header({"date": _field(raw, 100)}, LOOKALIKES)
|
||||
assert time.perf_counter() - start < 1.0
|
||||
assert header.report_date is None and header.report_date_recognized is False
|
||||
assert header.report_date_text == raw.strip() # исходный текст не теряется
|
||||
|
||||
|
||||
def test_missing_fields() -> None:
|
||||
header = parse_file_header({}, LOOKALIKES)
|
||||
assert header == FileHeader(None, None, None, None, None, None, None, False)
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
Перенос `_build_layout`/`_infer_layout` из старого ядра."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout
|
||||
|
||||
KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"})
|
||||
@ -19,6 +21,16 @@ def test_cells_by_index_normalizes_types() -> None:
|
||||
assert cell_text(cells, None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0), (10**400, 0.0), ("90", 0.0), (None, 0.0)],
|
||||
)
|
||||
def test_cells_by_index_clamps_confidence(raw: object, expected: float) -> None:
|
||||
"""Решение №30: `json.loads` принимает NaN/Infinity — уверенность ячейки приводится к [0, 100], NaN → 0."""
|
||||
row = {"cells": [{"column_index": 0, "value": "x", "confidence": raw}]}
|
||||
assert cells_by_index(row)[0].confidence == expected
|
||||
|
||||
|
||||
class TestBuildLayout:
|
||||
def test_header_with_code_and_years(self) -> None:
|
||||
cells = cells_by_index(
|
||||
@ -84,8 +96,8 @@ class TestInferLayout:
|
||||
assert layout is not None
|
||||
assert layout.code_idx == 1
|
||||
assert layout.label_idx == 0
|
||||
# R-1: column 5 contains only garbage (empty, ':', '+ :'), no actual amounts
|
||||
# column 4 has both garbage and amounts ('--', '3', '5 678') but amounts >= garbage
|
||||
# R-1: в колонке 5 только мусор (пусто, ':', '+ :'), настоящих сумм нет
|
||||
# в колонке 4 и мусор, и суммы ('--', '3', '5 678'), но сумм не меньше, чем мусора
|
||||
assert layout.value_idxs == (2, 4)
|
||||
|
||||
def test_no_known_codes_is_none(self) -> None:
|
||||
|
||||
@ -123,8 +123,31 @@ class TestStatus:
|
||||
noisy["data_ocr"]["date"] = {"form_name": "Общество", "confidence": 100}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
|
||||
assert f"{JOB}.json: ИНН не распознан" in outcome.warnings
|
||||
assert not any("ИНН распознан неуверенно" in w for w in outcome.warnings)
|
||||
assert f"{JOB}.json: дата отчёта не разобрана" in outcome.warnings
|
||||
|
||||
def test_inn_found_but_unsure_is_worded_differently(self) -> None:
|
||||
"""F-B: «не распознан» — только когда ИНН не извлечён; извлечённый с confidence < 100 — «неуверенно»."""
|
||||
unsure = copy.deepcopy(CLEAN)
|
||||
unsure["data_ocr"]["inn"] = {"form_name": "7700000000", "confidence": 75}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", unsure)))
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
assert f"{JOB}.json: ИНН распознан неуверенно" in outcome.warnings
|
||||
assert not any("ИНН не распознан" in w for w in outcome.warnings)
|
||||
|
||||
def test_inn_with_noise_is_unsure(self) -> None:
|
||||
"""ИНН извлечён из мусора (confidence ограничивается 99) — тоже «неуверенно», а не «не распознан»."""
|
||||
noisy = copy.deepcopy(CLEAN)
|
||||
noisy["data_ocr"]["inn"] = {"form_name": "77 0000 0000 |", "confidence": 100}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
|
||||
assert outcome.warnings == [f"{JOB}.json: ИНН распознан неуверенно"]
|
||||
|
||||
def test_inn_missing_field_is_not_recognized(self) -> None:
|
||||
missing = copy.deepcopy(CLEAN)
|
||||
del missing["data_ocr"]["inn"]
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", missing)))
|
||||
assert outcome.warnings == [f"{JOB}.json: ИНН не распознан"]
|
||||
|
||||
|
||||
class TestErrorFilesStillHandled:
|
||||
def test_all_files_failed_keeps_first_code(self) -> None:
|
||||
@ -137,8 +160,17 @@ class TestErrorFilesStillHandled:
|
||||
def test_mixed_files(self) -> None:
|
||||
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN), (f"{JOB}_ofr.json", failed)))
|
||||
assert outcome.status is ResultStatus.DONE # ошибочный файл — предупреждение, не needs_check
|
||||
# №28: потерянный ошибочный файл не остаётся под `done`, даже если успешный файл распознан полностью
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
assert outcome.error is None and len(outcome.documents) == 1
|
||||
assert outcome.warnings == [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет"]
|
||||
assert OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))).status is ResultStatus.DONE
|
||||
|
||||
def test_mixed_files_error_first_in_order(self) -> None:
|
||||
"""Порядок файлов не важен: ошибочный первым, успешный вторым — тоже needs_check с документом."""
|
||||
failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
|
||||
outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}_a.json", failed), (f"{JOB}_b.json", CLEAN)))
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
|
||||
|
||||
def test_not_merged_structure(self) -> None:
|
||||
outcome = OcrNormalizer(RULES).normalize({"job_id": JOB})
|
||||
|
||||
@ -121,6 +121,90 @@ class TestParseTableRows:
|
||||
assert table_type_by_codes(parsed) == "BALANCE"
|
||||
|
||||
|
||||
class TestSkippedRows:
|
||||
"""Решение №29: отброшенные строки считаются, не меняя сам разбор (строки данных те же, что и раньше)."""
|
||||
|
||||
def test_clean_table_counts_nothing(self) -> None:
|
||||
rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100))]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 0)
|
||||
|
||||
def test_non_numeric_code_is_counted(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "ОС", 100), (1, "1150 :", 100), (2, "5", 100), (4, "6", 100)),
|
||||
_row(2, (0, "НМА", 100), (1, "1l10", 100), (2, "5", 100), (4, "6", 100)), # латинская l вместо единицы
|
||||
_row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None
|
||||
assert [r.ordinal for r in parsed.rows] == [0, 3] # разбор прежний: строки с кодом-мусором отброшены
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (2, 0)
|
||||
|
||||
def test_empty_code_counted_only_with_amount_in_value_columns(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "БАЛАНС", 100), (1, "", 100), (2, "5 553 383", 100), (4, "", 100)), # сумма — считается
|
||||
_row(2, (0, "Итого", 100), (4, "(77)", 100)), # ячейки кода нет вовсе, сумма в колонке 4 — считается
|
||||
_row(3, (0, "в том числе:", 93), (1, "", 100), (2, "", 100), (4, "-", 100)), # прочерки — не сумма
|
||||
_row(4, (0, "Раздел I", 100), (1, "", 100), (2, "текст", 100)), # текст — не сумма
|
||||
_row(5, (0, "Справочно", 100), (1, "", 100), (3, "9", 100)), # сумма вне колонок значений — не считается
|
||||
_row(6, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 6]
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 2)
|
||||
|
||||
def test_repeated_header_is_not_counted_and_later_rows_use_new_layout(self) -> None:
|
||||
rows = [
|
||||
HEADER,
|
||||
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
|
||||
_row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)),
|
||||
_row(3, (0, "Мусор", 100), (1, "21x0", 100), (3, "7", 100)),
|
||||
_row(4, (0, "БАЛАНС", 100), (1, "", 100), (3, "8", 100)), # сумма в колонке новой раскладки (3)
|
||||
_row(5, (0, "Прочее", 100), (1, "", 100), (2, "8", 100)), # колонка 2 в новой раскладке — не значение
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 1]
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
def test_leading_rows_before_header_are_counted(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (1, "11x0", 100), (0, "Мусор", 100), (2, "5", 100)),
|
||||
_row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)),
|
||||
_row(3, (1, "", 100), (0, "в том числе:", 100), (2, "", 100)),
|
||||
_row(4, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)),
|
||||
_row(5, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and not parsed.synthetic_header
|
||||
assert [r.ordinal for r in parsed.rows] == [-1, 0, 5] # строка шапки — не в счёте
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
def test_synthetic_header_table_counts_leading_rows(self) -> None:
|
||||
rows = [
|
||||
_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)),
|
||||
_row(1, (1, "1150 :", 100), (0, "ОС", 100), (2, "5", 100)),
|
||||
_row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)),
|
||||
]
|
||||
parsed = parse_table_rows(rows, KNOWN)
|
||||
assert parsed is not None and parsed.synthetic_header
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1)
|
||||
|
||||
@pytest.mark.fixtures
|
||||
@pytest.mark.parametrize(
|
||||
("name", "index", "non_numeric", "no_code_amounts"),
|
||||
[("aep_3.json", 0, 8, 2), ("aep_3.json", 1, 2, 2), ("aep_6.json", 0, 2, 19), ("aep_6.json", 1, 1, 2)],
|
||||
)
|
||||
def test_real_tables(self, name: str, index: int, non_numeric: int, no_code_amounts: int) -> None:
|
||||
"""aep_3: «БАЛАНС» и «Выручка» с ПУСТЫМ кодом и суммами теряются; aep_6: «в том числе:» с суммами (OQ-141)."""
|
||||
parsed = parse_table_rows(_real_table(name, index), KNOWN)
|
||||
assert parsed is not None
|
||||
assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (non_numeric, no_code_amounts)
|
||||
|
||||
|
||||
class TestTableType:
|
||||
def test_by_first_digit(self) -> None:
|
||||
rows = [
|
||||
|
||||
@ -45,12 +45,11 @@ class TestRealRules:
|
||||
assert rules.labels["бухгалтерский баланс"] == "BALANCE"
|
||||
assert rules.labels["отчет о финансовых результатах"] == "INCOME"
|
||||
|
||||
def test_codes_are_four_or_five_digits_without_duplicates(self) -> None:
|
||||
def test_codes_are_four_or_five_digits_with_names(self) -> None:
|
||||
"""Дубли кодов проверяет загрузчик yaml (`TestDuplicateKeys`): после `safe_load` в словаре их уже не видно."""
|
||||
rules = load_rules(RULES_DIR)
|
||||
for edition in rules.editions:
|
||||
codes = list(edition.lines)
|
||||
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in codes), edition.id
|
||||
assert len(codes) == len(set(codes)), edition.id
|
||||
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in edition.lines), edition.id
|
||||
assert all(name.strip() for name in edition.lines.values()), edition.id
|
||||
|
||||
def test_fsbu4_edition_exists_even_if_empty(self) -> None:
|
||||
@ -119,6 +118,61 @@ class TestValidation:
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
class TestDuplicateKeys:
|
||||
"""Решение №33: повторяющийся ключ yaml — ошибка загрузки (иначе `safe_load` молча оставил бы последний)."""
|
||||
|
||||
def test_duplicate_line_code_fails_with_file_and_key(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_lines.yaml").write_text(
|
||||
"editions:\n"
|
||||
" - id: 66n\n"
|
||||
" lines:\n"
|
||||
' "1150": "Основные средства"\n'
|
||||
' "1110": "Нематериальные активы"\n'
|
||||
' "1150": "Другое наименование"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="statement_lines.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ '1150'" in str(caught.value) and "line 6" in str(caught.value)
|
||||
|
||||
def test_duplicate_edition_field_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_lines.yaml").write_text(
|
||||
"editions:\n - id: 66n\n id: other\n lines: {}\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(ValueError, match="повторяющийся ключ 'id'"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
def test_duplicate_lookalike_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "ocr_lookalikes.yaml").write_text('digits:\n "О": "0"\n "l": "1"\n "О": "6"\n', encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ 'О'" in str(caught.value)
|
||||
|
||||
def test_duplicate_label_fails(self, tmp_path: Path) -> None:
|
||||
_write_rules(tmp_path)
|
||||
(tmp_path / "statement_labels.yaml").write_text(
|
||||
"labels:\n бухгалтерский баланс: BALANCE\n бухгалтерский баланс: INCOME\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(ValueError, match="statement_labels.yaml") as caught:
|
||||
load_rules(tmp_path)
|
||||
assert "повторяющийся ключ 'бухгалтерский баланс'" in str(caught.value)
|
||||
|
||||
def test_same_code_in_different_editions_is_fine(self, tmp_path: Path) -> None:
|
||||
_write_rules(
|
||||
tmp_path,
|
||||
{
|
||||
"editions": [
|
||||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС"}},
|
||||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert load_rules(tmp_path).editions_with("1150") == ["66n", "fsbu4"]
|
||||
|
||||
|
||||
def test_settings_defaults() -> None:
|
||||
settings = get_settings()
|
||||
assert settings.SCAN_CLEANUP is False
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user