Merge pull request 'Нормализатор OCR: при конфликте label и единодушных кодов строк тип документа — по кодам (#30)' (#31) from feat/30-type-by-codes into main
Reviewed-on: #31
This commit is contained in:
commit
d49fa63e55
@ -42,8 +42,8 @@
|
||||
Ошибочные файлы — в `metrics.warnings` (`<имя>: <код>: <сообщение>`); все файлы ошибочны → код первого; есть
|
||||
успешный — документы из успешных файлов, статус не выше `needs_check` (№28). `NOT_IMPLEMENTED` сервис больше не публикует (`NullNormalizer` остался только
|
||||
для тестов воркера).
|
||||
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы (запасной
|
||||
путь — коды строк). `checks` в документах не передаются — их считает ядро (Срез 3b).
|
||||
- В одном json может быть несколько таблиц разных форм (баланс, ОФР); тип документа задаёт `label` таблицы; если все коды
|
||||
строк одной группы (`1xxx`/`2xxx`) и противоречат `label` — тип по кодам с предупреждением (#30); `label` не сопоставлен — коды по большинству. `checks` в документах не передаются — их считает ядро (Срез 3b).
|
||||
- Dev-режим: эмулятор ИС Сканирование (`make emulator`, то же, что `python -m src.scan_emulator` из `api/`; в контейнере —
|
||||
тот же образ с `command: python -m src.scan_emulator`): по входящему `<uuid>.<ext>` считает sha256 содержимого, находит
|
||||
пару в `SCAN_FIXTURES_DIR` (PDF + `<имя>.json` + `<имя>_*.json`) и кладёт в `Output/<uuid>/` набор как `<uuid>.json` /
|
||||
|
||||
@ -260,7 +260,8 @@ pydantic-валидацией; битый файл (код не из 4–5 ци
|
||||
| таблица | `колонка <i> без даты: <подпись колонки>` |
|
||||
| таблица | `нет колонок значений` |
|
||||
| таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») |
|
||||
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (берётся тип по `label`) |
|
||||
| таблица | `тип по label (<label>) не совпал с кодами: взят <тип>` (все коды строк данных одной группы — `1xxx` или `2xxx`, а `label` говорит иное: берётся тип по кодам, `header.form_name` — как распознан; #30) |
|
||||
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (коды обеих групп — шум OCR: берётся тип по `label`) |
|
||||
| таблица | `тип определён по кодам` (`label` не сопоставлен) |
|
||||
| таблица | `тип не определён: OTHER` |
|
||||
| таблица | `редакция определена по кодам: <id>` (нет ни даты отчёта, ни дат колонок) |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Сборка `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 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.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
|
||||
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.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:
|
||||
"""Тип документа: label и коды согласны → label; расходятся при единодушных кодах → коды (#30), при смешанных →
|
||||
label; label не сопоставлен → коды по большинству; иначе OTHER. Любое расхождение — предупреждение."""
|
||||
by_label = _type_by_label(label, rules)
|
||||
by_codes = table_type_by_codes(parsed)
|
||||
if by_label is not None:
|
||||
if by_codes is not None and by_codes != by_label:
|
||||
build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})")
|
||||
if by_codes is None or by_codes == by_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
|
||||
if by_codes is not None:
|
||||
build.warn("тип определён по кодам")
|
||||
|
||||
@ -19,6 +19,7 @@ __all__ = [
|
||||
"parse_table_rows",
|
||||
"split_merged_row",
|
||||
"table_type_by_codes",
|
||||
"table_type_by_unanimous_codes",
|
||||
]
|
||||
|
||||
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:
|
||||
"""Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME."""
|
||||
def _code_group_counts(parsed: ParsedTable) -> dict[str, int]:
|
||||
"""Число кодов строк данных по первой цифре (`1xxx` — ББ, `2xxx` — ОФР); ключи «1» и «2» есть всегда."""
|
||||
counts = {"1": 0, "2": 0}
|
||||
for row in parsed.rows:
|
||||
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()
|
||||
if code:
|
||||
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"]:
|
||||
return "BALANCE"
|
||||
if counts["2"] > counts["1"]:
|
||||
return "INCOME"
|
||||
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
|
||||
|
||||
@ -166,10 +166,31 @@ class TestTypeAndEdition:
|
||||
built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"]))
|
||||
assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings
|
||||
|
||||
def test_label_wins_over_codes_with_warning(self) -> None:
|
||||
built = _build(_table("Отчет о финансовых результатах", *CLEAN_BALANCE["rows"]))
|
||||
def test_unanimous_codes_win_over_label_with_warning(self) -> None:
|
||||
"""#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 any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings)
|
||||
assert built.fully_recognized is False
|
||||
|
||||
def test_other_when_nothing_known(self) -> None:
|
||||
table = _table(
|
||||
|
||||
@ -6,7 +6,14 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
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]
|
||||
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(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
|
||||
|
||||
@ -216,8 +216,11 @@ SHALL отражать состояние.
|
||||
Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный
|
||||
`statementDocument` по §11 и §11.1 `05-domain-rules.md`:
|
||||
- `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не
|
||||
сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`); иначе `OTHER`. Если `label` и коды
|
||||
дают разный тип — тип по `label`, предупреждение; `locator = "file:<имя json>;table:<i>"`.
|
||||
сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`, по большинству); иначе `OTHER`.
|
||||
Если `label` и коды дают разный тип: при единодушных кодах (все коды данных одной группы) — тип по кодам,
|
||||
предупреждение `тип по label (<label>) не совпал с кодами: взят <тип>` (#30); при смешанных кодах — тип по `label`,
|
||||
предупреждение `тип по кодам (<тип>) не совпал с label (<label>)`. `header.form_name` в обоих случаях — текст `label`
|
||||
как распознан. `locator = "file:<имя json>;table:<i>"`.
|
||||
- Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки —
|
||||
`infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее,
|
||||
где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица
|
||||
@ -280,6 +283,16 @@ SHALL отражать состояние.
|
||||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`
|
||||
- **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: Таблица без столбца кода
|
||||
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника
|
||||
- **THEN** таблица пропущена, предупреждение `<имя json>: table:<i>: нет шапки и столбца кода`; остальные таблицы файла разобраны
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user