# Срез 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: 06.10.2026 слиты docs #27/#44, scan-gateway #5, arm-core #27, tabular-parser #16/#19, fixtures #10; `origin/main` влит в ветку (merge 3bc01e5), `make check` — 749 passed. Контракт **не меняется**: `api/contracts/*.schema.json` байт в байт равны `../docs/contracts` (тест `test_contract.py`). - `CONTRACT_VERSION` scan-gateway остаётся `"1.0"` (решение №14 `design.md`): документы 3a не используют полей, появившихся в 1.1–1.2 (`DISCLOSURE`, `checks`, границы периода ОСВ), а потребитель (arm-core #27) принимает любой MINOR. - Язык комментариев, 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`. - [x] **Step 1: Влить main с копией схемы v1.2** — выполнено 06.10.2026 при подготовке плана: `git merge origin/main` (merge 3bc01e5, PR #5 уже в main). - [x] **Step 2: Окружение и базовая проверка** — `make venv`, `make check`: линт чист, 749 passed. Исполнитель повторяет `make check` перед Task 1, чтобы убедиться, что окружение живо. - [ ] **Step 3: Проверить, что модель видит v1.2** ```bash cd api && .venv/bin/python -c "from src.contract.result import StatementValue, StatementChecks; print(StatementValue.model_fields['string_value'])" ``` Ожидание: печать описания поля без ошибки импорта. - [x] **Step 4: Commit** — merge-коммит 3bc01e5; дополнительного коммита нет. --- ### 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 (черновой перечень кодов — шаблоны fixtures/statements-xlsx/templates/*после 01.01.2025*.xlsx); # пока пуста, документы 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 (