arm-scan-gateway/docs/superpowers/plans/2026-10-06-slice-3a-ocr-normalizer.md

184 KiB
Raw Blame History

Срез 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/<job_id>.<ext>, Output/<job_id>/*.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/<job_id>.<ext> уже есть, а Output/<job_id>/ уже удалён очисткой прошлого прогона: 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 (<job_id>.<ext>), 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, <uuid>.<ext>
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: Влить main с копией схемы v1.2 — выполнено 06.10.2026 при подготовке плана: git merge origin/main (merge 3bc01e5, PR #5 уже в main).

  • Step 2: Окружение и базовая проверка — make venv, make check: линт чист, 749 passed. Исполнитель повторяет make check перед Task 1, чтобы убедиться, что окружение живо.

  • Step 3: Проверить, что модель видит v1.2

cd api && .venv/bin/python -c "from src.contract.result import StatementValue, StatementChecks; print(StatementValue.model_fields['string_value'])"

Ожидание: печать описания поля без ошибки импорта.

  • 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:

"""Справочники нормализатора (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 не включаются):

# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§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):

# Буквы и знаки, которые 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:

# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
labels:
  "бухгалтерский баланс": BALANCE
  "отчет о финансовых результатах": INCOME
  "отчёт о финансовых результатах": INCOME
  "отчет о прибылях и убытках": INCOME
  • Step 4: Настройки

В api/src/core/config.py после строки SCAN_MAX_INFLIGHT: PositiveInt = 4 … добавить:

    # Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
    # На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
    SCAN_CLEANUP: bool = False
    SCAN_RULES_DIR: str = "rules"  # справочники нормализатора (yaml), путь от текущего каталога (api/)
  • Step 5: Модуль правил

api/src/normalizer/rules.py:

"""Справочники нормализатора 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
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:

"""Очистка 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):

"""Нормализатор 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:

"""Очистка 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

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:

"""Разбор дат из 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: и телом:
    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", " "))
  1. 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 — как в источнике.
  2. Если 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

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:

"""Суммы из 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:

"""Суммы из 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

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:

"""Раскладка колонок таблицы 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:

"""Раскладка колонок таблицы 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

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:

"""Строки таблицы 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:

"""Строки таблицы 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

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:

"""Шапка документа из `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:

"""Шапка документа из полей `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

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 (<label>)", "тип определён по кодам", "тип не определён: OTHER", "колонка <i> без даты: <label>", "редакция определена по кодам: <id>", "редакция <id> не заполнена", "код <код> из редакции <id>", "редакция не определена".
    • Правила значений, кодов, колонок — spec «Нормализация ответа ИС в statementDocument».
  • Step 1: Падающие тесты — api/tests/unit/normalizer/test_document.py:

"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7)."""

from datetime import date
from decimal import Decimal
from pathlib import Path

import pytest

from src.normalizer.ocr.document import BuiltDocument, build_document
from src.normalizer.ocr.header import FileHeader
from src.normalizer.rules import load_rules

API_DIR = Path(__file__).resolve().parents[3]
RULES = load_rules(API_DIR / "rules")
HEADER_2024 = FileHeader("ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True)


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]}


def _table(label: str, *rows: dict) -> dict:
    return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)}


CLEAN_BALANCE = _table(
    "Бухгалтерский баланс",
    _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100)),
    _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)),
    _row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)),
)


def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument:
    built = build_document(table, index, "job.json", header, RULES)
    assert built is not None
    return built


class TestCleanTable:
    def test_document_fields(self) -> None:
        built = _build(CLEAN_BALANCE)
        doc = built.document
        assert doc.document_type == "BALANCE"
        assert doc.locator == "file:job.json;table:0"
        assert doc.header.form_name == "Бухгалтерский баланс"
        assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100
        assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г."
        assert doc.header.units == "тыс. руб." and doc.header.okud is None
        assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [
            (0, "На 31 декабря 2024 г.", date(2024, 12, 31)), (1, "На 31 декабря 2023 г.", date(2023, 12, 31))]
        assert doc.checks is None

    def test_lines_and_values(self) -> None:
        doc = _build(CLEAN_BALANCE).document
        assert [line.line_code for line in doc.lines] == ["1150", "1100"]
        first = doc.lines[0]
        assert first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100
        values = first.values
        assert [v.column_index for v in values] == [0, 1]
        assert values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True
        assert values[1].amount == Decimal("-56") and values[1].is_recognized is True
        blank = doc.lines[1].values[1]
        assert blank.amount is None and blank.string_value is None and blank.is_recognized is True

    def test_fully_recognized_and_edition(self) -> None:
        built = _build(CLEAN_BALANCE)
        assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n"


class TestNoise:
    def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None:
        table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0],
                       _row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)),
                       _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)))
        built = _build(table)
        doc = built.document
        # кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)»)
        assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка"
        assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100
        assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90  # уже < 100, не поднимается
        assert doc.lines[1].line_name == "Основные средства"
        assert built.fully_recognized is False

    def test_non_integer_code_row_is_dropped(self) -> None:
        """Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается."""
        table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0],
                       _row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)),
                       _row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)))
        assert [line.line_code for line in _build(table).document.lines] == ["1110"]

    def test_duplicate_code_keeps_first(self) -> None:
        """Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение."""
        table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0],
                       _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)),
                       _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)))
        built = _build(table)
        assert [line.line_code for line in built.document.lines] == ["1150"]
        assert built.document.lines[0].values[0].amount == Decimal("1")
        assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False

    def test_missing_cells_keep_column_count(self) -> None:
        """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок."""
        table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100)))
        values = _build(table).document.lines[0].values
        assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [
            (0, None, None, 0, False), (1, None, None, 0, False)]

    def test_unverified_amount_is_not_recognized(self) -> None:
        table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)))
        values = _build(table).document.lines[0].values
        assert values[0].amount == Decimal("7744536") and values[0].is_recognized is False and values[0].string_value is None
        assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x"

    def test_synthetic_header_columns_without_dates(self) -> None:
        table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)), _row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)))
        built = _build(table)
        assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)]
        assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False


class TestTypeAndEdition:
    def test_type_by_codes_when_label_unknown(self) -> None:
        built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"]))
        assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings

    def test_label_wins_over_codes_with_warning(self) -> None:
        built = _build(_table("Отчет о финансовых результатах", *CLEAN_BALANCE["rows"]))
        assert built.document.document_type == "INCOME"
        assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings)

    def test_other_when_nothing_known(self) -> None:
        table = _table("Приложение", CLEAN_BALANCE["rows"][0], _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)),
                       _row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)))
        built = _build(table)
        assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings

    def test_edition_2025_is_empty(self) -> None:
        header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False)
        table = _table("Бухгалтерский баланс",
                       _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)),
                       _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)))
        built = _build(table, header)
        assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings
        assert built.document.lines[0].line_name == "Основные средства"  # из OCR
        assert built.document.lines[0].line_name_confidence == 100  # confidence ячейки наименования
        assert built.fully_recognized is False

    def test_edition_by_codes_when_no_dates(self) -> None:
        header = FileHeader(None, None, None, None, None, None, None, False)
        table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)))
        built = _build(table, header)
        assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings

    def test_unparsed_table_is_none(self) -> None:
        assert build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES) is None


def test_table_rows_not_list_is_none() -> None:
    assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None
  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/normalizer/test_document.py -q.

  • Step 3: Реализация — api/src/normalizer/ocr/document.py:

"""Сборка `statementDocument` v1.2 из одной таблицы `data_ocr.table_structure[i]` (spec «Нормализация ответа ИС»,
D-6, D-7): тип по label или по кодам, колонки из раскладки, строки только с кодом, значения с `is_recognized`,
имена строк из справочника редакции."""

from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Literal

from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue
from src.normalizer.ocr.amounts import is_blank, parse_amount
from src.normalizer.ocr.dates import parse_date
from src.normalizer.ocr.header import FileHeader
from src.normalizer.ocr.layout import Cell, cell_text
from src.normalizer.ocr.rows import ParsedTable, RowKind, parse_table_rows, table_type_by_codes
from src.normalizer.ocr.text import clean_value, collapse_whitespace, fix_lookalikes, normalize_header
from src.normalizer.rules import Edition, OcrRules

__all__ = ["BuiltDocument", "build_document"]

FULL_CONFIDENCE = 100.0
CAPPED_CONFIDENCE = 99.0
DocumentType = Literal["BALANCE", "INCOME", "OTHER"]


@dataclass(frozen=True)
class BuiltDocument:
    document: StatementDocument
    warnings: list[str]
    fully_recognized: bool
    edition_id: str | None


@dataclass
class _Build:
    """Накопитель предупреждений и признака «всё распознано» на время сборки одного документа."""

    warnings: list[str]
    fully_recognized: bool = True

    def warn(self, text: str) -> None:
        self.warnings.append(text)
        self.fully_recognized = False


def _label_text(table: Mapping[str, object]) -> str:
    label = table.get("label")
    if isinstance(label, Mapping) and isinstance(label.get("form_name"), str):
        return collapse_whitespace(label["form_name"])
    return ""


def _type_by_label(label: str, rules: OcrRules) -> DocumentType | None:
    normalized = normalize_header(label)
    for needle, kind in rules.labels.items():
        if needle in normalized:
            return kind
    return None


def _resolve_type(label: str, parsed: ParsedTable, rules: OcrRules, build: _Build) -> DocumentType:
    by_label = _type_by_label(label, rules)
    by_codes = table_type_by_codes(parsed)
    if by_label is not None:
        if by_codes is not None and by_codes != by_label:
            build.warn(f"тип по кодам ({by_codes}) не совпал с label ({label})")
        return by_label
    if by_codes is not None:
        build.warn("тип определён по кодам")
        return by_codes
    build.warn("тип не определён: OTHER")
    return "OTHER"


def _columns(parsed: ParsedTable, lookalikes: Mapping[str, str], build: _Build) -> tuple[list[StatementColumn], list[int]]:
    """Колонки значений первой шапки: `label` — текст ячейки шапки, `period_date` — `parse_date`."""
    header = parsed.rows[0]
    columns: list[StatementColumn] = []
    for position, source_idx in enumerate(header.layout.value_idxs):
        cell = header.cells.get(source_idx)
        label = collapse_whitespace(cell.value) if cell is not None else ""
        period = parse_date(fix_lookalikes(label, lookalikes)) if label else None
        if period is None and not parsed.synthetic_header:
            build.warn(f"колонка {position} без даты: {label}")
        columns.append(StatementColumn(column_index=position, label=label, period_date=period))
    if parsed.synthetic_header:
        build.warn("шапка синтезирована: колонки без дат")
    return columns, list(header.layout.value_idxs)


def _latest_period(columns: list[StatementColumn]) -> date | None:
    dates = [column.period_date for column in columns if column.period_date is not None]
    return max(dates) if dates else None


def _choose_edition(
    header: FileHeader, columns: list[StatementColumn], codes: list[str], rules: OcrRules, build: _Build
) -> Edition | None:
    edition = rules.edition_for(header.report_date) or rules.edition_for(_latest_period(columns))
    if edition is None:
        edition = rules.edition_by_codes(codes)
        if edition is None:
            build.warn("редакция не определена")
            return None
        build.warn(f"редакция определена по кодам: {edition.id}")
    if not edition.lines:
        build.warn(f"редакция {edition.id} не заполнена")
    return edition


def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValue:
    if cell is None:
        build.fully_recognized = False
        return StatementValue(column_index=column_index, amount=None, string_value=None, confidence=0, is_recognized=False)
    parsed = parse_amount(cell.value)
    text = collapse_whitespace(cell.value)
    # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана
    string_value = text if parsed.amount is None and text and not is_blank(text) else None
    recognized = cell.confidence == FULL_CONFIDENCE and parsed.verified
    if not recognized:
        build.fully_recognized = False
    return StatementValue(
        column_index=column_index,
        amount=parsed.amount,
        string_value=string_value,
        confidence=cell.confidence,
        is_recognized=recognized,
    )


def _line(row_cells: Mapping[int, Cell], layout_code_idx: int, layout_label_idx: int | None, value_idxs: list[int],
          edition: Edition | None, rules: OcrRules, build: _Build) -> StatementLine:
    code_cell = row_cells[layout_code_idx]
    code = clean_value(code_cell.value)
    code_clean = code == code_cell.value.strip()
    name_cell = row_cells.get(layout_label_idx) if layout_label_idx is not None else None
    known_here = edition is not None and code in edition.lines
    if known_here and edition is not None:
        name = edition.lines[code]
        code_confidence = code_cell.confidence if code_clean else min(code_cell.confidence, CAPPED_CONFIDENCE)
        name_confidence = code_confidence
    else:
        elsewhere = rules.editions_with(code)
        if elsewhere:
            build.warn(f"код {code} из редакции {', '.join(elsewhere)}")
        name = collapse_whitespace(name_cell.value) if name_cell is not None else ""
        code_confidence = code_cell.confidence if code_clean else min(code_cell.confidence, CAPPED_CONFIDENCE)
        name_confidence = name_cell.confidence if name_cell is not None else 0.0
    if code_confidence < FULL_CONFIDENCE or name_confidence < FULL_CONFIDENCE:
        build.fully_recognized = False
    values = [_value(row_cells.get(source_idx), position, build) for position, source_idx in enumerate(value_idxs)]
    return StatementLine(
        line_code=code,
        line_code_confidence=code_confidence,
        line_name=name,
        line_name_confidence=name_confidence,
        values=values,
    )


def _statement_header(label: str, header: FileHeader, build: _Build) -> StatementHeader:
    if header.inn_confidence != FULL_CONFIDENCE or header.organization_confidence != FULL_CONFIDENCE:
        build.fully_recognized = False
    if header.report_date is None or not header.report_date_recognized:
        build.fully_recognized = False
    return StatementHeader(
        form_name=label or None,
        organization_name=header.organization_name,
        organization_name_confidence=header.organization_confidence,
        inn=header.inn,
        inn_confidence=header.inn_confidence,
        report_date_text=header.report_date_text,
        report_date=header.report_date,
        units=header.units,
    )


def build_document(
    table: Mapping[str, object], index: int, file_name: str, header: FileHeader, rules: OcrRules
) -> BuiltDocument | None:
    rows = table.get("rows")
    if not isinstance(rows, list):
        return None
    parsed = parse_table_rows(rows, rules.known_codes())
    if parsed is None or len(parsed.rows) < 2:  # noqa: PLR2004 — шапка и хотя бы одна строка данных
        return None
    build = _Build(warnings=[])
    label = _label_text(table)
    document_type = _resolve_type(label, parsed, rules, build)
    columns, value_idxs = _columns(parsed, rules.lookalikes, build)
    data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER]
    codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows]
    edition = _choose_edition(header, columns, codes, rules, build)
    lines: list[StatementLine] = []
    seen: set[str] = set()
    for row in data_rows:
        code = clean_value(cell_text(row.cells, row.layout.code_idx))
        if code in seen:  # решение №15: DBML уникален по (meta_id, line_code), как §12 п. 5 у Excel-пути
            build.warn(f"код {code} повторяется: оставлена первая строка")
            continue
        seen.add(code)
        lines.append(
            _line(row.cells, row.layout.code_idx, row.layout.label_idx, list(row.layout.value_idxs) or value_idxs,
                  edition, rules, build)
        )
    statement_header = _statement_header(label, header, build)
    document = StatementDocument(
        document_type=document_type,
        locator=f"file:{file_name};table:{index}",
        header=statement_header,
        columns=columns,
        lines=lines,
    )
    return BuiltDocument(document, build.warnings, build.fully_recognized, edition.id if edition else None)

Пояснения: values строки берутся по раскладке её шапки (повторная шапка может иметь другой набор колонок), а columns документа — по первой; если у раскладки строки value_idxs пуст, используются индексы первой шапки. Decimal импорт нужен для типов значений (parsed.amount: Decimal | None); если ruff отметит неиспользуемый импорт — удалить.

  • Step 4: Тесты и линт — cd api && .venv/bin/pytest tests/unit/normalizer/test_document.py -q && cd .. && make lint → PASS. Ожидаемые тонкости: test_lines_and_values — пустое значение - даёт parse_amount("-"): ALLOWED_RE оставляет -, Decimal("-") → InvalidOperation → amount None, verified = ("-" == "-") True → is_recognized True, string_value — прочерк (is_blank) → None. Совпадает с тестом.

  • Step 5: Commit

git add api/src/normalizer/ocr/document.py api/tests/unit/normalizer/test_document.py
git commit -m "feat(normalizer): сборка statementDocument из таблицы OCR — тип, колонки, строки, редакции (D-6, D-7)"

Task 9: OcrNormalizer — статус, предупреждения, подключение

Files:

  • Modify: api/src/normalizer/ocr/__init__.py (добавить класс), api/src/normalizer/__init__.py (экспорт)
  • Modify: api/tests/unit/test_normalizer.py (сценарии на успешных файлах)
  • Test: api/tests/unit/normalizer/test_ocr_normalizer.py

Interfaces:

  • Consumes: _file_error, _FileError, _error_outcome, NormalizeOutcome, сообщения NOT_MERGED_MESSAGE, NO_FILES_MESSAGE из src/normalizer/__init__.py (существующие); parse_file_header (Task 7), build_document (Task 8), OcrRules (Task 1).

  • Produces: class OcrNormalizer: def __init__(self, rules: OcrRules), normalize(raw: object) -> NormalizeOutcome; экспорт OcrNormalizer из src.normalizer; константа NO_TABLES_MESSAGE = "ни одной таблицы не разобрано". NullNormalizer остаётся (тесты воркера Среза 0 и ветка «все файлы ошибочны» разделяют код).

  • Step 1: Падающие тесты — api/tests/unit/normalizer/test_ocr_normalizer.py:

"""`OcrNormalizer` (spec «Статус результата нормализации», «Результат по контракту», D-6)."""

import copy
import json
from pathlib import Path
from typing import Any

import pytest

from src.contract import ErrorCode, ResultStatus
from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import merge_answers
from src.normalizer import NO_TABLES_MESSAGE, OcrNormalizer
from src.normalizer.rules import load_rules
from tests.worker_helpers import SUCCESS_ANSWER

API_DIR = Path(__file__).resolve().parents[3]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
JOB = "3f1c2b7a-0000-4000-8000-000000000001"
RULES = load_rules(API_DIR / "rules")


def _merged(*files: tuple[str, Any]) -> dict[str, Any]:
    return dict(merge_answers(JOB, [AnswerFile(name, json.dumps(body, ensure_ascii=False).encode()) for name, body in files]))


def _real_set(*names: str) -> dict[str, Any]:
    if not REAL_FIXTURES.is_dir():
        pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
    files = []
    for name in names:
        suffix = name.removesuffix(".json").partition("_")[2].partition("_")[2]
        target = f"{JOB}_{suffix}.json" if suffix else f"{JOB}.json"
        files.append(AnswerFile(target, (REAL_FIXTURES / name).read_bytes()))
    return dict(merge_answers(JOB, files))


CLEAN: dict[str, Any] = {
    "file_uid": JOB,
    "error": {"code": "", "message": ""},
    "data_ocr": {
        "label": {"form_name": "Бухгалтерская отчетность", "confidence": 100},
        "organization": {"form_name": "ООО Ромашка", "confidence": 100},
        "inn": {"form_name": "7700000000", "confidence": 100},
        "units": {"form_name": "тыс. руб.", "confidence": 100},
        "date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100},
        "table_structure": [
            {
                "label": {"form_name": "Бухгалтерский баланс", "confidence": 100},
                "rows": [
                    {"row_index": 0, "cells": [{"column_index": 0, "value": "Наименование показателя", "confidence": 100},
                                               {"column_index": 1, "value": "Код", "confidence": 100},
                                               {"column_index": 2, "value": "На 31 декабря 2024 г.", "confidence": 100}]},
                    {"row_index": 1, "cells": [{"column_index": 0, "value": "Основные средства", "confidence": 100},
                                               {"column_index": 1, "value": "1150", "confidence": 100},
                                               {"column_index": 2, "value": "1 234", "confidence": 100}]},
                ],
            }
        ],
    },
}


class TestStatus:
    def test_done_when_everything_recognized(self) -> None:
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN)))
        assert outcome.status is ResultStatus.DONE and outcome.warnings == []
        assert len(outcome.documents) == 1 and outcome.documents[0].document_type == "BALANCE"
        assert outcome.metrics is not None and outcome.metrics.rows == 1

    def test_needs_check_with_table_warning(self) -> None:
        noisy = copy.deepcopy(CLEAN)
        noisy["data_ocr"]["table_structure"][0]["rows"][1]["cells"][2]["confidence"] = 90
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
        assert outcome.status is ResultStatus.NEEDS_CHECK

    def test_no_tables_is_recognition_failed(self) -> None:
        empty = copy.deepcopy(CLEAN)
        empty["data_ocr"]["table_structure"] = []
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", empty)))
        assert outcome.status is ResultStatus.ERROR
        assert outcome.error is not None and outcome.error.code is ErrorCode.RECOGNITION_FAILED
        assert outcome.error.message == NO_TABLES_MESSAGE

    def test_skipped_table_is_warned(self) -> None:
        bad = copy.deepcopy(CLEAN)
        bad["data_ocr"]["table_structure"].append({"label": {"form_name": "x"}, "rows": [{"row_index": 0, "cells": []}]})
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", bad)))
        assert outcome.status is ResultStatus.NEEDS_CHECK
        assert f"{JOB}.json: table:1: нет шапки и столбца кода" in outcome.warnings

    def test_exception_in_one_table_becomes_warning(self, monkeypatch: pytest.MonkeyPatch) -> None:
        import src.normalizer.ocr as ocr_module

        real = ocr_module.build_document

        def boom(table: Any, index: int, *args: Any, **kwargs: Any) -> Any:
            if index == 1:
                raise RuntimeError("сломалось")
            return real(table, index, *args, **kwargs)

        monkeypatch.setattr(ocr_module, "build_document", boom)
        two = copy.deepcopy(CLEAN)
        two["data_ocr"]["table_structure"].append(copy.deepcopy(two["data_ocr"]["table_structure"][0]))
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", two)))
        assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1
        assert f"{JOB}.json: table:1: BACKEND_PARSING_ERROR: RuntimeError: сломалось" in outcome.warnings

    def test_file_level_warnings(self) -> None:
        noisy = copy.deepcopy(CLEAN)
        noisy["data_ocr"]["inn"] = {"form_name": "+", "confidence": 0}
        noisy["data_ocr"]["date"] = {"form_name": "Общество", "confidence": 100}
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy)))
        assert f"{JOB}.json: ИНН не распознан" in outcome.warnings
        assert f"{JOB}.json: дата отчёта не разобрана" in outcome.warnings


class TestErrorFilesStillHandled:
    def test_all_files_failed_keeps_first_code(self) -> None:
        failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", failed)))
        assert outcome.status is ResultStatus.ERROR and outcome.error is not None
        assert outcome.error.code is ErrorCode.INVALID_DOCUMENT and outcome.warnings == [f"{JOB}.json: INVALID_DOCUMENT: нет"]

    def test_mixed_files(self) -> None:
        failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}}
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN), (f"{JOB}_ofr.json", failed)))
        assert outcome.status is ResultStatus.DONE  # ошибочный файл — предупреждение, не needs_check
        assert outcome.warnings == [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет"]

    def test_not_merged_structure(self) -> None:
        outcome = OcrNormalizer(RULES).normalize({"job_id": JOB})
        assert outcome.status is ResultStatus.ERROR and outcome.error is not None and outcome.error.code is ErrorCode.INVALID_FORMAT

    def test_success_answer_helper_from_worker_tests(self) -> None:
        outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", SUCCESS_ANSWER)))
        assert outcome.status is ResultStatus.NEEDS_CHECK  # confidence < 100 у шапки


@pytest.mark.fixtures
class TestRealFixtures:
    def test_aep6(self) -> None:
        outcome = OcrNormalizer(RULES).normalize(_real_set("aep_6.json"))
        assert outcome.status is ResultStatus.NEEDS_CHECK
        types = [d.document_type for d in outcome.documents]
        assert types == ["BALANCE", "INCOME"]
        balance, income = outcome.documents
        assert [(c.column_index, c.label, str(c.period_date)) for c in balance.columns] == [
            (0, "На 30 июня 2025 г", "2025-06-30"), (1, "На 31 декабря 2024 г.", "2024-12-31"), (2, "На 31 декабря 2023 г.", "2023-12-31")]
        codes = [line.line_code for line in balance.lines]
        assert "1105" in codes and "1110" in codes
        assert all(line.line_code for line in balance.lines)
        assert [str(c.period_date) for c in income.columns] == ["2025-06-30", "2024-06-30"]
        revenue = next(line for line in income.lines if line.line_code == "2110")
        assert str(revenue.values[0].amount) == "13070"
        assert balance.header.units == "тыс. руб." and balance.header.inn is None and balance.header.report_date is None

    def test_aep3(self) -> None:
        outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json"))
        assert outcome.status is ResultStatus.NEEDS_CHECK
        balance = next(d for d in outcome.documents if d.document_type == "BALANCE")
        assert all(c.period_date is None for c in balance.columns)
        assert balance.header.inn == "3121200351" and balance.header.inn_confidence is not None and balance.header.inn_confidence < 100
        assert str(balance.header.report_date) == "2024-12-31"
        assert any(line.line_code == "1120" for line in balance.lines)
        assert any("шапка синтезирована" in w for w in outcome.warnings)

    def test_aep3_with_failed_ofr(self) -> None:
        outcome = OcrNormalizer(RULES).normalize(_real_set("aep_3.json", "aep_3_ofr.json"))
        assert outcome.status is ResultStatus.NEEDS_CHECK
        assert any(w.startswith(f"{JOB}_ofr.json: INVALID_DOCUMENT:") for w in outcome.warnings)
  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/normalizer/test_ocr_normalizer.py -q → ImportError: OcrNormalizer.

  • Step 3: Реализация OcrNormalizer

Дополнить api/src/normalizer/ocr/__init__.py:

from collections.abc import Mapping
from typing import Any

from src.contract import ErrorCode, Metrics, ResultStatus, StatementDocument
from src.normalizer.ocr.document import build_document
from src.normalizer.ocr.header import parse_file_header
from src.normalizer.rules import OcrRules

__all__ = ["NO_TABLES_MESSAGE", "OcrNormalizer"]

NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"
NO_CODE_COLUMN_MESSAGE = "нет шапки и столбца кода"


class OcrNormalizer:
    """Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по
    одному на таблицу; статус — `done` только когда всё распознано (D-6)."""

    def __init__(self, rules: OcrRules) -> None:
        self._rules = rules

    def normalize(self, raw: object) -> "NormalizeOutcome":
        from src.normalizer import NO_FILES_MESSAGE, NOT_MERGED_MESSAGE, NormalizeOutcome, _error_outcome, _file_error

        if not isinstance(raw, dict) or not isinstance(raw.get("files"), list):
            return _error_outcome(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE)
        files: list[object] = raw["files"]
        if not files:
            return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE)
        warnings: list[str] = []
        documents: list[StatementDocument] = []
        fully_recognized = True
        succeeded = 0
        first_error = None
        for index, entry in enumerate(files):
            error = _file_error(index, entry)
            if error is not None:
                warnings.append(error.warning)
                first_error = first_error or error
                continue
            succeeded += 1
            assert isinstance(entry, dict)  # noqa: S101 — проверено _file_error
            built_docs, file_warnings, file_recognized = self._normalize_file(entry)
            documents.extend(built_docs)
            warnings.extend(file_warnings)
            fully_recognized = fully_recognized and file_recognized
        if not succeeded and first_error is not None:
            return _error_outcome(first_error.code, first_error.summary, warnings)
        if not documents:
            return _error_outcome(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE, warnings)
        status = ResultStatus.DONE if fully_recognized else ResultStatus.NEEDS_CHECK
        rows = sum(len(document.lines) for document in documents)
        return NormalizeOutcome(status=status, documents=documents, metrics=Metrics(rows=rows), warnings=warnings)

    def _normalize_file(self, entry: dict[str, Any]) -> tuple[list[StatementDocument], list[str], bool]:
        name = str(entry["name"])
        response = entry["response"]
        data_ocr = response.get("data_ocr") if isinstance(response, dict) else None
        if not isinstance(data_ocr, Mapping):
            return [], [f"{name}: data_ocr отсутствует или не объект"], False
        header = parse_file_header(data_ocr, self._rules.lookalikes)
        warnings: list[str] = []
        if header.inn is None or header.inn_confidence != 100:  # noqa: PLR2004
            warnings.append(f"{name}: ИНН не распознан")
        if header.report_date is None:
            warnings.append(f"{name}: дата отчёта не разобрана")
        tables = data_ocr.get("table_structure")
        documents: list[StatementDocument] = []
        recognized = True
        for index, table in enumerate(tables if isinstance(tables, list) else []):
            prefix = f"{name}: table:{index}: "
            try:
                built = build_document(table if isinstance(table, Mapping) else {}, index, name, header, self._rules)
            except Exception as exc:  # noqa: BLE001 — §11.1 п. 9: ошибка одной таблицы не роняет файл
                warnings.append(f"{prefix}BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}")
                recognized = False
                continue
            if built is None:
                warnings.append(prefix + NO_CODE_COLUMN_MESSAGE)
                recognized = False
                continue
            documents.append(built.document)
            warnings.extend(prefix + warning for warning in built.warnings)
            recognized = recognized and built.fully_recognized
        return documents, warnings, recognized and not warnings

NormalizeOutcome импортируется внутри метода, потому что src/normalizer/__init__.py импортирует OcrNormalizer (циклический импорт на уровне модулей). Если mypy требует аннотацию возврата, импортировать NormalizeOutcome под if TYPE_CHECKING: и писать -> NormalizeOutcome строкой.

Проверить метрику: Metrics в src.contract.common имеет поля duration_ms, rows, warnings (все необязательные) — см. grep -n "class Metrics" -A8 api/src/contract/common.py; если rows отсутствует, использовать существующие поля.

  • Step 4: Экспорт и обновление старых тестов

В api/src/normalizer/__init__.py после определения _error_outcome добавить в конец файла:

from src.normalizer.ocr import NO_TABLES_MESSAGE, OcrNormalizer  # noqa: E402 — после определений, которые использует OcrNormalizer

__all__ += ["NO_TABLES_MESSAGE", "OcrNormalizer"]

(__all__ объявлен списком — проверить; иначе переписать как список.) ruff правило E402 подавлено осознанно.

В api/tests/unit/test_normalizer.py тесты, которые ожидают NOT_IMPLEMENTED от NullNormalizer, остаются (класс живёт); добавить в докстринг модуля строку «Срез 3a: настоящая нормализация — tests/unit/normalizer/test_ocr_normalizer.py».

  • Step 5: Тесты и линт — cd api && .venv/bin/pytest tests/unit/normalizer tests/unit/test_normalizer.py -q && cd .. && make lint → PASS. В test_aep6 при расхождении значений не править тест вслепую: сверить с aep_6.json (строка 0 — шапка, колонки 2, 4, 5).

  • Step 6: Commit

git add api/src/normalizer api/tests/unit/normalizer/test_ocr_normalizer.py api/tests/unit/test_normalizer.py
git commit -m "feat(normalizer): OcrNormalizer — документы из успешных файлов, статусы done/needs_check/error (D-6)"

Task 10: Golden на реальных фикстурах (../fixtures/ocr/expected_normalize/)

Files:

  • Create (репозиторий fixtures, worktree .wt/slice-3a/fixtures): ocr/expected_normalize/aep_3.json, ocr/expected_normalize/aep_6.json, ocr/expected_normalize/README.md
  • Create: api/tests/golden_update.py, api/tests/unit/normalizer/test_golden.py
  • Modify: Makefile (цель golden-update)

Interfaces:

  • Consumes: OcrNormalizer, load_rules, merge_answers, AnswerFile.

  • Produces: формат эталона — JSON {"status": "...", "warnings": [...], "metrics": {...}, "error": {...} | null, "documents": [<model_dump(mode="json", exclude_none=True)>]}; функции tests.golden_update.outcome_to_json(outcome: NormalizeOutcome) -> dict, real_set(name: str) -> dict, golden_cases() -> list[str]; константы REAL_FIXTURES, GOLDEN_DIR.

  • Step 1: Worktree и issue репозитория fixtures

Выполнять из каталога workspace (/Users/andrey.epifancev/projects/awp-ca), не из fixtures/ (он на main, хук блокирует запись):

cd /Users/andrey.epifancev/projects/awp-ca
tea issues create --login home --repo RSHB/arm-ka-fixtures --title "Срез 3a: пары PDF+JSON ИС Сканирование и golden ocr/expected_normalize/" \
  --description "Эталоны нормализатора scan-gateway (Срез 3a, arm-scan-gateway#6): ocr/expected_normalize/<name>.json для каждого <name>.json из ocr/. Пары PDF+JSON для эмулятора по sha256 дополняет владелец."
# записать номер issue как <F>
rm /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/fixtures   # симлинк на основной checkout
FIX=/Users/andrey.epifancev/projects/awp-ca/fixtures
git -C "$FIX" worktree add /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/fixtures -b feat/<F>-ocr-golden main

Ожидание: ls .wt/slice-3a/fixtures/ocr показывает aep_*.json/.pdf; тесты scan-gateway видят ../fixtures как раньше.

  • Step 2: Падающий golden-тест — api/tests/unit/normalizer/test_golden.py:
"""Golden нормализатора на реальных ответах ИС (D-8): `../fixtures/ocr/<name>.json` → `../fixtures/ocr/expected_normalize/<name>.json`.
Обновление эталонов — `make golden-update` (не автоматически)."""

import json
from pathlib import Path

import pytest

from src.normalizer import OcrNormalizer
from src.normalizer.rules import load_rules
from tests.golden_update import GOLDEN_DIR, REAL_FIXTURES, golden_cases, outcome_to_json, real_set

API_DIR = Path(__file__).resolve().parents[3]
RULES = load_rules(API_DIR / "rules")
pytestmark = pytest.mark.fixtures


def _cases() -> list[str]:
    return golden_cases() if REAL_FIXTURES.is_dir() else []


@pytest.mark.parametrize("name", _cases() or ["<нет fixtures>"])
def test_matches_golden(name: str) -> None:
    if not REAL_FIXTURES.is_dir():
        pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
    expected_path = GOLDEN_DIR / f"{name}.json"
    assert expected_path.is_file(), f"нет эталона {expected_path}: выполните make golden-update и проверьте diff"
    expected = json.loads(expected_path.read_text(encoding="utf-8"))
    actual = outcome_to_json(OcrNormalizer(RULES).normalize(real_set(name)))
    assert actual == expected, f"эталон {name} разошёлся: make golden-update и git diff в fixtures"

api/tests/golden_update.py:

"""Golden нормализатора: сбор склейки реального ответа и запись эталонов (`make golden-update`).

Набор задания `<name>` — `<name>.json` + `<name>_*.json` из `../fixtures/ocr` (как у эмулятора); эталон —
`../fixtures/ocr/expected_normalize/<name>.json`. Запуск: `cd api && .venv/bin/python -m tests.golden_update`.
"""

import json
import sys
from pathlib import Path
from typing import Any

from src.gateway.hotfolder import AnswerFile
from src.gateway.merge import merge_answers
from src.normalizer import NormalizeOutcome, OcrNormalizer
from src.normalizer.rules import load_rules

API_DIR = Path(__file__).resolve().parents[1]
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
GOLDEN_DIR = REAL_FIXTURES / "expected_normalize"
JOB = "3f1c2b7a-0000-4000-8000-000000000001"


def golden_cases() -> list[str]:
    """Имена `<name>` без суффикса `_*`: `aep_3`, `aep_6`, … (по одному на набор)."""
    names = sorted(p.name[: -len(".json")] for p in REAL_FIXTURES.glob("*.json") if p.is_file())
    stems = {name for name in names if not any(name.startswith(f"{other}_") for other in names if other != name)}
    return sorted(stems)


def real_set(name: str) -> dict[str, Any]:
    files = []
    for path in sorted(REAL_FIXTURES.glob(f"{name}*.json")):
        base = path.name[: -len(".json")]
        if base == name:
            target = f"{JOB}.json"
        elif base.startswith(f"{name}_"):
            target = f"{JOB}_{base[len(name) + 1 :]}.json"
        else:
            continue
        files.append(AnswerFile(target, path.read_bytes()))
    return dict(merge_answers(JOB, files))


def outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]:
    return {
        "status": outcome.status.value,
        "warnings": list(outcome.warnings),
        "metrics": outcome.metrics.model_dump(mode="json", exclude_none=True) if outcome.metrics else None,
        "error": outcome.error.model_dump(mode="json", exclude_none=True) if outcome.error else None,
        "documents": [doc.model_dump(mode="json", exclude_none=True) for doc in outcome.documents],
    }


def main() -> int:
    if not REAL_FIXTURES.is_dir():
        print(f"нет каталога {REAL_FIXTURES}", file=sys.stderr)
        return 2
    rules = load_rules(API_DIR / "rules")
    GOLDEN_DIR.mkdir(exist_ok=True)
    for name in golden_cases():
        payload = outcome_to_json(OcrNormalizer(rules).normalize(real_set(name)))
        (GOLDEN_DIR / f"{name}.json").write_text(
            json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8"
        )
        print(f"{name}: {payload['status']}, документов {len(payload['documents'])}, предупреждений {len(payload['warnings'])}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

В Makefile после цели emulator добавить (и в help, и в .PHONY):

# Эталоны нормализатора на реальных ответах ИС: пишет ../fixtures/ocr/expected_normalize/<name>.json; diff смотреть в fixtures
golden-update:
	cd api && $(BIN)/python -m tests.golden_update
  • Step 3: Убедиться, что тест падает — cd api && .venv/bin/pytest tests/unit/normalizer/test_golden.py -q → AssertionError: нет эталона ….

  • Step 4: Сгенерировать эталоны и проверить их глазами

make golden-update
python3 -c "
import json; d=json.load(open('/Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/fixtures/ocr/expected_normalize/aep_6.json')); print(d['status'], [x['document_type'] for x in d['documents']], d['warnings'][:5])"

Ожидание: aep_3: needs_check, BALANCE (+ INCOME, если ОФР aep_3.json таблица 1 разобралась), предупреждения про синтетическую шапку; aep_5, aep_8: error INVALID_DOCUMENT (ответы-ошибки ИС); aep_6: needs_check, ["BALANCE", "INCOME"]. Сверить aep_6 с ожиданиями сценария спеки «Чистый скан с шапкой»: колонки баланса 2025-06-30, 2024-12-31, 2023-12-31; строка 2110 ОФР amount = "13070". Расхождение — дефект конвейера, чинить в Task 6–9, не в эталоне.

  • Step 5: README эталонов и коммит в fixtures

.wt/slice-3a/fixtures/ocr/expected_normalize/README.md:

# Эталоны нормализатора scan-gateway (Срез 3a)

`<name>.json` — результат `OcrNormalizer` для набора `ocr/<name>.json` + `ocr/<name>_*.json` в формате
`{status, warnings, metrics, error, documents}` (документы — `statementDocument` контракта v1.2, `exclude_none`).
Обновление: `make golden-update` в scan-gateway; изменения смотреть через `git diff` и объяснять в PR.
Эталоны фиксируют поведение на реальных сканах, включая известные ограничения (решение №12 спеки: `(7744536}` → без минуса).

Коммит и push — из каталога worktree fixtures (cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/fixtures), затем git add ocr/expected_normalize, коммит с сообщением feat(ocr): эталоны нормализатора scan-gateway expected_normalize/ (Срез 3a, #<F>) и git push -u origin feat/<F>-ocr-golden. PR в fixtures — в Task 17.

  • Step 6: Тесты и линт scan-gateway — cd api && .venv/bin/pytest tests/unit/normalizer/test_golden.py -q && cd .. && make lint → PASS.

  • Step 7: Commit (scan-gateway) — из worktree scan-gateway: git add api/tests/golden_update.py api/tests/unit/normalizer/test_golden.py Makefile, сообщение test(normalizer): golden на реальных ответах ИС, make golden-update (D-8).


Task 11: Горячая папка local: <job_id>.<ext>, rename без замены, cleanup

Files:

  • Modify: api/src/gateway/hotfolder.py (inbox_name, HotFolder, LocalHotFolder.put, новый cleanup, конструктор)
  • Modify: api/src/gateway/__init__.py (cleanup=settings.SCAN_CLEANUP)
  • Modify: api/tests/unit/test_local_hotfolder.py, api/tests/unit/test_hotfolder_factory.py, api/tests/worker_helpers.py (SpyHotFolder.cleanup)

Interfaces:

  • Produces:

    • inbox_name(job_id: str, original_name: str) -> str — <job_id>.<ext>, ext — суффикс после последней точки последнего компонента original_name, в нижнем регистре, 1–16 символов [0-9a-z]; иначе ValueError("у файла … нет допустимого расширения"). MAX_PART_NAME_BYTES, _fit_name, _MAX_EXTENSION_CHARS, _utf8_len удаляются (имя всегда короткое).
    • HotFolder.put(name, data) — семантика «без замены»: назначение существует → ничего не пишется, .part остаётся (удаляется только при cleanup=True).
    • HotFolder.cleanup(job_id: str, name: str) -> bool — при cleanup=False возвращает False, ничего не трогая; иначе удаляет <output>/<job_id>/ целиком, <input>/<name>, <input>/<name>.done, <tmp>/.<name>.part; возвращает True. Отсутствующие файлы — не ошибка; ошибки удаления поднимаются (воркер логирует).
    • LocalHotFolder(root, input_dir, output_dir, tmp_dir, *, settle_seconds, clock, cleanup: bool = False); атрибут cleanup_enabled.
  • Step 1: Падающие тесты — в api/tests/unit/test_local_hotfolder.py заменить класс TestInboxName и добавить тесты put/cleanup:

class TestInboxName:
    def test_job_id_and_lowercase_extension(self) -> None:
        job_id = _job_id()
        assert inbox_name(job_id, "aep_5.PDF") == f"{job_id}.pdf"
        assert inbox_name(job_id, "dir/sub/Отчёт за 2024.Jpeg") == f"{job_id}.jpeg"
        assert inbox_name(job_id, "C:\\scans\\report.pdf") == f"{job_id}.pdf"

    @pytest.mark.parametrize("original", ["scan", "", ".", "..", "report.", "a/..", "x.a:b", "x." + "p" * 17, "name.тар"])
    def test_rejects_names_without_safe_extension(self, original: str) -> None:
        with pytest.raises(ValueError, match="расширени"):
            inbox_name("job", original)


class TestPutWithoutReplace:
    def test_existing_destination_is_kept(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path)
        hf.put("job.pdf", b"first")
        hf.put("job.pdf", b"second")
        assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
        assert (hf.tmp_dir / ".job.pdf.part").read_bytes() == b"second"  # .part остаётся: удаление только под флагом

    def test_existing_destination_part_removed_when_cleanup_enabled(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path, cleanup=True)
        hf.put("job.pdf", b"first")
        hf.put("job.pdf", b"second")
        assert (hf.input_dir / "job.pdf").read_bytes() == b"first"
        assert not (hf.tmp_dir / ".job.pdf.part").exists()

    def test_no_temp_files_in_input(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path)
        hf.put("job.pdf", b"x")
        assert [p.name for p in hf.input_dir.iterdir()] == ["job.pdf"]
        assert not (hf.tmp_dir / ".job.pdf.part").exists()  # после успешной публикации .part нет


class TestCleanup:
    def _fill(self, hf: LocalHotFolder, job_id: str) -> None:
        hf.put(f"{job_id}.pdf", b"x")
        (hf.input_dir / f"{job_id}.pdf.done").write_bytes(b"")
        (hf.tmp_dir / f".{job_id}.pdf.part").write_bytes(b"orphan")
        _write_answer(hf, job_id, b"{}")
        _write_answer(hf, job_id, b"{}", name=f"{job_id}_ofr.json")
        (hf.answer_dir(job_id) / "readme.txt").write_bytes(b"keep")

    def test_disabled_touches_nothing(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path)
        job_id = _job_id()
        self._fill(hf, job_id)
        assert hf.cleanup(job_id, f"{job_id}.pdf") is False
        assert (hf.input_dir / f"{job_id}.pdf").exists() and hf.answer_dir(job_id).is_dir()
        assert (hf.tmp_dir / f".{job_id}.pdf.part").exists()

    def test_enabled_removes_job_files(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path, cleanup=True)
        job_id = _job_id()
        self._fill(hf, job_id)
        assert hf.cleanup(job_id, f"{job_id}.pdf") is True
        assert not (hf.input_dir / f"{job_id}.pdf").exists()
        assert not (hf.input_dir / f"{job_id}.pdf.done").exists()
        assert not (hf.tmp_dir / f".{job_id}.pdf.part").exists()
        assert not hf.answer_dir(job_id).exists()  # каталог удалён вместе с readme.txt
        assert hf.cleanup(job_id, f"{job_id}.pdf") is True  # повтор — не ошибка

    def test_missing_files_are_fine(self, tmp_path: Path) -> None:
        hf = LocalHotFolder(tmp_path, cleanup=True)
        assert hf.cleanup(_job_id(), "nothing.pdf") is True

    def test_permission_error_propagates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
        hf = LocalHotFolder(tmp_path, cleanup=True)
        job_id = _job_id()
        self._fill(hf, job_id)

        def deny(path: Any, *args: Any, **kwargs: Any) -> None:
            raise PermissionError(f"нет прав: {path}")

        monkeypatch.setattr(os, "unlink", deny)
        with pytest.raises(PermissionError):
            hf.cleanup(job_id, f"{job_id}.pdf")

Все прочие использования inbox_name(job, "x.pdf") в этом файле заменить на inbox_name с расширением или литерал f"{job}.pdf"; тест протокола (isinstance(hf, HotFolder), если есть) дополнить проверкой наличия cleanup.

  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/test_local_hotfolder.py -q → TypeError: unexpected keyword 'cleanup', AttributeError: cleanup.

  • Step 3: Реализация

В api/src/gateway/hotfolder.py (добавить import errno, import re, import shutil):

_EXTENSION_RE = re.compile(r"[0-9a-z]{1,16}")


def inbox_name(job_id: str, original_name: str) -> str:
    """Имя входящего файла `<job_id>.<ext>` (раскладка FileWorker, D-1): расширение — суффикс после последней точки
    последнего компонента `original_name`, в нижнем регистре, 1–16 букв/цифр ASCII; ИС различает форматы по нему.
    Нет расширения или оно недопустимо — `ValueError`."""
    validate_job_id(job_id)
    base = _split_basename(original_name)
    dot = base.rfind(".")
    extension = base[dot + 1 :].lower() if dot >= 0 else ""
    if not extension or _EXTENSION_RE.fullmatch(extension) is None:
        raise ValueError(f"у файла {original_name!r} нет допустимого расширения")
    return f"{job_id}.{extension}"

Удалить MAX_PART_NAME_BYTES, _MAX_EXTENSION_CHARS, _utf8_len, _fit_name и их упоминания в docstring'ах. В протокол HotFolder добавить:

    def cleanup(self, job_id: str, name: str) -> bool:
        """Удалить следы задания (ответ `<output>/<job_id>/`, входящий `<input>/<name>` и его `.done`, `.part` в `Tmp`),
        если очистка включена (`SCAN_CLEANUP`); иначе ничего не трогать и вернуть `False`. Ошибки удаления поднимаются."""
        ...

LocalHotFolder: конструктор получает cleanup: bool = False → self.cleanup_enabled = cleanup; put:

    def put(self, name: str, data: bytes) -> None:
        """Атомарная запись без замены: `.<name>.part` в `tmp_dir`, затем жёсткая ссылка в `input_dir/<name>` и
        удаление `.part`. Назначение уже есть — файл считается доставленным (повтор задания), `.part` остаётся, если
        очистка выключена. Корень не создаётся (нет корня — `FileNotFoundError`)."""
        validate_name(name)
        self.tmp_dir.mkdir(exist_ok=True)
        self.input_dir.mkdir(exist_ok=True)
        part = self.tmp_dir / f".{name}.part"
        target = self.input_dir / name
        try:
            part.write_bytes(data)
            os.link(part, target)  # без замены: существующее назначение даёт FileExistsError
        except FileExistsError:
            self._drop_part(part)
            return
        except OSError as err:
            # ФС без жёстких ссылок (некоторые тома Docker/сетевые монтирования): EPERM/EXDEV/ENOTSUP — запасной путь
            # через rename с проверкой назначения (окно гонки крошечное, повтор задания с тем же job_id редок)
            if err.errno in (errno.EPERM, errno.EXDEV, errno.ENOTSUP) and not target.exists():
                os.replace(part, target)
                return
            self._drop_part(part)
            raise
        except BaseException:
            self._drop_part(part)
            raise
        part.unlink(missing_ok=True)  # завершение успешной записи, а не «очистка следов»: флаг не действует

    def _drop_part(self, part: Path) -> None:
        if self.cleanup_enabled:
            with contextlib.suppress(OSError):
                part.unlink(missing_ok=True)

    def cleanup(self, job_id: str, name: str) -> bool:
        if not self.cleanup_enabled:
            return False
        folder = self.answer_dir(job_id)
        if folder.is_dir():
            shutil.rmtree(folder)
        for path in (self.input_dir / name, self.input_dir / f"{name}.done", self.tmp_dir / f".{name}.part"):
            try:
                os.unlink(path)
            except FileNotFoundError:
                continue
        return True

В api/src/gateway/__init__.py обоим конструкторам передать cleanup=settings.SCAN_CLEANUP (в SmbHotFolder параметр появится в Task 12 — здесь добавить его заглушкой cleanup: bool = False → self.cleanup_enabled = cleanup, без логики). В api/tests/worker_helpers.py SpyHotFolder добавить:

    def cleanup(self, job_id: str, name: str) -> bool:
        self.events.append(("cleanup", name))
        return self.inner.cleanup(job_id, name)

В tests/unit/test_hotfolder_factory.py добавить проверку create_hotfolder(settings.model_copy(update={"SCAN_CLEANUP": True})).cleanup_enabled is True (имя фикстуры настроек — как в модуле).

  • Step 4: Тесты и линт — cd api && .venv/bin/pytest tests/unit/test_local_hotfolder.py tests/unit/test_hotfolder_factory.py -q && cd .. && make lint. Тесты test_worker.py, test_emulator.py, test_worker_concurrency.py пока могут падать из-за старых имён <job>_<name> — их чинят Task 13–14; зафиксировать в отчёте.

  • Step 5: Commit — git add api/src/gateway api/tests/unit/test_local_hotfolder.py api/tests/unit/test_hotfolder_factory.py api/tests/worker_helpers.py, сообщение feat(hotfolder): имя <job_id>.<ext>, запись без замены, cleanup под SCAN_CLEANUP (D-1, D-2).


Task 12: Горячая папка smb: то же поведение

Files:

  • Modify: api/src/gateway/smb.py
  • Modify: api/tests/unit/test_smb_hotfolder.py (FakeSmbClient: rename, remove, rmdir)

Interfaces:

  • Produces: SmbHotFolder(..., cleanup: bool = False), cleanup_enabled, put без замены через smbclient.rename (FileExistsError/STATUS_OBJECT_NAME_COLLISION — назначение есть), cleanup(job_id, name) -> bool через smbclient.remove/smbclient.rmdir.

  • Step 1: Падающие тесты — в api/tests/unit/test_smb_hotfolder.py расширить FakeSmbClient методами (если remove/rmdir уже есть — оставить, добавить только rename):

    def rename(self, src: str, dst: str, **kwargs: Any) -> None:
        self._record("rename", (src, dst), kwargs)
        target = self._local(dst)
        if target.exists():
            raise FileExistsError(dst)
        os.rename(self._local(src), target)

    def remove(self, path: str, **kwargs: Any) -> None:
        self._record("remove", (path,), kwargs)
        try:
            os.unlink(self._local(path))
        except FileNotFoundError:
            raise SMBOSError(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, path) from None

    def rmdir(self, path: str, **kwargs: Any) -> None:
        self._record("rmdir", (path,), kwargs)
        os.rmdir(self._local(path))

Тесты (имена фикстур hf, fake, clock — как в существующем модуле; проверить grep -n "def hf\|def fake\|def clock" tests/unit/test_smb_hotfolder.py и подставить реальные):

class TestPutWithoutReplace:
    def test_uses_rename_not_replace(self, hf: SmbHotFolder, fake: FakeSmbClient) -> None:
        hf.put("job.pdf", b"x")
        names = [call[0] for call in fake.calls]
        assert "rename" in names and "replace" not in names
        assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"x"
        assert not fake._local(TMP + "\\.job.pdf.part").exists()

    def test_existing_destination_kept_and_part_left(self, hf: SmbHotFolder, fake: FakeSmbClient) -> None:
        hf.put("job.pdf", b"first")
        hf.put("job.pdf", b"second")
        assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"first"
        assert fake._local(TMP + "\\.job.pdf.part").read_bytes() == b"second"


class TestCleanup:
    def test_disabled_is_noop(self, hf: SmbHotFolder, fake: FakeSmbClient) -> None:
        hf.put("job.pdf", b"x")
        before = len(fake.calls)
        assert hf.cleanup("job", "job.pdf") is False
        assert len(fake.calls) == before

    def test_enabled_removes_everything(self, fake: FakeSmbClient, clock: FakeClock) -> None:
        hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
        job_id = str(uuid.uuid4())
        hf.put(f"{job_id}.pdf", b"x")
        answer = fake._local(OUTPUT + f"\\{job_id}")
        answer.mkdir(parents=True)
        (answer / f"{job_id}.json").write_bytes(b"{}")
        (answer / "readme.txt").write_bytes(b"keep")
        assert hf.cleanup(job_id, f"{job_id}.pdf") is True
        assert not answer.exists() and not fake._local(INPUT + f"\\{job_id}.pdf").exists()
        assert [c[0] for c in fake.calls].count("rmdir") == 1

    def test_missing_is_fine(self, fake: FakeSmbClient, clock: FakeClock) -> None:
        hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
        assert hf.cleanup(str(uuid.uuid4()), "nothing.pdf") is True

    def test_access_denied_propagates(self, fake: FakeSmbClient, clock: FakeClock) -> None:
        hf = SmbHotFolder(HOST, "share\\scan", settle_seconds=0, clock=clock, cleanup=True)
        hf.put("job.pdf", b"x")
        fake.fail["remove"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT + "\\job.pdf")
        with pytest.raises(SMBOSError):
            hf.cleanup("job", "job.pdf")

Упоминания inbox_name(job, "x.pdf") в модуле заменить на f"{job}.pdf".

  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/test_smb_hotfolder.py -q.

  • Step 3: Реализация в api/src/gateway/smb.py (конструктор: cleanup: bool = False → self.cleanup_enabled; NtStatus из smbprotocol.exceptions):

    def put(self, name: str, data: bytes) -> None:
        """`.<name>.part` в служебный каталог, затем `smbclient.rename` в `<input>\\<name>` без замены: назначение есть —
        файл считается доставленным, `.part` остаётся (удаляется только при включённой очистке)."""
        validate_name(name)
        self._ensure_dir(self.tmp_dir)
        self._ensure_dir(self.input_dir)
        part = ntpath.join(self.tmp_dir, f".{name}.part")
        try:
            with smbclient.open_file(part, mode="wb", **self._credentials) as fd:
                fd.write(data)
            smbclient.rename(part, ntpath.join(self.input_dir, name), **self._credentials)
        except OSError as err:
            self._drop_part(part)
            if _has_errno(err, errno.EEXIST, FileExistsError) or getattr(err, "ntstatus", None) == NtStatus.STATUS_OBJECT_NAME_COLLISION:
                return
            raise
        except BaseException:
            self._drop_part(part)
            raise

    def _drop_part(self, path: str) -> None:
        if self.cleanup_enabled:
            with contextlib.suppress(Exception):
                smbclient.remove(path, **self._credentials)

    def cleanup(self, job_id: str, name: str) -> bool:
        if not self.cleanup_enabled:
            return False
        folder = self.answer_dir(job_id)
        try:
            entries = list(smbclient.scandir(folder, **self._credentials))
        except OSError as err:
            if not _has_errno(err, errno.ENOENT, FileNotFoundError) and not _has_errno(err, errno.ENOTDIR, NotADirectoryError):
                raise
        else:
            for entry in entries:
                if entry.is_file():
                    smbclient.remove(ntpath.join(folder, entry.name), **self._credentials)
            smbclient.rmdir(folder, **self._credentials)
        for path in (
            ntpath.join(self.input_dir, name),
            ntpath.join(self.input_dir, f"{name}.done"),
            ntpath.join(self.tmp_dir, f".{name}.part"),
        ):
            try:
                smbclient.remove(path, **self._credentials)
            except OSError as err:
                if not _has_errno(err, errno.ENOENT, FileNotFoundError):
                    raise
        return True

_has_errno — существующий помощник модуля (проверить сигнатуру: (err, errno_value, exc_type)); у SMBOSError STATUS_OBJECT_NAME_NOT_FOUND отображается на ENOENT — убедиться тестом test_missing_is_fine.

  • Step 4: Тесты и линт — cd api && .venv/bin/pytest tests/unit/test_smb_hotfolder.py tests/unit/test_local_hotfolder.py -q && cd .. && make lint → PASS.

  • Step 5: Commit — git add api/src/gateway/smb.py api/tests/unit/test_smb_hotfolder.py, сообщение feat(hotfolder): SMB — rename без замены и cleanup под флагом (D-1, D-2).


Task 13: Воркер и приложение: очистка под флагом, расширение, OcrNormalizer в lifespan

Files:

  • Modify: api/src/core/worker.py (_JobContext, _send, _process, _accept_answer, новый _cleanup)
  • Modify: api/src/app.py (OcrNormalizer(load_rules(...)))
  • Modify: api/tests/unit/conftest.py (cleanup=settings.SCAN_CLEANUP, ok_doc.pdf), api/tests/unit/test_worker.py, api/tests/unit/test_worker_concurrency.py, api/tests/unit/test_app.py

Interfaces:

  • Consumes: HotFolder.cleanup(job_id, name) -> bool (Task 11), inbox_name (Task 11), OcrNormalizer, load_rules (Task 1, 9).

  • Produces: _JobContext.inbox_name: str | None; Worker._cleanup(job_id: str, name: str | None) -> None (лог warning при ошибке, исключений наружу нет); create_app падает при битых справочниках (ValueError из load_rules).

  • Step 1: Падающие тесты

api/tests/unit/conftest.py, в build: после записи ok_doc.json добавить (fixtures / "ok_doc.pdf").write_bytes(PDF_BYTES) (импорт PDF_BYTES из tests.worker_helpers); LocalHotFolder(..., settle_seconds=settings.SCAN_SETTLE_SECONDS, cleanup=settings.SCAN_CLEANUP).

В api/tests/unit/test_worker.py все ожидания имени входящего файла f"{job}_ok_doc.pdf" / f"{job}_{name}" заменить на f"{job}.pdf" (grep -n "_ok_doc\|inbox_name" tests/unit/test_worker.py tests/unit/test_worker_concurrency.py). Добавить класс:

class TestSlice3a:
    async def test_no_extension_is_recognition_failed(self, make_env: EnvFactory) -> None:
        env = await make_env()
        body = env.request(original_name="scan")
        result = await env.run(body)
        assert result["status"] == "error" and result["error"]["code"] == "RECOGNITION_FAILED"
        assert "расширени" in result["error"]["message"]
        assert env.inbox() == []

    async def test_cleanup_after_answer_when_enabled(self, make_env: EnvFactory) -> None:
        env = await make_env(SCAN_CLEANUP=True)
        body = env.request()
        async with emulator(env):
            result = await env.run(body)
        assert result["status"] == "error" and result["error"]["code"] == "NOT_IMPLEMENTED"  # NullNormalizer по умолчанию
        assert env.inbox() == []  # и <job>.pdf, и <job>.pdf.done удалены
        assert not env.local.answer_dir(body["job_id"]).exists()
        assert ("cleanup", f"{body['job_id']}.pdf") in env.events

    async def test_no_cleanup_when_disabled(self, make_env: EnvFactory) -> None:
        env = await make_env(SCAN_CLEANUP=False)
        body = env.request()
        async with emulator(env):
            await env.run(body)
        assert env.inbox() == [f"{body['job_id']}.pdf.done"]
        assert env.local.answer_dir(body["job_id"]).is_dir()

    async def test_no_cleanup_on_timeout(self, make_env: EnvFactory) -> None:
        env = await make_env(SCAN_CLEANUP=True)
        body = env.request(deadline_s=0.2)
        result = await env.run(body)
        assert result["error"]["code"] == "TIMEOUT"
        assert env.inbox() == [f"{body['job_id']}.pdf"]
        assert ("cleanup", f"{body['job_id']}.pdf") not in env.events

    async def test_cleanup_error_does_not_change_result(self, make_env: EnvFactory, monkeypatch: pytest.MonkeyPatch) -> None:
        env = await make_env(SCAN_CLEANUP=True)

        def deny(job_id: str, name: str) -> bool:
            raise PermissionError("нет прав")

        monkeypatch.setattr(env.hotfolder.inner, "cleanup", deny)
        body = env.request()
        async with emulator(env):
            result = await env.run(body)
        assert result["status"] == "error" and result["error"]["code"] == "NOT_IMPLEMENTED"
        assert "metrics" not in result or "warnings" not in result.get("metrics", {})

    async def test_existing_input_waits_for_answer(self, make_env: EnvFactory) -> None:
        """Review Focus 4: входящий файл уже лежит (повтор задания), ответа нет — TIMEOUT, файл не перезаписан."""
        env = await make_env()
        body = env.request(deadline_s=0.3)
        env.local.put(f"{body['job_id']}.pdf", b"already there")
        result = await env.run(body)
        assert result["error"]["code"] == "TIMEOUT"
        assert (env.local.input_dir / f"{body['job_id']}.pdf").read_bytes() == b"already there"
        assert env.inbox() == [f"{body['job_id']}.pdf"]

    async def test_ocr_normalizer_produces_documents(self, make_env: EnvFactory) -> None:
        from src.normalizer import OcrNormalizer
        from src.normalizer.rules import load_rules

        env = await make_env(normalizer=OcrNormalizer(load_rules(API_DIR / "rules")))
        body = env.request()
        async with emulator(env):
            result = await env.run(body)
        assert result["status"] == "needs_check"  # SUCCESS_ANSWER: confidence < 100 в шапке
        key = result["result_s3"]["key"]
        stored = json.loads(env.storage.objects[(BUCKET, key)])
        assert stored["documents"][0]["document_type"] == "BALANCE"
        assert stored["documents"][0]["lines"][0]["line_code"] == "1600"
        assert result["raw_s3_key"] == f"raw/{body['job_id']}.json"

(API_DIR, BUCKET, emulator, EnvFactory, json, pytest — импорты, уже используемые в модуле; API_DIR = Path(__file__).resolve().parents[2] при отсутствии.) CONTRACT_VERSION и проверка schema_version == "1.0" в tests/worker_helpers.py не меняются (решение №14).

В api/tests/unit/test_app.py добавить:

def test_create_app_fails_on_broken_rules(tmp_path: Path) -> None:
    (tmp_path / "statement_lines.yaml").write_text("editions: []\n", encoding="utf-8")
    settings = get_settings().model_copy(update={"SCAN_RULES_DIR": str(tmp_path), "SCAN_HOTFOLDER_PATH": str(tmp_path)})
    with pytest.raises(ValueError, match="statement_lines.yaml"):
        create_app(settings)


def test_create_app_uses_ocr_normalizer() -> None:
    from src.normalizer import OcrNormalizer

    app = create_app(get_settings().model_copy(update={"SCAN_HOTFOLDER_PATH": "."}))
    assert isinstance(app.state.normalizer, OcrNormalizer)
  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/test_worker.py tests/unit/test_app.py -q.

  • Step 3: Реализация воркера

_JobContext:

@dataclass
class _JobContext:
    """Состояние задания для конверта при непредвиденной ошибке и для очистки: сырой ответ уже в S3, имя входящего файла."""

    raw_s3_key: str | None = None
    inbox_name: str | None = None

В _process после name, started = sent добавить context.inbox_name = name. В _send сообщение ветки ValueError:

        except ValueError as exc:
            return self._error(
                request,
                ErrorCode.RECOGNITION_FAILED,
                f"{exc}: файл не передан в ИС Сканирование (имя {_fragment(request.original_name)})",
            )

В _accept_answer сразу после context.raw_s3_key = key (сырой ответ сохранён — набор прочитан, D-2):

        await self._cleanup(job_id, context.inbox_name)

и метод:

    async def _cleanup(self, job_id: str, name: str | None) -> None:
        """Очистка горячей папки после прочтения набора (D-2): только при `SCAN_CLEANUP`; ошибка — одна строка лога,
        исход задания не меняется."""
        if name is None:
            return
        try:
            removed = await asyncio.to_thread(self._hotfolder.cleanup, job_id, name)
        except Exception as exc:  # noqa: BLE001 — любая ошибка удаления не влияет на конверт
            logger.warning(
                "Очистка горячей папки не удалась, задание продолжается. job_id={job_id}, error={error}",
                job_id=job_id,
                error=_describe(exc),
            )
            return
        if removed:
            logger.info("Горячая папка очищена. job_id={job_id}, name={name}", job_id=job_id, name=name)

Docstring модуля: пункт про очистку и ссылку на D-2.

  • Step 4: Реализация приложения

api/src/app.py: импорт from src.normalizer import OcrNormalizer вместо NullNormalizer, from src.normalizer.rules import load_rules; в create_app после hotfolder = create_hotfolder(settings):

    normalizer = OcrNormalizer(load_rules(settings.SCAN_RULES_DIR))  # битый справочник — сервис не стартует (D-7)

в lifespan Worker(..., normalizer=normalizer); app.state.normalizer = normalizer рядом с app.state.health = health. Docstring app.py: Worker с OcrNormalizer, справочники из SCAN_RULES_DIR.

  • Step 5: Тесты и линт — cd api && .venv/bin/pytest tests/unit -q -k "not emulator" && cd .. && make lint → PASS (test_emulator.py чинит Task 14).

  • Step 6: Commit — git add api/src/core/worker.py api/src/app.py api/tests/unit/conftest.py api/tests/unit/test_worker.py api/tests/unit/test_worker_concurrency.py api/tests/unit/test_app.py, сообщение feat(worker): очистка горячей папки под SCAN_CLEANUP, RECOGNITION_FAILED без расширения, OcrNormalizer в lifespan (D-2, D-4).


Task 14: Эмулятор: пара по sha256, набор по умолчанию, <uuid>.<ext>

Files:

  • Modify: api/src/scan_emulator/__init__.py
  • Modify: api/tests/unit/test_emulator.py
  • Modify: api/.env.example (SCAN_EMULATOR_DEFAULT)

Interfaces:

  • Produces:

    • EmulatorSettings.default: str = "" (SCAN_EMULATOR_DEFAULT).
    • @dataclass FixtureIndex(names: tuple[str, ...], by_hash: dict[str, str]); build_index(fixtures: Path) -> FixtureIndex (sha256 каждого не-*.json файла → основа имени); FixtureIndex.refresh(fixtures) -> FixtureIndex (пересчёт, если список файлов каталога изменился); resolve_stem(data: bytes, index: FixtureIndex, default: str) -> tuple[str | None, str] (основа имени или None, и источник "hash"/"default"/"none").
    • process_once(root, fixtures, delay, *, input_dir, output_dir, tmp_dir, default="", index=None) -> list[str]; шаблон входящего ^(?P<uuid>UUID)\.(?P<ext>[0-9A-Za-z]{1,16})$; .part, .tmp, .done, dot-файлы игнорируются.
    • Ответ-ошибка при промахе: error.message = f"{sha256[:12]}: нет пары по содержимому в фикстурах".
  • Step 1: Падающие тесты — в api/tests/unit/test_emulator.py: _drop(hf, name, data) теперь кладёт f"{job_id}{Path(name).suffix.lower()}", а соответствие фикстуре задаётся содержимым: в _fixtures_dir для каждого <name>.json создавать и <name>.pdf с уникальными байтами (f"%PDF {name}".encode()), а _drop по умолчанию брать data = (fixtures / f"{Path(name).stem}.pdf").read_bytes(). Сценарии spec «Точное имя»/«Суффикс»/«Несколько файлов»/«Чужой префикс» переписать на пары по содержимому (реальные aep_5.pdf/aep_5.json, aep_8.pdf/aep_8_form4.json, aep_3.pdf/aep_3*.json из REAL_FIXTURES, маркер fixtures). Добавить:

class TestHashIndex:
    def test_resolves_by_content(self, tmp_path: Path) -> None:
        fixtures = _fixtures_dir(tmp_path, {"ok.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}})
        (fixtures / "ok.pdf").write_bytes(b"%PDF ok")
        index = emulator.build_index(fixtures)
        assert emulator.resolve_stem(b"%PDF ok", index, "") == ("ok", "hash")
        assert emulator.resolve_stem(b"other", index, "") == (None, "none")
        assert emulator.resolve_stem(b"other", index, "ok") == ("ok", "default")

    def test_refresh_only_when_listing_changes(self, tmp_path: Path) -> None:
        fixtures = _fixtures_dir(tmp_path)
        (fixtures / "a.pdf").write_bytes(b"a")
        index = emulator.build_index(fixtures)
        assert index.refresh(fixtures) is index
        (fixtures / "b.pdf").write_bytes(b"b")
        refreshed = index.refresh(fixtures)
        assert refreshed is not index and set(refreshed.by_hash.values()) == {"a", "b"}

    def test_ignores_part_tmp_done(self, tmp_path: Path) -> None:
        """Review Focus 5: временные и обработанные файлы не обрабатываются."""
        hf = _hotfolder(tmp_path)
        fixtures = _fixtures_dir(tmp_path)
        hf.input_dir.mkdir(exist_ok=True)
        hf.tmp_dir.mkdir(exist_ok=True)
        job = _uid()
        (hf.tmp_dir / f".{job}.pdf.part").write_bytes(b"x")
        (hf.input_dir / f"{job}.pdf.done").write_bytes(b"x")
        (hf.input_dir / f"{job}.pdf.tmp").write_bytes(b"x")
        assert process_once(hf.root, fixtures, 0) == []
        assert not hf.output_dir.exists() or list(hf.output_dir.iterdir()) == []

    def test_default_set_on_miss(self, tmp_path: Path) -> None:
        hf = _hotfolder(tmp_path)
        fixtures = _fixtures_dir(tmp_path, {"dflt.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}})
        job = _drop(hf, "any.pdf", data=b"unknown bytes")
        assert process_once(hf.root, fixtures, 0, default="dflt") == [job]
        assert _answer(hf, job)["error"]["code"] == ""

    def test_miss_without_default_is_invalid_document(self, tmp_path: Path) -> None:
        hf = _hotfolder(tmp_path)
        fixtures = _fixtures_dir(tmp_path)
        job = _drop(hf, "any.pdf", data=b"unknown bytes")
        assert process_once(hf.root, fixtures, 0) == [job]
        answer = _answer(hf, job)
        assert answer["error"]["code"] == "INVALID_DOCUMENT" and "нет пары по содержимому" in answer["error"]["message"]

    def test_name_does_not_matter(self, tmp_path: Path) -> None:
        hf = _hotfolder(tmp_path)
        fixtures = _fixtures_dir(tmp_path, {"ok.json": {"file_uid": "x", "error": {"code": "", "message": ""}, "data_ocr": {}}})
        (fixtures / "ok.pdf").write_bytes(b"%PDF ok")
        job = _drop(hf, "whatever.PDF", data=b"%PDF ok")
        assert process_once(hf.root, fixtures, 0) == [job]
        assert (hf.input_dir / f"{job}.pdf.done").exists()
        assert _answer(hf, job)["file_uid"] == job


def test_settings_default_from_env(tmp_path: Path) -> None:
    settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path), "SCAN_EMULATOR_DEFAULT": "aep_6"})
    assert settings.default == "aep_6"
  • Step 2: Убедиться, что падают — cd api && .venv/bin/pytest tests/unit/test_emulator.py -q.

  • Step 3: Реализация в api/src/scan_emulator/__init__.py:

import hashlib

_INBOX_NAME = re.compile(
    r"^(?P<uuid>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.(?P<ext>[0-9A-Za-z]{1,16})$"
)
_SKIP_SUFFIXES = (".done", ".part", ".tmp")


@dataclass(frozen=True)
class FixtureIndex:
    """sha256 содержимого не-JSON файлов каталога фикстур → основа имени (`aep_6.pdf` → `aep_6`)."""

    names: tuple[str, ...]
    by_hash: dict[str, str]

    def refresh(self, fixtures: Path) -> "FixtureIndex":
        """Тот же объект, если список файлов каталога не изменился; иначе новый индекс."""
        return self if _listing(fixtures) == self.names else build_index(fixtures)


def _listing(fixtures: Path) -> tuple[str, ...]:
    try:
        return tuple(sorted(p.name for p in fixtures.iterdir() if p.is_file() and not p.name.lower().endswith(".json")))
    except OSError:
        return ()


def build_index(fixtures: Path) -> FixtureIndex:
    names = _listing(fixtures)
    by_hash: dict[str, str] = {}
    for name in names:
        try:
            digest = hashlib.sha256((fixtures / name).read_bytes()).hexdigest()
        except OSError:
            continue
        by_hash.setdefault(digest, Path(name).stem)
    return FixtureIndex(names, by_hash)


def resolve_stem(data: bytes, index: FixtureIndex, default: str) -> tuple[str | None, str]:
    """Основа имени набора по содержимому; промах — `default`, если задан; иначе `(None, "none")`."""
    stem = index.by_hash.get(hashlib.sha256(data).hexdigest())
    if stem is not None:
        return stem, "hash"
    if default:
        return default, "default"
    return None, "none"

EmulatorSettings: поле default: str = "", в from_env — default=(env.get("SCAN_EMULATOR_DEFAULT") or "").strip(). _build_answers(job_id, stem, fixtures, *, miss_message): при stem is None или пустом _select_fixtures — ответ-ошибка с miss_message. process_once(..., default: str = "", index: FixtureIndex | None = None): index = (index or build_index(fixtures_dir)).refresh(fixtures_dir); цикл по входящим: пропуск entry.name.startswith("."), entry.name.lower().endswith(_SKIP_SUFFIXES), не файл; match = _INBOX_NAME.match(entry.name); для каждого — data = entry.read_bytes(), stem, source = resolve_stem(data, index, default), digest = hashlib.sha256(data).hexdigest(), answers = _build_answers(job_id, stem, fixtures_dir, miss_message=f"{digest[:12]}: нет пары по содержимому в фикстурах"); лог "Эмулятор: {} -> {} (пара: {})" с source. run() строит индекс один раз до цикла и передаёт его в process_once, обновляя возвращаемым значением — для этого process_once возвращает list[str], а индекс хранится в замыкании: проще держать index переменной цикла и вызывать index = index.refresh(settings.fixtures_dir) перед каждым process_once(..., index=index). Docstring модуля — обновить под раскладку FileWorker и поиск по содержимому. _count_fixtures → считать пары (len(index.by_hash)).

api/.env.example: после SCAN_EMULATOR_POLL_SECONDS добавить # набор ответа по умолчанию при промахе по содержимому (основа имени фикстуры, например aep_6); пусто — INVALID_DOCUMENT и SCAN_EMULATOR_DEFAULT=.

  • Step 4: Тесты и линт — cd api && .venv/bin/pytest tests/unit -q && cd .. && make lint → PASS (включая воркер: make_env пишет ok_doc.pdf = PDF_BYTES, Task 13).

  • Step 5: Commit — git add api/src/scan_emulator api/tests/unit/test_emulator.py api/.env.example, сообщение feat(emulator): пара фикстур по sha256 содержимого, SCAN_EMULATOR_DEFAULT, входящий <uuid>.<ext> (D-3).


Task 15: Сквозной сценарий, README и CLAUDE.md сервиса

Files:

  • Modify: api/tests/integration/test_roundtrip.py
  • Modify: README.md, CLAUDE.md, openspec/changes/slice-3a-ocr-normalizer/tasks.md

Interfaces:

  • Consumes: всё выше. Produces: документация сервиса, соответствующая коду.

  • Step 1: Обновить roundtrip

В api/tests/integration/test_roundtrip.py: docstring сценариев — 2 (aep_3.pdf) → needs_check с документами и metrics.warnings про _ofr.json; 3 (aep_6.pdf) → needs_check, result_s3 с двумя документами; 4 (ok_doc.pdf) → needs_check; проверки имени входящего файла → <job_id>.pdf; эмулятор запускается с SCAN_EMULATOR_DEFAULT пустым, а сценарий 4 кладёт ok_doc.pdf с теми же байтами, что отправляет в S3; добавить сценарий 6: SCAN_CLEANUP=true → после результата Input/ и Output/<job_id>/ пусты. Файл результата из MinIO валидировать схемой recognition-result.schema.json (Draft202012Validator по api/contracts/recognition-result.schema.json). Импорт NOT_IMPLEMENTED_MESSAGE удалить.

Run: make up && make test-integration → PASS (не запускать одновременно с интеграцией arm-core, OQ-83).

  • Step 2: README и CLAUDE.md

README.md: раздел «Протокол горячих папок» — имя <job_id>.<ext>, .part в Tmp, rename без замены, очистка под SCAN_CLEANUP (что удаляется, когда не удаляется, права на SMB); новый раздел «Нормализация (statementDocument v1.2)» — конвейер, справочники api/rules/ (редакции, как заполнить fsbu4), статусы done/needs_check/error RECOGNITION_FAILED, предупреждения и их префиксы, make golden-update; «Переменные окружения» — SCAN_CLEANUP, SCAN_RULES_DIR, SCAN_EMULATOR_DEFAULT; «Коды ошибок» — строки про расширение и «ни одной таблицы»; эмулятор — пара по sha256, дефолт; удалить «Ограничения Среза 0» в части NOT_IMPLEMENTED. CLAUDE.md: «Что это» и «Правила» — то же кратко; «Добавлено» — src/normalizer/ocr/, src/normalizer/rules.py, api/rules/, tests/golden_update.py; ссылка на openspec/changes/slice-3a-ocr-normalizer/.

  • Step 3: tasks.md — заполнить openspec/changes/slice-3a-ocr-normalizer/tasks.md списком задач 0–17 этого плана с [x] по факту; openspec validate slice-3a-ocr-normalizer --strict → valid.

  • Step 4: Commit — git add api/tests/integration/test_roundtrip.py README.md CLAUDE.md openspec/changes/slice-3a-ocr-normalizer/tasks.md, сообщение docs(scan-gateway): README/CLAUDE.md под Срез 3a, roundtrip с документами, tasks.md.


Task 16: Документы workspace (репозиторий docs, bounded-PR)

Files (репозиторий docs, worktree .wt/slice-3a/docs):

  • Modify: context/03-architecture.md, context/05-domain-rules.md (§11.1 п. 6, 9), context/07-open-questions.md, context/09-delivery-plan.md, dev/docker-compose.yml, dev/.env.example, dev/qa-guide.md

  • Step 1: Worktree docs и issue

cd /Users/andrey.epifancev/projects/awp-ca
tea issues create --login home --repo RSHB/arm-ka-docs --title "Срез 3a (docs): раскладка горячих папок, §11.1 п. 6/9, OQ по редакциям форм и очистке на SMB, compose" \
  --description "Сопровождение change scan-gateway slice-3a-ocr-normalizer (arm-scan-gateway#6): 03-architecture (Tmp остаётся, очистка под флагом, эмулятор по sha256), OQ-137 уточнить, §11.1 п. 6 (свой statement_lines.yaml с редакциями) и п. 9, новые OQ (права на удаление на SMB; редакция формы в контракте 1.3; §7/§12 по редакциям для 2b/3b; скобки `}` — решение №12), 09-delivery-plan (3a/3b), dev/compose и qa-guide."
# номер issue — <D>
rm /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/docs   # симлинк
DOCS=/Users/andrey.epifancev/projects/awp-ca/docs
git -C "$DOCS" worktree add /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/docs -b feat/<D>-slice-3a-docs main
touch /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/docs/.bounded
  • Step 2: Правки
  1. 03-architecture.md, пункт scan-gateway (раздел «Сервисы»): «временный .part остаётся в Tmp (решение владельца 06.10.2026: в Input временных файлов не класть), очистка Output/<job_id>/ и входящего файла — под SCAN_CLEANUP, по умолчанию выключена; нормализатор OcrNormalizer по §11/§11.1, справочники api/rules/ с редакциями; эмулятор ищет пару PDF+JSON по sha256». Раздел «Дополнено 06.10.2026 …» (горячие папки): та же оговорка про Tmp.
  2. 05-domain-rules.md §11.1: п. 6 — «справочник — api/rules/statement_lines.yaml scan-gateway с редакциями 66n/fsbu4 (а не rules/statement_lines.yaml из §12: в tabular-parser такого файла нет)»; п. 9 — «исключение при разборе одной таблицы → предупреждение BACKEND_PARSING_ERROR уровня таблицы, остальные таблицы разбираются; ни одной таблицы → error RECOGNITION_FAILED». п. 7 — «string_value — текст ячейки как распознан, только если сумма не разобрана (DBML), иначе null»; п. 4 — «повтор кода — остаётся первая строка, предупреждение, needs_check (как §12 п. 5)». Добавить п. 10 «Редакции форм»: выбор по дате отчёта → поздней period_date → совпадениям кодов; пустая редакция → имена из OCR.
  3. 07-open-questions.md: OQ-137 — дописать «уточнение 06.10.2026: Tmp остаётся, очистка под SCAN_CLEANUP»; новые строки OQ-144…OQ-147 (на 06.10.2026 последний — OQ-143 из 2b; перед правкой проверить последний номер в таблице): права на удаление на SMB-шаре контура; редакция формы в контракте (MINOR 1.3); словари §7/§12 по редакциям для tabular-parser и ядра (3b), расширение OQ-12 и OQ-139; скобки, распознанные как }/] (решение №12 спеки 3a).
  4. 09-delivery-plan.md: Срез 3 → подпункты 3a (scan-gateway, change slice-3a-ocr-normalizer) и 3b (arm-core).
  5. dev/docker-compose.yml: scan-gateway → SCAN_CLEANUP: "true"; scan-emulator → SCAN_EMULATOR_DEFAULT: "" с комментарием; комментарий про UUID_<имя>.ext заменить на <uuid>.<ext> и пары по sha256. dev/.env.example: при наличии SCAN_* — те же переменные.
  6. dev/qa-guide.md: раздел про PDF — «на стенде загружать PDF из fixtures/ocr (aep_3.pdf, aep_6.pdf): эмулятор узнаёт их по содержимому; любой другой PDF даёт INVALID_DOCUMENT, если не задан SCAN_EMULATOR_DEFAULT».
  • Step 3: Проверка и PR
cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/docs && make validate-contracts

Коммит docs(context): Срез 3a — раскладка горячих папок, §11.1 п. 6/9/10, OQ по редакциям и очистке, compose (#<D>), push, tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/<D>-slice-3a-docs --base main с «Closes #» и рулингами.


Task 17: Завершение — проверка всей ветки и PR

Files: нет новых.

  • Step 1: Синхронизация с main и полная проверка
cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3a/scan-gateway
git fetch origin && git merge --no-edit origin/main
make check-full
openspec validate slice-3a-ocr-normalizer --strict

Ожидание: check-full зелёный (lint, unit, integration, docker-build). Падение test_contract.py из-за ../docs/contracts v1.0 означает, что docs #27 ещё не слит — зафиксировать в PR как блокер мержа.

  • Step 2: Golden и fixtures

make golden-update не должен менять эталоны (git -C .wt/slice-3a/fixtures status --short пуст). PR в fixtures: tea pulls create --login home --repo RSHB/arm-ka-fixtures --head feat/<F>-ocr-golden --base main --title "feat(ocr): эталоны нормализатора scan-gateway (Срез 3a)".

  • Step 3: PR scan-gateway
git push -u origin feat/6-slice-3a-ocr-normalizer
tea pulls create --login home --repo RSHB/arm-scan-gateway --head feat/6-slice-3a-ocr-normalizer --base main \
  --title "Срез 3a: горячие папки как у armka_file_worker, нормализатор OCR → statementDocument v1.2" \
  --description-file <файл с описанием>

Описание: «Closes #6», ссылки на change slice-3a-ocr-normalizer, PR fixtures и docs, порядок мержа (docs #27 → scan-gateway #5 → этот PR; fixtures-PR — до него), таблица находок ревью с маркерами и статусами, раздел «Rulings I made», строки атрибуции сессии.

  • Step 4: Отчёт — итоговое сообщение: что сделано по задачам, deferred-находки (пойдут в follow-up при «закрывай»), что осталось владельцу (мержи, заполнение fsbu4, пары PDF+JSON).