Merge pull request 'Нормализатор OCR: при конфликте label и единодушных кодов строк тип документа — по кодам (#30)' (#31) from feat/30-type-by-codes into main

Reviewed-on: #31
This commit is contained in:
aep 2026-10-09 19:08:42 +03:00
commit d49fa63e55
7 changed files with 100 additions and 15 deletions

View File

@ -42,8 +42,8 @@
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть
успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
для тестов воркера). для тестов воркера).
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы (запасной - В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы; если все коды
путь — коды строк). `checks` в документах не передаются — их считает ядро (Срез 3b). строк одной группы (`1xxx`/`2xxx`) и противоречат `label` — тип по кодам с предупреждением (#30); `label` не сопоставлен — коды по большинству. `checks` в документах не передаются — их считает ядро (Срез 3b).
- Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере — - Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере —
тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит
пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` / пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` /

View File

@ -260,7 +260,8 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
| таблица | `колонка <i> без даты: <подпись колонки>` | | таблица | `колонка <i> без даты: <подпись колонки>` |
| таблица | `нет колонок значений` | | таблица | `нет колонок значений` |
| таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») | | таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») |
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (берётся тип по `label`) | | таблица | `тип по label (<label>) не совпал с кодами: взят <тип>` (все коды строк данных одной группы — `1xxx` или `2xxx`, а `label` говорит иное: берётся тип по кодам, `header.form_name` — как распознан; #30) |
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (коды обеих групп — шум OCR: берётся тип по `label`) |
| таблица | `тип определён по кодам` (`label` не сопоставлен) | | таблица | `тип определён по кодам` (`label` не сопоставлен) |
| таблица | `тип не определён: OTHER` | | таблица | `тип не определён: OTHER` |
| таблица | `редакция определена по кодам: <id>` (нет ни даты отчёта, ни дат колонок) | | таблица | `редакция определена по кодам: <id>` (нет ни даты отчёта, ни дат колонок) |

View File

@ -1,6 +1,6 @@
"""Сборка `statementDocument` v1.2 из одной таблицы `data_ocr.table_structure[i]` (spec «Нормализация ответа ИС», """Сборка `statementDocument` v1.2 из одной таблицы `data_ocr.table_structure[i]` (spec «Нормализация ответа ИС»,
D-6, D-7): тип по label или по кодам, колонки из раскладки, строки только с кодом, значения с `is_recognized`, D-6, D-7): тип по label, при расхождении с единодушными кодами — по кодам (#30), колонки из раскладки, строки только
имена строк из справочника редакции.""" с кодом, значения с `is_recognized`, имена строк из справочника редакции."""
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
@ -13,7 +13,14 @@ from src.normalizer.ocr.amounts import is_blank, parse_amount
from src.normalizer.ocr.dates import is_date_sized, parse_date from src.normalizer.ocr.dates import is_date_sized, parse_date
from src.normalizer.ocr.header import FileHeader from src.normalizer.ocr.header import FileHeader
from src.normalizer.ocr.layout import Cell, cell_text 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 from src.normalizer.ocr.rows import (
ParsedRow,
ParsedTable,
RowKind,
parse_table_rows,
table_type_by_codes,
table_type_by_unanimous_codes,
)
from src.normalizer.ocr.text import clean_value, collapse_whitespace, fix_lookalikes, normalize_header from src.normalizer.ocr.text import clean_value, collapse_whitespace, fix_lookalikes, normalize_header
from src.normalizer.rules import Edition, OcrRules from src.normalizer.rules import Edition, OcrRules
@ -60,11 +67,18 @@ def _type_by_label(label: str, rules: OcrRules) -> DocumentType | None:
def _resolve_type(label: str, parsed: ParsedTable, rules: OcrRules, build: _Build) -> DocumentType: def _resolve_type(label: str, parsed: ParsedTable, rules: OcrRules, build: _Build) -> DocumentType:
"""Тип документа: label и коды согласны → label; расходятся при единодушных кодах → коды (#30), при смешанных →
label; label не сопоставлен → коды по большинству; иначе OTHER. Любое расхождение — предупреждение."""
by_label = _type_by_label(label, rules) by_label = _type_by_label(label, rules)
by_codes = table_type_by_codes(parsed) by_codes = table_type_by_codes(parsed)
if by_label is not None: if by_label is not None:
if by_codes is not None and by_codes != by_label: if by_codes is None or by_codes == by_label:
build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})") return by_label
unanimous = table_type_by_unanimous_codes(parsed)
if unanimous is not None:
build.warn(f"тип по label ({label}) не совпал с кодами: взят {unanimous}")
return unanimous
build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})")
return by_label return by_label
if by_codes is not None: if by_codes is not None:
build.warn("тип определён по кодам") build.warn("тип определён по кодам")

View File

@ -19,6 +19,7 @@ __all__ = [
"parse_table_rows", "parse_table_rows",
"split_merged_row", "split_merged_row",
"table_type_by_codes", "table_type_by_codes",
"table_type_by_unanimous_codes",
] ]
HEADER_CODE = "Код" HEADER_CODE = "Код"
@ -201,8 +202,8 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co
) )
def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None: def _code_group_counts(parsed: ParsedTable) -> dict[str, int]:
"""Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME.""" """Число кодов строк данных по первой цифре (`1xxx` — ББ, `2xxx` — ОФР); ключи «1» и «2» есть всегда."""
counts = {"1": 0, "2": 0} counts = {"1": 0, "2": 0}
for row in parsed.rows: for row in parsed.rows:
if row.kind is RowKind.HEADER: if row.kind is RowKind.HEADER:
@ -210,8 +211,24 @@ def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | N
code = cell_text(row.cells, row.layout.code_idx).strip() code = cell_text(row.cells, row.layout.code_idx).strip()
if code: if code:
counts[code[0]] = counts.get(code[0], 0) + 1 counts[code[0]] = counts.get(code[0], 0) + 1
return counts
def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None:
"""Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME."""
counts = _code_group_counts(parsed)
if counts["1"] > counts["2"]: if counts["1"] > counts["2"]:
return "BALANCE" return "BALANCE"
if counts["2"] > counts["1"]: if counts["2"] > counts["1"]:
return "INCOME" return "INCOME"
return None return None
def table_type_by_unanimous_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None:
"""Тип по единодушным кодам (#30): все коды данных `1xxx` → BALANCE, все `2xxx` → INCOME; смесь или пусто → None."""
counts = _code_group_counts(parsed)
if counts["1"] and not counts["2"]:
return "BALANCE"
if counts["2"] and not counts["1"]:
return "INCOME"
return None

View File

@ -166,10 +166,31 @@ class TestTypeAndEdition:
built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"])) built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"]))
assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings
def test_label_wins_over_codes_with_warning(self) -> None: def test_unanimous_codes_win_over_label_with_warning(self) -> None:
built = _build(_table("Отчет о финансовых результатах", *CLEAN_BALANCE["rows"])) """#30: label «Бухгалтерский баланс» от ИС при кодах только 2xxx — тип по кодам, label в предупреждении."""
table = _table(
"Бухгалтерский баланс",
CLEAN_BALANCE["rows"][0],
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1 182 108", 100), (3, "807 029", 100)),
_row(2, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(618 060)", 100), (3, "(371 193)", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME"
assert built.document.header.form_name == "Бухгалтерский баланс" # первичка не мутируется
assert "тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME" in built.warnings
assert built.fully_recognized is False
def test_label_wins_over_mixed_codes_with_warning(self) -> None:
"""Коды обеих групп (шум OCR) — кодам не верим, тип по label."""
table = _table(
"Отчет о финансовых результатах",
*CLEAN_BALANCE["rows"],
_row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (3, "2", 100)),
)
built = _build(table)
assert built.document.document_type == "INCOME" assert built.document.document_type == "INCOME"
assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings) assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings)
assert built.fully_recognized is False
def test_other_when_nothing_known(self) -> None: def test_other_when_nothing_known(self) -> None:
table = _table( table = _table(

View File

@ -6,7 +6,14 @@ from pathlib import Path
import pytest import pytest
from src.normalizer.ocr.layout import ColumnLayout, cell_text, cells_by_index from src.normalizer.ocr.layout import ColumnLayout, cell_text, cells_by_index
from src.normalizer.ocr.rows import ParsedTable, RowKind, classify_row, parse_table_rows, table_type_by_codes from src.normalizer.ocr.rows import (
ParsedTable,
RowKind,
classify_row,
parse_table_rows,
table_type_by_codes,
table_type_by_unanimous_codes,
)
API_DIR = Path(__file__).resolve().parents[3] API_DIR = Path(__file__).resolve().parents[3]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
@ -296,3 +303,15 @@ class TestTableType:
] ]
assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None
assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None
def test_unanimous_requires_single_group(self) -> None:
"""#30: тип «по единодушным кодам» — только когда все коды данных одной группы."""
income_only = [
HEADER,
_row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)),
_row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)),
]
assert table_type_by_unanimous_codes(parse_table_rows(income_only, KNOWN)) == "INCOME"
mixed = [*income_only, _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100))]
assert table_type_by_unanimous_codes(parse_table_rows(mixed, KNOWN)) is None
assert table_type_by_unanimous_codes(ParsedTable(rows=[], synthetic_header=False)) is None

View File

@ -216,8 +216,11 @@ SHALL отражать состояние.
Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный
`statementDocument` по §11 и §11.1 `05-domain-rules.md`: `statementDocument` по §11 и §11.1 `05-domain-rules.md`:
- `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не - `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не
сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`); иначе `OTHER`. Если `label` и коды сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`, по большинству); иначе `OTHER`.
дают разный тип — тип по `label`, предупреждение; `locator = "file:<имя json>;table:<i>"`. Если `label` и коды дают разный тип: при единодушных кодах (все коды данных одной группы) — тип по кодам,
предупреждение `тип по label (<label>) не совпал с кодами: взят <тип>` (#30); при смешанных кодах — тип по `label`,
предупреждение `тип по кодам (<тип>) не совпал с label (<label>)`. `header.form_name` в обоих случаях — текст `label`
как распознан. `locator = "file:<имя json>;table:<i>"`.
- Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки — - Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки —
`infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее, `infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее,
где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица
@ -280,6 +283,16 @@ SHALL отражать состояние.
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700` - **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`
- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам» - **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам»
#### Scenario: Label противоречит единодушным кодам
- **WHEN** `label.form_name` таблицы — `Бухгалтерский баланс`, все коды строк данных `2110…2910`
- **THEN** `document_type = INCOME`, `header.form_name = "Бухгалтерский баланс"`, предупреждение
`тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME`, статус `needs_check`
#### Scenario: Label противоречит смешанным кодам
- **WHEN** `label.form_name` таблицы — `Отчет о финансовых результатах`, коды строк данных `1150`, `1100` и `2110`
- **THEN** `document_type = INCOME`, предупреждение `тип по кодам (BALANCE) не совпал с label (Отчет о финансовых
результатах)`, статус `needs_check`
#### Scenario: Таблица без столбца кода #### Scenario: Таблица без столбца кода
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника - **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника
- **THEN** таблица пропущена, предупреждение `<имя json>: table:<i>: нет шапки и столбца кода`; остальные таблицы файла разобраны - **THEN** таблица пропущена, предупреждение `<имя json>: table:<i>: нет шапки и столбца кода`; остальные таблицы файла разобраны