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

3408 lines
184 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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