diff --git a/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md new file mode 100644 index 0000000..1ac840b --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md @@ -0,0 +1,3390 @@ +# Срез 3a — горячие папки как у armka_file_worker и нормализатор OCR: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** scan-gateway отдаёт ядру `statementDocument` v1.2 из ответов ИС Сканирование (вместо `NOT_IMPLEMENTED`), +а горячие папки и эмулятор работают по раскладке проверенного в контуре PDFWorker (`Input/.`, +`Output//*.json`, очистка под флагом). + +**Architecture:** `OcrNormalizer` реализует существующий протокол `Normalizer` конвейером чистых функций +`src/normalizer/ocr/{text,dates,amounts,layout,rows,header,document}.py`; проверенные на стенде функции старого ядра +(`../reference-old-arm/api/src/core/PDFWorker/utils.py`, `date_utils.py`) переносятся дословно (единственное +разрешённое исключение для `reference-old-arm`), DB-обвязка не переносится. Справочники — yaml в `api/rules/` с +редакциями форм. Горячие папки: `put(job_id, ext, data)` + rename без замены + `cleanup` под `SCAN_CLEANUP`; +эмулятор ищет пару фикстур по sha256. + +**Tech Stack:** Python 3.11, pydantic v2, PyYAML (уже в requirements), pytest (`asyncio_mode=auto`), ruff, mypy; +без новых зависимостей. + +**Spec:** `openspec/changes/slice-3a-ocr-normalizer/` (`proposal.md`, `design.md` D-1…D-9, `specs/image-recognition-gateway/spec.md`, +`specs/scan-emulator/spec.md`). Правила предметной области — `../docs/context/05-domain-rules.md` §11, §11.1. + +## Global Constraints + +- Репозиторий scan-gateway, ветка `feat/6-slice-3a-ocr-normalizer`, worktree `.wt/slice-3a/scan-gateway`; команды `make` — из корня + репозитория, `pytest` — из `api/` (`cd api && .venv/bin/pytest …`). Перед первой задачей — `make venv`. +- Ветка должна содержать контракт v1.2: Task 0 вливает `origin/chore/contract-v1-2` (scan-gateway PR #5). Контракт + **не меняется**: `api/contracts/*.schema.json` остаются байт в байт равными `../docs/contracts` (тест `test_contract.py`). + Если `../docs/contracts` ещё v1.0 (docs #27 не слит), тест идентичности упадёт — это ожидаемо до мержа docs #27; задача 17 проверяет. +- Язык комментариев, docstring'ов, сообщений и коммитов — русский; идентификаторы — английские. Строка ≤ 120 символов + (`ruff`, правила `E, F, I, UP, B, PL`); `mypy src` с `check_untyped_defs`. `make check` зелёный после каждой задачи. +- Функции `src/normalizer/ocr/*` — чистые: без I/O, без `get_settings()`, без логирования внутри конвейера (кроме + `OcrNormalizer.normalize`, который возвращает предупреждения строками). Деньги — `Decimal`, никаких `float` в суммах. +- Перенос из `../reference-old-arm/api/src/core/PDFWorker/` разрешён **только** для функций очистки (`utils.py`: + `_keep_digits_only`, `_clear_value`, `_remove_spec_space`, `_parse_amount`, `_normalize_header`, `_is_code_header`, + `_is_value_header`, `_build_layout`, `_classify_row`, `_is_blank`, `_infer_layout`, `_parse_table_rows`, + `_synthetic_header_cells`, `_resolve_table_type`, регулярки `_YEAR_RE`, `_BLANK_CHARS_RE`, `NUM_RE`, `ALLOWED_RE`, + `BRACKET_RE`; `date_utils.py` целиком). Алгоритмы и регулярки — как в источнике; меняются только имена, типы, + docstring'и и передача справочников параметром. `json_to_db_rows`, `_process_file`, dataclass'ы `Meta/DbRow/DbValue`, + `consts.py` как код — **не переносятся**. +- `reference-old-arm` только для чтения: никаких записей, `git pull` и т. п. в нём (хук блокирует). +- Никаких `cd` в основные checkout'ы workspace (`../docs`, `../arm-core` на `main`): параллельно работает контроллер + Среза 2b; читать их файлы — только абсолютными путями. +- Коммиты после каждой задачи; сообщения вида `feat(normalizer): …`, `feat(hotfolder): …`, `test(...)`, `docs(...)`; + в конце — строки атрибуции из системного напоминания сессии. + +## Review Focus + +1. **Шапка-повтор внутри таблицы с другим числом колонок** (`aep_6` ОФР имеет 2 колонки, баланс — 3): после повторной + шапки раскладка перезаписывается, и значения строк после неё должны идти по новым индексам, а `columns` документа + — по первой шапке. Тест в Task 6 (`test_second_header_overrides_layout`). +2. **Строка данных, у которой ячейка кода есть, а ячеек значений нет вовсе** (`cells` короче раскладки): `values` + обязаны содержать по элементу на каждую колонку с `is_recognized = false`, иначе ядро сдвинет колонки. Тест в Task 8 + (`test_missing_cells_keep_column_count`). +3. **`report_date` после `fix_lookalikes` даёт дату из названия организации** (`aep_6`: `date.form_name` = «Общество с + ограниченной ответственностью»): `parse_date` обязан вернуть `None`, а не год из случайных цифр. Тест в Task 3 + (`test_organization_name_is_not_a_date`). +4. **Повторная публикация задания, когда `Input/.` уже есть, а `Output//` уже удалён очисткой** + прошлого прогона: `put` считает файл доставленным, опрос ждёт до дедлайна → `TIMEOUT`, повторной отправки нет. Тест + в Task 13 (`test_existing_input_waits_for_answer`). +5. **Эмулятор получает файл, который ещё дописывается шлюзом** (`.part` в `Tmp`, а не в `Input`) и файл `.done`: оба + игнорируются, индекс хешей не пересчитывается на каждый проход. Тест в Task 14 (`test_ignores_part_tmp_done`). + +--- + +## Структура файлов + +| Путь (от `api/`) | Ответственность | +|---|---| +| `rules/statement_lines.yaml` | справочник код → наименование по редакциям `66n`, `fsbu4` | +| `rules/ocr_lookalikes.yaml` | таблица двойников букв и цифр | +| `rules/statement_labels.yaml` | подстроки `label` таблицы → `BALANCE`/`INCOME` | +| `src/normalizer/rules.py` | pydantic-модели правил, `load_rules(path) -> OcrRules`, выбор редакции | +| `src/normalizer/ocr/text.py` | `fix_lookalikes`, `clean_value`, `digits_only`, `normalize_header`, `collapse_whitespace` | +| `src/normalizer/ocr/dates.py` | `parse_date` (дословный перенос `date_utils`) | +| `src/normalizer/ocr/amounts.py` | `parse_amount`, `is_blank` | +| `src/normalizer/ocr/layout.py` | `Cell`, `ColumnLayout`, `cells_by_index`, `build_layout`, `infer_layout` | +| `src/normalizer/ocr/rows.py` | `RowKind`, `ParsedRow`, `classify_row`, `parse_table_rows`, `table_type_by_codes` | +| `src/normalizer/ocr/header.py` | `FileHeader`, `parse_file_header` | +| `src/normalizer/ocr/document.py` | `BuiltDocument`, `build_document` | +| `src/normalizer/ocr/__init__.py` | `OcrNormalizer` (статус, предупреждения, `metrics`) | +| `src/normalizer/__init__.py` | экспорт `OcrNormalizer`, общие помощники ошибок файлов (остаются) | +| `src/gateway/hotfolder.py` | `inbox_name(job_id, original_name) -> str` (`.`), `put`, `cleanup`, rename без замены | +| `src/gateway/smb.py` | то же для SMB | +| `src/core/worker.py` | очистка после склейки под флагом, `RECOGNITION_FAILED` без расширения | +| `src/core/config.py` | `SCAN_CLEANUP`, `SCAN_RULES_DIR` | +| `src/app.py` | `OcrNormalizer(load_rules(...))` в lifespan | +| `src/scan_emulator/__init__.py` | индекс sha256, `SCAN_EMULATOR_DEFAULT`, `.` | +| `tests/unit/normalizer/test_*.py` | юнит по модулям конвейера, golden | +| `tests/unit/test_*hotfolder.py`, `test_emulator.py`, `test_worker.py`, `test_normalizer.py` | обновление | +| `tests/integration/test_roundtrip.py` | сквозной сценарий с документами | + +--- + +### Task 0: Контракт v1.2 в ветке и окружение + +**Files:** +- Modify: ветка (merge), `api/.venv` (создание) + +**Interfaces:** +- Produces: модели `src.contract.result`: `StatementDocument` с `document_type ∈ {BALANCE, INCOME, DISCLOSURE, OTHER}`, + `checks: StatementChecks | None`; `StatementValue.string_value: str | None`; `StatementChecks`, `FailedRelation`. + +- [ ] **Step 1: Влить копию схемы v1.2** + +```bash +cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/scan-gateway +git fetch origin +git merge --no-edit origin/chore/contract-v1-2 +``` +Ожидание: merge без конфликтов (ветка #5 меняет только `api/contracts/`, `api/src/contract/result.py`, `tests/unit/test_contract.py`, README). + +- [ ] **Step 2: Окружение и базовая проверка** + +```bash +make venv +make check +``` +Ожидание: `ruff`/`mypy` чисты; pytest зелёный, кроме, возможно, `tests/unit/test_contract.py::test_schemas_identical_to_docs`, +если `../docs/contracts` ещё v1.0 (docs #27 не слит) — тогда зафиксировать это в отчёте задачи и продолжать. + +- [ ] **Step 3: Проверить, что модель видит v1.2** + +```bash +cd api && .venv/bin/python -c "from src.contract.result import StatementValue, StatementChecks; print(StatementValue.model_fields['string_value'])" +``` +Ожидание: печать описания поля без ошибки импорта. + +- [ ] **Step 4: Commit** — merge-коммит уже создан шагом 1; дополнительного коммита нет. + +--- + +### Task 1: Настройки и справочники (`rules/*.yaml`, `src/normalizer/rules.py`) + +**Files:** +- Create: `api/rules/statement_lines.yaml`, `api/rules/ocr_lookalikes.yaml`, `api/rules/statement_labels.yaml` +- Create: `api/src/normalizer/rules.py` +- Modify: `api/src/core/config.py` (после `SCAN_MAX_INFLIGHT`) +- Test: `api/tests/unit/normalizer/__init__.py` (пустой), `api/tests/unit/normalizer/test_rules.py` + +**Interfaces:** +- Produces: + - `Settings.SCAN_CLEANUP: bool = False`, `Settings.SCAN_RULES_DIR: str = "rules"`. + - `class Edition(BaseModel)`: `id: str`, `valid_from: date | None`, `valid_until: date | None`, `lines: dict[str, str]`. + - `class OcrRules(BaseModel)`: `editions: list[Edition]`, `lookalikes: dict[str, str]`, `labels: dict[str, Literal["BALANCE","INCOME"]]`; + методы `edition_for(report_date: date | None) -> Edition | None` (по датам), `edition_by_codes(codes: Iterable[str]) -> Edition | None` + (максимум совпадений, при равенстве — первая), `known_codes() -> frozenset[str]` (объединение всех редакций), + `editions_with(code: str) -> list[str]` (id редакций, где код есть). + - `load_rules(directory: Path | str) -> OcrRules` — читает три yaml, `ValueError` с именем файла при нарушении. + +- [ ] **Step 1: Написать падающие тесты** + +`api/tests/unit/normalizer/__init__.py` — пустой файл. `api/tests/unit/normalizer/test_rules.py`: + +```python +"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7).""" + +from datetime import date +from pathlib import Path + +import pytest +import yaml + +from src.normalizer.rules import Edition, OcrRules, load_rules + +API_DIR = Path(__file__).resolve().parents[3] +RULES_DIR = API_DIR / "rules" + + +def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path: + (tmp_path / "statement_lines.yaml").write_text( + yaml.safe_dump( + lines + or { + "editions": [ + {"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}}, + {"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}}, + ] + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + (tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8") + (tmp_path / "statement_labels.yaml").write_text( + yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8" + ) + return tmp_path + + +class TestRealRules: + def test_loads_repository_rules(self) -> None: + rules = load_rules(RULES_DIR) + ids = [edition.id for edition in rules.editions] + assert ids == ["66n", "fsbu4"] + assert rules.editions[0].lines["1150"] == "Основные средства" + assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок" + assert rules.lookalikes["О"] == "0" + assert rules.labels["бухгалтерский баланс"] == "BALANCE" + assert rules.labels["отчет о финансовых результатах"] == "INCOME" + + def test_codes_are_four_or_five_digits_without_duplicates(self) -> None: + 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(name.strip() for name in edition.lines.values()), edition.id + + def test_fsbu4_edition_exists_even_if_empty(self) -> None: + rules = load_rules(RULES_DIR) + fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4") + assert fsbu4.valid_from == date(2025, 1, 1) + + +class TestEditionChoice: + def test_by_report_date(self, tmp_path: Path) -> None: + rules = load_rules(_write_rules(tmp_path)) + assert rules.edition_for(date(2024, 12, 31)).id == "66n" + assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4" + assert rules.edition_for(None) is None + + def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None: + rules = load_rules( + _write_rules( + tmp_path, + { + "editions": [ + {"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}}, + {"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}}, + ] + }, + ) + ) + assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4" + assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая + assert rules.edition_by_codes(["9999"]) is None + + def test_known_codes_and_editions_with(self, tmp_path: Path) -> None: + rules = load_rules(_write_rules(tmp_path)) + assert rules.known_codes() == frozenset({"1150"}) + assert rules.editions_with("1150") == ["66n"] + assert rules.editions_with("1110") == [] + + +class TestValidation: + def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None: + _write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]}) + with pytest.raises(ValueError, match="statement_lines.yaml"): + load_rules(tmp_path) + + def test_missing_file_fails(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "ocr_lookalikes.yaml").unlink() + with pytest.raises(ValueError, match="ocr_lookalikes.yaml"): + load_rules(tmp_path) + + def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8") + with pytest.raises(ValueError, match="ocr_lookalikes.yaml"): + load_rules(tmp_path) + + +def test_settings_defaults() -> None: + from src.core.config import get_settings + + settings = get_settings() + assert settings.SCAN_CLEANUP is False + assert settings.SCAN_RULES_DIR == "rules" + assert isinstance(Edition, type) and isinstance(OcrRules, type) +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py -q` +Expected: `ImportError: cannot import name 'load_rules'`. + +- [ ] **Step 3: Написать yaml-справочники** + +`api/rules/statement_lines.yaml` (редакция `66n` — из `codes_mapping` старого ядра; опечатки исправлены: 1120 +«разработок», 1220 — «Налог на добавленную стоимость по приобретённым ценностям»; служебные `Код`, `1000`, `2000` не включаются): + +```yaml +# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§11.1 п. 6, D-7). +# Редакция выбирается по дате отчёта документа: valid_until / valid_from — включительно. +# 66n — Приказ Минфина № 66н (отчётность до 2024 года включительно), источник — codes_mapping старого ядра DKB_ARMKA +# (проверен на стенде ИС Сканирование). fsbu4 — ФСБУ 4/2023 (отчётность за 2025 год и позже): заполняет владелец +# по Приложениям 3–4 ФСБУ 4/2023; пока пуста, документы 2025 года получают имена из OCR и предупреждение. +editions: + - id: 66n + valid_until: 2024-12-31 + lines: + "1105": "Гудвил" + "1100": "Итого по разделу I" + "1110": "Нематериальные активы" + "1120": "Результаты исследований и разработок" + "1130": "Нематериальные поисковые активы" + "1140": "Материальные поисковые активы" + "1150": "Основные средства" + "1160": "Инвестиционная недвижимость" + "1170": "Финансовые вложения" + "1180": "Отложенные налоговые активы" + "1190": "Прочие внеоборотные активы" + "1200": "Итого по разделу II" + "1210": "Запасы" + "1215": "Долгосрочные активы к продаже" + "1220": "Налог на добавленную стоимость по приобретённым ценностям" + "1230": "Дебиторская задолженность" + "1240": "Финансовые вложения (за исключением денежных эквивалентов)" + "1250": "Денежные средства и денежные эквиваленты" + "1260": "Прочие оборотные активы" + "1600": "БАЛАНС" + "1300": "Итого по разделу III" + "1310": "Уставный капитал" + "1320": "Собственные акции, выкупленные у акционеров" + "1330": "Целевые средства" + "1340": "Переоценка внеоборотных активов" + "1350": "Добавочный капитал (без переоценки)" + "1360": "Резервный капитал" + "1370": "Нераспределенная прибыль (непокрытый убыток)" + "1400": "Итого по разделу IV" + "1410": "Заемные средства" + "1420": "Отложенные налоговые обязательства" + "1430": "Оценочные обязательства" + "1450": "Прочие обязательства" + "1500": "Итого по разделу V" + "1510": "Заемные средства" + "1520": "Кредиторская задолженность" + "1530": "Доходы будущих периодов" + "1540": "Оценочные обязательства" + "1550": "Прочие обязательства" + "1700": "БАЛАНС" + "2110": "Выручка" + "2120": "Себестоимость продаж" + "2100": "Валовая прибыль (убыток)" + "2210": "Коммерческие расходы" + "2220": "Управленческие расходы" + "2200": "Прибыль (убыток) от продаж" + "2310": "Доходы от участия в других организациях" + "2320": "Проценты к получению" + "2330": "Проценты к уплате" + "2340": "Прочие доходы" + "2350": "Прочие расходы" + "2300": "Прибыль (убыток) до налогообложения" + "2410": "Налог на прибыль" + "2411": "в т.ч. текущий налог на прибыль" + "2412": "отложенный налог на прибыль" + "2460": "Прочее" + "2400": "Чистая прибыль (убыток)" + "2510": "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода" + "2520": "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода" + "2530": "Налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток) периода" + "2500": "Совокупный финансовый результат периода" + "2900": "Базовая прибыль (убыток) на акцию" + "2910": "Разводненная прибыль (убыток) на акцию" + - id: fsbu4 + valid_from: 2025-01-01 + lines: {} +``` + +`api/rules/ocr_lookalikes.yaml` (из `date_utils._LOOKALIKES`): + +```yaml +# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов. +digits: + "О": "0" + "о": "0" + "O": "0" + "o": "0" + "З": "3" + "з": "3" + "l": "1" + "I": "1" + "|": "1" + "і": "1" + "б": "6" + "S": "5" + "s": "5" + "Ч": "4" +``` + +`api/rules/statement_labels.yaml`: + +```yaml +# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6). +labels: + "бухгалтерский баланс": BALANCE + "отчет о финансовых результатах": INCOME + "отчёт о финансовых результатах": INCOME + "отчет о прибылях и убытках": INCOME +``` + +- [ ] **Step 4: Настройки** + +В `api/src/core/config.py` после строки `SCAN_MAX_INFLIGHT: PositiveInt = 4 …` добавить: + +```python + # Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true. + # На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено + SCAN_CLEANUP: bool = False + SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/) +``` + +- [ ] **Step 5: Модуль правил** + +`api/src/normalizer/rules.py`: + +```python +"""Справочники нормализатора OCR (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте. + +`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование; +`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа. +Битый файл — `ValueError` с именем файла (сервис не стартует). +""" + +from collections.abc import Iterable +from datetime import date +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator + +__all__ = ["Edition", "OcrRules", "load_rules"] + +LINES_FILE = "statement_lines.yaml" +LOOKALIKES_FILE = "ocr_lookalikes.yaml" +LABELS_FILE = "statement_labels.yaml" +DocumentKind = Literal["BALANCE", "INCOME"] + + +class Edition(BaseModel): + """Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str = Field(min_length=1) + valid_from: date | None = None + valid_until: date | None = None + lines: dict[str, str] = Field(default_factory=dict) + + @field_validator("lines") + @classmethod + def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]: + for code, name in lines.items(): + if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр + raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр") + if not name.strip(): + raise ValueError(f"код строки {code!r}: пустое наименование") + return lines + + def covers(self, report_date: date) -> bool: + return (self.valid_from is None or self.valid_from <= report_date) and ( + self.valid_until is None or report_date <= self.valid_until + ) + + +class _LinesFile(BaseModel): + model_config = ConfigDict(extra="forbid") + editions: list[Edition] = Field(min_length=1) + + @model_validator(mode="after") + def _unique_ids(self) -> "_LinesFile": + ids = [edition.id for edition in self.editions] + if len(ids) != len(set(ids)): + raise ValueError(f"id редакций повторяются: {ids}") + return self + + +class _LookalikesFile(BaseModel): + model_config = ConfigDict(extra="forbid") + digits: dict[str, str] + + @field_validator("digits") + @classmethod + def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]: + for char, digit in digits.items(): + if len(char) != 1 or len(digit) != 1 or not digit.isdigit(): + raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра") + return digits + + +class _LabelsFile(BaseModel): + model_config = ConfigDict(extra="forbid") + labels: dict[str, DocumentKind] + + +class OcrRules(BaseModel): + """Все справочники нормализатора; передаётся в конвейер параметром.""" + + model_config = ConfigDict(frozen=True) + + editions: list[Edition] + lookalikes: dict[str, str] + labels: dict[str, DocumentKind] + + def edition_for(self, report_date: date | None) -> Edition | None: + """Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов.""" + if report_date is None: + return None + return next((edition for edition in self.editions if edition.covers(report_date)), None) + + def edition_by_codes(self, codes: Iterable[str]) -> Edition | None: + """Редакция с наибольшим числом известных кодов; при равенстве — первая по порядку; `None`, если совпадений нет.""" + wanted = set(codes) + best: Edition | None = None + best_hits = 0 + for edition in self.editions: + hits = len(wanted & edition.lines.keys()) + if hits > best_hits: + best, best_hits = edition, hits + return best + + def known_codes(self) -> frozenset[str]: + return frozenset(code for edition in self.editions for code in edition.lines) + + def editions_with(self, code: str) -> list[str]: + return [edition.id for edition in self.editions if code in edition.lines] + + +def _read_yaml(directory: Path, name: str) -> object: + path = directory / name + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc + try: + return yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc + + +def load_rules(directory: Path | str) -> OcrRules: + """Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке.""" + base = Path(directory) + loaded: dict[str, BaseModel] = {} + for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)): + try: + loaded[name] = model.model_validate(_read_yaml(base, name)) + except ValidationError as exc: + raise ValueError(f"справочник {name}: {exc}") from exc + lines = loaded[LINES_FILE] + lookalikes = loaded[LOOKALIKES_FILE] + labels = loaded[LABELS_FILE] + assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов + assert isinstance(labels, _LabelsFile) # noqa: S101 + return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels) +``` + +Если `mypy` ругается на `yaml` без stubs — добавить в `api/requirements-dev.txt` строку `types-PyYAML==6.0.12.20250915` +и выполнить `make venv`. + +- [ ] **Step 6: Запустить тесты и линт** + +Run: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py -q && cd .. && make lint` +Expected: все PASS, линт чист. + +- [ ] **Step 7: Commit** + +```bash +git add api/rules api/src/normalizer/rules.py api/src/core/config.py api/tests/unit/normalizer api/requirements-dev.txt +git commit -m "feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)" +``` + +--- + +### Task 2: Очистка текста (`src/normalizer/ocr/text.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/__init__.py` (пока пустой, с docstring), `api/src/normalizer/ocr/text.py` +- Test: `api/tests/unit/normalizer/test_text.py` + +**Interfaces:** +- Produces: + - `fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str` — перенос `date_utils._normalize`: `|` как + разделитель клеток (если первая строка состоит из `[\s\d|•·.:,]`), затем замена двойников вплотную к цифре в + несколько проходов. + - `clean_value(text: str) -> str` — перенос `_clear_value`: `\n`/`\t` → пробел, остаются буквы, цифры, пробел, точка; `strip`. + - `digits_only(text: str) -> str` — перенос `_keep_digits_only`. + - `collapse_whitespace(text: str) -> str` — перенос `_remove_spec_space` (`\n`/`\t` → пробел, `strip`). + - `normalize_header(text: object) -> str` — перенос `_normalize_header`: `str(text or "").lower()`, не-буквенно-цифровое → пробел, схлопнуть пробелы. + - `is_code_header(text: str) -> bool` (`text.startswith("код")` на нормализованном тексте), `is_value_header(text: str) -> bool` + (есть 4 цифры подряд или начинается с «на » / «за »). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_text.py`: + +```python +"""Очистка OCR-текста (§11.1 п. 1, 6; D-4 `text.py`): перенос функций старого ядра, поведение — как в источнике.""" + +import pytest + +from src.normalizer.ocr.text import ( + clean_value, + collapse_whitespace, + digits_only, + fix_lookalikes, + is_code_header, + is_value_header, + normalize_header, +) + +LOOKALIKES = {"О": "0", "о": "0", "O": "0", "o": "0", "З": "3", "з": "3", "l": "1", "I": "1", "|": "1", "і": "1", + "б": "6", "S": "5", "s": "5", "Ч": "4"} + + +class TestFixLookalikes: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("2ОО5", "2005"), # два прохода: О после 2, затем О после 0 + ("3l.12.2О24", "31.12.2024"), + ("Общество", "Общество"), # буквы без соседней цифры не трогаются + ("2|0|2|4|", "2 0 2 4 "), # первая строка — только цифры и |: это разделитель клеток + ("3l", "31"), # | не разделитель, если есть буквы + ("", ""), + ], + ) + def test_replaces_only_next_to_digits(self, raw: str, expected: str) -> None: + assert fix_lookalikes(raw, LOOKALIKES) == expected + + def test_separator_rule_looks_at_first_line_only(self) -> None: + assert fix_lookalikes("2|0|2|4|\nмусор", LOOKALIKES) == "2 0 2 4 \nмусор" + + def test_empty_table_keeps_text(self) -> None: + assert fix_lookalikes("2ОО5", {}) == "2ОО5" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("13 070", "13 070"), ("(7744536}", "7744536"), ("изо :", "изо"), ("На 30 июня\n2025 г", "На 30 июня 2025 г"), ("", "")], +) +def test_clean_value(raw: str, expected: str) -> None: + assert clean_value(raw) == expected + + +def test_digits_only() -> None: + assert digits_only("3|1 21 2| о! О' 0 0; 3: 51") == "3121200351" + assert digits_only("+") == "" + + +def test_collapse_whitespace() -> None: + assert collapse_whitespace(" На 31 декабря\n2024 г.\t") == "На 31 декабря 2024 г." + + +class TestHeaders: + def test_normalize_header(self) -> None: + assert normalize_header("Наименование\nпоказателя:") == "наименование показателя" + assert normalize_header(None) == "" + + def test_code_header(self) -> None: + assert is_code_header(normalize_header("Код")) + assert is_code_header(normalize_header("Код строки")) + assert not is_code_header(normalize_header("Наименование")) + + @pytest.mark.parametrize( + ("text", "expected"), + [("на 30 июня 2025 г", True), ("за январь июнь 2025 г", True), ("2024", True), ("пояснения", False), ("", False)], + ) + def test_value_header(self, text: str, expected: bool) -> None: + assert is_value_header(text) is expected +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_text.py -q` → `ModuleNotFoundError: src.normalizer.ocr`. + +- [ ] **Step 3: Реализация** + +`api/src/normalizer/ocr/__init__.py` (пока только docstring; `OcrNormalizer` появится в Task 9): + +```python +"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document` +(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены +дословно; DB-обвязка не переносится.""" +``` + +`api/src/normalizer/ocr/text.py`: + +```python +"""Очистка OCR-текста (§11.1 п. 1, 6). Перенос из `date_utils._normalize`, `utils._clear_value`, `_keep_digits_only`, +`_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header` старого ядра; алгоритмы без изменений.""" + +import re +from collections.abc import Mapping + +__all__ = [ + "clean_value", + "collapse_whitespace", + "digits_only", + "fix_lookalikes", + "is_code_header", + "is_value_header", + "normalize_header", +] + +_YEAR_RE = re.compile(r"\d{4}") +# первая строка значения из цифр, пробелов, `|`, точек и т. п. — `|` в ней разделяет клетки формы, а не единица +_CELL_SEPARATOR_LINE = re.compile(r"[\s\d|•·.:,]*") + + +def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str: + """Буквы-двойники → цифры только вплотную к цифре, в несколько проходов («2ОО5» → «2005»); `|` — разделитель + клеток, если первая строка состоит из цифр, пробелов и пунктуации («2|0|2|4|» → «2 0 2 4 »).""" + text = text.replace(" ", " ") + if _CELL_SEPARATOR_LINE.fullmatch(text.split("\n", 1)[0]): + text = text.replace("|", " ") + if not lookalikes: + return text + chars = re.escape("".join(lookalikes)) + near_digit = re.compile(rf"(?<=\d)[{chars}]|[{chars}](?=\d)") + table = str.maketrans(dict(lookalikes)) + previous = None + while previous != text: # «2ОО5» требует двух проходов + previous = text + text = near_digit.sub(lambda match: match.group(0).translate(table), text) + return text + + +def clean_value(text: str) -> str: + """Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются.""" + cleaned = "".join( + char for char in text.replace("\n", " ").replace("\t", " ") if char.isdigit() or char.isalpha() or char in " ." + ) + return cleaned.strip() + + +def digits_only(text: str) -> str: + return "".join(char for char in text if char.isdigit()) + + +def collapse_whitespace(text: str) -> str: + return text.replace("\n", " ").replace("\t", " ").strip() + + +def normalize_header(text: object) -> str: + """Нижний регистр, всё не-буквенно-цифровое → пробел, пробелы схлопнуты (для сравнения заголовков и label).""" + lowered = str(text or "").lower() + return " ".join("".join(char if char.isalnum() else " " for char in lowered).split()) + + +def is_code_header(text: str) -> bool: + """Ячейка шапки «Код…» (текст уже нормализован `normalize_header`).""" + return text.startswith("код") + + +def is_value_header(text: str) -> bool: + """Ячейка шапки колонки значений: есть четыре цифры года или текст начинается с «на » / «за ».""" + return bool(_YEAR_RE.search(text)) or text.startswith(("на ", "за ")) +``` + +Примечание к переносу: в источнике `_normalize` заменял `" "` (неразрывный пробел U+00A0) на обычный — сохранено; +таблица двойников в источнике была константой, здесь приходит из `ocr_lookalikes.yaml` (D-7). + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_text.py -q && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr api/tests/unit/normalizer/test_text.py +git commit -m "feat(normalizer): очистка OCR-текста — двойники, разделитель клеток, clean_value (§11.1 п. 1, 6)" +``` + +--- + +### Task 3: Разбор дат (`src/normalizer/ocr/dates.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/dates.py` +- Test: `api/tests/unit/normalizer/test_dates.py` + +**Interfaces:** +- Produces: `parse_date(text: object) -> date | None` — текст **уже после** `fix_lookalikes`; не-строка и пустая строка → `None`. + Текстовые формы по всей строке в порядке приоритета, затем числовые по первой строке, затем по всему тексту с `\n` → пробел + (точно как `date_utils._parse_date`, только без внутреннего `_normalize`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_dates.py`: + +```python +"""Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`.""" + +from datetime import date + +import pytest + +from src.normalizer.ocr.dates import parse_date +from src.normalizer.ocr.text import fix_lookalikes + +LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"} + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("на 30 сентября 2025 г.", date(2025, 9, 30)), + ("На 30 июня\n2025 г", date(2025, 6, 30)), + ("31 декабря 2024 года", date(2024, 12, 31)), + ("30 сентября 2025 r.", date(2025, 9, 30)), # латинская r вместо г + ("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), # диапазон — конец + ("I квартал 2026", date(2026, 3, 31)), + ("1-й кв. 2026", date(2026, 3, 31)), + ("первый квартал 2025 г.", date(2025, 3, 31)), + ("1 полугодие 2026", date(2026, 6, 30)), + ("1 п/г 2026", date(2026, 6, 30)), + ("9 месяцев 2025", date(2025, 9, 30)), + ("9 мес. 2025", date(2025, 9, 30)), + ("За январь - июнь\n2025 г.", date(2025, 6, 30)), + ("январь – сентябрь 2025", date(2025, 9, 30)), + ("за 2025 г.", date(2025, 12, 31)), + ("2024 год", date(2024, 12, 31)), + ("30.09.2025", date(2025, 9, 30)), + ("2025 03 31", date(2025, 3, 31)), + ("30/09/25", date(2025, 9, 30)), + ("2 0 2 4", date(2024, 12, 31)), + ("30 09 2 0 2 5", date(2025, 9, 30)), + ("30092025", date(2025, 9, 30)), + ("20250930", date(2025, 9, 30)), + ("30 09 25", date(2025, 9, 30)), + ("30.09.2025 0:00:00", date(2025, 9, 30)), + ("2|0|2|4|", date(2024, 12, 31)), # через fix_lookalikes + ("3l.12.2О24", date(2024, 12, 31)), + ], +) +def test_parses_real_forms(raw: str, expected: date) -> None: + assert parse_date(fix_lookalikes(raw, LOOKALIKES)) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "Общество с ограниченной ответственностью", # Review Focus 3: название организации — не дата + "ЗАКРЫТОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО", + "", + "+", + "тыс. руб.", + "1999999999999", # 13 цифр — ни одна форма + ], +) +def test_organization_name_is_not_a_date(raw: str) -> None: + assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None + + +def test_non_string_is_none() -> None: + assert parse_date(None) is None + assert parse_date(20250930) is None + + +def test_year_out_of_range_is_none() -> None: + assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100 +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_dates.py -q` → `ModuleNotFoundError`. + +- [ ] **Step 3: Перенос `date_utils.py`** + +Скопировать `/Users/andrey.epifancev/projects/awp-ca/reference-old-arm/api/src/core/PDFWorker/date_utils.py` в +`api/src/normalizer/ocr/dates.py` и внести ровно эти правки: +1. Docstring модуля: `"""Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA + (проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после + `text.fix_lookalikes`; замена двойников здесь не выполняется."""` и `__all__ = ["parse_date"]`. +2. Удалить `_LOOKALIKES`, `_LOOKALIKE_CHARS`, `_NEAR_DIGIT` и функцию `_normalize` (они перенесены в `text.fix_lookalikes`). +3. `_parse_date` переименовать в `parse_date` с сигнатурой `def parse_date(value: object) -> date | None:` и телом: + +```python + if not isinstance(value, str): + return None + s = value.strip() + if not s: + return None + # текстовые формы ищем по всей строке, числовые — по первой (дальше мусор) + return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " ")) +``` +4. `Optional[...]` → `... | None`, комментарии оставить. Больше ничего не менять: `_ROMAN`, `_ORDINAL`, `_MONTHS`, + `_Y`, `_MONTH_WORD`, `_NUM`, `_PATTERNS`, `_period`, `_safe`, `_text_date`, `_numeric_date`, `_from_digits`, + `_from_parts`, `_year`, `_month_num`, `_month_end`, `_year_full`, `_qh` — как в источнике. +5. Если `ruff` требует: `# noqa: PLR2004` у сравнений `len(groups) == 3`, `len(digits) == 8/4/6`, `y > 100`; + `# noqa: E741` не нужен. `mypy`: аннотировать `_PATTERNS: list[tuple[re.Pattern[str], Callable[[re.Match[str]], date | None]]]`. + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_dates.py -q && cd .. && make lint` → PASS. +Если какой-то параметр падает — сверить с источником: расхождение означает ошибку переноса, а не повод править тест. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/dates.py api/tests/unit/normalizer/test_dates.py +git commit -m "feat(normalizer): разбор дат из OCR — дословный перенос date_utils старого ядра (§11.1 п. 2)" +``` + +--- + +### Task 4: Суммы (`src/normalizer/ocr/amounts.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/amounts.py` +- Test: `api/tests/unit/normalizer/test_amounts.py` + +**Interfaces:** +- Produces: `@dataclass(frozen=True) ParsedAmount(amount: Decimal | None, verified: bool)`; + `parse_amount(raw: str) -> ParsedAmount` (перенос `_parse_amount`); `is_blank(text: str) -> bool` (перенос `_is_blank`: + пусто после удаления пробелов и прочерков `-–—`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_amounts.py`: + +```python +"""Суммы из OCR-ячеек (§11.1 п. 7): перенос `_parse_amount`/`_is_blank` старого ядра.""" + +from decimal import Decimal + +import pytest + +from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount + + +@pytest.mark.parametrize( + ("raw", "amount", "verified"), + [ + ("13 070", Decimal("13070"), True), + ("(6032409)", Decimal("-6032409"), True), + ("(7744536}", Decimal("7744536"), False), # закрывающая скобка не распознана: минус не ставится (решение №12) + ("1 234,56", Decimal("1234.56"), True), + ("1.234.567", Decimal("1234567"), False), # несколько точек — разделители тысяч; текст изменён → не verified + ("30127", Decimal("30127"), True), + ("211(1 |", Decimal("2111"), False), + ("не ****", None, False), + ("", None, True), + ("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL) + (":", None, False), + ], +) +def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None: + assert parse_amount(raw) == ParsedAmount(amount, verified) + + +@pytest.mark.parametrize(("raw", "expected"), [("", True), ("-", True), ("—", True), (" – ", True), ("0", False), ("x", False)]) +def test_is_blank(raw: str, expected: bool) -> None: + assert is_blank(raw) is expected +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_amounts.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/amounts.py`: + +```python +"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра без изменений: +запятая → точка, пробелы долой, содержимое круглых скобок — отрицательное, прочие символы отбрасываются, несколько +точек — разделители тысяч (остаётся последняя). `verified` — разбор не отбрасывал символов.""" + +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +__all__ = ["ParsedAmount", "is_blank", "parse_amount"] + +_BLANK_CHARS_RE = re.compile(r"[\s\-–—]") +_ALLOWED_RE = re.compile(r"[^\d+\-.]") +_BRACKET_RE = re.compile(r"\(([^()]*)\)") + + +@dataclass(frozen=True) +class ParsedAmount: + amount: Decimal | None + verified: bool + + +def parse_amount(raw: str) -> ParsedAmount: + s = raw.strip().replace(",", ".").replace(" ", "") + stripped_raw = s + if not stripped_raw: + return ParsedAmount(None, stripped_raw == raw) + match = _BRACKET_RE.search(s) + bracketed = match is not None + if match is not None: + s = match.group(1) + s = _ALLOWED_RE.sub("", s) + if s.count(".") > 1: + *head, tail = s.split(".") + s = "".join(head) + tail + # verified считается ДО постановки знака (отличие от источника, решение №13 design.md): в источнике сравнение шло + # после `s = "-" + s`, и любая сумма в скобках получала verified = False, то есть is_recognized = false + verified = s == stripped_raw.replace(")", "").replace("(", "") + if bracketed: + s = "-" + s.lstrip("+-") + try: + return ParsedAmount(Decimal(s), verified) + except (InvalidOperation, ValueError): + return ParsedAmount(None, verified) + + +def is_blank(text: str) -> bool: + """Прочерк в отчётности означает ноль — считаем пустым значением (NULL, не 0).""" + return not _BLANK_CHARS_RE.sub("", text) +``` + +Примечание: в источнике `except:` без типа; здесь `InvalidOperation, ValueError` (`Decimal("")` и `Decimal("-")` дают `InvalidOperation`). +Единственное осознанное отличие от источника — порядок `verified`/знака (решение №13 в `design.md`); остальное дословно. +Проверить параметр `("", None, True)`: `raw=""` → `stripped_raw == raw` → `True` — как в источнике. + +- [ ] **Step 4: Тесты и линт** — PASS. Если `("211(1 |", …)` расходится — сверить с источником пошагово (`(1 |` → скобка не закрыта → `ALLOWED_RE` удаляет `(`, пробел уже удалён, `|` удалён → `2111`). + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/amounts.py api/tests/unit/normalizer/test_amounts.py +git commit -m "feat(normalizer): разбор сумм OCR — перенос _parse_amount (§11.1 п. 7)" +``` + +--- + +### Task 5: Раскладка колонок (`src/normalizer/ocr/layout.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/layout.py` +- Test: `api/tests/unit/normalizer/test_layout.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) Cell(value: str, confidence: float)`; `Cells = dict[int, Cell]`. + - `cells_by_index(row: Mapping[str, object]) -> Cells` — из `{"row_index", "cells": [{"column_index", "value", "confidence"}]}`; + `value` → `str(value or "")`, `confidence` → `float(confidence or 0)`; ячейка без `column_index` пропускается. + - `cell_text(cells: Cells, idx: int | None) -> str` (перенос `_cell_value`). + - `@dataclass(frozen=True) ColumnLayout(label_idx: int | None, code_idx: int, value_idxs: tuple[int, ...])`. + - `build_layout(cells: Cells) -> ColumnLayout | None` (перенос `_build_layout`). + - `infer_layout(rows_cells: Sequence[Cells], known_codes: Collection[str]) -> ColumnLayout | None` (перенос `_infer_layout`, + справочник кодов — параметр). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_layout.py`: + +```python +"""Раскладка колонок таблицы OCR (§11.1 п. 3): шапка «Код» и вывод по данным — перенос `_build_layout`/`_infer_layout`.""" + +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"}) + + +def _row(*cells: tuple[int, str, float]) -> dict: + return {"row_index": 0, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} + + +def test_cells_by_index_normalizes_types() -> None: + cells = cells_by_index(_row((0, "Выручка", 71.4), (1, None, None))) + assert cells == {0: Cell("Выручка", 71.4), 1: Cell("", 0.0)} + assert cell_text(cells, 0) == "Выручка" + assert cell_text(cells, 7) == "" + assert cell_text(cells, None) == "" + + +class TestBuildLayout: + def test_header_with_code_and_years(self) -> None: + cells = cells_by_index( + _row((0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня\n2025 г", 100), + (4, "На 31 декабря\n2024 г.", 100), (5, "На 31 декабря\n2023 г.", 100)) + ) + assert build_layout(cells) == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5)) + + def test_label_defaults_to_last_filled_left_of_code(self) -> None: + cells = cells_by_index(_row((0, "Пояснения", 100), (1, "Показатель", 100), (2, "Код", 100), (3, "2024", 100))) + assert build_layout(cells) == ColumnLayout(label_idx=1, code_idx=2, value_idxs=(3,)) + + def test_no_code_cell_is_not_a_header(self) -> None: + assert build_layout(cells_by_index(_row((0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)))) is None + + +class TestInferLayout: + def test_aep3_like_rows_without_header(self) -> None: + rows = [ + cells_by_index(_row((1, "НИ", 0), (0, "Нематериальные активы", 100), (2, "30127", 60), (4, "| | г", 16), (5, "", 100))), + cells_by_index(_row((1, "1120", 50), (0, "Результаты исследований и", 94), (2, ":", 0), (4, "--", 0), (5, ":", 0))), + cells_by_index(_row((1, "1130", 80), (0, "Нематериальные поисковые", 87), (2, "не ****", 5), (4, "3", 0), (5, "+ :", 0))), + cells_by_index(_row((1, "1150", 100), (0, "Основные средства", 100), (2, "1 234", 100), (4, "5 678", 100), (5, "", 100))), + ] + layout = infer_layout(rows, KNOWN) + assert layout is not None + assert layout.code_idx == 1 + assert layout.label_idx == 0 + assert 2 in layout.value_idxs and 5 in layout.value_idxs # суммы не реже мусора / столбец пуст + + def test_no_known_codes_is_none(self) -> None: + rows = [cells_by_index(_row((0, "текст", 100), (1, "мусор", 100)))] + assert infer_layout(rows, KNOWN) is None + + def test_empty_rows_is_none(self) -> None: + assert infer_layout([], KNOWN) is None +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_layout.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/layout.py`: + +```python +"""Раскладка колонок таблицы OCR (§11.1 п. 3). Перенос `_build_layout`, `_infer_layout`, `_cells_by_index`, +`_cell_value` старого ядра: справочник кодов приходит параметром, ячейки — `Cell` вместо словарей.""" + +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 + +__all__ = ["Cell", "Cells", "ColumnLayout", "build_layout", "cell_text", "cells_by_index", "infer_layout"] + + +@dataclass(frozen=True) +class Cell: + value: str + confidence: float + + +Cells = dict[int, Cell] + + +@dataclass(frozen=True) +class ColumnLayout: + """Индексы значимых столбцов таблицы OCR: из строки заголовка или выведенные по строкам данных.""" + + label_idx: int | None + code_idx: int + value_idxs: tuple[int, ...] + + +def cells_by_index(row: Mapping[str, object]) -> Cells: + cells: Cells = {} + raw_cells = row.get("cells") + if not isinstance(raw_cells, list): + return cells + for raw in raw_cells: + if not isinstance(raw, Mapping) or not isinstance(raw.get("column_index"), int): + continue + 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 + ) + return cells + + +def cell_text(cells: Cells, idx: int | None) -> str: + if idx is None or idx not in cells: + return "" + return cells[idx].value + + +def build_layout(cells: Cells) -> ColumnLayout | None: + """Раскладка, если строка является заголовком (есть ячейка «Код…»): наименование — ячейка с «наименование», + иначе последняя заполненная левее кода; значения — правее кода, где год или «на »/«за ».""" + headers = {idx: normalize_header(cells[idx].value) for idx in sorted(cells)} + code_idx = next((idx for idx, text in headers.items() if is_code_header(text)), None) + if code_idx is None: + return None + label_idx = next((idx for idx, text in headers.items() if "наименование" in text), None) + if label_idx is None: + left_filled = [idx for idx, text in headers.items() if idx < code_idx and text] + label_idx = left_filled[-1] if left_filled else None + value_idxs = tuple(idx for idx, text in headers.items() if idx > code_idx and is_value_header(text)) + return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=value_idxs) + + +def infer_layout(rows_cells: Sequence[Cells], known_codes: Collection[str]) -> ColumnLayout | None: + """Раскладка по строкам данных, когда заголовка нет: код — столбец с наибольшим числом известных кодов; наименование — + левее кода с наибольшим числом текстовых ячеек; значения — правее кода, где суммы не реже мусора или столбец пуст. + Столбец оценивается по всем строкам сразу, поэтому пустые ячейки отдельных строк не влияют.""" + columns: dict[int, list[str]] = {} + for cells in rows_cells: + for idx, cell in cells.items(): + columns.setdefault(idx, []).append(cell.value.strip()) + code_hits = {idx: sum(1 for v in values if v in known_codes) for idx, values in columns.items()} + code_idx = min(code_hits, key=lambda idx: (-code_hits[idx], idx), default=None) + if code_idx is None or code_hits[code_idx] == 0: + return None + text_hits = { + idx: sum(1 for v in values if any(c.isalpha() for c in v)) for idx, values in columns.items() if idx < code_idx + } + label_idx = min(text_hits, key=lambda idx: (-text_hits[idx], -idx), default=None) + if label_idx is not None and text_hits[label_idx] == 0: + label_idx = None + value_idxs = [] + for idx in sorted(columns): + if idx <= code_idx: + continue + filled = [v for v in columns[idx] if not is_blank(v)] + amounts = sum(1 for v in filled if parse_amount(v).amount is not None) + if amounts >= len(filled) - amounts: + value_idxs.append(idx) + return ColumnLayout(label_idx=label_idx, code_idx=code_idx, value_idxs=tuple(value_idxs)) +``` + +- [ ] **Step 4: Тесты и линт** — PASS. В `test_aep3_like_rows_without_header` столбец 4 («| | г», «--», «3», «5 678»): заполнено 3 (`--` — прочерк), суммы 2 (`3`, `5 678`) ≥ 1 → тоже значение; тест этого не требует, но и не запрещает. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/layout.py api/tests/unit/normalizer/test_layout.py +git commit -m "feat(normalizer): раскладка колонок OCR — шапка «Код» и вывод по данным (§11.1 п. 3)" +``` + +--- + +### Task 6: Строки таблицы (`src/normalizer/ocr/rows.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/rows.py` +- Test: `api/tests/unit/normalizer/test_rows.py` + +**Interfaces:** +- Consumes: `Cells`, `ColumnLayout`, `build_layout`, `infer_layout`, `cells_by_index`, `cell_text` (Task 5); `clean_value`, `normalize_header` (Task 2). +- Produces: + - `class RowKind(StrEnum)`: `HEADER`, `ROW`, `TOTAL`, `BALANCE`. + - `@dataclass(frozen=True) ParsedRow(cells: Cells, layout: ColumnLayout, kind: RowKind, ordinal: int)`. + - `classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None` (перенос `_classify_row`: пустой код → `None`; + нечисловой код → `HEADER`; наименование «Баланс» → `BALANCE`; начинается с «итого» → `TOTAL`; иначе `ROW`). + - `@dataclass(frozen=True) ParsedTable(rows: list[ParsedRow], synthetic_header: bool)` — `rows[0]` всегда шапка. + - `parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Collection[str]) -> ParsedTable | None` + (перенос `_parse_table_rows`; `None` — ни шапки, ни столбца кода). + - `table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None` (перенос `_resolve_table_type`: + больше кодов на `1` → BALANCE, на `2` → INCOME, равенство/нет кодов → `None`; `KeyError`/`IndexError` источника → `None`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_rows.py`: + +```python +"""Строки таблицы OCR (§11.1 п. 3–5): шапка настоящая/повторная/синтетическая, классы строк, тип по кодам.""" + +import json +from pathlib import Path + +import pytest + +from src.normalizer.ocr.layout import ColumnLayout, cells_by_index +from src.normalizer.ocr.rows import ParsedTable, RowKind, classify_row, parse_table_rows, table_type_by_codes + +API_DIR = Path(__file__).resolve().parents[3] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +KNOWN = frozenset({"1105", "1110", "1120", "1130", "1150", "1600", "2110", "2120", "2100"}) + + +def _row(index: int, *cells: tuple[int, str, float]) -> dict: + return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} + + +HEADER = _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня\n2025 г", 100), (4, "На 31 декабря\n2024 г.", 100)) + + +def _real_table(name: str, index: int) -> list[dict]: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + data = json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8")) + return list(data["data_ocr"]["table_structure"][index]["rows"]) + + +class TestClassifyRow: + layout = ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2,)) + + @pytest.mark.parametrize( + ("label", "code", "expected"), + [ + ("Выручка", "2110", RowKind.ROW), + ("Итого по разделу I", "1100", RowKind.TOTAL), + ("Баланс", "1600", RowKind.BALANCE), + ("Наименование", "Код", RowKind.HEADER), + ("в том числе:", "", None), + ], + ) + def test_kinds(self, label: str, code: str, expected: RowKind | None) -> None: + cells = cells_by_index(_row(1, (0, label, 100), (1, code, 100), (2, "1", 100))) + assert classify_row(cells, self.layout) is expected + + +class TestParseTableRows: + def test_header_then_rows(self) -> None: + rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100), (4, "", 100)), + _row(2, (0, "в том числе:", 93), (1, "", 100), (2, "13 070", 100), (4, "", 100))] + parsed = parse_table_rows(rows, KNOWN) + assert isinstance(parsed, ParsedTable) and not parsed.synthetic_header + assert [r.kind for r in parsed.rows] == [RowKind.HEADER, RowKind.ROW] + assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4)) + assert parsed.rows[1].ordinal == 1 + + def test_second_header_overrides_layout(self) -> None: + """Review Focus 1: после повторной шапки с другим набором колонок строки идут по новой раскладке.""" + rows = [ + HEADER, + _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100), (4, "2", 100)), + _row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)), + _row(3, (0, "Себестоимость", 100), (1, "2120", 100), (3, "(5)", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None + kinds = [r.kind for r in parsed.rows] + assert kinds == [RowKind.HEADER, RowKind.ROW, RowKind.ROW] # повторная шапка не становится строкой + assert parsed.rows[1].layout.value_idxs == (2, 4) + assert parsed.rows[2].layout.value_idxs == (3,) + + def test_rows_before_header_use_inferred_layout(self) -> None: + rows = [ + _row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)), + _row(2, (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, 2] # шапка встаёт раньше первой строки данных + assert parsed.rows[0].kind is RowKind.HEADER + + def test_synthetic_header_when_none(self) -> None: + rows = [_row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (1, "1150", 100), (0, "Основные средства", 100), (2, "7", 100))] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and parsed.synthetic_header + header = parsed.rows[0] + assert header.kind is RowKind.HEADER and header.ordinal == -1 + assert header.cells[1].value == "Код" and header.cells[0].value == "Наименование показателя" + assert header.layout.value_idxs == (2,) and header.cells.get(2) is None # тексты периодов пустые + + def test_no_header_no_codes_is_none(self) -> None: + assert parse_table_rows([_row(0, (0, "текст", 100), (1, "мусор", 100))], KNOWN) is None + assert parse_table_rows([], KNOWN) is None + + @pytest.mark.fixtures + def test_aep3_balance_without_header(self) -> None: + parsed = parse_table_rows(_real_table("aep_3.json", 0), KNOWN) + assert parsed is not None and parsed.synthetic_header + assert parsed.rows[0].layout.code_idx == 1 + assert any(r.cells[1].value.strip() == "1120" for r in parsed.rows[1:]) + + @pytest.mark.fixtures + def test_aep6_balance_with_header(self) -> None: + parsed = parse_table_rows(_real_table("aep_6.json", 0), KNOWN) + assert parsed is not None and not parsed.synthetic_header + assert parsed.rows[0].layout == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5)) + assert table_type_by_codes(parsed) == "BALANCE" + + +class TestTableType: + def test_by_first_digit(self) -> None: + rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)), + _row(2, (0, "Себестоимость", 100), (1, "2120", 100), (2, "1", 100)), + _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100))] + assert table_type_by_codes(parse_table_rows(rows, KNOWN)) == "INCOME" + + def test_tie_or_empty_is_none(self) -> None: + rows = [HEADER, _row(1, (0, "a", 100), (1, "2110", 100), (2, "1", 100)), _row(2, (0, "b", 100), (1, "1150", 100), (2, "1", 100))] + assert table_type_by_codes(parse_table_rows(rows, KNOWN)) is None + assert table_type_by_codes(ParsedTable(rows=[], synthetic_header=False)) is None +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_rows.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/rows.py`: + +```python +"""Строки таблицы OCR (§11.1 п. 3–5). Перенос `_classify_row`, `_parse_table_rows`, `_synthetic_header_cells`, +`_resolve_table_type` старого ядра: шапка — строка с «Код» (повторная перезаписывает раскладку), без шапки раскладка +выводится по данным и шапка синтезируется; строки до первой шапки — по выведенной раскладке.""" + +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal + +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"] + +HEADER_CODE = "Код" +HEADER_LABEL = "Наименование показателя" +FULL_CONFIDENCE = 100.0 + + +class RowKind(StrEnum): + HEADER = "HEADER" + ROW = "ROW" + TOTAL = "TOTAL" + BALANCE = "BALANCE" + + +@dataclass(frozen=True) +class ParsedRow: + cells: Cells + layout: ColumnLayout + kind: RowKind + ordinal: int + + +@dataclass(frozen=True) +class ParsedTable: + """Разобранные строки: `rows[0]` — шапка (настоящая или синтетическая), дальше строки данных по порядку.""" + + rows: list[ParsedRow] + synthetic_header: bool + + +def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None: + code = cell_text(cells, layout.code_idx).strip() + if not code: + return None + try: + int(code) + except (TypeError, ValueError): + return RowKind.HEADER + label = cell_text(cells, layout.label_idx) + if label == "Баланс": + return RowKind.BALANCE + if label.lower().startswith("итого"): + return RowKind.TOTAL + return RowKind.ROW + + +def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | None: + kind = classify_row(cells, layout) + if kind is None or kind is RowKind.HEADER: + return None + return ParsedRow(cells, layout, kind, ordinal) + + +def _synthetic_header_cells(layout: ColumnLayout) -> Cells: + """Ячейки восстановленного заголовка: код и наименование, тексты периодов пустые (ячеек под значениями нет).""" + cells: Cells = {layout.code_idx: Cell(HEADER_CODE, FULL_CONFIDENCE)} + if layout.label_idx is not None: + cells[layout.label_idx] = Cell(HEADER_LABEL, FULL_CONFIDENCE) + return cells + + +def _ordinal(row: Mapping[str, object], fallback: int) -> int: + index = row.get("row_index") + return index if isinstance(index, int) else fallback + + +def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Collection[str]) -> ParsedTable | None: + rows_cells = [(_ordinal(row, position), cells_by_index(row)) for position, row in enumerate(table_rows)] + first_header = next( + (pos for pos, (_, cells) in enumerate(rows_cells) if build_layout(cells) is not None), len(rows_cells) + ) + 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] = [] + 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) + + 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) + + header_row_index, header_cells = rows_cells[first_header] + layout = build_layout(header_cells) + if layout is None: # исключено выбором first_header + return None + header_ordinal = min([header_row_index, *(p.ordinal - 1 for p in leading_rows)]) + parsed_rows = [ParsedRow(header_cells, layout, RowKind.HEADER, header_ordinal), *leading_rows] + for ordinal, cells in rows_cells[first_header + 1 :]: + header_layout = build_layout(cells) + if header_layout is not None: + layout = header_layout + continue + parsed = _data_row(cells, layout, ordinal) + if parsed is not None: + parsed_rows.append(parsed) + return ParsedTable(parsed_rows, synthetic_header=False) + + +def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None: + """Тип таблицы по первой цифре кодов строк данных: больше `1xxx` → BALANCE, больше `2xxx` → INCOME.""" + counts = {"1": 0, "2": 0} + for row in parsed.rows: + if row.kind is RowKind.HEADER: + continue + code = cell_text(row.cells, row.layout.code_idx).strip() + if code: + counts[code[0]] = counts.get(code[0], 0) + 1 + if counts["1"] > counts["2"]: + return "BALANCE" + if counts["2"] > counts["1"]: + return "INCOME" + return None +``` + +Отличия от источника (фиксируются в отчёте задачи): `row_index` не-int → позиция в списке; `_resolve_table_type` +бросал `ValueError` при `KeyError` — здесь пустой код пропускается, равенство → `None` (источник возвращал `None` неявно). + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_rows.py -q && cd .. && make lint` → PASS +(тесты с маркером `fixtures` требуют `../fixtures/ocr`; в worktree это симлинк). + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/rows.py api/tests/unit/normalizer/test_rows.py +git commit -m "feat(normalizer): строки таблицы OCR — шапки, классы строк, тип по кодам (§11.1 п. 3–5)" +``` + +--- + +### Task 7: Шапка файла (`src/normalizer/ocr/header.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/header.py` +- Test: `api/tests/unit/normalizer/test_header.py` + +**Interfaces:** +- Consumes: `fix_lookalikes`, `digits_only`, `clean_value`, `collapse_whitespace` (Task 2); `parse_date` (Task 3); `OcrRules.lookalikes` (Task 1). +- Produces: + - `@dataclass(frozen=True) FileHeader(organization_name: str | None, organization_confidence: float | None, + inn: str | None, inn_confidence: float | None, units: str | None, report_date_text: str | None, + report_date: date | None, report_date_recognized: bool)`. + - `parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader` — §11.1 п. 8: + `inn = digits_only(form_name)` или `None`, `inn_confidence = confidence`, если сырой текст после `strip` состоял только из + цифр, иначе `min(confidence, 99)`; `organization_name = collapse_whitespace(form_name)` или `None`, `organization_confidence = confidence`; + `units = clean_value(form_name)` или `None`; `report_date_text = form_name` как есть (`None`, если пусто), + `report_date = parse_date(fix_lookalikes(form_name))`, `report_date_recognized = confidence == 100 and report_date is not None`. + Поле отсутствует/не объект → все его значения `None`/`0`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/unit/normalizer/test_header.py`: + +```python +"""Шапка документа из `data_ocr` (§11.1 п. 8) на реальных `aep_3.json` (мусор) и `aep_6.json` (чисто).""" + +import json +from datetime import date +from pathlib import Path + +import pytest + +from src.normalizer.ocr.header import FileHeader, parse_file_header + +API_DIR = Path(__file__).resolve().parents[3] +REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr" +LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"} + + +def _real(name: str) -> dict: + if not REAL_FIXTURES.is_dir(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + return dict(json.loads((REAL_FIXTURES / name).read_text(encoding="utf-8"))["data_ocr"]) + + +def _field(text: str, confidence: float) -> dict: + return {"form_name": text, "confidence": confidence} + + +def test_clean_header() -> None: + header = parse_file_header( + {"organization": _field("ООО Ромашка", 97.5), "inn": _field("7700000000", 100), "units": _field("тыс. руб.", 100), + "date": _field("на 31 декабря 2024 г.", 100)}, + LOOKALIKES, + ) + assert header == FileHeader("ООО Ромашка", 97.5, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", + date(2024, 12, 31), True) + + +def test_noisy_inn_caps_confidence() -> None: + header = parse_file_header({"inn": _field("3|1 21 2| о! О' 0 0; 3: 51", 100)}, LOOKALIKES) + assert header.inn == "3121200351" + assert header.inn_confidence == 99.0 + assert header.organization_name is None and header.organization_confidence is None + + +def test_missing_fields() -> None: + header = parse_file_header({}, LOOKALIKES) + assert header == FileHeader(None, None, None, None, None, None, None, False) + + +@pytest.mark.fixtures +def test_aep6_header() -> None: + header = parse_file_header(_real("aep_6.json"), LOOKALIKES) + assert header.inn is None # текст «+» + assert header.units == "тыс. руб." + assert header.report_date is None # в поле date — название организации + assert header.report_date_text is not None and header.report_date_recognized is False + assert header.organization_name is not None and "Дорстроймонтаж" in header.organization_name + + +@pytest.mark.fixtures +def test_aep3_header() -> None: + header = parse_file_header(_real("aep_3.json"), LOOKALIKES) + assert header.inn == "3121200351" and header.inn_confidence is not None and header.inn_confidence < 100 + assert header.report_date == date(2024, 12, 31) + assert header.report_date_recognized is False # confidence 75 +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/unit/normalizer/test_header.py -q`. + +- [ ] **Step 3: Реализация** — `api/src/normalizer/ocr/header.py`: + +```python +"""Шапка документа из полей `data_ocr` (§11.1 п. 8): ИНН — только цифры, единицы — очищенный текст, дата отчёта — +`parse_date` после замены двойников; «распознано» — `confidence == 100` и сырой текст без мусора.""" + +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 + +__all__ = ["FileHeader", "parse_file_header"] + +FULL_CONFIDENCE = 100.0 +CAPPED_CONFIDENCE = 99.0 + + +@dataclass(frozen=True) +class FileHeader: + organization_name: str | None + organization_confidence: float | None + inn: str | None + inn_confidence: float | None + units: str | None + report_date_text: str | None + report_date: date | None + report_date_recognized: bool + + +def _field(data_ocr: Mapping[str, object], name: str) -> tuple[str, float | None]: + raw = data_ocr.get(name) + if not isinstance(raw, Mapping): + return "", None + text = raw.get("form_name") + confidence = raw.get("confidence") + return (str(text) if isinstance(text, str) else "", float(confidence) if isinstance(confidence, int | float) else None) + + +def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader: + organization, organization_confidence = _field(data_ocr, "organization") + inn_raw, inn_confidence = _field(data_ocr, "inn") + units_raw, _ = _field(data_ocr, "units") + date_raw, date_confidence = _field(data_ocr, "date") + + organization_name = collapse_whitespace(organization) or None + inn = digits_only(inn_raw) or None + if inn is None: + 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 + return FileHeader( + organization_name=organization_name, + organization_confidence=organization_confidence if organization_name is not None else None, + inn=inn, + inn_confidence=inn_confidence, + units=clean_value(units_raw) or None, + report_date_text=date_raw.strip() or None, + report_date=report_date, + report_date_recognized=date_confidence == FULL_CONFIDENCE and report_date is not None, + ) +``` + +- [ ] **Step 4: Тесты и линт** — PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/normalizer/ocr/header.py api/tests/unit/normalizer/test_header.py +git commit -m "feat(normalizer): шапка документа из data_ocr — ИНН, единицы, дата отчёта (§11.1 п. 8)" +``` + +--- + +### Task 8: Сборка документа (`src/normalizer/ocr/document.py`) + +**Files:** +- Create: `api/src/normalizer/ocr/document.py` +- Test: `api/tests/unit/normalizer/test_document.py` + +**Interfaces:** +- Consumes: `OcrRules`, `Edition` (Task 1); `clean_value`, `collapse_whitespace`, `fix_lookalikes`, `normalize_header` (Task 2); + `parse_date` (Task 3); `parse_amount` (Task 4); `cell_text` (Task 5); `ParsedTable`, `RowKind`, `parse_table_rows`, + `table_type_by_codes` (Task 6); `FileHeader` (Task 7); модели `StatementDocument`, `StatementHeader`, `StatementColumn`, + `StatementLine`, `StatementValue` из `src.contract.result`. +- Produces: + - `@dataclass(frozen=True) BuiltDocument(document: StatementDocument, warnings: list[str], fully_recognized: bool, edition_id: str | None)`. + - `build_document(table: Mapping[str, object], index: int, file_name: str, header: FileHeader, rules: OcrRules) -> BuiltDocument | None` + — `None`, если таблица не разобрана (ни шапки, ни кодов); предупреждения без префикса файла/таблицы (их добавляет + `OcrNormalizer`): `"нет шапки и столбца кода"` (возвращается через `None`, вызывающий пишет сам), `"шапка синтезирована: колонки без дат"`, + `"тип по кодам (<тип>) не совпал с label (