Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
3408 lines
184 KiB
Markdown
3408 lines
184 KiB
Markdown
# Срез 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).
|